import * as plugins from '../plugins.js'; import { apiService, type IS3Object } from '../services/index.js'; import { 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; // 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; // 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; // Internal drag state @state() private accessor draggedItem: { key: string; isFolder: boolean } | 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; private folderInputElement: 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; } /* 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; } .column-item.dragging { opacity: 0.5; } .column-item.drop-target { background: rgba(59, 130, 246, 0.15) !important; outline: 1px dashed rgba(59, 130, 246, 0.5); } .column-item.drop-invalid { background: rgba(239, 68, 68, 0.1) !important; 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; } if (this.folderInputElement) { this.folderInputElement.remove(); this.folderInputElement = null; } } updated(changedProperties: Map) { if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) { this.loadInitialColumn(); } else if (changedProperties.has('refreshKey')) { this.refreshAllColumns(); } } private async loadInitialColumn() { this.loading = true; try { // Parse the path segments from currentPrefix // e.g., "folder1/folder2/folder3/" → ["folder1/", "folder1/folder2/", "folder1/folder2/folder3/"] const pathSegments = this.getPathSegments(this.currentPrefix); // Build all prefixes we need to load (including root) const prefixesToLoad = ['', ...pathSegments]; // Load all columns in parallel const columnResults = await Promise.all( prefixesToLoad.map(prefix => apiService.listObjects(this.bucketName, prefix, '/') ) ); // Build columns array with proper selections this.columns = columnResults.map((result, index) => { const prefix = prefixesToLoad[index]; // The selected item is the next prefix in the path (if any) const selectedItem = index < pathSegments.length ? pathSegments[index] : null; return { prefix, objects: result.objects, prefixes: result.prefixes, selectedItem, width: this.DEFAULT_COLUMN_WIDTH, }; }); // Auto-scroll to show the rightmost column this.updateComplete.then(() => this.scrollToEnd()); } catch (err) { console.error('Error loading objects:', err); this.columns = []; } this.loading = false; } // Helper to parse prefix into cumulative path segments private getPathSegments(prefix: string): string[] { if (!prefix) return []; const parts = prefix.split('/').filter(p => p); // Remove empty strings const segments: string[] = []; let cumulative = ''; for (const part of parts) { cumulative += part + '/'; segments.push(cumulative); } return segments; } 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: 'Move to...', iconName: 'lucide:folderInput', action: async () => this.openMovePickerDialog(prefix, true), }, { 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 Files...', iconName: 'lucide:file', action: async () => this.triggerFileUpload(prefix, 'files'), }, { name: 'Upload Folder...', iconName: 'lucide:folderUp', action: async () => this.triggerFileUpload(prefix, 'folder'), }, { 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: '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.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 Files...', iconName: 'lucide:file', action: async () => this.triggerFileUpload(prefix, 'files'), }, { name: 'Upload Folder...', iconName: 'lucide:folderUp', action: async () => this.triggerFileUpload(prefix, 'folder'), }, ]); } 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 ensureFileInputs(): void { if (!this.fileInputElement) { this.fileInputElement = document.createElement('input'); this.fileInputElement.type = 'file'; this.fileInputElement.multiple = true; this.fileInputElement.style.display = 'none'; this.shadowRoot!.appendChild(this.fileInputElement); } if (!this.folderInputElement) { this.folderInputElement = document.createElement('input'); this.folderInputElement.type = 'file'; this.folderInputElement.multiple = true; (this.folderInputElement as any).webkitdirectory = true; this.folderInputElement.style.display = 'none'; this.shadowRoot!.appendChild(this.folderInputElement); } } private triggerFileUpload(targetPrefix: string, type: 'files' | 'folder') { this.ensureFileInputs(); const input = type === 'folder' ? this.folderInputElement! : this.fileInputElement!; 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 = this.draggedItem ? 'move' : '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(); const targetPrefix = this.dragOverFolderPrefix ?? this.columns[columnIndex].prefix; // Check if this is an internal move (item from within the app) if (this.draggedItem) { this.initiateMove(this.draggedItem.key, this.draggedItem.isFolder, targetPrefix); this.draggedItem = null; this.clearFolderHover(); this.dragCounters.clear(); this.dragOverColumnIndex = -1; return; } this.dragCounters.clear(); this.dragOverColumnIndex = -1; const items = e.dataTransfer?.items; if (!items || items.length === 0) return; 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(); // Check if we're leaving to a child element - if so, don't cancel the hover const target = e.currentTarget as HTMLElement; const relatedTarget = e.relatedTarget as Node | null; if (relatedTarget && target.contains(relatedTarget)) { return; // Still inside the folder, ignore this dragleave } if (this.dragOverFolderPrefix === folderPrefix) this.dragOverFolderPrefix = null; if (this.folderHoverTimer) { clearTimeout(this.folderHoverTimer); this.folderHoverTimer = null; } } private handleFolderDragOver(e: DragEvent) { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) { e.dataTransfer.dropEffect = this.draggedItem ? 'move' : 'copy'; } } private clearFolderHover() { if (this.folderHoverTimer) { clearTimeout(this.folderHoverTimer); this.folderHoverTimer = null; } this.dragOverFolderPrefix = null; } // --- Internal drag handlers for move --- private handleItemDragStart(e: DragEvent, key: string, isFolder: boolean) { this.draggedItem = { key, isFolder }; e.dataTransfer?.setData('text/plain', key); e.dataTransfer!.effectAllowed = 'copyMove'; // Allow both for flexibility // Add visual feedback to dragged item const target = e.target as HTMLElement; target.classList.add('dragging'); } private handleItemDragEnd(e: DragEvent) { this.draggedItem = null; const target = e.target as HTMLElement; target.classList.remove('dragging'); } // --- 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.refreshAllColumns(); } 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; } 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)}
`)}
`; } 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()} ${this.renderMoveDialog()} ${this.renderMovePickerDialog()} `; } 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)} @dragstart=${(e: DragEvent) => this.handleItemDragStart(e, prefix, true)} @dragend=${(e: DragEvent) => this.handleItemDragEnd(e)} @dragenter=${(e: DragEvent) => this.handleFolderDragEnter(e, prefix)} @dragover=${(e: DragEvent) => this.handleFolderDragOver(e)} @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)} @dragstart=${(e: DragEvent) => this.handleItemDragStart(e, obj.key, false)} @dragend=${(e: DragEvent) => this.handleItemDragEnd(e)} > ${getFileName(obj.key)}
` )}
${this.dragOverColumnIndex === index ? html`
${this.draggedItem ? (this.dragOverFolderPrefix ? `Move to ${getFileName(this.dragOverFolderPrefix)}` : 'Move here') : (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
` : ''}
` : ''}
`; } }