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; // FileSystem API types for drag-and-drop folder support interface FileSystemEntry { isFile: boolean; isDirectory: boolean; name: string; } interface FileSystemFileEntry extends FileSystemEntry { file(successCallback: (file: File) => void, errorCallback?: (error: Error) => void): void; } interface FileSystemDirectoryEntry extends FileSystemEntry { createReader(): FileSystemDirectoryReader; } interface FileSystemDirectoryReader { readEntries( successCallback: (entries: FileSystemEntry[]) => void, errorCallback?: (error: Error) => void ): void; } 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 = ''; @state() private accessor dragOverColumnIndex: number = -1; @state() private accessor dragOverFolderPrefix: string | null = null; @state() private accessor uploading: boolean = false; @state() private accessor uploadProgress: { current: number; total: number } | null = null; 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; private dragCounters: Map = new Map(); private folderHoverTimer: ReturnType | null = null; private fileInputElement: HTMLInputElement | null = null; 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; position: relative; } .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; } .column.drag-over { background: rgba(59, 130, 246, 0.08); outline: 2px dashed rgba(59, 130, 246, 0.4); outline-offset: -2px; } .column-item.folder.drag-target { background: rgba(59, 130, 246, 0.2) !important; outline: 1px solid rgba(59, 130, 246, 0.5); } .upload-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.6); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; z-index: 10; border-radius: 4px; } .upload-overlay .upload-text { color: #e0e0e0; font-size: 13px; } .upload-overlay .upload-progress { color: #888; font-size: 11px; } .drag-hint { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); background: rgba(59, 130, 246, 0.9); color: white; padding: 4px 12px; border-radius: 4px; font-size: 11px; pointer-events: none; white-space: nowrap; z-index: 5; } .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(); } disconnectedCallback() { super.disconnectedCallback(); this.clearFolderHover(); this.dragCounters.clear(); if (this.fileInputElement) { this.fileInputElement.remove(); this.fileInputElement = null; } } updated(changedProperties: Map) { if (changedProperties.has('bucketName')) { this.loadInitialColumn(); } else if (changedProperties.has('refreshKey')) { this.refreshAllColumns(); } } 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 refreshAllColumns() { const updatedColumns = await Promise.all( this.columns.map(async (col) => { try { const result = await apiService.listObjects(this.bucketName, col.prefix, '/'); return { ...col, objects: result.objects, prefixes: result.prefixes }; } catch { return col; } }) ); this.columns = updatedColumns; } 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), }, { name: 'Upload...', iconName: 'lucide:upload', action: async () => this.triggerFileUpload(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) { // Remove columns that were inside the deleted folder this.columns = this.columns.slice(0, columnIndex + 1); this.columns = this.columns.map((col, i) => i === columnIndex ? { ...col, selectedItem: null } : col ); await this.refreshColumnByPrefix(this.columns[columnIndex].prefix); } } }, }, ]); } 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.refreshColumnByPrefix(this.columns[columnIndex].prefix); } } }, }, ]); } 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), }, { name: 'Upload...', iconName: 'lucide:upload', action: async () => this.triggerFileUpload(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] || ''; } // --- File upload helpers --- private ensureFileInput(): HTMLInputElement { if (!this.fileInputElement) { this.fileInputElement = document.createElement('input'); this.fileInputElement.type = 'file'; this.fileInputElement.multiple = true; (this.fileInputElement as any).webkitdirectory = true; // Enable folder selection this.fileInputElement.style.display = 'none'; this.shadowRoot!.appendChild(this.fileInputElement); } return this.fileInputElement; } private triggerFileUpload(targetPrefix: string) { const input = this.ensureFileInput(); input.value = ''; const handler = async () => { input.removeEventListener('change', handler); if (input.files && input.files.length > 0) { await this.uploadFiles(Array.from(input.files), targetPrefix); } }; input.addEventListener('change', handler); input.click(); } private async uploadFiles(files: File[], targetPrefix: string) { this.uploading = true; this.uploadProgress = { current: 0, total: files.length }; for (let i = 0; i < files.length; i++) { const file = files[i]; this.uploadProgress = { current: i + 1, total: files.length }; try { const base64 = await this.readFileAsBase64(file); // Use webkitRelativePath if available (folder upload), otherwise just filename const relativePath = (file as any).webkitRelativePath || file.name; const key = targetPrefix + relativePath; const contentType = file.type || this.getContentType(file.name.split('.').pop()?.toLowerCase() || ''); await apiService.putObject(this.bucketName, key, base64, contentType); } catch (err) { console.error(`Failed to upload ${file.name}:`, err); } } this.uploading = false; this.uploadProgress = null; await this.refreshColumnByPrefix(targetPrefix); } private async uploadFilesWithPaths( files: { file: File; relativePath: string }[], targetPrefix: string ) { this.uploading = true; this.uploadProgress = { current: 0, total: files.length }; for (let i = 0; i < files.length; i++) { const { file, relativePath } = files[i]; this.uploadProgress = { current: i + 1, total: files.length }; try { const base64 = await this.readFileAsBase64(file); const key = targetPrefix + relativePath; const contentType = file.type || this.getContentType(file.name.split('.').pop()?.toLowerCase() || ''); await apiService.putObject(this.bucketName, key, base64, contentType); } catch (err) { console.error(`Failed to upload ${relativePath}:`, err); } } this.uploading = false; this.uploadProgress = null; await this.refreshColumnByPrefix(targetPrefix); } private readFileAsBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; resolve(result.split(',')[1] || ''); }; reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } private async refreshColumnByPrefix(prefix: string) { const columnIndex = this.columns.findIndex(col => col.prefix === prefix); if (columnIndex === -1) { await this.refreshAllColumns(); return; } try { const result = await apiService.listObjects(this.bucketName, prefix, '/'); this.columns = this.columns.map((col, i) => i === columnIndex ? { ...col, objects: result.objects, prefixes: result.prefixes } : col ); } catch { await this.refreshAllColumns(); } } // --- Drag-and-drop handlers --- private handleColumnDragEnter(e: DragEvent, columnIndex: number) { e.preventDefault(); e.stopPropagation(); const count = (this.dragCounters.get(columnIndex) || 0) + 1; this.dragCounters.set(columnIndex, count); this.dragOverColumnIndex = columnIndex; } private handleColumnDragOver(e: DragEvent) { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; } private handleColumnDragLeave(e: DragEvent, columnIndex: number) { e.stopPropagation(); const count = (this.dragCounters.get(columnIndex) || 1) - 1; this.dragCounters.set(columnIndex, count); if (count <= 0) { this.dragCounters.delete(columnIndex); if (this.dragOverColumnIndex === columnIndex) this.dragOverColumnIndex = -1; this.clearFolderHover(); } } private async handleColumnDrop(e: DragEvent, columnIndex: number) { e.preventDefault(); e.stopPropagation(); this.dragCounters.clear(); this.dragOverColumnIndex = -1; const items = e.dataTransfer?.items; if (!items || items.length === 0) return; const targetPrefix = this.dragOverFolderPrefix ?? this.columns[columnIndex].prefix; this.clearFolderHover(); // Collect all files (including from nested folders) const allFiles: { file: File; relativePath: string }[] = []; const processEntry = async (entry: FileSystemEntry, path: string): Promise => { if (entry.isFile) { const fileEntry = entry as FileSystemFileEntry; const file = await new Promise((resolve, reject) => { fileEntry.file(resolve, reject); }); allFiles.push({ file, relativePath: path + file.name }); } else if (entry.isDirectory) { const dirEntry = entry as FileSystemDirectoryEntry; const reader = dirEntry.createReader(); const entries = await new Promise((resolve, reject) => { reader.readEntries(resolve, reject); }); for (const childEntry of entries) { await processEntry(childEntry, path + entry.name + '/'); } } }; // Process all dropped items for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const entry = (item as any).webkitGetAsEntry(); if (entry) { await processEntry(entry, ''); } } } if (allFiles.length > 0) { await this.uploadFilesWithPaths(allFiles, targetPrefix); } } private handleFolderDragEnter(e: DragEvent, folderPrefix: string) { e.stopPropagation(); this.clearFolderHover(); this.folderHoverTimer = setTimeout(() => { this.dragOverFolderPrefix = folderPrefix; }, 500); } private handleFolderDragLeave(e: DragEvent, folderPrefix: string) { e.stopPropagation(); if (this.dragOverFolderPrefix === folderPrefix) this.dragOverFolderPrefix = null; if (this.folderHoverTimer) { clearTimeout(this.folderHoverTimer); this.folderHoverTimer = null; } } private clearFolderHover() { if (this.folderHoverTimer) { clearTimeout(this.folderHoverTimer); this.folderHoverTimer = null; } this.dragOverFolderPrefix = null; } 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.refreshColumnByPrefix(this.createDialogPrefix); } } 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`
this.handleColumnDragEnter(e, index)} @dragover=${(e: DragEvent) => this.handleColumnDragOver(e)} @dragleave=${(e: DragEvent) => this.handleColumnDragLeave(e, index)} @drop=${(e: DragEvent) => this.handleColumnDrop(e, index)} >
${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)} @dragenter=${(e: DragEvent) => this.handleFolderDragEnter(e, prefix)} @dragleave=${(e: DragEvent) => this.handleFolderDragLeave(e, 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)}
` )}
${this.dragOverColumnIndex === index ? html`
${this.dragOverFolderPrefix ? `Drop to upload into ${getFileName(this.dragOverFolderPrefix)}` : 'Drop to upload here'}
` : ''} ${this.uploading ? html`
Uploading...
${this.uploadProgress ? html`
${this.uploadProgress.current} / ${this.uploadProgress.total} files
` : ''}
` : ''}
`; } }