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 = `
2026-07-30 20:02:14 +00:00
< ftp - header >
2026-07-06 17:28:59 +00:00
< 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 >
2026-07-30 20:02:14 +00:00
< / f t p - h e a d e r >
< ftp - tree > Loading … < / f t p - t r e e >
2026-07-06 17:28:59 +00:00
` ;
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 {
2026-07-30 20:02:14 +00:00
const label = this . _dirnameDisplay ( ) ;
2026-07-06 17:28:59 +00:00
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 ( ) ;
2026-07-30 20:02:14 +00:00
const treeUrl = state . localRoot
? ` /api/local/tree?root= ${ encodeURIComponent ( state . localRoot ) } `
: state . remoteProject
? ` /api/remote/files/ ${ state . remoteProject } /tree `
: ` /api/files/ ${ state . projectId } /tree ` ;
2026-07-31 11:17:40 +00:00
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 ) ;
} ) ;
2026-07-30 20:02:14 +00:00
const res = await fetch ( treeUrl ) ;
2026-07-06 17:28:59 +00:00
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 ) {
2026-07-30 20:02:14 +00:00
html += ` <ftp-up data-action="go-up">[ .. ]</ftp-up> ` ;
2026-07-06 17:28:59 +00:00
}
2026-07-30 20:02:14 +00:00
html += nodes . length ? this . renderNodes ( nodes ) : '<ftp-empty>Empty</ftp-empty>' ;
2026-07-06 17:28:59 +00:00
tree . innerHTML = html ;
2026-07-31 11:17:40 +00:00
openPaths . forEach ( path = > {
const label = tree . querySelector ( ` ftp-dir-label[data-path=" ${ CSS . escape ( path ) } "] ` ) ;
if ( label ) label . closest ( 'ftp-dir' ) ? . classList . add ( 'open' ) ;
} ) ;
2026-07-06 17:28:59 +00:00
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 ( ) ;
2026-07-30 20:02:14 +00:00
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 ;
2026-07-30 20:02:14 +00:00
tree . querySelectorAll ( 'ftp-file, ftp-dir-label' ) . forEach ( f = > f . classList . remove ( 'active' ) ) ;
2026-07-06 17:28:59 +00:00
el . classList . add ( 'active' ) ;
2026-07-31 11:17:40 +00:00
await this . _openFileDefault ( path ) ;
2026-07-06 17:28:59 +00:00
} ) ;
el . addEventListener ( 'contextmenu' , ( e : Event ) = > {
e . preventDefault ( ) ;
const me = e as MouseEvent ;
this . showItemMenu ( ( el as HTMLElement ) . dataset . path ! , me . clientX , me . clientY ) ;
} ) ;
} ) ;
2026-07-30 20:02:14 +00:00
tree . querySelectorAll ( 'ftp-dir-label' ) . forEach ( el = > {
2026-07-06 17:28:59 +00:00
el . addEventListener ( 'click' , ( ) = > {
2026-07-30 20:02:14 +00:00
const dir = el . closest ( 'ftp-dir' ) ! ;
dir . classList . toggle ( 'open' ) ;
2026-07-06 17:28:59 +00:00
const path = ( el as HTMLElement ) . dataset . path ! ;
this . selectedPath = path ;
2026-07-30 20:02:14 +00:00
tree . querySelectorAll ( 'ftp-file, ftp-dir-label' ) . forEach ( f = > f . classList . remove ( 'active' ) ) ;
2026-07-06 17:28:59 +00:00
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 ) ;
} ) ;
} ) ;
}
2026-07-31 11:17:40 +00:00
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 >
2026-07-06 17:28:59 +00:00
{
this . selectedPath = targetPath ;
2026-07-30 20:02:14 +00:00
const tree = this . querySelector ( 'ftp-tree' ) ! ;
tree . querySelectorAll ( 'ftp-file, ftp-dir-label' ) . forEach ( f = > f . classList . remove ( 'active' ) ) ;
2026-07-06 17:28:59 +00:00
tree . querySelector ( ` [data-path=" ${ CSS . escape ( targetPath ) } "] ` ) ? . classList . add ( 'active' ) ;
2026-07-31 11:17:40 +00:00
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 ;
2026-07-06 17:28:59 +00:00
const menu = new ContextMenuDirectory ( null ) ;
2026-07-31 11:17:40 +00:00
menu . add ( new ContextMenuReadOnlyEntry ( menu , menuLabel ) ) ;
2026-07-06 17:28:59 +00:00
menu . add ( new ContextMenuSeparator ( menu ) ) ;
2026-07-31 11:17:40 +00:00
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 ) ) ;
}
}
2026-07-06 17:28:59 +00:00
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
{
2026-07-30 20:02:14 +00:00
const tree = this . querySelector ( 'ftp-tree' ) ! ;
2026-07-06 17:28:59 +00:00
const lastSlash = oldPath . lastIndexOf ( '/' ) ;
const currentName = lastSlash === - 1 ? oldPath : oldPath.slice ( lastSlash + 1 ) ;
2026-07-30 20:02:14 +00:00
const overlay = document . createElement ( 'ftp-inline-create' ) ;
2026-07-06 17:28:59 +00:00
const input = document . createElement ( 'input' ) ;
input . className = 'ftp-rename-input' ;
input . value = currentName ;
input . type = 'text' ;
2026-07-30 20:02:14 +00:00
overlay . innerHTML = ` <ftp-inline-label>✎ Rename: ${ oldPath } </ftp-inline-label> ` ;
2026-07-06 17:28:59 +00:00
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 ( ) ;
2026-07-30 20:02:14 +00:00
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 , {
2026-07-06 17:28:59 +00:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-07-30 20:02:14 +00:00
body : JSON.stringify ( body ) ,
2026-07-06 17:28:59 +00:00
} ) ;
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 ( ) ;
2026-07-30 20:02:14 +00:00
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 , {
2026-07-06 17:28:59 +00:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-07-30 20:02:14 +00:00
body : JSON.stringify ( body ) ,
2026-07-06 17:28:59 +00:00
} ) ;
if ( ! res . ok ) return ;
if ( this . selectedPath === targetPath ) this . selectedPath = null ;
Editor . get ( ) . onFilesChanged . dispatch ( ) ;
}
resolveTargetDir ( ) : string {
if ( ! this . selectedPath ) return this . _rootPath ;
2026-07-30 20:02:14 +00:00
const tree = this . querySelector ( 'ftp-tree' ) ! ;
const dirLabel = tree . querySelector ( ` ftp-dir-label[data-path=" ${ CSS . escape ( this . selectedPath ) } "] ` ) ;
2026-07-06 17:28:59 +00:00
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' ;
2026-07-30 20:02:14 +00:00
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 ) ;
2026-07-06 17:28:59 +00:00
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 {
2026-07-30 20:02:14 +00:00
const tree = this . querySelector ( 'ftp-tree' ) ! ;
const overlay = document . createElement ( 'ftp-inline-create' ) ;
2026-07-06 17:28:59 +00:00
const input = document . createElement ( 'input' ) ;
input . className = 'ftp-rename-input' ;
input . value = defaultName ;
input . type = 'text' ;
2026-07-30 20:02:14 +00:00
overlay . innerHTML = ` <ftp-inline-label> ${ type === 'file' ? '📄' : '📁' } New ${ type } in: ${ fullPath . includes ( '/' ) ? fullPath . slice ( 0 , fullPath . lastIndexOf ( '/' ) ) || '/' : '/' } </ftp-inline-label> ` ;
2026-07-06 17:28:59 +00:00
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' ;
2026-07-30 20:02:14 +00:00
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 , {
2026-07-06 17:28:59 +00:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-07-30 20:02:14 +00:00
body : JSON.stringify ( body ) ,
} ) ;
2026-07-06 17:28:59 +00:00
if ( res . ok )
{
if ( type === 'file' )
{
this . selectedPath = path ;
}
Editor . get ( ) . onFilesChanged . dispatch ( ) ;
}
}
renderNodes ( nodes : FileNode [ ] , depth = 0 ) : string {
2026-07-30 20:02:14 +00:00
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 = > {
2026-07-06 17:28:59 +00:00
if ( n . type === 'directory' ) {
2026-07-30 20:02:14 +00:00
return ` <ftp-dir>
< ftp - dir - label data - path = "${n.path}" > $ { dirIcon } $ { n . name } < / f t p - d i r - l a b e l >
2026-07-06 17:28:59 +00:00
$ { this . renderNodes ( n . children ? ? [ ] , depth + 1 ) }
2026-07-30 20:02:14 +00:00
< / f t p - d i r > ` ;
2026-07-06 17:28:59 +00:00
}
2026-07-30 20:02:14 +00:00
return ` <ftp-file data-path=" ${ n . path } "> ${ fileIcon } ${ n . name } </ftp-file> ` ;
} ) . join ( '' ) + '</ftp-list>' ;
2026-07-06 17:28:59 +00:00
}
2026-07-12 11:05:53 +00:00
2026-07-06 17:28:59 +00:00
}
customElements . define ( 'file-tree-panel' , FileTreePanel ) ;