rojects/source/components/file-tree-panel/file-tree-panel.ts

564 lines
20 KiB
TypeScript
Raw Permalink Normal View History

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<void> {
if ( this._initialized ) return;
this._initialized = true;
this.className = 'file-tree-panel';
this.innerHTML = `
<ftp-header>
<button class="ftp-btn" data-action="add-file" title="Add file">+F</button>
<button class="ftp-btn" data-action="add-dir" title="Add directory">+D</button>
</ftp-header>
<ftp-tree>Loading</ftp-tree>
`;
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<void> {
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 tree = this.querySelector( 'ftp-tree' )!;
const openPaths = new Set<string>();
tree.querySelectorAll( 'ftp-dir.open > ftp-dir-label' ).forEach( el => {
const path = ( el as HTMLElement ).dataset.path;
if ( path ) openPaths.add( path );
} );
const res = await fetch( treeUrl );
const allNodes = await res.json() as FileNode[];
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 += `<ftp-up data-action="go-up">[ .. ]</ftp-up>`;
}
html += nodes.length ? this.renderNodes( nodes ) : '<ftp-empty>Empty</ftp-empty>';
tree.innerHTML = html;
openPaths.forEach( path => {
const label = tree.querySelector( `ftp-dir-label[data-path="${ CSS.escape( path ) }"]` );
if ( label ) label.closest( 'ftp-dir' )?.classList.add( 'open' );
} );
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 this._openFileDefault( 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( 'contextmenu', ( e: Event ) => {
e.preventDefault();
const me = e as MouseEvent;
this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY );
} );
} );
}
static readonly _panelTypeMap: Record<string, { panelType: string; label: string }> =
{
'page-editor-panel': { panelType: 'page-editor', label: 'Page Editor' },
'code-panel': { panelType: 'code-panel', label: 'Code Editor' },
'rojo-settings-panel': { panelType: 'rojo-settings', label: 'Rojo Settings' },
'rojo-chat-panel': { panelType: 'rojo-chat', label: 'Rojo Chat' },
};
static readonly _editorAlternatives: Record<string, string[]> =
{
'page-editor-panel': [ 'code-panel' ],
'rojo-settings-panel': [ 'rojo-chat-panel' ],
};
async _openFileDefault( path: string ): Promise<void>
{
const state = Editor.get();
await state.fileEditorRegistry.load( state.projectId );
const editorTag = state.fileEditorRegistry.resolve( path );
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;
}
}
}
if ( editorTag )
{
await this._openFileIn( path, editorTag );
return;
}
state.openDocument( path );
}
async _openFileIn( path: string, editorTag: string ): Promise<void>
{
const state = Editor.get();
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.tagName.toLowerCase() === editorTag && ( 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 info = FileTreePanel._panelTypeMap[ editorTag ];
if ( !info ) return;
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 );
}
}
async showItemMenu( targetPath: string, x: number, y: number ): Promise<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 isDir = !!tree.querySelector( `ftp-dir-label[data-path="${CSS.escape( targetPath )}"]` );
const menuLabelMaxChars = 20;
const fileName = targetPath.slice( targetPath.lastIndexOf( '/' ) + 1 );
const menuLabel = fileName.length > menuLabelMaxChars
? '...' + fileName.slice( -menuLabelMaxChars )
: fileName;
const menu = new ContextMenuDirectory( null );
menu.add( new ContextMenuReadOnlyEntry( menu, menuLabel ) );
menu.add( new ContextMenuSeparator( menu ) );
if ( isDir )
{
menu.add( new ContextMenuEntry( menu, 'As Root Directory', () =>
{
this._rootPath = targetPath;
this.selectedPath = null;
this.refresh();
} ) );
menu.add( new ContextMenuSeparator( menu ) );
}
else
{
const state = Editor.get();
await state.fileEditorRegistry.load( state.projectId );
const defaultTag = state.fileEditorRegistry.resolve( targetPath );
const alternatives = defaultTag ? ( FileTreePanel._editorAlternatives[ defaultTag ] ?? [] ) : [];
if ( defaultTag && alternatives.length > 0 )
{
const openSub = new ContextMenuDirectory( menu, 'Open >' );
const defaultLabel = FileTreePanel._panelTypeMap[ defaultTag ]?.label ?? defaultTag;
openSub.add( new ContextMenuEntry( openSub, `Default (${ defaultLabel })`, () => this._openFileDefault( targetPath ) ) );
for ( const altTag of alternatives )
{
const altLabel = FileTreePanel._panelTypeMap[ altTag ]?.label ?? altTag;
openSub.add( new ContextMenuEntry( openSub, altLabel, () => this._openFileIn( targetPath, altTag ) ) );
}
menu.add( openSub );
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 = `<ftp-inline-label>✎ Rename: ${oldPath}</ftp-inline-label>`;
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<void>
{
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<void>
{
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<void> {
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<void> {
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<string> {
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 = `<ftp-inline-label>${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}</ftp-inline-label>`;
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<void> {
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 = `<img class="ftp-icon" src="/icons/directory.svg" alt="">`;
const fileIcon = `<img class="ftp-icon" src="/icons/file.svg" alt="">`;
const sorted = [ ...nodes ].sort( ( a, b ) => a.type === b.type ? 0 : a.type === 'directory' ? -1 : 1 );
return `<ftp-list style="--depth:${depth}">` + sorted.map(n => {
if (n.type === 'directory') {
return `<ftp-dir>
<ftp-dir-label data-path="${n.path}">${dirIcon}${n.name}</ftp-dir-label>
${this.renderNodes(n.children ?? [], depth + 1)}
</ftp-dir>`;
}
return `<ftp-file data-path="${n.path}">${fileIcon}${n.name}</ftp-file>`;
}).join('') + '</ftp-list>';
}
}
customElements.define('file-tree-panel', FileTreePanel);