2026-10-08 01:16:37 -04:00
/* global __EDITOR_SHA__ */
2026-10-08 01:40:47 -04:00
// Files pane (devdesk#19, #37), laid out like VS Code: an explorer tree (or the
// git changes list) beside an editor area with tabs. Read the session's files
// with syntax highlighting, preview markdown and images, diff against HEAD,
// see what Claude edited, and jump to any file by name. Read-only for now.
// Everything goes through `devdesk attach --fs-*` over ttyd.
2026-10-08 01:16:37 -04:00
import { runBlob , runCommand } from './connection.js' ;
import { parseListOutput } from './protocol.js' ;
import { el } from './dom.js' ;
import { ago } from './sessions.js' ;
2026-10-08 01:40:47 -04:00
import { CommandPalette , toast } from './ui.js' ;
2026-10-08 01:16:37 -04:00
// ---------------------------------------------------------------- editor bundle
const DB = 'devdesk' ;
const STORE = 'assets' ;
function idb ( mode , run ) {
return new Promise (( resolve , reject ) => {
const open = indexedDB . open ( DB , 1 );
open . onupgradeneeded = () => open . result . createObjectStore ( STORE );
open . onerror = () => reject ( open . error );
open . onsuccess = () => {
const tx = open . result . transaction ( STORE , mode );
const request = run ( tx . objectStore ( STORE ));
tx . oncomplete = () => {
open . result . close ();
resolve ( request ? . result );
};
tx . onerror = () => reject ( tx . error );
};
});
}
async function sha256 ( text ) {
const digest = await crypto . subtle . digest ( 'SHA-256' , new TextEncoder (). encode ( text ));
return [... new Uint8Array ( digest )]. map (( b ) => b . toString ( 16 ). padStart ( 2 , '0' )). join ( '' );
}
function decodeBase64 ( b64 ) {
const bin = atob ( b64 );
const bytes = new Uint8Array ( bin . length );
for ( let i = 0 ; i < bin . length ; i += 1 ) bytes [ i ] = bin . charCodeAt ( i );
return bytes ;
}
// The editor (~600 KB) is not in the page: it's fetched over ttyd the first time
// the pane opens, checked against the hash this page was built with, cached in
// IndexedDB under that hash, and run.
let editorLoading = null ;
export function loadEditor () {
editorLoading ??= ( async () => {
if ( window . DevDeskEditor ) return window . DevDeskEditor ;
let code = null ;
try {
code = await idb ( 'readonly' , ( store ) => store . get ( __EDITOR_SHA__ ));
} catch {
// No IndexedDB (private mode): fetch every time.
}
if ( ! code ) {
const { base64 } = await runBlob ([ '--asset' , 'editor.js' ], 60000 );
if ( ! base64 ) throw new Error ( 'the editor bundle is missing on the server' );
code = new TextDecoder (). decode ( decodeBase64 ( base64 ));
if ( crypto . subtle && ( await sha256 ( code )) !== __EDITOR_SHA__ ) {
throw new Error ( 'the server has a different DevDesk version: reload the page' );
}
idb ( 'readwrite' , ( store ) => {
store . clear ();
store . put ( code , __EDITOR_SHA__ );
}). catch (() => {});
}
const script = document . createElement ( 'script' );
script . textContent = code ;
document . head . append ( script );
script . remove ();
if ( ! window . DevDeskEditor ) throw new Error ( 'the editor failed to start' );
return window . DevDeskEditor ;
})(). catch (( error ) => {
editorLoading = null ;
throw error ;
});
return editorLoading ;
}
// ---------------------------------------------------------------- helpers
const size = ( n ) => ( n < 1024 ? ` ${ n } B` : n < 1048576 ? ` ${ ( n / 1024 ). toFixed ( 1 ) } KB` : ` ${ ( n / 1048576 ). toFixed ( 1 ) } MB` );
const parent = ( path ) => path . split ( '/' ). slice ( 0 , - 1 ). join ( '/' );
const isMarkdown = ( path ) => / \ .( md | markdown | mdx ) $ / i . test ( path );
const STATUS = {
M : [ 'M' , 'modified' , 'amber' ], A : [ 'A' , 'added' , 'green' ], D : [ 'D' , 'deleted' , 'red' ], R : [ 'R' , 'renamed' , 'cyan' ],
C : [ 'C' , 'copied' , 'cyan' ], U : [ 'U' , 'conflict' , 'red' ], '?' : [ 'N' , 'new' , 'green' ], ' ' : [ '·' , 'edited by Claude' , 'cyan' ],
};
function statusOf ( f ) {
const code = f . x === '?' ? '?' : f . x !== ' ' ? f . x : f . y ;
return STATUS [ code ] ?? STATUS . M ;
}
// Markdown preview document: sanitized HTML in a sandboxed iframe (no scripts,
// no same-origin), styled from the page's current TDS colours.
function previewDocument ( html ) {
const css = getComputedStyle ( document . documentElement );
const v = ( name ) => css . getPropertyValue ( name ). trim ();
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
:root { color-scheme: ${ document . documentElement . dataset . theme === 'light' ? 'light' : 'dark' } ; }
body { margin: 0; padding: 18px 22px 40px; background: ${ v ( '--bg-terminal' ) } ; color: ${ v ( '--text-primary' ) } ;
font: 14px/1.65 ${ v ( '--font-mono' ) || 'monospace' } ; overflow-wrap: anywhere; }
h1, h2, h3, h4 { color: ${ v ( '--accent-cyan' ) } ; line-height: 1.3; margin: 1.4em 0 0.5em; }
h1 { font-size: 1.45em; border-bottom: 1px solid ${ v ( '--border-color' ) } ; padding-bottom: 0.3em; }
h2 { font-size: 1.2em; border-bottom: 1px solid ${ v ( '--border-dim' ) } ; padding-bottom: 0.2em; }
a { color: ${ v ( '--accent-orange' ) } ; }
code { background: ${ v ( '--bg-tertiary' ) } ; padding: 1px 5px; color: ${ v ( '--accent-amber' ) } ; font-family: inherit; }
pre { background: ${ v ( '--bg-secondary' ) } ; border: 1px solid ${ v ( '--border-dim' ) } ; padding: 10px 12px; overflow-x: auto; }
pre code { background: none; padding: 0; color: ${ v ( '--text-primary' ) } ; }
blockquote { margin: 0.8em 0; padding: 2px 14px; border-left: 3px solid ${ v ( '--accent-cyan' ) } ; color: ${ v ( '--text-secondary' ) } ; }
table { border-collapse: collapse; margin: 0.8em 0; } th, td { border: 1px solid ${ v ( '--border-color' ) } ; padding: 4px 10px; }
th { background: ${ v ( '--bg-secondary' ) } ; } hr { border: 0; border-top: 1px solid ${ v ( '--border-color' ) } ; }
img { max-width: 100%; } li { margin: 0.2em 0; } input[type=checkbox] { margin-right: 6px; }
</style></head><body> ${ html } </body></html>` ;
}
// Relative <img> in a markdown file (e.g. docs/screenshot.png) can't load from
// the sandboxed preview: fetch up to 10 of them over ttyd and inline them.
async function inlineImages ( html , session , mdPath ) {
const doc = new DOMParser (). parseFromString ( `<body> ${ html } </body>` , 'text/html' );
const images = [... doc . querySelectorAll ( 'img[src]' )]
. filter (( img ) => ! /^([a-z][a-z0-9+.-]*:|\/\/|#)/i . test ( img . getAttribute ( 'src' ))). slice ( 0 , 10 );
if ( ! images . length ) return html ;
const base = parent ( mdPath );
await Promise . all ( images . map ( async ( img ) => {
const parts = [];
for ( const part of ` ${ base } / ${ decodeURIComponent ( img . getAttribute ( 'src' ). split ( /[?#]/ )[ 0 ]) } ` . split ( '/' )) {
if ( part === '..' ) parts . pop ();
else if ( part && part !== '.' ) parts . push ( part );
}
try {
const { header , base64 } = await runBlob ([ '--fs-read' , session , parts . join ( '/' )]);
if ( header . mime ? . startsWith ( 'image/' ) && base64 ) img . setAttribute ( 'src' , `data: ${ header . mime } ;base64, ${ base64 } ` );
} catch {
// missing image: leave the broken link
}
}));
return doc . body . innerHTML ;
}
2026-10-08 01:40:47 -04:00
// ---------------------------------------------------------------- prefs
2026-10-08 01:16:37 -04:00
2026-10-08 01:40:47 -04:00
function readJson ( key , fallback ) {
2026-10-08 01:16:37 -04:00
try {
2026-10-08 01:40:47 -04:00
const value = JSON . parse ( localStorage . getItem ( `devdesk.files. ${ key } ` ));
return value ?? fallback ;
2026-10-08 01:16:37 -04:00
} catch {
2026-10-08 01:40:47 -04:00
return fallback ;
2026-10-08 01:16:37 -04:00
}
}
2026-10-08 01:40:47 -04:00
function writeJson ( key , value ) {
2026-10-08 01:16:37 -04:00
try {
2026-10-08 01:40:47 -04:00
localStorage . setItem ( `devdesk.files. ${ key } ` , JSON . stringify ( value ));
2026-10-08 01:16:37 -04:00
} catch {
2026-10-08 01:40:47 -04:00
// Private mode: the state lasts until the tab closes.
}
}
// ---------------------------------------------------------------- icons
// A two-character badge per file type (VS Code's icon idea, in the terminal style).
const ICONS = [
[ /\.(md|markdown|mdx)$/i , 'M↓' , 'cyan' ],
[ /\.(js|mjs|cjs|jsx)$/i , 'JS' , 'amber' ],
[ /\.(ts|tsx|mts)$/i , 'TS' , 'blue' ],
[ /\.(json|jsonc|webmanifest)$/i , '{}' , 'amber' ],
[ /\.(py|pyi)$/i , 'PY' , 'blue' ],
[ /\.(sh|bash|zsh|bats|env)$|^\.env/i , '$_' , 'green' ],
[ /\.(ya?ml)$/i , 'YM' , 'purple' ],
[ /\.(toml|ini|cfg|conf|properties)$/i , '⚙' , 'muted' ],
[ /\.(service|timer|socket|mount)$/i , 'SD' , 'muted' ],
[ /\.(html?|xml|svg|j2|jinja|php)$/i , '<>' , 'orange' ],
[ /\.(css|scss)$/i , '#' , 'blue' ],
[ /\.(png|jpe?g|gif|webp|ico|bmp)$/i , '▣' , 'purple' ],
[ /\.(go)$/i , 'GO' , 'cyan' ],
[ /\.(rs)$/i , 'RS' , 'orange' ],
[ /\.(sql)$/i , 'DB' , 'amber' ],
[ /\.(lock)$|-lock\.json$/i , '≡' , 'muted' ],
[ /^(dockerfile|.*\.dockerfile)$/i , 'DK' , 'cyan' ],
[ /^(makefile|gnumakefile)$/i , 'MK' , 'orange' ],
[ /^(license|copying)(\..*)?$/i , '©' , 'muted' ],
[ /^\.git/i , '±' , 'muted' ],
[ /\.(diff|patch)$/i , '±' , 'green' ],
[ /\.(txt|log)$/i , '≡' , 'muted' ],
];
function iconFor ( name ) {
for ( const [ re , text , tone ] of ICONS ) if ( re . test ( name )) return [ text , tone ];
return [ '·' , 'muted' ];
}
const base = ( path ) => path . split ( '/' ). pop ();
// Fuzzy "go to file": every query character in order; consecutive runs, the
// file name, and starts of segments score higher; shorter paths break ties.
export function fuzzyScore ( path , query ) {
if ( ! query ) return 1 ;
const p = path . toLowerCase ();
const q = query . toLowerCase (). replace ( /\s+/g , '' );
const nameStart = p . lastIndexOf ( '/' ) + 1 ;
let score = 0 ;
let last = - 1 ;
let run = 0 ;
for ( const ch of q ) {
const i = p . indexOf ( ch , last + 1 );
if ( i < 0 ) return null ;
run = i === last + 1 ? run + 1 : 0 ;
score += 1 + run * 3 + ( i >= nameStart ? 4 : 0 ) + ( i === 0 || p [ i - 1 ] === '/' || p [ i - 1 ] === '.' || p [ i - 1 ] === '-' || p [ i - 1 ] === '_' ? 3 : 0 );
last = i ;
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
if ( p . slice ( nameStart ). includes ( q )) score += 20 ;
return score - path . length / 100 ;
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
// ---------------------------------------------------------------- pane
2026-10-08 01:16:37 -04:00
export class FilesPane {
// hooks: session() -> name, paste(text), onToggle(open), focusTerminal()
constructor ( root , hooks ) {
this . root = root ;
this . hooks = hooks ;
this . session = null ;
2026-10-08 01:40:47 -04:00
this . listings = new Map (); // dir -> listing (per session; cleared on switch)
this . loadingDirs = new Set ();
2026-10-08 01:16:37 -04:00
this . changes = null ;
2026-10-08 01:40:47 -04:00
this . tabs = []; // { path, mode, pinned }
this . active = null ;
this . views = new Map (); // tab key -> { node, editor, header, base64, stale }
this . view = 'explorer' ;
this . expanded = new Set ();
this . focusIndex = 0 ;
this . wrap = readJson ( 'wrap' , false );
this . fileList = null ; // go-to-file cache { at, files }
this . explorer = root . querySelector ( '.dd-explorer-body' );
this . tabStrip = root . querySelector ( '.dd-tabs' );
this . editorArea = root . querySelector ( '.dd-editor-area' );
2026-10-08 01:16:37 -04:00
this . count = root . querySelector ( '.dd-files-count' );
2026-10-08 01:40:47 -04:00
this . viewButtons = [... root . querySelectorAll ( '[data-files-view]' )];
for ( const button of this . viewButtons ) button . addEventListener ( 'click' , () => this . setView ( button . dataset . filesView ));
root . querySelector ( '.dd-files-refresh' ). addEventListener ( 'click' , () => this . refresh ());
2026-10-08 01:16:37 -04:00
root . querySelector ( '.dd-files-close' ). addEventListener ( 'click' , () => this . close ());
2026-10-08 01:40:47 -04:00
root . querySelector ( '.dd-files-find' ). addEventListener ( 'click' , () => this . quickOpen . open ());
root . querySelector ( '.dd-explorer-toggle' ). addEventListener ( 'click' , () => this . setExplorer ( ! this . explorerShown ()));
this . explorer . addEventListener ( 'keydown' , ( event ) => this . treeKey ( event ));
root . addEventListener ( 'keydown' , ( event ) => {
if (( event . ctrlKey || event . metaKey ) && ! event . shiftKey && ! event . altKey && event . code === 'KeyP' ) {
event . preventDefault ();
this . quickOpen . open ();
}
});
this . wireExplorerResize ();
// Narrow pane (phone, or dragged small): the explorer slides over the editor.
new ResizeObserver (() => root . classList . toggle ( 'is-narrow' , root . clientWidth < 680 )). observe ( root );
this . quickOpen = new CommandPalette (() => this . loadFileList (), {
id : 'dd-goto' , placeholder : 'Go to file… (type part of a name)' ,
search : ( files , query ) => ( query
? files . map (( c ) => ({ c , s : fuzzyScore ( c . id , query ) })). filter (( x ) => x . s !== null ). sort (( a , b ) => b . s - a . s ). slice ( 0 , 60 ). map (( x ) => x . c )
: [... this . recent (). map (( p ) => files . find (( c ) => c . id === p )). filter ( Boolean ), ... files . slice ( 0 , 40 )]. slice ( 0 , 40 )),
});
2026-10-08 01:16:37 -04:00
}
get isOpen () {
return ! this . root . hidden ;
}
2026-10-08 01:40:47 -04:00
// Drag the explorer's right edge; the width is remembered per browser.
wireExplorerResize () {
const handle = el ( 'div' , { class : 'dd-explorer-resizer' , role : 'separator' , 'aria-orientation' : 'vertical' , 'aria-label' : 'Resize the explorer' });
this . root . querySelector ( '.dd-explorer' ). after ( handle );
const apply = ( px ) => {
const width = Math . round ( Math . max ( 160 , Math . min ( this . root . clientWidth * 0.6 , px )));
this . root . style . setProperty ( '--dd-explorer-w' , ` ${ width } px` );
return width ;
};
const saved = readJson ( 'explorerWidth' , 0 );
if ( saved ) apply ( saved );
handle . addEventListener ( 'pointerdown' , ( event ) => {
event . preventDefault ();
handle . setPointerCapture ( event . pointerId );
document . body . classList . add ( 'dd-resizing' );
const left = this . root . querySelector ( '.dd-explorer' ). getBoundingClientRect (). left ;
const move = ( e ) => apply ( e . clientX - left );
const up = () => {
document . body . classList . remove ( 'dd-resizing' );
handle . removeEventListener ( 'pointermove' , move );
handle . removeEventListener ( 'pointerup' , up );
writeJson ( 'explorerWidth' , this . root . querySelector ( '.dd-explorer' ). getBoundingClientRect (). width );
};
handle . addEventListener ( 'pointermove' , move );
handle . addEventListener ( 'pointerup' , up );
});
}
// ------------------------------------------------------------ open / close / session
open ( view ) {
2026-10-08 01:16:37 -04:00
this . root . hidden = false ;
this . hooks . onToggle ( true );
this . syncSession ();
2026-10-08 01:40:47 -04:00
if ( view ) this . setView ( view === 'files' ? 'explorer' : view );
else this . render ();
this . revalidate ();
loadEditor (). catch (() => {}); // warm up
2026-10-08 01:16:37 -04:00
}
close () {
this . root . hidden = true ;
this . hooks . onToggle ( false );
this . hooks . focusTerminal ();
}
2026-10-08 01:40:47 -04:00
toggle ( view ) {
if ( this . isOpen && ( ! view || ( view === 'changes' ) === ( this . view === 'changes' ))) this . close ();
else this . open ( view );
}
escape () {
if ( this . root . classList . contains ( 'is-narrow' ) && this . explorerShown () && this . active ) this . setExplorer ( false );
else this . close ();
}
stateKey () {
return `state. ${ this . session } ` ;
}
saveState () {
if ( ! this . session ) return ;
writeJson ( this . stateKey (), {
expanded : [... this . expanded ], view : this . view , active : this . active ,
tabs : this . tabs . filter (( t ) => t . pinned || t . key === this . active ). map (({ path , mode }) => ({ path , mode })),
});
2026-10-08 01:16:37 -04:00
}
syncSession () {
const session = this . hooks . session ();
if ( session === this . session ) return false ;
2026-10-08 01:40:47 -04:00
for ( const v of this . views . values ()) v . editor ? . destroy ();
this . views . clear ();
this . listings . clear ();
2026-10-08 01:16:37 -04:00
this . changes = null ;
2026-10-08 01:40:47 -04:00
this . fileList = null ;
this . session = session ;
const saved = session ? readJson ( this . stateKey (), {}) : {};
this . expanded = new Set ( saved . expanded ?? []);
this . view = saved . view ?? 'explorer' ;
this . tabs = ( saved . tabs ?? []). map (( t ) => ({ ... t , key : ` ${ t . mode } : ${ t . path } ` , pinned : true }));
this . active = this . tabs . some (( t ) => t . key === saved . active ) ? saved . active : ( this . tabs [ 0 ] ? . key ?? null );
2026-10-08 01:16:37 -04:00
return true ;
}
sessionChanged () {
2026-10-08 01:40:47 -04:00
if ( this . isOpen && this . syncSession ()) {
this . render ();
this . revalidate ();
}
}
// ------------------------------------------------------------ data
async listDir ( dir ) {
if ( this . loadingDirs . has ( dir )) return ;
const session = this . session ;
this . loadingDirs . add ( dir );
try {
const listing = parseListOutput ( await runCommand ([ '--fs-list' , session , dir || '.' ], 15000 ));
if ( session !== this . session ) return ;
this . listings . set ( dir , listing );
} catch ( error ) {
if ( session !== this . session ) return ;
if ( dir ) {
this . expanded . delete ( dir );
toast . error ( ` ${ dir } : ${ error . message } ` );
} else {
this . listings . set ( '' , { error : error . message , entries : [] });
}
} finally {
this . loadingDirs . delete ( dir );
}
if ( session === this . session ) this . renderExplorer ();
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
async loadChanges () {
const session = this . session ;
try {
const changes = parseListOutput ( await runCommand ([ '--git-status' , session ], 15000 ));
if ( session !== this . session ) return ;
this . changes = changes ;
} catch {
return ;
}
const n = this . changes . files ? . length ?? 0 ;
this . count . textContent = n ? String ( n ) : '' ;
this . renderExplorer ();
2026-10-08 01:16:37 -04:00
this . renderTabs ();
2026-10-08 01:40:47 -04:00
if ( ! this . active ) this . renderEditor ();
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
async loadFileList () {
if ( this . fileList && Date . now () - this . fileList . at < 60000 && this . fileList . session === this . session ) return this . fileList . items ;
2026-10-08 01:50:50 -04:00
const list = parseListOutput ( await runCommand ([ '--fs-find' , this . session ], 45000 ));
2026-10-08 01:40:47 -04:00
const items = list . files . map (( path ) => {
const name = base ( path );
const [ icon ] = iconFor ( name );
return { id : path , label : name , hint : path . includes ( '/' ) ? path . slice ( 0 , path . length - name . length - 1 ) : '' , icon , group : 'Files' , run : () => this . openFile ( path , { pin : true }) };
});
this . fileList = { at : Date . now (), session : this . session , items };
if ( list . truncated ) toast . info ( `Go to file searches the first ${ list . files . length } files` );
return items ;
}
// Refresh what's visible: the tree's open folders, git status, the active file.
revalidate () {
if ( ! this . session ) return ;
this . listDir ( '' );
for ( const dir of this . expanded ) this . listDir ( dir );
this . loadChanges ();
}
refresh () {
this . fileList = null ;
this . revalidate ();
if ( this . active ) this . reloadTab ( this . active , { quiet : false });
2026-10-08 01:16:37 -04:00
}
async claudeTurnEnded () {
2026-10-08 01:40:47 -04:00
if ( ! this . isOpen ) {
// Next time it opens it revalidates anyway; open tabs re-check on activation.
for ( const v of this . views . values ()) v . stale = true ;
2026-10-08 01:16:37 -04:00
return ;
}
2026-10-08 01:40:47 -04:00
this . fileList = null ;
this . revalidate ();
for ( const [ key , v ] of this . views ) if ( key !== this . active ) v . stale = true ;
if ( this . active ) this . reloadTab ( this . active , { quiet : true });
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
changeFor ( path ) {
return this . changes ? . files ? . find (( f ) => f . path === path ) ?? null ;
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
recent () {
return readJson ( `recent. ${ this . session } ` , []);
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
pushRecent ( path ) {
writeJson ( `recent. ${ this . session } ` , [ path , ... this . recent (). filter (( p ) => p !== path )]. slice ( 0 , 8 ));
}
2026-10-08 01:16:37 -04:00
2026-10-08 01:40:47 -04:00
// ------------------------------------------------------------ layout
explorerShown () {
return ! this . root . classList . contains ( 'explorer-hidden' );
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
setExplorer ( show ) {
this . root . classList . toggle ( 'explorer-hidden' , ! show );
if ( show ) this . focusTree ();
}
setView ( view ) {
this . view = view ;
this . saveState ();
this . setExplorer ( true );
this . render ();
if ( view === 'changes' ) this . loadChanges ();
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
render () {
for ( const b of this . viewButtons ) b . setAttribute ( 'aria-selected' , String ( b . dataset . filesView === this . view ));
this . renderExplorer ();
2026-10-08 01:16:37 -04:00
this . renderTabs ();
2026-10-08 01:40:47 -04:00
this . renderEditor ();
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
// ------------------------------------------------------------ explorer
// Visible rows: the root listing plus every expanded folder's children.
rows () {
const out = [];
const walk = ( dir , depth ) => {
const listing = this . listings . get ( dir );
if ( ! listing ) {
out . push ({ kind : 'loading' , depth , dir });
return ;
}
for ( const e of listing . entries ) {
const path = dir ? ` ${ dir } / ${ e . n } ` : e . n ;
const isDir = e . t === 'd' || e . to === 'd' ;
out . push ({ kind : isDir ? 'dir' : 'file' , depth , path , entry : e });
if ( isDir && this . expanded . has ( path )) walk ( path , depth + 1 );
}
if ( listing . truncated ) out . push ({ kind : 'note' , depth , text : `first ${ listing . entries . length } entries only` });
};
walk ( '' , 0 );
return out ;
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
renderExplorer () {
if ( ! this . session ) {
this . explorer . replaceChildren ( el ( 'div' , { class : 'dd-files-msg' }, 'Open a session to browse its files.' ));
return ;
}
if ( this . view === 'changes' ) {
this . renderChangesList ();
return ;
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
const root = this . listings . get ( '' );
if ( root ? . error ) {
this . explorer . replaceChildren ( el ( 'div' , { class : 'dd-files-msg is-error' }, root . error ));
return ;
}
const activePath = this . tabs . find (( t ) => t . key === this . active ) ? . path ;
const changed = new Map (( this . changes ? . files ?? []). filter (( f ) => f . path ). map (( f ) => [ f . path , f ]));
this . treeRows = this . rows ();
this . focusIndex = Math . min ( this . focusIndex , Math . max ( 0 , this . treeRows . length - 1 ));
const nodes = this . treeRows . map (( row , i ) => {
const indent = { style : `padding-left: ${ 8 + row . depth * 14 } px` };
if ( row . kind === 'loading' ) return el ( 'div' , { class : 'dd-tree-row is-note' , ... indent }, 'Loading…' );
if ( row . kind === 'note' ) return el ( 'div' , { class : 'dd-tree-row is-note' , ... indent }, row . text );
const { entry , path } = row ;
const isDir = row . kind === 'dir' ;
const open = isDir && this . expanded . has ( path );
const change = isDir ? null : changed . get ( path );
const dirty = isDir && [... changed . keys ()]. some (( p ) => p . startsWith ( ` ${ path } /` ));
const [ icon , tone ] = isDir ? [ open ? '▾' : '▸' , 'dir' ] : iconFor ( entry . n );
const status = change ? statusOf ( change ) : null ;
return el ( 'div' , {
class : `dd-tree-row ${ isDir ? ' is-dir' : '' }${ entry . ig ? ' is-ignored' : '' }${ path === activePath ? ' is-active' : '' }${ status ? ` git- ${ status [ 2 ] } ` : '' }${ dirty ? ' git-dirty' : '' } ` ,
role : 'treeitem' , 'aria-expanded' : isDir ? String ( open ) : null , 'aria-level' : String ( row . depth + 1 ),
tabindex : i === this . focusIndex ? '0' : '-1' , 'data-index' : String ( i ),
title : ` ${ path }${ entry . t === 'l' ? ' (symlink)' : '' }${ status ? ` · ${ status [ 1 ] } ` : '' }${ change ? . claude ? ' · edited by Claude' : '' } ` ,
... indent ,
onclick : () => {
this . focusIndex = i ;
if ( isDir ) this . toggleDir ( path );
else this . openFile ( path );
},
ondblclick : () => {
if ( ! isDir ) this . pin ( path );
},
2026-10-08 01:16:37 -04:00
},
2026-10-08 01:40:47 -04:00
el ( 'span' , { class : `dd-ficon tone- ${ tone } ` , 'aria-hidden' : 'true' }, icon ),
el ( 'span' , { class : 'dd-tree-name' }, entry . n ),
2026-10-08 01:16:37 -04:00
change ? . claude ? el ( 'span' , { class : 'dd-claude-chip' , title : 'Claude edited this file' }, 'C' ) : null ,
2026-10-08 01:40:47 -04:00
status ? el ( 'span' , { class : `dd-file-status is- ${ status [ 2 ] } ` }, status [ 0 ]) : null ,
dirty ? el ( 'span' , { class : 'dd-dirty-dot' , title : 'contains changes' }) : null );
});
if ( ! nodes . length ) nodes . push ( el ( 'div' , { class : 'dd-files-msg' }, 'Empty folder.' ));
const hadFocus = this . explorer . contains ( document . activeElement );
this . explorer . setAttribute ( 'role' , 'tree' );
this . explorer . replaceChildren (... nodes );
if ( hadFocus ) this . focusTree ();
}
toggleDir ( path , open = ! this . expanded . has ( path )) {
if ( open ) {
this . expanded . add ( path );
if ( ! this . listings . has ( path )) this . listDir ( path );
else this . listDir ( path ); // revalidate in the background, show the cached one now
} else {
this . expanded . delete ( path );
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
this . saveState ();
this . renderExplorer ();
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
focusTree () {
const row = this . explorer . querySelector ( `[data-index=" ${ this . focusIndex } "]` ) ?? this . explorer . querySelector ( '[data-index]' );
row ? . focus ();
}
2026-10-08 01:16:37 -04:00
2026-10-08 01:40:47 -04:00
// ↑/↓ move, → open folder / step in, ← close folder / step out, Enter/Space open.
treeKey ( event ) {
if ( this . view !== 'explorer' || ! this . treeRows ? . length ) return ;
const row = this . treeRows [ this . focusIndex ];
const move = ( i ) => {
this . focusIndex = Math . max ( 0 , Math . min ( this . treeRows . length - 1 , i ));
for ( const node of this . explorer . querySelectorAll ( '[data-index]' )) node . tabIndex = Number ( node . dataset . index ) === this . focusIndex ? 0 : - 1 ;
this . focusTree ();
};
switch ( event . key ) {
case 'ArrowDown' : move ( this . focusIndex + 1 ); break ;
case 'ArrowUp' : move ( this . focusIndex - 1 ); break ;
case 'Home' : move ( 0 ); break ;
case 'End' : move ( this . treeRows . length - 1 ); break ;
case 'ArrowRight' :
if ( row ? . kind === 'dir' ) {
if ( ! this . expanded . has ( row . path )) this . toggleDir ( row . path , true );
else move ( this . focusIndex + 1 );
}
break ;
case 'ArrowLeft' :
if ( row ? . kind === 'dir' && this . expanded . has ( row . path )) this . toggleDir ( row . path , false );
else if ( row ? . path ? . includes ( '/' )) {
const up = row . path . split ( '/' ). slice ( 0 , - 1 ). join ( '/' );
move ( this . treeRows . findIndex (( r ) => r . path === up ));
}
break ;
case 'Enter' :
case ' ' :
if ( row ? . kind === 'dir' ) this . toggleDir ( row . path );
else if ( row ? . kind === 'file' ) this . openFile ( row . path , { pin : event . key === 'Enter' });
break ;
default :
2026-10-08 01:16:37 -04:00
return ;
2026-10-08 01:40:47 -04:00
}
event . preventDefault ();
}
// Expand the folders above a file and scroll it into view.
reveal ( path ) {
const parts = path . split ( '/' );
let changed = false ;
for ( let i = 1 ; i < parts . length ; i += 1 ) {
const dir = parts . slice ( 0 , i ). join ( '/' );
if ( ! this . expanded . has ( dir )) {
this . expanded . add ( dir );
changed = true ;
if ( ! this . listings . has ( dir )) this . listDir ( dir );
2026-10-08 01:16:37 -04:00
}
}
2026-10-08 01:40:47 -04:00
if ( changed ) this . saveState ();
if ( this . view !== 'explorer' ) this . setView ( 'explorer' );
else this . renderExplorer ();
this . explorer . querySelector ( '.is-active' ) ? . scrollIntoView ({ block : 'nearest' });
}
renderChangesList () {
if ( ! this . changes ) {
this . explorer . replaceChildren ( el ( 'div' , { class : 'dd-files-msg' }, 'Loading…' ));
return ;
}
2026-10-08 01:16:37 -04:00
const { files = [], git , branch } = this . changes ;
2026-10-08 01:40:47 -04:00
const head = el ( 'div' , { class : 'dd-changes-head' }, git ? ` ${ branch } · ${ files . length } uncommitted` : 'Not a git repository · files Claude edited' );
2026-10-08 01:16:37 -04:00
if ( ! files . length ) {
2026-10-08 01:40:47 -04:00
this . explorer . replaceChildren ( head , el ( 'div' , { class : 'dd-files-msg' }, git ? 'No uncommitted changes.' : 'Claude hasn\'t edited files here yet.' ));
2026-10-08 01:16:37 -04:00
return ;
}
2026-10-08 01:40:47 -04:00
const activePath = this . tabs . find (( t ) => t . key === this . active ) ? . path ;
2026-10-08 01:16:37 -04:00
const rows = files . map (( f ) => {
const [ letter , label , tone ] = statusOf ( f );
2026-10-08 01:40:47 -04:00
const path = f . path ?? f . repoPath ;
const name = base ( path );
const dir = path . slice ( 0 , path . length - name . length ). replace ( /\/$/ , '' );
2026-10-08 01:16:37 -04:00
const openable = f . path !== null && f . path !== undefined ;
2026-10-08 01:40:47 -04:00
const canDiff = git && f . x !== '?' && f . y !== '?' ;
return el ( 'div' , {
class : `dd-tree-row dd-change-row git- ${ tone }${ path === activePath ? ' is-active' : '' }${ openable ? '' : ' is-disabled' } ` ,
tabindex : '0' , role : 'button' ,
title : ` ${ path } · ${ label }${ f . from ? ` from ${ f . from } ` : '' }${ f . claude ? ' · edited by Claude' : '' }${ openable ? '' : ' (outside this folder)' } ` ,
onclick : () => openable && this . openFile ( f . path , { mode : canDiff ? 'diff' : undefined }),
onkeydown : ( event ) => {
if ( event . key === 'Enter' && openable ) this . openFile ( f . path , { mode : canDiff ? 'diff' : undefined , pin : true });
},
2026-10-08 01:16:37 -04:00
},
2026-10-08 01:40:47 -04:00
el ( 'span' , { class : `dd-ficon tone- ${ iconFor ( name )[ 1 ] } ` , 'aria-hidden' : 'true' }, iconFor ( name )[ 0 ]),
el ( 'span' , { class : 'dd-tree-name' }, name , dir ? el ( 'span' , { class : 'dd-change-dir' }, ` ${ dir } ` ) : null ),
2026-10-08 01:16:37 -04:00
f . claude ? el ( 'span' , { class : 'dd-claude-chip' , title : 'Claude edited this file' }, 'C' ) : null ,
2026-10-08 01:40:47 -04:00
f . add !== null && f . add !== undefined ? el ( 'span' , { class : 'dd-add' }, `+ ${ f . add } ` ) : null ,
f . del !== null && f . del !== undefined ? el ( 'span' , { class : 'dd-del' }, `− ${ f . del } ` ) : null ,
el ( 'span' , { class : `dd-file-status is- ${ tone } ` }, letter ));
2026-10-08 01:16:37 -04:00
});
2026-10-08 01:40:47 -04:00
this . explorer . setAttribute ( 'role' , 'list' );
this . explorer . replaceChildren ( head , ... rows );
}
// ------------------------------------------------------------ tabs
// Single click opens a preview tab (italic) that the next click replaces;
// double click, Enter or go-to-file keeps it.
openFile ( path , { mode , pin = false } = {}) {
const wanted = mode ?? ( isMarkdown ( path ) ? 'preview' : 'view' );
const key = ` ${ wanted } : ${ path } ` ;
let tab = this . tabs . find (( t ) => t . key === key ) ?? this . tabs . find (( t ) => t . path === path && ! mode );
if ( ! tab ) {
const preview = this . tabs . findIndex (( t ) => ! t . pinned );
tab = { key , path , mode : wanted , pinned : pin };
if ( preview >= 0 && ! pin ) {
this . dropView ( this . tabs [ preview ]. key );
this . tabs [ preview ] = tab ;
} else {
this . tabs . push ( tab );
while ( this . tabs . length > 12 ) this . closeTab ( this . tabs . find (( t ) => t . key !== key ). key , { render : false });
}
} else if ( pin ) {
tab . pinned = true ;
}
this . pushRecent ( path );
this . activate ( tab . key );
if ( this . root . classList . contains ( 'is-narrow' )) this . setExplorer ( false );
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
pin ( path ) {
const tab = this . tabs . find (( t ) => t . path === path );
if ( tab ) {
tab . pinned = true ;
this . saveState ();
this . renderTabs ();
}
}
2026-10-08 01:16:37 -04:00
2026-10-08 01:40:47 -04:00
activate ( key ) {
this . active = key ;
this . saveState ();
this . renderTabs ();
this . renderEditor ();
this . renderExplorer ();
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
closeTab ( key , { render = true } = {}) {
const i = this . tabs . findIndex (( t ) => t . key === key );
if ( i < 0 ) return ;
this . tabs . splice ( i , 1 );
this . dropView ( key );
if ( this . active === key ) this . active = ( this . tabs [ i ] ?? this . tabs [ i - 1 ]) ? . key ?? null ;
this . saveState ();
if ( render ) {
this . renderTabs ();
this . renderEditor ();
this . renderExplorer ();
}
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
dropView ( key ) {
this . views . get ( key ) ? . editor ? . destroy ();
this . views . get ( key ) ? . node . remove ();
this . views . delete ( key );
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
renderTabs () {
const nodes = this . tabs . map (( tab ) => {
const name = base ( tab . path );
const change = this . changeFor ( tab . path );
const status = change ? statusOf ( change ) : null ;
return el ( 'div' , {
class : `dd-tab ${ tab . key === this . active ? ' is-active' : '' }${ tab . pinned ? '' : ' is-preview' }${ status ? ` git- ${ status [ 2 ] } ` : '' } ` ,
role : 'tab' , 'aria-selected' : String ( tab . key === this . active ), tabindex : tab . key === this . active ? '0' : '-1' ,
title : ` ${ tab . path }${ tab . mode === 'diff' ? ' (diff against HEAD)' : '' }${ tab . pinned ? '' : ' · preview: the next file replaces it; double-click to keep' } ` ,
onclick : () => this . activate ( tab . key ),
ondblclick : () => this . pin ( tab . path ),
onauxclick : ( event ) => {
if ( event . button === 1 ) this . closeTab ( tab . key );
},
onkeydown : ( event ) => {
if ( event . key === 'Enter' ) this . activate ( tab . key );
},
},
el ( 'span' , { class : `dd-ficon tone- ${ iconFor ( name )[ 1 ] } ` , 'aria-hidden' : 'true' }, iconFor ( name )[ 0 ]),
el ( 'span' , { class : 'dd-tab-name' }, name ),
tab . mode === 'diff' ? el ( 'span' , { class : 'dd-tab-tag' }, 'diff' ) : null ,
el ( 'button' , {
type : 'button' , class : 'dd-tab-close' , 'aria-label' : `Close ${ name } ` , title : 'Close' ,
onclick : ( event ) => {
event . stopPropagation ();
this . closeTab ( tab . key );
},
}, '× ' ));
});
this . tabStrip . replaceChildren (... nodes );
this . tabStrip . querySelector ( '.is-active' ) ? . scrollIntoView ({ block : 'nearest' , inline : 'nearest' });
}
// ------------------------------------------------------------ editor
renderEditor () {
for ( const [ key , v ] of this . views ) v . node . hidden = key !== this . active ;
let empty = this . editorArea . querySelector ( '.dd-editor-empty' );
if ( ! this . active ) {
if ( ! empty ) {
empty = el ( 'div' , { class : 'dd-editor-empty' });
this . editorArea . append ( empty );
}
empty . hidden = false ;
this . renderEmpty ( empty );
return ;
}
if ( empty ) empty . hidden = true ;
const view = this . views . get ( this . active );
if ( ! view ) this . loadTab ( this . active );
else if ( view . stale ) this . reloadTab ( this . active , { quiet : true });
}
renderEmpty ( node ) {
const recent = this . recent ();
const n = this . changes ? . files ? . length ?? 0 ;
node . replaceChildren (...[
el ( 'div' , { class : 'dd-empty-title' }, this . session ? `No file open in ${ this . session } ` : 'No session' ),
el ( 'div' , { class : 'dd-empty-actions' },
el ( 'button' , { type : 'button' , class : 'dd-viewer-btn' , onclick : () => this . quickOpen . open () }, 'Go to file… Ctrl+P' ),
n ? el ( 'button' , { type : 'button' , class : 'dd-viewer-btn' , onclick : () => this . setView ( 'changes' ) }, `Review ${ n } change ${ n === 1 ? '' : 's' } ` ) : null ),
recent . length ? el ( 'div' , { class : 'dd-empty-recent' },
el ( 'div' , { class : 'dd-empty-label' }, 'Recent' ),
... recent . map (( path ) => el ( 'button' , { type : 'button' , class : 'dd-recent' , title : path , onclick : () => this . openFile ( path , { pin : true }) },
el ( 'span' , { class : `dd-ficon tone- ${ iconFor ( base ( path ))[ 1 ] } ` }, iconFor ( base ( path ))[ 0 ]),
el ( 'span' , { class : 'dd-recent-name' }, base ( path )),
el ( 'span' , { class : 'dd-change-dir' }, path . includes ( '/' ) ? path . slice ( 0 , path . lastIndexOf ( '/' )) : '' )))) : null ,
el ( 'div' , { class : 'dd-empty-hint' }, 'Click a file for a preview tab, double-click to keep it open. Esc returns to the terminal.' ),
]. filter ( Boolean ));
}
async fetchTab ( tab ) {
const change = this . changeFor ( tab . path );
const deleted = change && ( change . x === 'D' || change . y === 'D' );
const [ blob , original ] = await Promise . all ([
deleted ? Promise . resolve ({ header : { path : tab . path , size : 0 , utf8 : true , body : true }, base64 : '' }) : runBlob ([ '--fs-read' , this . session , tab . path ]),
tab . mode === 'diff' ? runBlob ([ '--git-show' , this . session , tab . path ]) : Promise . resolve ( null ),
]);
return { blob , original };
}
async loadTab ( key ) {
const tab = this . tabs . find (( t ) => t . key === key );
if ( ! tab ) return ;
const node = el ( 'div' , { class : 'dd-tab-view' }, el ( 'div' , { class : 'dd-files-msg' }, 'Loading…' ));
this . editorArea . append ( node );
const view = { node , editor : null , header : null , stale : false };
this . views . set ( key , view );
node . hidden = key !== this . active ;
2026-10-08 01:16:37 -04:00
const session = this . session ;
try {
2026-10-08 01:40:47 -04:00
const [{ blob , original }, editor ] = await Promise . all ([ this . fetchTab ( tab ), loadEditor (). catch (( error ) => ({ error }))]);
if ( session !== this . session || this . views . get ( key ) !== view ) return ;
this . fill ( view , tab , blob , original , editor );
2026-10-08 01:16:37 -04:00
} catch ( error ) {
2026-10-08 01:40:47 -04:00
if ( this . views . get ( key ) !== view ) return ;
node . replaceChildren ( this . bar ( tab , null ), el ( 'div' , { class : 'dd-files-msg is-error' }, error . message ));
}
}
// Claude changed files: re-read; if the content differs, swap it in place
// and keep the scroll position (read-only, so nothing of yours is lost).
async reloadTab ( key , { quiet }) {
const tab = this . tabs . find (( t ) => t . key === key );
const view = this . views . get ( key );
if ( ! tab || ! view ) {
if ( tab ) this . loadTab ( key );
2026-10-08 01:16:37 -04:00
return ;
}
2026-10-08 01:40:47 -04:00
view . stale = false ;
try {
const session = this . session ;
const [{ blob , original }, editor ] = await Promise . all ([ this . fetchTab ( tab ), loadEditor (). catch (( error ) => ({ error }))]);
if ( session !== this . session || this . views . get ( key ) !== view ) return ;
if ( blob . header . sha && blob . header . sha === view . header ? . sha && tab . mode !== 'diff' ) return ;
const scroller = view . node . querySelector ( '.cm-scroller' );
const top = scroller ? . scrollTop ?? 0 ;
view . editor ? . destroy ();
this . fill ( view , tab , blob , original , editor );
const next = view . node . querySelector ( '.cm-scroller' );
if ( next ) next . scrollTop = top ;
if ( quiet && view . header ? . sha !== blob . header . sha ) view . node . querySelector ( '.dd-viewer-meta' ) ? . classList . add ( 'is-flash' );
} catch {
// gone or unreadable: keep what's shown
}
}
2026-10-08 01:16:37 -04:00
2026-10-08 01:40:47 -04:00
bar ( tab , header ) {
const change = this . changeFor ( tab . path );
const meta = header ? [
size ( header . size ?? 0 ),
header . mtime ? `changed ${ ago ( header . mtime ) } ` : '' ,
header . utf8 === false ? 'Latin-1' : '' ,
tab . mode === 'diff' ? 'diff against HEAD' : '' ,
change ? statusOf ( change )[ 1 ] : '' ,
change ? . claude ? 'edited by Claude' : '' ,
]. filter ( Boolean ). join ( ' · ' ) : '' ;
const button = ( label , run , extra = {}) => el ( 'button' , { type : 'button' , class : 'dd-viewer-btn' , onclick : run , ... extra }, label );
const switchMode = ( mode ) => this . openFile ( tab . path , { mode , pin : true });
2026-10-08 01:16:37 -04:00
const actions = [];
2026-10-08 01:40:47 -04:00
if ( tab . mode !== 'diff' && isMarkdown ( tab . path )) actions . push ( button ( tab . mode === 'preview' ? 'Source' : 'Preview' , () => switchMode ( tab . mode === 'preview' ? 'view' : 'preview' )));
2026-10-08 01:16:37 -04:00
if ( change && change . x !== '?' && change . y !== '?' && this . changes ? . git ) {
2026-10-08 01:40:47 -04:00
actions . push ( button ( tab . mode === 'diff' ? 'File' : 'Diff' , () => switchMode ( tab . mode === 'diff' ? ( isMarkdown ( tab . path ) ? 'preview' : 'view' ) : 'diff' )));
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
if ( tab . mode !== 'preview' ) {
actions . push ( button ( this . wrap ? 'No wrap' : 'Wrap' , () => {
2026-10-08 01:16:37 -04:00
this . wrap = ! this . wrap ;
2026-10-08 01:40:47 -04:00
writeJson ( 'wrap' , this . wrap );
for ( const key of [... this . views . keys ()]) this . dropView ( key );
this . renderEditor ();
}, { title : 'Wrap long lines' }));
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
actions . push ( button ( 'Reveal' , () => this . reveal ( tab . path ), { title : 'Show in the explorer' }));
actions . push ( button ( 'Copy' , () => navigator . clipboard ? . writeText ( tab . path ). then (() => toast . success ( 'Path copied' ), () => toast . error ( 'Clipboard refused' )), { title : 'Copy the path' }));
actions . push ( button ( '@Claude' , () => {
this . hooks . paste ( `@ ${ tab . path } ` );
toast . info ( `Mentioned @ ${ tab . path } in the prompt` );
}, { title : 'Type @path into the current prompt so Claude reads the file' }));
return el ( 'div' , { class : 'dd-viewer-bar' },
el ( 'span' , { class : 'dd-viewer-where' , title : [ tab . path , meta ]. filter ( Boolean ). join ( '\n' ) },
el ( 'span' , { class : 'dd-viewer-path' }, tab . path ),
meta ? el ( 'span' , { class : 'dd-viewer-meta' }, ` · ${ meta } ` ) : null ),
el ( 'span' , { class : 'dd-viewer-actions' }, ... actions ));
}
2026-10-08 01:16:37 -04:00
2026-10-08 01:40:47 -04:00
fill ( view , tab , blob , original , editor ) {
const { header , base64 } = blob ;
view . header = header ;
const text = ( b64 , utf8 = true ) => new TextDecoder ( utf8 ? 'utf-8' : 'latin1' ). decode ( decodeBase64 ( b64 || '' ));
const content = el ( 'div' , { class : 'dd-viewer-content' });
2026-10-08 01:16:37 -04:00
if ( header . tooLarge ) {
content . append ( el ( 'div' , { class : 'dd-files-msg' }, `This file is ${ size ( header . size ) } ; the viewer opens files up to ${ size ( header . max ) } .` ));
} else if ( header . mime ? . startsWith ( 'image/' ) && base64 ) {
content . classList . add ( 'is-image' );
2026-10-08 01:40:47 -04:00
content . append ( el ( 'img' , { src : `data: ${ header . mime } ;base64, ${ base64 } ` , alt : tab . path }));
2026-10-08 01:16:37 -04:00
} else if ( header . binary || ! header . body ) {
content . append ( el ( 'div' , { class : 'dd-files-msg' }, `Binary file ( ${ size ( header . size ) } ).` ));
} else if ( editor ? . error ) {
content . append ( el ( 'pre' , { class : 'dd-plain' }, text ( base64 , header . utf8 )));
2026-10-08 01:40:47 -04:00
} else if ( tab . mode === 'preview' ) {
2026-10-08 01:16:37 -04:00
content . classList . add ( 'is-preview' );
2026-10-08 01:40:47 -04:00
const frame = el ( 'iframe' , {
class : 'dd-md-frame' , title : `Preview of ${ tab . path } ` ,
2026-10-08 01:16:37 -04:00
sandbox : 'allow-popups allow-popups-to-escape-sandbox' , srcdoc : previewDocument ( editor . markdown ( text ( base64 ))),
2026-10-08 01:40:47 -04:00
});
content . append ( frame );
inlineImages ( editor . markdown ( text ( base64 )), this . session , tab . path ). then (( html ) => {
2026-10-08 01:16:37 -04:00
if ( frame . isConnected ) frame . srcdoc = previewDocument ( html );
});
2026-10-08 01:40:47 -04:00
} else if ( tab . mode === 'diff' ) {
view . editor = editor . diff ( content , { original : text ( original ? . base64 ), modified : text ( base64 ), path : tab . path , wrap : this . wrap });
2026-10-08 01:16:37 -04:00
} else {
2026-10-08 01:40:47 -04:00
view . editor = editor . viewer ( content , { doc : text ( base64 , header . utf8 ), path : tab . path , wrap : this . wrap });
2026-10-08 01:16:37 -04:00
}
2026-10-08 01:40:47 -04:00
view . node . replaceChildren ( this . bar ( tab , header ), content );
}
2026-10-08 01:16:37 -04:00
2026-10-08 01:40:47 -04:00
// Theme switch: the editor follows CSS variables; previews need a re-render.
2026-10-08 01:16:37 -04:00
themeChanged () {
2026-10-08 01:40:47 -04:00
for ( const tab of this . tabs ) {
if ( tab . mode === 'preview' ) this . dropView ( tab . key );
}
if ( this . isOpen ) this . renderEditor ();
2026-10-08 01:16:37 -04:00
}
}