2026-06-09 18:37:49 +02:00
import { Http } from "./Http.js" ;
2026-06-08 22:51:09 +02:00
import { Toast } from "./Toast.js" ;
import { modeForFilename } from "./codemirrorModes.js" ;
export class ProjectFiles {
constructor ( root ) {
this . root = root ;
this . slug = root . dataset . slug ;
this . isOwner = root . dataset . owner === "1" ;
this . base = ` /projects/ ${ this . slug } /files ` ;
this . files = [ ] ;
this . editor = null ;
this . currentPath = null ;
this . currentDir = "" ;
this . selected = new Set ( ) ;
this . order = [ ] ;
this . anchor = null ;
this . dragPaths = null ;
this . tree = root . querySelector ( "#pf-tree" ) ;
this . preview = root . querySelector ( "#pf-preview" ) ;
this . empty = root . querySelector ( "#pf-empty" ) ;
this . emptyMsg = root . querySelector ( ".pf-empty-msg" ) ;
this . currentLabel = root . querySelector ( "#pf-current" ) ;
this . editorWrap = root . querySelector ( "#pf-editor-wrap" ) ;
this . uploadWidget = root . querySelector ( "#pf-upload-widget" ) ;
this . status = root . querySelector ( "#pf-status" ) ;
}
notify ( message ) {
if ( this . status ) Toast . flash ( this . status , message , 3000 , "" ) ;
}
init ( ) {
const data = this . root . querySelector ( "#pf-data" ) ;
this . files = data ? JSON . parse ( data . textContent || "[]" ) : [ ] ;
this . initEditor ( ) ;
this . bindToolbar ( ) ;
this . bindTree ( ) ;
this . renderTree ( ) ;
this . openDefault ( ) ;
}
firstFilePath ( ) {
for ( const path of this . order ) {
const node = this . files . find ( ( file ) => file . path === path ) ;
if ( node && node . type === "file" ) return path ;
}
return null ;
}
openDefault ( ) {
const files = this . files . filter ( ( file ) => file . type === "file" ) ;
const readme = files . find ( ( file ) => file . name . toLowerCase ( ) === "readme.md" )
|| files . find ( ( file ) => file . name . toLowerCase ( ) . startsWith ( "readme." ) ) ;
const path = readme ? readme . path : this . firstFilePath ( ) ;
if ( ! path ) {
this . showEmpty ( this . isOwner ? "Create a file to get started." : "No files yet." ) ;
return ;
}
this . selected = new Set ( [ path ] ) ;
this . anchor = path ;
this . applySelection ( ) ;
this . markActive ( path ) ;
this . currentDir = this . dirOf ( path ) ;
this . openFile ( path ) ;
}
markActive ( path ) {
this . root . querySelectorAll ( ".pf-node-row.active" ) . forEach ( ( el ) => el . classList . remove ( "active" ) ) ;
const row = this . root . querySelector ( ` .pf-node-row[data-path=" ${ CSS . escape ( path ) } "] ` ) ;
if ( row ) row . classList . add ( "active" ) ;
}
initEditor ( ) {
if ( typeof CodeMirror === "undefined" ) return ;
const textarea = this . root . querySelector ( "#project-file-editor" ) ;
if ( ! textarea ) return ;
this . editor = CodeMirror . fromTextArea ( textarea , {
lineNumbers : true ,
mode : "plaintext" ,
theme : "monokai" ,
indentUnit : 4 ,
tabSize : 4 ,
lineWrapping : true ,
readOnly : ! this . isOwner ,
extraKeys : { "Ctrl-S" : ( ) => this . save ( ) } ,
} ) ;
this . editor . setSize ( null , "100%" ) ;
}
bindToolbar ( ) {
const on = ( id , handler ) => {
const el = this . root . querySelector ( id ) ;
if ( el ) el . addEventListener ( "click" , ( e ) => { e . preventDefault ( ) ; handler ( ) ; } ) ;
} ;
on ( "#pf-new-file" , ( ) => this . newFile ( ) ) ;
on ( "#pf-new-folder" , ( ) => this . newFolder ( ) ) ;
on ( "#pf-upload" , ( ) => this . uploadTo ( this . currentDir ) ) ;
on ( "#pf-save" , ( ) => this . save ( ) ) ;
on ( "#pf-rename" , ( ) => this . rename ( ) ) ;
on ( "#pf-delete" , ( ) => this . remove ( ) ) ;
if ( this . uploadWidget ) {
this . uploadWidget . addEventListener ( "dp-upload:done" , ( ) => this . refresh ( ) ) ;
this . uploadWidget . addEventListener ( "dp-upload:error" , ( e ) => this . notify ( e . detail . message ) ) ;
}
}
bindTree ( ) {
this . tree . addEventListener ( "click" , ( e ) => {
if ( e . target === this . tree ) {
this . selected . clear ( ) ;
this . applySelection ( ) ;
}
} ) ;
if ( ! this . isOwner ) return ;
if ( window . app && window . app . contextMenu ) {
window . app . contextMenu . attach ( this . tree , ( e ) => this . buildMenu ( e ) ) ;
}
this . tree . addEventListener ( "dragover" , ( e ) => {
if ( ! this . dragPaths ) return ;
e . preventDefault ( ) ;
e . dataTransfer . dropEffect = "move" ;
this . tree . classList . add ( "pf-root-drop" ) ;
} ) ;
this . tree . addEventListener ( "dragleave" , ( e ) => {
if ( e . target === this . tree ) this . tree . classList . remove ( "pf-root-drop" ) ;
} ) ;
this . tree . addEventListener ( "drop" , ( e ) => {
if ( ! this . dragPaths ) return ;
e . preventDefault ( ) ;
this . tree . classList . remove ( "pf-root-drop" ) ;
this . onDrop ( "" ) ;
} ) ;
}
dirOf ( path ) {
return path . includes ( "/" ) ? path . slice ( 0 , path . lastIndexOf ( "/" ) ) : "" ;
}
basename ( path ) {
return path . split ( "/" ) . pop ( ) ;
}
joinDir ( dir , name ) {
const clean = name . replace ( /^\/+/ , "" ) ;
return dir ? ` ${ dir } / ${ clean } ` : clean ;
}
buildTree ( ) {
const nodes = new Map ( ) ;
this . files . forEach ( ( file ) => nodes . set ( file . path , { ... file , children : [ ] } ) ) ;
const roots = [ ] ;
this . files . forEach ( ( file ) => {
const node = nodes . get ( file . path ) ;
const parent = file . path . includes ( "/" ) ? nodes . get ( file . path . slice ( 0 , file . path . lastIndexOf ( "/" ) ) ) : null ;
( parent ? parent . children : roots ) . push ( node ) ;
} ) ;
const sort = ( list ) => {
list . sort ( ( a , b ) => ( a . type === b . type ? a . name . localeCompare ( b . name ) : a . type === "dir" ? - 1 : 1 ) ) ;
list . forEach ( ( node ) => sort ( node . children ) ) ;
} ;
sort ( roots ) ;
return roots ;
}
renderTree ( ) {
this . order = [ ] ;
const known = new Set ( this . files . map ( ( f ) => f . path ) ) ;
this . selected = new Set ( [ ... this . selected ] . filter ( ( p ) => known . has ( p ) ) ) ;
this . tree . textContent = "" ;
this . tree . appendChild ( this . renderRootRow ( ) ) ;
const roots = this . buildTree ( ) ;
if ( ! roots . length ) {
const hint = document . createElement ( "div" ) ;
hint . className = "pf-tree-empty" ;
hint . textContent = this . isOwner ? "Empty project. Create a file or folder." : "No files yet." ;
this . tree . appendChild ( hint ) ;
return ;
}
this . tree . appendChild ( this . renderNodes ( roots ) ) ;
this . applySelection ( ) ;
}
renderRootRow ( ) {
const row = document . createElement ( "button" ) ;
row . type = "button" ;
row . className = "pf-node-row pf-root-row" ;
row . dataset . path = "" ;
row . dataset . type = "dir" ;
row . draggable = false ;
row . innerHTML = '<span class="pf-icon">📦</span><span class="pf-label">project root</span>' ;
row . addEventListener ( "click" , ( ) => {
this . selected . clear ( ) ;
this . applySelection ( ) ;
this . root . querySelectorAll ( ".pf-node-row.active" ) . forEach ( ( el ) => el . classList . remove ( "active" ) ) ;
row . classList . add ( "active" ) ;
this . currentPath = null ;
this . currentDir = "" ;
this . showEmpty ( "Project root" ) ;
} ) ;
if ( this . isOwner ) {
row . addEventListener ( "dragover" , ( e ) => {
if ( ! this . dragPaths ) return ;
e . preventDefault ( ) ;
e . dataTransfer . dropEffect = "move" ;
row . classList . add ( "pf-dragover" ) ;
} ) ;
row . addEventListener ( "dragleave" , ( ) => row . classList . remove ( "pf-dragover" ) ) ;
row . addEventListener ( "drop" , ( e ) => {
if ( ! this . dragPaths ) return ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
row . classList . remove ( "pf-dragover" ) ;
this . onDrop ( "" ) ;
} ) ;
}
return row ;
}
renderNodes ( list ) {
const ul = document . createElement ( "ul" ) ;
ul . className = "pf-tree-list" ;
list . forEach ( ( node ) => {
this . order . push ( node . path ) ;
const li = document . createElement ( "li" ) ;
li . className = ` pf-node pf- ${ node . type } ` ;
const row = document . createElement ( "button" ) ;
row . type = "button" ;
row . className = "pf-node-row" ;
row . dataset . path = node . path ;
row . dataset . type = node . type ;
row . draggable = this . isOwner ;
row . innerHTML = ` <span class="pf-icon"> ${ node . type === "dir" ? "📁" : "📄" } </span><span class="pf-label"></span> ` ;
row . querySelector ( ".pf-label" ) . textContent = node . name ;
row . addEventListener ( "click" , ( e ) => this . onRowClick ( node , li , e ) ) ;
if ( this . isOwner ) this . bindDrag ( row , node ) ;
li . appendChild ( row ) ;
if ( node . type === "dir" ) {
const child = this . renderNodes ( node . children ) ;
child . classList . add ( "pf-children" ) ;
li . appendChild ( child ) ;
}
ul . appendChild ( li ) ;
} ) ;
return ul ;
}
bindDrag ( row , node ) {
row . addEventListener ( "dragstart" , ( e ) => {
const paths = this . selected . has ( node . path ) && this . selected . size ? Array . from ( this . selected ) : [ node . path ] ;
this . dragPaths = paths ;
e . dataTransfer . effectAllowed = "move" ;
e . dataTransfer . setData ( "text/plain" , paths . join ( "\n" ) ) ;
row . classList . add ( "pf-dragging" ) ;
} ) ;
row . addEventListener ( "dragend" , ( ) => {
row . classList . remove ( "pf-dragging" ) ;
this . dragPaths = null ;
this . root . querySelectorAll ( ".pf-dragover" ) . forEach ( ( el ) => el . classList . remove ( "pf-dragover" ) ) ;
this . tree . classList . remove ( "pf-root-drop" ) ;
} ) ;
if ( node . type !== "dir" ) return ;
row . addEventListener ( "dragover" , ( e ) => {
if ( ! this . dragPaths ) return ;
e . preventDefault ( ) ;
e . dataTransfer . dropEffect = "move" ;
row . classList . add ( "pf-dragover" ) ;
} ) ;
row . addEventListener ( "dragleave" , ( ) => row . classList . remove ( "pf-dragover" ) ) ;
row . addEventListener ( "drop" , ( e ) => {
if ( ! this . dragPaths ) return ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
row . classList . remove ( "pf-dragover" ) ;
this . onDrop ( node . path ) ;
} ) ;
}
onDrop ( targetDir ) {
const paths = this . dragPaths || [ ] ;
this . dragPaths = null ;
if ( paths . length ) this . moveInto ( targetDir , paths ) ;
}
onRowClick ( node , li , e ) {
if ( e . shiftKey && this . anchor ) {
this . selectRange ( this . anchor , node . path ) ;
return ;
}
if ( e . ctrlKey || e . metaKey ) {
if ( this . selected . has ( node . path ) ) this . selected . delete ( node . path ) ;
else this . selected . add ( node . path ) ;
this . anchor = node . path ;
this . applySelection ( ) ;
return ;
}
this . selected = new Set ( [ node . path ] ) ;
this . anchor = node . path ;
this . applySelection ( ) ;
this . activate ( node , li ) ;
}
activate ( node , li ) {
this . root . querySelectorAll ( ".pf-node-row.active" ) . forEach ( ( el ) => el . classList . remove ( "active" ) ) ;
const row = li . querySelector ( ".pf-node-row" ) ;
if ( row ) row . classList . add ( "active" ) ;
if ( node . type === "dir" ) {
li . classList . toggle ( "collapsed" ) ;
this . currentDir = node . path ;
return ;
}
this . currentDir = this . dirOf ( node . path ) ;
this . openFile ( node . path ) ;
}
selectRange ( fromPath , toPath ) {
const i = this . order . indexOf ( fromPath ) ;
const j = this . order . indexOf ( toPath ) ;
if ( i < 0 || j < 0 ) return ;
const [ a , b ] = i <= j ? [ i , j ] : [ j , i ] ;
this . selected = new Set ( this . order . slice ( a , b + 1 ) ) ;
this . applySelection ( ) ;
}
applySelection ( ) {
this . root . querySelectorAll ( ".pf-node-row" ) . forEach ( ( row ) => {
row . classList . toggle ( "pf-selected" , this . selected . has ( row . dataset . path ) ) ;
} ) ;
if ( this . status && this . selected . size > 1 ) Toast . flash ( this . status , ` ${ this . selected . size } selected ` , 1500 , "" ) ;
}
buildMenu ( e ) {
if ( ! this . isOwner ) return [ ] ;
const rowEl = e . target . closest ( ".pf-node-row" ) ;
if ( ! rowEl ) {
return [
{ label : "New file" , icon : "📄" , onSelect : ( ) => this . newFile ( "" ) } ,
{ label : "New folder" , icon : "📁" , onSelect : ( ) => this . newFolder ( "" ) } ,
{ label : "Upload here" , icon : "⬆" , onSelect : ( ) => this . uploadTo ( "" ) } ,
] ;
}
const path = rowEl . dataset . path ;
const type = rowEl . dataset . type ;
if ( this . selected . size > 1 && this . selected . has ( path ) ) {
const count = this . selected . size ;
return [
{ label : ` Move ${ count } items to... ` , icon : "➡" , onSelect : ( ) => this . promptMove ( Array . from ( this . selected ) ) } ,
{ separator : true } ,
{ label : ` Delete ${ count } items ` , icon : "🗑" , danger : true , onSelect : ( ) => this . remove ( path ) } ,
] ;
}
const dir = type === "dir" ? path : this . dirOf ( path ) ;
const items = [ ] ;
if ( type === "file" ) {
items . push ( { label : "Open" , icon : "📄" , onSelect : ( ) => this . openFile ( path ) } ) ;
} else {
items . push ( { label : "New file here" , icon : "📄" , onSelect : ( ) => this . newFile ( dir ) } ) ;
items . push ( { label : "New folder here" , icon : "📁" , onSelect : ( ) => this . newFolder ( dir ) } ) ;
items . push ( { label : "Upload here" , icon : "⬆" , onSelect : ( ) => this . uploadTo ( dir ) } ) ;
}
items . push ( { separator : true } ) ;
2026-06-09 01:32:57 +02:00
items . push ( { label : "Download as zip" , icon : "📦" , onSelect : ( ) => window . app . zipDownloader . start ( ` ${ this . base } /zip?path= ${ encodeURIComponent ( path ) } ` ) } ) ;
2026-06-08 22:51:09 +02:00
items . push ( { label : "Rename / move" , icon : "✏" , onSelect : ( ) => this . rename ( path ) } ) ;
items . push ( { label : "Delete" , icon : "🗑" , danger : true , onSelect : ( ) => this . remove ( path ) } ) ;
return items ;
}
async openFile ( path ) {
try {
const res = await fetch ( ` ${ this . base } /raw?path= ${ encodeURIComponent ( path ) } ` , { headers : { "Accept" : "application/json" } } ) ;
const data = await res . json ( ) ;
if ( ! res . ok ) throw new Error ( data . error ? data . error . message : "Could not open file" ) ;
this . currentPath = path ;
this . currentLabel . textContent = path ;
this . currentLabel . hidden = false ;
if ( data . is _binary ) {
this . showPreview ( data ) ;
} else {
this . showEditor ( path , data . content || "" ) ;
}
} catch ( err ) {
this . notify ( err . message ) ;
}
}
showEditor ( path , content ) {
this . empty . hidden = true ;
this . preview . hidden = true ;
this . editorWrap . hidden = false ;
if ( this . editor ) {
this . editor . setOption ( "mode" , modeForFilename ( path ) ) ;
this . editor . setValue ( content ) ;
setTimeout ( ( ) => this . editor . refresh ( ) , 50 ) ;
}
}
showPreview ( data ) {
this . empty . hidden = true ;
this . editorWrap . hidden = true ;
this . preview . hidden = false ;
this . preview . textContent = "" ;
const mime = data . mime _type || "" ;
if ( mime . startsWith ( "image/" ) ) {
const img = document . createElement ( "img" ) ;
img . src = data . url ;
img . alt = data . name ;
this . preview . appendChild ( img ) ;
} else if ( mime . startsWith ( "video/" ) ) {
const video = document . createElement ( "video" ) ;
video . src = data . url ;
video . controls = true ;
video . preload = "metadata" ;
this . preview . appendChild ( video ) ;
} else {
const link = document . createElement ( "a" ) ;
link . href = data . url ;
link . textContent = ` Download ${ data . name } ` ;
link . className = "pf-download" ;
link . setAttribute ( "download" , data . name ) ;
this . preview . appendChild ( link ) ;
}
}
showEmpty ( message ) {
this . editorWrap . hidden = true ;
this . preview . hidden = true ;
this . empty . hidden = false ;
if ( this . emptyMsg ) this . emptyMsg . textContent = message || "Select a file to view or edit." ;
this . currentLabel . textContent = "" ;
this . currentLabel . hidden = true ;
}
2026-06-09 18:37:49 +02:00
post ( action , body ) {
return Http . send ( ` ${ this . base } / ${ action } ` , body ) ;
2026-06-08 22:51:09 +02:00
}
async refresh ( openPath ) {
const res = await fetch ( this . base , { headers : { "Accept" : "application/json" } } ) ;
const data = await res . json ( ) ;
this . files = data . files || [ ] ;
this . renderTree ( ) ;
if ( openPath ) this . openFile ( openPath ) ;
}
async promptInput ( options ) {
const dialog = window . app && window . app . dialog ;
if ( dialog ) return dialog . prompt ( options ) ;
return window . prompt ( options . message || options . label || options . title || "" , options . value || "" ) ;
}
async confirmAction ( options ) {
const dialog = window . app && window . app . dialog ;
if ( dialog ) return dialog . confirm ( options ) ;
return window . confirm ( options . message ) ;
}
async newFile ( baseDir ) {
const dir = baseDir !== undefined ? baseDir : this . currentDir ;
const name = await this . promptInput ( {
title : "New file" ,
label : dir ? ` Inside ${ dir } / ` : "In project root" ,
placeholder : "main.py" ,
confirmLabel : "Create" ,
} ) ;
if ( ! name || ! name . trim ( ) ) return ;
const path = this . joinDir ( dir , name . trim ( ) ) ;
try {
await this . post ( "write" , { path , content : "" } ) ;
await this . refresh ( path ) ;
} catch ( err ) { this . notify ( err . message ) ; }
}
async newFolder ( baseDir ) {
const dir = baseDir !== undefined ? baseDir : this . currentDir ;
const name = await this . promptInput ( {
title : "New folder" ,
label : dir ? ` Inside ${ dir } / ` : "In project root" ,
placeholder : "src" ,
confirmLabel : "Create" ,
} ) ;
if ( ! name || ! name . trim ( ) ) return ;
const path = this . joinDir ( dir , name . trim ( ) ) ;
try {
await this . post ( "mkdir" , { path } ) ;
this . currentDir = path ;
await this . refresh ( ) ;
} catch ( err ) { this . notify ( err . message ) ; }
}
uploadTo ( dir ) {
if ( ! this . uploadWidget ) return ;
this . uploadWidget . extraFields = { path : dir } ;
this . uploadWidget . open ( ) ;
}
async save ( ) {
if ( ! this . currentPath || ! this . editor ) return ;
try {
await this . post ( "write" , { path : this . currentPath , content : this . editor . getValue ( ) } ) ;
this . notify ( "Saved" ) ;
} catch ( err ) { this . notify ( err . message ) ; }
}
async rename ( target ) {
const from = target || this . currentPath || this . currentDir ;
if ( ! from ) { this . notify ( "Select a file or folder first" ) ; return ; }
const to = await this . promptInput ( {
title : "Rename / move" ,
label : "New path" ,
value : from ,
confirmLabel : "Save" ,
} ) ;
if ( ! to || ! to . trim ( ) || to . trim ( ) === from ) return ;
try {
await this . post ( "move" , { from _path : from , to _path : to . trim ( ) } ) ;
await this . refresh ( this . currentPath === from ? to . trim ( ) : null ) ;
} catch ( err ) { this . notify ( err . message ) ; }
}
async remove ( target ) {
const multi = this . selected . size > 1 && ( ! target || this . selected . has ( target ) ) ;
const paths = multi ? Array . from ( this . selected ) : [ target || this . currentPath || this . currentDir ] . filter ( Boolean ) ;
if ( ! paths . length ) { this . notify ( "Select a file or folder first" ) ; return ; }
const message = paths . length > 1
? ` Delete ${ paths . length } items? This cannot be undone. `
: ` Delete " ${ paths [ 0 ] } "? This cannot be undone. ` ;
const ok = await this . confirmAction ( { title : "Delete" , message , danger : true , confirmLabel : "Delete" } ) ;
if ( ! ok ) return ;
try {
for ( const path of paths ) await this . post ( "delete" , { path } ) ;
this . selected . clear ( ) ;
this . currentPath = null ;
await this . refresh ( ) ;
this . openDefault ( ) ;
} catch ( err ) { this . notify ( err . message ) ; }
}
async promptMove ( paths ) {
const to = await this . promptInput ( {
title : ` Move ${ paths . length } items ` ,
label : "Target directory (empty for project root)" ,
placeholder : "src" ,
confirmLabel : "Move" ,
} ) ;
if ( to === null || to === undefined ) return ;
await this . moveInto ( to . trim ( ) . replace ( /^\/+|\/+$/g , "" ) , paths ) ;
}
async moveInto ( targetDir , paths ) {
const valid = paths . filter ( ( path ) => {
if ( targetDir === path || targetDir . startsWith ( ` ${ path } / ` ) ) return false ;
if ( targetDir === this . dirOf ( path ) ) return false ;
return true ;
} ) ;
if ( ! valid . length ) return ;
try {
for ( const path of valid ) {
const to = targetDir ? ` ${ targetDir } / ${ this . basename ( path ) } ` : this . basename ( path ) ;
await this . post ( "move" , { from _path : path , to _path : to } ) ;
}
this . selected . clear ( ) ;
await this . refresh ( ) ;
} catch ( err ) { this . notify ( err . message ) ; }
}
}