import { Editor } from '../../editor/Editor.js'; import { ContextMenuDirectory, ContextMenuEntry, ContextMenuReadOnlyEntry, ContextMenuSeparator } from '../context-menu/context-menu.js'; import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; interface FileNode { name: string; path: string; type: 'file' | 'directory'; children?: FileNode[]; } class FileTreePanel extends HTMLElement { selectedPath: string | null = null; _rootPath: string = ''; _initialized = false; async connectedCallback(): Promise { if ( this._initialized ) return; this._initialized = true; this.className = 'file-tree-panel'; this.innerHTML = `
Loading…
`; this.querySelector( '[data-action="add-file"]' )!.addEventListener( 'click', () => this.addFile() ); this.querySelector( '[data-action="add-dir"]' )!.addEventListener( 'click', () => this.addDirectory() ); Editor.get().onFilesChanged.addListener( () => this.refresh() ); await this.refresh(); } addContextMenuEntries( dir: ContextMenuDirectory ): void { dir.add( new ContextMenuReadOnlyEntry( dir, 'File Tree' ) ); } _updateTabLabel(): void { const label = '📁 ' + this._dirnameDisplay(); this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label } } ) ); } _dirnameDisplay(): string { if ( !this._rootPath ) return '/'; const lastSlash = this._rootPath.lastIndexOf( '/' ); return lastSlash === -1 ? this._rootPath : this._rootPath.slice( lastSlash + 1 ); } _findNode( nodes: FileNode[], targetPath: string ): FileNode | null { for ( const n of nodes ) { if ( n.path === targetPath ) return n; if ( n.children ) { const found = this._findNode( n.children, targetPath ); if ( found ) return found; } } return null; } _goUp(): void { const lastSlash = this._rootPath.lastIndexOf( '/' ); this._rootPath = lastSlash === -1 ? '' : this._rootPath.slice( 0, lastSlash ); this.selectedPath = null; this.refresh(); } async refresh(): Promise { const state = Editor.get(); const res = await fetch( `/api/files/${state.projectId}/tree` ); const allNodes = await res.json() as FileNode[]; const tree = this.querySelector( '.ftp-tree' )!; let nodes: FileNode[]; if ( this._rootPath ) { const rootNode = this._findNode( allNodes, this._rootPath ); nodes = rootNode ? ( rootNode.children ?? [] ) : []; } else { nodes = allNodes; } let html = ''; if ( this._rootPath ) { html += `
[ .. ]
`; } html += nodes.length ? this.renderNodes( nodes ) : 'Empty'; tree.innerHTML = html; const upBtn = tree.querySelector( '[data-action="go-up"]' ); if ( upBtn ) { upBtn.addEventListener( 'click', () => this._goUp() ); } this._updateTabLabel(); this.bindTree( tree ); } bindTree( tree: Element ): void { const state = Editor.get(); tree.querySelectorAll( '.ftp-file' ).forEach( el => { el.addEventListener( 'click', () => { const path = ( el as HTMLElement ).dataset.path!; this.selectedPath = path; tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); el.classList.add( 'active' ); state.openDocument( path ); } ); el.addEventListener( 'contextmenu', ( e: Event ) => { e.preventDefault(); const me = e as MouseEvent; this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY ); } ); } ); tree.querySelectorAll( '.ftp-dir-label' ).forEach( el => { el.addEventListener( 'click', () => { const li = el.closest( 'li' )!; li.classList.toggle( 'open' ); const path = ( el as HTMLElement ).dataset.path!; this.selectedPath = path; tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); el.classList.add( 'active' ); } ); el.addEventListener( 'dblclick', () => { const path = ( el as HTMLElement ).dataset.path!; this._rootPath = path; this.selectedPath = null; this.refresh(); } ); el.addEventListener( 'contextmenu', ( e: Event ) => { e.preventDefault(); const me = e as MouseEvent; this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY ); } ); } ); } showItemMenu( targetPath: string, x: number, y: number ): void { this.selectedPath = targetPath; const tree = this.querySelector( '.ftp-tree' )!; tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); tree.querySelector( `[data-path="${CSS.escape( targetPath )}"]` )?.classList.add( 'active' ); const menu = new ContextMenuDirectory( null ); menu.add( new ContextMenuReadOnlyEntry( menu, targetPath ) ); menu.add( new ContextMenuSeparator( menu ) ); menu.add( new ContextMenuEntry( menu, 'Rename…', () => this.startInlineRename( targetPath ) ) ); menu.add( new ContextMenuEntry( menu, 'Delete', () => this.deleteEntry( targetPath ) ) ); menu.show( x, y ); } startInlineRename( oldPath: string ): void { const tree = this.querySelector( '.ftp-tree' )!; const lastSlash = oldPath.lastIndexOf( '/' ); const currentName = lastSlash === -1 ? oldPath : oldPath.slice( lastSlash + 1 ); const overlay = document.createElement( 'div' ); overlay.className = 'ftp-inline-create'; const input = document.createElement( 'input' ); input.className = 'ftp-rename-input'; input.value = currentName; input.type = 'text'; overlay.innerHTML = `✎ Rename: ${oldPath}`; overlay.appendChild( input ); const btnRename = document.createElement( 'button' ); btnRename.textContent = 'Rename'; btnRename.className = 'ftp-btn'; const btnCancel = document.createElement( 'button' ); btnCancel.textContent = 'Cancel'; btnCancel.className = 'ftp-btn'; overlay.appendChild( btnRename ); overlay.appendChild( btnCancel ); tree.prepend( overlay ); input.focus(); input.select(); const confirm = async () => { const newName = input.value.trim(); if ( !newName || newName === currentName ) { overlay.remove(); return; } await this.renameEntry( oldPath, newName ); overlay.remove(); }; const cancel = () => overlay.remove(); btnRename.addEventListener( 'click', confirm ); btnCancel.addEventListener( 'click', cancel ); input.addEventListener( 'keydown', ( e: KeyboardEvent ) => { if ( 'Enter' === e.key ) confirm(); if ( 'Escape' === e.key ) cancel(); } ); } async renameEntry( oldPath: string, newName: string ): Promise { const state = Editor.get(); const res = await fetch( `/api/files/${state.projectId}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( { path: oldPath, newName } ), } ); if ( !res.ok ) return; const lastSlash = oldPath.lastIndexOf( '/' ); const parent = lastSlash === -1 ? '' : oldPath.slice( 0, lastSlash ); this.selectedPath = parent ? `${parent}/${newName}` : newName; Editor.get().onFilesChanged.dispatch(); } async deleteEntry( targetPath: string ): Promise { const lastSlash = targetPath.lastIndexOf( '/' ); const name = lastSlash === -1 ? targetPath : targetPath.slice( lastSlash + 1 ); const confirmed = await showConfirmDialog( { icon: '🗑', title: 'Delete', message: `Delete "${name}"? This cannot be undone.`, confirmLabel: 'Delete', cancelLabel: 'Cancel', danger: true, } ); if ( !confirmed ) return; const state = Editor.get(); const res = await fetch( `/api/files/${state.projectId}/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( { path: targetPath } ), } ); if ( !res.ok ) return; if ( this.selectedPath === targetPath ) this.selectedPath = null; Editor.get().onFilesChanged.dispatch(); } resolveTargetDir(): string { if ( !this.selectedPath ) return this._rootPath; const tree = this.querySelector( '.ftp-tree' )!; const dirLabel = tree.querySelector( `.ftp-dir-label[data-path="${CSS.escape( this.selectedPath )}"]` ); if ( dirLabel ) return this.selectedPath; const lastSlash = this.selectedPath.lastIndexOf( '/' ); return lastSlash === -1 ? this._rootPath : this.selectedPath.slice( 0, lastSlash ); } async addFile(): Promise { const state = Editor.get(); const dir = this.resolveTargetDir(); const name = await this.findFreeName(state.projectId, dir, 'file', 'txt'); const filePath = dir ? `${dir}/${name}` : name; this.startInlineCreate(filePath, 'file', name); } async addDirectory(): Promise { const state = Editor.get(); const dir = this.resolveTargetDir(); const name = await this.findFreeName(state.projectId, dir, 'directory', ''); const dirPath = dir ? `${dir}/${name}` : name; this.startInlineCreate(dirPath, 'directory', name); } async findFreeName(projectId: string, dir: string, type: 'file' | 'directory', ext: string): Promise { const baseName = type === 'file' ? 'file' : 'directory'; const res = await fetch(`/api/files/${projectId}/tree`); const tree = await res.json() as FileNode[]; for (let i = 1; i <= 999; i++) { const name = ext ? `${baseName}${i > 1 ? i : ''}.${ext}` : `${baseName}${i > 1 ? i : ''}`; const testPath = dir ? `${dir}/${name}` : name; if (!this.pathExistsInTree(tree, testPath)) return name; } return `${baseName}-${Date.now()}${ext ? '.' + ext : ''}`; } pathExistsInTree(nodes: FileNode[], targetPath: string): boolean { for (const n of nodes) { if (n.path === targetPath) return true; if (n.children && this.pathExistsInTree(n.children, targetPath)) return true; } return false; } startInlineCreate(fullPath: string, type: 'file' | 'directory', defaultName: string): void { const tree = this.querySelector('.ftp-tree')!; const overlay = document.createElement('div'); overlay.className = 'ftp-inline-create'; const input = document.createElement('input'); input.className = 'ftp-rename-input'; input.value = defaultName; input.type = 'text'; overlay.innerHTML = `${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}`; overlay.appendChild(input); const btnCreate = document.createElement('button'); btnCreate.textContent = 'Create'; btnCreate.className = 'ftp-btn'; const btnCancel = document.createElement('button'); btnCancel.textContent = 'Cancel'; btnCancel.className = 'ftp-btn'; overlay.appendChild(btnCreate); overlay.appendChild(btnCancel); tree.prepend(overlay); input.focus(); input.select(); const confirm = async () => { const name = input.value.trim(); if (!name) return; const dir = fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) : ''; const finalPath = dir ? `${dir}/${name}` : name; await this.createEntry(type, finalPath); overlay.remove(); }; const cancel = () => overlay.remove(); btnCreate.addEventListener('click', confirm); btnCancel.addEventListener('click', cancel); input.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') cancel(); }); } async createEntry(type: 'file' | 'directory', path: string): Promise { const state = Editor.get(); const endpoint = type === 'file' ? 'create-file' : 'create-directory'; const res = await fetch(`/api/files/${state.projectId}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }), }); if ( res.ok ) { if ( type === 'file' ) { this.selectedPath = path; } Editor.get().onFilesChanged.dispatch(); } } renderNodes(nodes: FileNode[], depth = 0): string { return `
    ` + nodes.map(n => { if (n.type === 'directory') { return `
  • ▸ ${n.name} ${this.renderNodes(n.children ?? [], depth + 1)}
  • `; } const isHtml = n.name.endsWith('.html'); return `
  • ${n.name}
  • `; }).join('') + '
'; } } customElements.define('file-tree-panel', FileTreePanel);