2026-07-06 17:28:59 +00:00
|
|
|
import { Editor } from '../../editor/Editor.js';
|
2026-07-25 20:50:14 +00:00
|
|
|
import { EditorPanelDefinition } from '../../editor/editor-panel.js';
|
2026-07-06 17:28:59 +00:00
|
|
|
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 {
|
2026-07-25 20:50:14 +00:00
|
|
|
__interfaces__ = [ EditorPanelDefinition.type ];
|
2026-07-06 17:28:59 +00:00
|
|
|
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() );
|
2026-07-12 11:05:53 +00:00
|
|
|
Editor.get().onFileTypeUnknown.addListener( ( e ) => this._showTypeError( e.path ) );
|
2026-07-06 17:28:59 +00:00
|
|
|
|
|
|
|
|
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 => {
|
Editor: auth redirect, targeted file open, Godot file types
- editor-shell: redirect to / if not authenticated on load
- file-tree: open file in first clean matching panel, create new panel if all dirty
- Editor: add openDocumentIn() with targetElement for precise panel targeting
- panels: skip onDocumentOpened if targeted at a different panel
- FileEditorRegistry: add Godot extensions (gd, gdshader, gdshaderinc, gdinclude, glsl, tscn, tres, cs, gdextension)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 20:22:42 +00:00
|
|
|
el.addEventListener( 'click', async () => {
|
2026-07-06 17:28:59 +00:00
|
|
|
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' );
|
Editor: auth redirect, targeted file open, Godot file types
- editor-shell: redirect to / if not authenticated on load
- file-tree: open file in first clean matching panel, create new panel if all dirty
- Editor: add openDocumentIn() with targetElement for precise panel targeting
- panels: skip onDocumentOpened if targeted at a different panel
- FileEditorRegistry: add Godot extensions (gd, gdshader, gdshaderinc, gdinclude, glsl, tscn, tres, cs, gdextension)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 20:22:42 +00:00
|
|
|
|
|
|
|
|
await state.fileEditorRegistry.load( state.projectId );
|
|
|
|
|
const editorTag = state.fileEditorRegistry.resolve( path );
|
|
|
|
|
|
|
|
|
|
if ( editorTag )
|
|
|
|
|
{
|
|
|
|
|
const containers = Array.from( document.querySelectorAll( 'tab-container' ) );
|
2026-07-17 20:36:49 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Editor: auth redirect, targeted file open, Godot file types
- editor-shell: redirect to / if not authenticated on load
- file-tree: open file in first clean matching panel, create new panel if all dirty
- Editor: add openDocumentIn() with targetElement for precise panel targeting
- panels: skip onDocumentOpened if targeted at a different panel
- FileEditorRegistry: add Godot extensions (gd, gdshader, gdshaderinc, gdinclude, glsl, tscn, tres, cs, gdextension)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 20:22:42 +00:00
|
|
|
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 )
|
|
|
|
|
{
|
2026-07-17 20:36:49 +00:00
|
|
|
if ( tab.element.tagName.toLowerCase() === editorTag && !tab.dirty && !( tab.element as any )._pinned )
|
Editor: auth redirect, targeted file open, Godot file types
- editor-shell: redirect to / if not authenticated on load
- file-tree: open file in first clean matching panel, create new panel if all dirty
- Editor: add openDocumentIn() with targetElement for precise panel targeting
- panels: skip onDocumentOpened if targeted at a different panel
- FileEditorRegistry: add Godot extensions (gd, gdshader, gdshaderinc, gdinclude, glsl, tscn, tres, cs, gdextension)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 20:22:42 +00:00
|
|
|
{
|
|
|
|
|
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 }> =
|
|
|
|
|
{
|
page-editor-panel: replace html-editor-panel with structured .page editor
Renames html-editor-panel → page-editor-panel and changes the handled
extension from .html/.htm to .page. The new editor introduces a structured
format (page-header / page-root / page-block / page-area / page-footer),
a block registry with Full Width and Two Columns templates, a two-mode
sidebar (Blocks / Areas), rich-text wrapSelection helper, auto-template
injection for empty files, sandbox="allow-same-origin" on the iframe,
and editor-style injection that is stripped before saving.
Adds a default-roject theme (dark BG, Barlow font, blue headings) embedded
as a <style> block in the page <head>, scoped to [data-theme="default-roject"]
on both <body> and <page-root>. Areas toolbar gains H1, H2, H3 buttons.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 19:07:32 +00:00
|
|
|
'page-editor-panel': { panelType: 'page-editor', label: 'Page Editor' },
|
Editor: auth redirect, targeted file open, Godot file types
- editor-shell: redirect to / if not authenticated on load
- file-tree: open file in first clean matching panel, create new panel if all dirty
- Editor: add openDocumentIn() with targetElement for precise panel targeting
- panels: skip onDocumentOpened if targeted at a different panel
- FileEditorRegistry: add Godot extensions (gd, gdshader, gdshaderinc, gdinclude, glsl, tscn, tres, cs, gdextension)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 20:22:42 +00:00
|
|
|
'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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:28:59 +00:00
|
|
|
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>`;
|
|
|
|
|
}
|
2026-07-12 11:05:53 +00:00
|
|
|
return `<li class="ftp-file" data-path="${n.path}">${n.name}</li>`;
|
2026-07-06 17:28:59 +00:00
|
|
|
}).join('') + '</ul>';
|
|
|
|
|
}
|
2026-07-12 11:05:53 +00:00
|
|
|
|
|
|
|
|
_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 );
|
|
|
|
|
}
|
2026-07-06 17:28:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
customElements.define('file-tree-panel', FileTreePanel);
|