import { Editor } from '../../editor/Editor.js'; import { EditorPanelDefinition } from '../../editor/editor-panel.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 { __interfaces__ = [ EditorPanelDefinition.type ]; 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() ); Editor.get().onFileTypeUnknown.addListener( ( e ) => this._showTypeError( e.path ) ); 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 treeUrl = state.localRoot ? `/api/local/tree?root=${ encodeURIComponent( state.localRoot ) }` : state.remoteProject ? `/api/remote/files/${ state.remoteProject }/tree` : `/api/files/${ state.projectId }/tree`; const res = await fetch( treeUrl ); 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', async () => { 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' ); await state.fileEditorRegistry.load( state.projectId ); const editorTag = state.fileEditorRegistry.resolve( path ); if ( editorTag ) { const containers = Array.from( document.querySelectorAll( 'tab-container' ) ); for ( const tc of containers ) { const tabs = ( tc as any ).tabs as Array<{ id: string; element: HTMLElement }>; for ( const tab of tabs ) { if ( ( tab.element as any ).currentPath === path ) { ( tc as any ).activateTab( tab.id ); return; } } } let found: { panel: HTMLElement; tc: any; tabId: string } | null = null; for ( const tc of containers ) { const tabs = ( tc as any ).tabs as Array<{ id: string; element: HTMLElement; dirty: boolean }>; for ( const tab of tabs ) { if ( tab.element.tagName.toLowerCase() === editorTag && !tab.dirty && !( tab.element as any )._pinned ) { found = { panel: tab.element, tc, tabId: tab.id }; break; } } if ( found ) break; } if ( found ) { ( found.tc as any ).activateTab( found.tabId ); state.openDocumentIn( path, found.panel ); return; } const panelTypeMap: Record = { 'page-editor-panel': { panelType: 'page-editor', label: 'Page Editor' }, 'code-panel': { panelType: 'code-panel', label: 'Code' }, 'rojo-settings-panel': { panelType: 'rojo-settings', label: 'Rojo' }, }; const info = panelTypeMap[ editorTag ]; if ( info ) { const targetTc = ( containers.find( tc => ( tc as any ).tabs.some( ( t: any ) => t.element.tagName.toLowerCase() === editorTag ) ) ?? document.querySelector( '[data-panel="center"] tab-container' ) ?? containers[ 0 ] ) as any; if ( targetTc ) { const newId = info.panelType + '-' + Math.random().toString( 36 ).slice( 2 ); targetTc.addTab( { id: newId, label: info.label, panelType: info.panelType }, () => document.createElement( editorTag ) ); const newPanel = targetTc.tabs[ targetTc.tabs.length - 1 ].element as HTMLElement; state.openDocumentIn( path, newPanel ); return; } } } 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 dir = el.closest( 'ftp-dir' )!; dir.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( '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 body = state.localRoot ? { root: state.localRoot, path: oldPath, newName } : { path: oldPath, newName }; const url = state.localRoot ? `/api/local/rename` : state.remoteProject ? `/api/remote/files/${ state.remoteProject }/rename` : `/api/files/${ state.projectId }/rename`; const res = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( body ), } ); 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 body = state.localRoot ? { root: state.localRoot, path: targetPath } : { path: targetPath }; const url = state.localRoot ? `/api/local/delete` : state.remoteProject ? `/api/remote/files/${ state.remoteProject }/delete` : `/api/files/${ state.projectId }/delete`; const res = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( body ), } ); 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 state = Editor.get(); const treeUrl = state.localRoot ? `/api/local/tree?root=${ encodeURIComponent( state.localRoot ) }` : state.remoteProject ? `/api/remote/files/${ state.remoteProject }/tree` : `/api/files/${ projectId }/tree`; const res = await fetch( treeUrl ); 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('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 body = state.localRoot ? { root: state.localRoot, path } : { path }; const url = state.localRoot ? `/api/local/${ endpoint }` : state.remoteProject ? `/api/remote/files/${ state.remoteProject }/${ endpoint }` : `/api/files/${ state.projectId }/${ endpoint }`; const res = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( body ), } ); if ( res.ok ) { if ( type === 'file' ) { this.selectedPath = path; } Editor.get().onFilesChanged.dispatch(); } } renderNodes(nodes: FileNode[], depth = 0): string { const dirIcon = ``; const fileIcon = ``; const sorted = [ ...nodes ].sort( ( a, b ) => a.type === b.type ? 0 : a.type === 'directory' ? -1 : 1 ); return `` + sorted.map(n => { if (n.type === 'directory') { return ` ${dirIcon}${n.name} ${this.renderNodes(n.children ?? [], depth + 1)} `; } return `${fileIcon}${n.name}`; }).join('') + ''; } _showTypeError( filePath: string ): void { const lastDot = filePath.lastIndexOf( '.' ); const ext = lastDot === -1 ? '' : filePath.slice( lastDot ); const msg = ext ? `Can't open extension "${ext}"` : `Can't open file without extension`; const tree = this.querySelector( 'ftp-tree' )!; const existing = tree.querySelector( 'ftp-type-error' ); if ( existing ) existing.remove(); const el = document.createElement( 'ftp-type-error' ); el.textContent = msg; tree.prepend( el ); setTimeout( () => el.remove(), 3000 ); } } customElements.define('file-tree-panel', FileTreePanel);