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

452 lines
16 KiB
TypeScript
Raw Normal View History

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<void> {
if ( this._initialized ) return;
this._initialized = true;
this.className = 'file-tree-panel';
this.innerHTML = `
<div class="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>
</div>
<div class="ftp-tree">Loading</div>
`;
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<void> {
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 += `<div class="ftp-up" data-action="go-up">[ .. ]</div>`;
}
html += nodes.length ? this.renderNodes( nodes ) : '<span class="ftp-empty">Empty</span>';
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' ) );
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 )
{
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<string, { panelType: string; label: string }> =
{
'html-editor-panel': { panelType: 'html-editor', label: 'HTML 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 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 = `<span class="ftp-inline-label">✎ Rename: ${oldPath}</span>`;
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 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<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 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<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 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 = `<span class="ftp-inline-label">${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}</span>`;
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 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 `<ul class="ftp-list" style="--depth:${depth}">` + nodes.map(n => {
if (n.type === 'directory') {
return `<li class="ftp-dir open">
<span class="ftp-dir-label" data-path="${n.path}"> ${n.name}</span>
${this.renderNodes(n.children ?? [], depth + 1)}
</li>`;
}
return `<li class="ftp-file" data-path="${n.path}">${n.name}</li>`;
}).join('') + '</ul>';
}
_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 div = document.createElement( 'div' );
div.className = 'ftp-type-error';
div.textContent = msg;
tree.prepend( div );
setTimeout( () => div.remove(), 3000 );
}
}
customElements.define('file-tree-panel', FileTreePanel);