import * as plugins from '../plugins.js'; import { apiService, type IS3Object } from '../services/index.js'; import { getFileName } from '../utilities/index.js'; import { themeStyles } from '../styles/index.js'; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { DeesContextmenu } = plugins.deesCatalog; interface IColumn { prefix: string; objects: IS3Object[]; prefixes: string[]; selectedItem: string | null; width: number; } @customElement('tsview-s3-columns') export class TsviewS3Columns 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 columns: IColumn[] = []; @state() private accessor loading: boolean = false; @state() private accessor showCreateDialog: boolean = false; @state() private accessor createDialogType: 'folder' | 'file' = 'folder'; @state() private accessor createDialogPrefix: string = ''; @state() private accessor createDialogName: string = ''; private resizing: { columnIndex: number; startX: number; startWidth: number } | null = null; private readonly DEFAULT_COLUMN_WIDTH = 250; private readonly MIN_COLUMN_WIDTH = 150; private readonly MAX_COLUMN_WIDTH = 500; public static styles = [ cssManager.defaultStyles, themeStyles, css` :host { display: block; height: 100%; overflow-x: auto; overflow-y: hidden; } .columns-container { display: flex; height: 100%; min-width: max-content; } .column-wrapper { display: flex; height: 100%; flex-shrink: 0; } .column { display: flex; flex-direction: column; height: 100%; flex-shrink: 0; overflow: hidden; } .resize-handle { width: 5px; height: 100%; background: transparent; cursor: col-resize; position: relative; flex-shrink: 0; } .resize-handle::after { content: ''; position: absolute; top: 0; left: 2px; width: 1px; height: 100%; background: #333; } .resize-handle:hover::after, .resize-handle.active::after { background: #404040; width: 2px; left: 1px; } .column-wrapper:last-child .resize-handle { display: none; } .column-header { padding: 8px 12px; font-size: 12px; font-weight: 500; color: #666; background: rgba(0, 0, 0, 0.2); border-bottom: 1px solid #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .column-items { flex: 1; overflow-y: auto; padding: 4px; } .column-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: background 0.1s; } .column-item:hover { background: rgba(255, 255, 255, 0.05); } .column-item.selected { background: rgba(255, 255, 255, 0.1); color: #e0e0e0; } .column-item.folder { color: #fbbf24; } .column-item .icon { width: 16px; height: 16px; flex-shrink: 0; } .column-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .column-item .chevron { width: 14px; height: 14px; color: #555; } .empty-state { padding: 16px; text-align: center; color: #666; font-size: 13px; } .loading { padding: 16px; 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; } `, ]; async connectedCallback() { super.connectedCallback(); await this.loadInitialColumn(); } updated(changedProperties: Map) { // Only reset columns when bucket changes or refresh is triggered // Internal folder navigation is handled by selectFolder() which appends columns if (changedProperties.has('bucketName') || changedProperties.has('refreshKey')) { this.loadInitialColumn(); } } private async loadInitialColumn() { this.loading = true; try { const result = await apiService.listObjects(this.bucketName, this.currentPrefix, '/'); this.columns = [ { prefix: this.currentPrefix, objects: result.objects, prefixes: result.prefixes, selectedItem: null, width: this.DEFAULT_COLUMN_WIDTH, }, ]; } catch (err) { console.error('Error loading objects:', err); this.columns = []; } this.loading = false; } private async selectFolder(columnIndex: number, prefix: string) { // Update selection in current column this.columns = this.columns.map((col, i) => { if (i === columnIndex) { return { ...col, selectedItem: prefix }; } return col; }); // Remove columns after current this.columns = this.columns.slice(0, columnIndex + 1); // Load new column try { const result = await apiService.listObjects(this.bucketName, prefix, '/'); this.columns = [ ...this.columns, { prefix, objects: result.objects, prefixes: result.prefixes, selectedItem: null, width: this.DEFAULT_COLUMN_WIDTH, }, ]; // Auto-scroll to show the new column this.updateComplete.then(() => this.scrollToEnd()); } catch (err) { console.error('Error loading folder:', err); } // Note: Don't dispatch navigate event here - columns view expands horizontally // The navigate event is only for breadcrumb sync, not for column navigation } private scrollToEnd() { this.scrollLeft = this.scrollWidth - this.clientWidth; } private startResize(e: MouseEvent, columnIndex: number) { e.preventDefault(); this.resizing = { columnIndex, startX: e.clientX, startWidth: this.columns[columnIndex].width, }; document.addEventListener('mousemove', this.handleResize); document.addEventListener('mouseup', this.stopResize); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; } private handleResize = (e: MouseEvent) => { if (!this.resizing) return; const delta = e.clientX - this.resizing.startX; const newWidth = Math.min( this.MAX_COLUMN_WIDTH, Math.max(this.MIN_COLUMN_WIDTH, this.resizing.startWidth + delta) ); this.columns = this.columns.map((col, i) => { if (i === this.resizing!.columnIndex) { return { ...col, width: newWidth }; } return col; }); }; private stopResize = () => { this.resizing = null; document.removeEventListener('mousemove', this.handleResize); document.removeEventListener('mouseup', this.stopResize); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; private selectFile(columnIndex: number, key: string) { // Update selection this.columns = this.columns.map((col, i) => { if (i === columnIndex) { return { ...col, selectedItem: key }; } return col; }); // Remove columns after current this.columns = this.columns.slice(0, columnIndex + 1); // Dispatch key-selected event this.dispatchEvent( new CustomEvent('key-selected', { detail: { key }, bubbles: true, composed: true, }) ); } private getFileIcon(key: string): string { const ext = key.split('.').pop()?.toLowerCase() || ''; const iconMap: Record = { json: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z', txt: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z', png: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z', jpg: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z', jpeg: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z', gif: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z', pdf: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z', }; return iconMap[ext] || 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'; } private handleFolderContextMenu(event: MouseEvent, columnIndex: number, prefix: string) { event.preventDefault(); DeesContextmenu.openContextMenuWithOptions(event, [ { name: 'Open', iconName: 'lucide:folderOpen', action: async () => { this.selectFolder(columnIndex, prefix); }, }, { name: 'Copy Path', iconName: 'lucide:copy', action: async () => { await navigator.clipboard.writeText(prefix); }, }, { divider: true }, { name: 'New Folder Inside', iconName: 'lucide:folderPlus', action: async () => this.openCreateDialog('folder', prefix), }, { name: 'New File Inside', iconName: 'lucide:filePlus', action: async () => this.openCreateDialog('file', prefix), }, { divider: true }, { name: 'Delete Folder', iconName: 'lucide:trash2', action: async () => { if (confirm(`Delete folder "${getFileName(prefix)}" and all its contents?`)) { const success = await apiService.deletePrefix(this.bucketName, prefix); if (success) { await this.loadInitialColumn(); } } }, }, ]); } private handleFileContextMenu(event: MouseEvent, columnIndex: number, key: string) { event.preventDefault(); DeesContextmenu.openContextMenuWithOptions(event, [ { name: 'Preview', iconName: 'lucide:eye', action: async () => { this.selectFile(columnIndex, key); }, }, { 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); }, }, { 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.loadInitialColumn(); } } }, }, ]); } private handleEmptySpaceContextMenu(event: MouseEvent, columnIndex: number) { // Only trigger if clicking on the container itself, not on items if (event.target !== event.currentTarget) return; event.preventDefault(); const prefix = this.columns[columnIndex].prefix; DeesContextmenu.openContextMenuWithOptions(event, [ { name: 'New Folder', iconName: 'lucide:folderPlus', action: async () => this.openCreateDialog('folder', prefix), }, { name: 'New File', iconName: 'lucide:filePlus', action: async () => this.openCreateDialog('file', prefix), }, ]); } 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') { // Support deep paths: "a/b/c" creates nested folders 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.loadInitialColumn(); } } 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'})
`; } render() { if (this.loading && this.columns.length === 0) { return html`
Loading...
`; } return html`
${this.columns.map((column, index) => this.renderColumnWrapper(column, index))}
${this.renderCreateDialog()} `; } private renderColumnWrapper(column: IColumn, index: number) { return html`
${this.renderColumn(column, index)}
this.startResize(e, index)} >
`; } private renderColumn(column: IColumn, index: number) { const headerName = column.prefix ? getFileName(column.prefix) : this.bucketName; return html`
${headerName}
this.handleEmptySpaceContextMenu(e, index)}> ${column.prefixes.length === 0 && column.objects.length === 0 ? html`
Empty folder
` : ''} ${column.prefixes.map( (prefix) => html`
this.selectFolder(index, prefix)} @contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)} > ${getFileName(prefix)}
` )} ${column.objects.map( (obj) => html`
this.selectFile(index, obj.key)} @contextmenu=${(e: MouseEvent) => this.handleFileContextMenu(e, index, obj.key)} > ${getFileName(obj.key)}
` )}
`; } }