import * as plugins from '../plugins.js'; import { apiService, type IS3Object } from '../services/index.js'; import { formatSize, getFileName, validateMove, getParentPrefix } from '../utilities/index.js'; import { themeStyles } from '../styles/index.js'; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { DeesContextmenu } = plugins.deesCatalog; @customElement('tsview-s3-keys') export class TsviewS3Keys extends DeesElement { @property({ type: String }) public accessor bucketName: string = ''; @property({ type: String }) public accessor currentPrefix: string = ''; @property({ type: Number }) public accessor refreshKey: number = 0; @state() private accessor allKeys: IS3Object[] = []; @state() private accessor prefixes: string[] = []; @state() private accessor loading: boolean = false; @state() private accessor selectedKey: string = ''; @state() private accessor filterText: string = ''; @state() private accessor showCreateDialog: boolean = false; @state() private accessor createDialogType: 'folder' | 'file' = 'folder'; @state() private accessor createDialogPrefix: string = ''; @state() private accessor createDialogName: string = ''; // Move dialog state @state() private accessor showMoveDialog: boolean = false; @state() private accessor moveSource: { key: string; isFolder: boolean } | null = null; @state() private accessor moveDestination: string = ''; @state() private accessor moveInProgress: boolean = false; @state() private accessor moveError: string | null = null; // Move picker dialog state @state() private accessor showMovePickerDialog: boolean = false; @state() private accessor movePickerSource: { key: string; isFolder: boolean } | null = null; @state() private accessor movePickerCurrentPrefix: string = ''; @state() private accessor movePickerPrefixes: string[] = []; @state() private accessor movePickerLoading: boolean = false; // Rename dialog state @state() private accessor showRenameDialog: boolean = false; @state() private accessor renameSource: { key: string; isFolder: boolean } | null = null; @state() private accessor renameName: string = ''; @state() private accessor renameInProgress: boolean = false; @state() private accessor renameError: string | null = null; public static styles = [ cssManager.defaultStyles, themeStyles, css` :host { display: block; height: 100%; overflow: hidden; } .keys-container { display: flex; flex-direction: column; height: 100%; } .filter-bar { padding: 12px; border-bottom: 1px solid #333; } .filter-input { width: 100%; padding: 8px 12px; background: rgba(0, 0, 0, 0.3); border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; } .filter-input:focus { outline: none; border-color: #404040; } .filter-input::placeholder { color: #666; } .keys-list { flex: 1; overflow-y: auto; } table { width: 100%; border-collapse: collapse; } thead { position: sticky; top: 0; background: #1a1a1a; z-index: 1; } th { text-align: left; padding: 10px 12px; font-size: 12px; font-weight: 500; color: #666; text-transform: uppercase; border-bottom: 1px solid #333; } td { padding: 8px 12px; font-size: 13px; border-bottom: 1px solid #2a2a3e; } tr:hover td { background: rgba(255, 255, 255, 0.03); } tr.selected td { background: rgba(255, 255, 255, 0.08); } .key-cell { display: flex; align-items: center; gap: 8px; cursor: pointer; } .key-icon { width: 16px; height: 16px; flex-shrink: 0; } .folder-icon { color: #fbbf24; } .key-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .size-cell { color: #888; font-variant-numeric: tabular-nums; } .empty-state { padding: 32px; text-align: center; color: #666; } .dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } .dialog { background: #1e1e1e; border-radius: 12px; padding: 24px; min-width: 400px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); } .dialog-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #fff; } .dialog-location { font-size: 12px; color: #888; margin-bottom: 12px; font-family: monospace; } .dialog-input { width: 100%; padding: 10px 12px; background: #141414; border: 1px solid #333; border-radius: 6px; color: #fff; font-size: 14px; margin-bottom: 8px; box-sizing: border-box; } .dialog-input:focus { outline: none; border-color: #e0e0e0; } .dialog-hint { font-size: 11px; color: #666; margin-bottom: 16px; } .dialog-actions { display: flex; gap: 12px; justify-content: flex-end; } .dialog-btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; transition: all 0.2s; } .dialog-btn-cancel { background: transparent; border: 1px solid #444; color: #aaa; } .dialog-btn-cancel:hover { background: rgba(255, 255, 255, 0.05); color: #fff; } .dialog-btn-create { background: #404040; border: none; color: #fff; } .dialog-btn-create:hover { background: #505050; } .dialog-btn-create:disabled { opacity: 0.5; cursor: not-allowed; } /* Move dialog styles */ .move-summary { background: rgba(0, 0, 0, 0.2); border-radius: 8px; padding: 16px; margin-bottom: 16px; } .move-item { display: flex; gap: 8px; margin-bottom: 8px; } .move-item:last-child { margin-bottom: 0; } .move-label { color: #888; min-width: 40px; } .move-path { color: #e0e0e0; font-family: monospace; font-size: 12px; word-break: break-all; } .move-error { background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.3); color: #f87171; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; } /* Move picker dialog styles */ .move-picker-dialog { min-width: 450px; max-height: 500px; } .picker-breadcrumb { display: flex; align-items: center; gap: 4px; padding: 8px 12px; background: rgba(0, 0, 0, 0.2); border-radius: 6px; margin-bottom: 12px; font-size: 12px; overflow-x: auto; } .picker-crumb { color: #888; cursor: pointer; white-space: nowrap; } .picker-crumb:hover { color: #fff; } .picker-separator { color: #555; } .picker-list { max-height: 280px; overflow-y: auto; border: 1px solid #333; border-radius: 6px; margin-bottom: 16px; min-height: 100px; } .picker-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; color: #fbbf24; } .picker-item:hover { background: rgba(255, 255, 255, 0.05); } .picker-item .icon { width: 16px; height: 16px; flex-shrink: 0; } .picker-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .picker-empty { padding: 24px; text-align: center; color: #666; font-size: 13px; } `, ]; async connectedCallback() { super.connectedCallback(); await this.loadObjects(); } updated(changedProperties: Map) { if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix') || changedProperties.has('refreshKey')) { this.loadObjects(); } } private async loadObjects() { this.loading = true; try { const result = await apiService.listObjects(this.bucketName, this.currentPrefix, '/'); this.allKeys = result.objects; this.prefixes = result.prefixes; } catch (err) { console.error('Error loading objects:', err); this.allKeys = []; this.prefixes = []; } this.loading = false; } private handleFilterInput(e: Event) { this.filterText = (e.target as HTMLInputElement).value; } private selectKey(key: string, isFolder: boolean) { this.selectedKey = key; if (isFolder) { this.dispatchEvent( new CustomEvent('navigate', { detail: { prefix: key }, bubbles: true, composed: true, }) ); } else { this.dispatchEvent( new CustomEvent('key-selected', { detail: { key }, bubbles: true, composed: true, }) ); } } private get filteredItems() { const filter = this.filterText.toLowerCase(); const folders = this.prefixes .filter((p) => !filter || getFileName(p).toLowerCase().includes(filter)) .map((p) => ({ key: p, isFolder: true, size: undefined })); const files = this.allKeys .filter((o) => !filter || getFileName(o.key).toLowerCase().includes(filter)) .map((o) => ({ key: o.key, isFolder: false, size: o.size })); return [...folders, ...files]; } private handleItemContextMenu(event: MouseEvent, key: string, isFolder: boolean) { event.preventDefault(); if (isFolder) { DeesContextmenu.openContextMenuWithOptions(event, [ { name: 'Open', iconName: 'lucide:folderOpen', action: async () => { this.selectKey(key, true); }, }, { name: 'Copy Path', iconName: 'lucide:copy', action: async () => { await navigator.clipboard.writeText(key); }, }, { name: 'Rename', iconName: 'lucide:pencil', action: async () => this.openRenameDialog(key, true), }, { divider: true }, { name: 'Move to...', iconName: 'lucide:folderInput', action: async () => this.openMovePickerDialog(key, true), }, { divider: true }, { name: 'New Folder Inside', iconName: 'lucide:folderPlus', action: async () => this.openCreateDialog('folder', key), }, { name: 'New File Inside', iconName: 'lucide:filePlus', action: async () => this.openCreateDialog('file', key), }, { divider: true }, { name: 'Delete Folder', iconName: 'lucide:trash2', action: async () => { if (confirm(`Delete folder "${getFileName(key)}" and all its contents?`)) { const success = await apiService.deletePrefix(this.bucketName, key); if (success) { await this.loadObjects(); } } }, }, ]); } else { DeesContextmenu.openContextMenuWithOptions(event, [ { name: 'Preview', iconName: 'lucide:eye', action: async () => { this.selectKey(key, false); }, }, { name: 'Download', iconName: 'lucide:download', action: async () => { const url = await apiService.getObjectUrl(this.bucketName, key); const link = document.createElement('a'); link.href = url; link.download = getFileName(key); link.click(); }, }, { name: 'Copy Path', iconName: 'lucide:copy', action: async () => { await navigator.clipboard.writeText(key); }, }, { name: 'Rename', iconName: 'lucide:pencil', action: async () => this.openRenameDialog(key, false), }, { divider: true }, { name: 'Move to...', iconName: 'lucide:folderInput', action: async () => this.openMovePickerDialog(key, false), }, { divider: true }, { name: 'Delete', iconName: 'lucide:trash2', action: async () => { if (confirm(`Delete file "${getFileName(key)}"?`)) { const success = await apiService.deleteObject(this.bucketName, key); if (success) { await this.loadObjects(); } } }, }, ]); } } private handleEmptySpaceContextMenu(event: MouseEvent) { // Only trigger if clicking on the container itself, not on items if ((event.target as HTMLElement).closest('tr')) return; event.preventDefault(); DeesContextmenu.openContextMenuWithOptions(event, [ { name: 'New Folder', iconName: 'lucide:folderPlus', action: async () => this.openCreateDialog('folder', this.currentPrefix), }, { name: 'New File', iconName: 'lucide:filePlus', action: async () => this.openCreateDialog('file', this.currentPrefix), }, ]); } private openCreateDialog(type: 'folder' | 'file', prefix: string) { this.createDialogType = type; this.createDialogPrefix = prefix; this.createDialogName = ''; this.showCreateDialog = true; } private getContentType(ext: string): string { const contentTypes: Record = { json: 'application/json', txt: 'text/plain', html: 'text/html', css: 'text/css', js: 'application/javascript', ts: 'text/typescript', md: 'text/markdown', xml: 'application/xml', yaml: 'text/yaml', yml: 'text/yaml', csv: 'text/csv', }; return contentTypes[ext] || 'application/octet-stream'; } private getDefaultContent(ext: string): string { const defaults: Record = { json: '{\n \n}', html: '\n\n\n \n\n\n \n\n', md: '# Title\n\n', txt: '', }; return defaults[ext] || ''; } private async handleCreate() { if (!this.createDialogName.trim()) return; const name = this.createDialogName.trim(); let path: string; if (this.createDialogType === 'folder') { path = this.createDialogPrefix + name + '/.keep'; } else { path = this.createDialogPrefix + name; } const ext = name.split('.').pop()?.toLowerCase() || ''; const contentType = this.createDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream'; const content = this.createDialogType === 'file' ? this.getDefaultContent(ext) : ''; const success = await apiService.putObject( this.bucketName, path, btoa(content), contentType ); if (success) { this.showCreateDialog = false; await this.loadObjects(); } } private renderCreateDialog() { if (!this.showCreateDialog) return ''; const isFolder = this.createDialogType === 'folder'; const title = isFolder ? 'Create New Folder' : 'Create New File'; const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt'; return html`
this.showCreateDialog = false}>
e.stopPropagation()}>
${title}
Location: ${this.bucketName}/${this.createDialogPrefix}
this.createDialogName = (e.target as HTMLInputElement).value} @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()} />
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
`; } // --- Helper for path segments --- private getPathSegments(prefix: string): string[] { if (!prefix) return []; const parts = prefix.split('/').filter(p => p); const segments: string[] = []; let cumulative = ''; for (const part of parts) { cumulative += part + '/'; segments.push(cumulative); } return segments; } // --- Move picker dialog --- private async openMovePickerDialog(key: string, isFolder: boolean) { this.movePickerSource = { key, isFolder }; this.movePickerCurrentPrefix = ''; this.showMovePickerDialog = true; await this.loadMovePickerPrefixes(''); } private async navigateMovePicker(prefix: string) { this.movePickerCurrentPrefix = prefix; await this.loadMovePickerPrefixes(prefix); } private async loadMovePickerPrefixes(prefix: string) { this.movePickerLoading = true; try { const result = await apiService.listObjects(this.bucketName, prefix, '/'); this.movePickerPrefixes = result.prefixes; } catch { this.movePickerPrefixes = []; } this.movePickerLoading = false; } private selectMoveDestination(destPrefix: string) { if (!this.movePickerSource) return; this.closeMovePickerDialog(); this.initiateMove(this.movePickerSource.key, this.movePickerSource.isFolder, destPrefix); } private closeMovePickerDialog() { this.showMovePickerDialog = false; this.movePickerSource = null; this.movePickerCurrentPrefix = ''; this.movePickerPrefixes = []; } // --- Move confirmation dialog --- private initiateMove(sourceKey: string, isFolder: boolean, destPrefix: string) { const validation = validateMove(sourceKey, destPrefix); if (!validation.valid) { this.moveSource = { key: sourceKey, isFolder }; this.moveDestination = destPrefix; this.moveError = validation.error!; this.showMoveDialog = true; return; } this.moveSource = { key: sourceKey, isFolder }; this.moveDestination = destPrefix; this.moveError = null; this.showMoveDialog = true; } private async executeMove() { if (!this.moveSource) return; this.moveInProgress = true; try { const sourceName = getFileName(this.moveSource.key); const destKey = this.moveDestination + sourceName; let result: { success: boolean; error?: string }; if (this.moveSource.isFolder) { result = await apiService.movePrefix( this.bucketName, this.moveSource.key, destKey ); } else { result = await apiService.moveObject( this.bucketName, this.moveSource.key, destKey ); } if (result.success) { this.closeMoveDialog(); await this.loadObjects(); } else { this.moveError = result.error || 'Move operation failed'; } } catch (err) { this.moveError = `Error: ${err}`; } this.moveInProgress = false; } private closeMoveDialog() { this.showMoveDialog = false; this.moveSource = null; this.moveDestination = ''; this.moveError = null; this.moveInProgress = false; } // --- Rename dialog methods --- private openRenameDialog(key: string, isFolder: boolean) { this.renameSource = { key, isFolder }; this.renameName = getFileName(key); this.renameError = null; this.showRenameDialog = true; // Auto-focus and smart selection this.updateComplete.then(() => { const input = this.shadowRoot?.querySelector('.rename-dialog-input') as HTMLInputElement; if (input) { input.focus(); if (!isFolder) { const lastDot = this.renameName.lastIndexOf('.'); if (lastDot > 0) { input.setSelectionRange(0, lastDot); } else { input.select(); } } else { input.select(); } } }); } private async executeRename() { if (!this.renameSource || !this.renameName.trim()) return; const newName = this.renameName.trim(); const currentName = getFileName(this.renameSource.key); if (newName === currentName) { this.renameError = 'Name is the same as current'; return; } if (!newName) { this.renameError = 'Name cannot be empty'; return; } if (newName.includes('/')) { this.renameError = 'Name cannot contain "/"'; return; } this.renameInProgress = true; this.renameError = null; try { const parentPrefix = getParentPrefix(this.renameSource.key); const newKey = parentPrefix + newName + (this.renameSource.isFolder ? '/' : ''); let result: { success: boolean; error?: string }; if (this.renameSource.isFolder) { result = await apiService.movePrefix(this.bucketName, this.renameSource.key, newKey); } else { result = await apiService.moveObject(this.bucketName, this.renameSource.key, newKey); } if (result.success) { this.closeRenameDialog(); await this.loadObjects(); } else { this.renameError = result.error || 'Rename failed'; } } catch (err) { this.renameError = `Error: ${err}`; } this.renameInProgress = false; } private closeRenameDialog() { this.showRenameDialog = false; this.renameSource = null; this.renameName = ''; this.renameError = null; this.renameInProgress = false; } private renderRenameDialog() { if (!this.showRenameDialog || !this.renameSource) return ''; const isFolder = this.renameSource.isFolder; const title = isFolder ? 'Rename Folder' : 'Rename File'; return html`
this.closeRenameDialog()}>
e.stopPropagation()}>
${title}
Location: ${this.bucketName}/${getParentPrefix(this.renameSource.key)}
${this.renameError ? html`
${this.renameError}
` : ''} { this.renameName = (e.target as HTMLInputElement).value; this.renameError = null; }} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this.executeRename(); if (e.key === 'Escape') this.closeRenameDialog(); }} />
`; } private renderMoveDialog() { if (!this.showMoveDialog || !this.moveSource) return ''; const sourceName = getFileName(this.moveSource.key); return html`
this.closeMoveDialog()}>
e.stopPropagation()}>
Move ${this.moveSource.isFolder ? 'Folder' : 'File'}
${this.moveError ? html`
${this.moveError}
` : html`
From: ${this.moveSource.key}
To: ${this.moveDestination}${sourceName}
`}
${!this.moveError ? html` ` : ''}
`; } private renderMovePickerDialog() { if (!this.showMovePickerDialog || !this.movePickerSource) return ''; const sourceName = getFileName(this.movePickerSource.key); const sourceParent = getParentPrefix(this.movePickerSource.key); return html`
this.closeMovePickerDialog()}>
e.stopPropagation()}>
Move "${sourceName}" to...
this.navigateMovePicker('')}> ${this.bucketName} ${this.getPathSegments(this.movePickerCurrentPrefix).map(seg => html` / this.navigateMovePicker(seg)}> ${getFileName(seg)} `)}
${this.movePickerLoading ? html`
Loading...
` : ''} ${!this.movePickerLoading && this.movePickerPrefixes.filter(p => p !== this.movePickerSource!.key).length === 0 ? html`
No subfolders
` : ''} ${this.movePickerPrefixes .filter(p => p !== this.movePickerSource!.key) // Hide source from list .map(prefix => html`
this.navigateMovePicker(prefix)} @dblclick=${() => this.selectMoveDestination(prefix)}> ${getFileName(prefix)}
`)}
`; } render() { return html`
this.handleEmptySpaceContextMenu(e)}> ${this.loading ? html`
Loading...
` : this.filteredItems.length === 0 ? html`
No objects found
` : html` ${this.filteredItems.map( (item) => html` this.selectKey(item.key, item.isFolder)} @contextmenu=${(e: MouseEvent) => this.handleItemContextMenu(e, item.key, item.isFolder)} > ` )}
Name Size
${item.isFolder ? html` ` : html` `} ${getFileName(item.key)}
${item.isFolder ? '-' : formatSize(item.size)}
`}
${this.renderCreateDialog()} ${this.renderMoveDialog()} ${this.renderMovePickerDialog()} ${this.renderRenameDialog()} `; } }