/** * KGDebugger - Global debugging utility for KGSP * Provides console access to internal methods for testing and debugging */ import { KGCore } from './KGCore'; import { KGMidiRegion } from './region/KGMidiRegion'; import { convertRegionToABCNotation, convertBeatRangeChordProgressionToABCNotation } from '../util/abcNotationUtil'; import { extractXMLFromString } from '../util/xmlUtil'; import { AgentCore } from '../agent/core/AgentCore'; import { AVAILABLE_TOOLS } from '../agent/tools'; import type { TimeSignature } from '../types/projectTypes'; import { useProjectStore } from '../stores/projectStore'; /** * Global debugger singleton for console-based testing */ export class KGDebugger { // Private static instance for singleton pattern private static _instance: KGDebugger | null = null; // Private constructor to prevent direct instantiation private constructor() { console.log("šŸ”§ KGDebugger initialized - Available methods:", [ 'convertSelectedRegionToABCNotation(startFromBeat?)', 'convertSelectedRegionChordProgressionToABCNotation()', 'testQuantizeDuration(durationBeats, timeSignature?)', 'debugSelectedItems()', 'createTestRegion()', 'testExtractXMLFromString(input)', 'testToolCall(jsonInput)', 'inputChatBox(content, interval?)', 'inputKGOneCaption(content, interval?)', 'inputKGOneLyrics(content, interval?)', 'opfs(command)', 'startShell()', ]); } /** * Get the singleton instance of KGDebugger */ public static instance(): KGDebugger { if (!KGDebugger._instance) { KGDebugger._instance = new KGDebugger(); } return KGDebugger._instance; } /** * Convert the currently selected region to ABC notation * @param startFromBeat - Optional absolute beat position to start from (defaults to region start) */ public convertSelectedRegionToABCNotation(startFromBeat?: number): void { const core = KGCore.instance(); const selectedItems = core.getSelectedItems(); let midiRegion: KGMidiRegion | null = null; // First try to find MIDI region in selected items if (selectedItems && selectedItems.length > 0) { console.log(`šŸ“ Converting selected region to ABC notation...`); console.log(`šŸ“Š Selected items count: ${selectedItems.length}`); midiRegion = selectedItems.find(item => item.getCurrentType() === 'KGMidiRegion' ) as KGMidiRegion; } // If no MIDI region selected, try to use active region from piano roll if (!midiRegion) { console.log("šŸ“ No MIDI region selected, checking for active region..."); const storeState = useProjectStore.getState(); const activeRegionId = storeState.activeRegionId; const tracks = storeState.tracks; if (activeRegionId) { // Find the active region in tracks for (const track of tracks) { const regions = track.getRegions(); const region = regions.find(r => r.getId() === activeRegionId); if (region && region instanceof KGMidiRegion) { midiRegion = region; console.log(`āœ… Found active region: "${region.getName()}"`); break; } } } } if (!midiRegion) { console.error("āŒ No MIDI region found."); console.log("šŸ’” Try one of these:"); console.log(" • Select a region in the track grid"); console.log(" • Open a region in the piano roll editor"); return; } // Use provided startFromBeat or default to region start const effectiveStartBeat = startFromBeat ?? midiRegion.getStartFromBeat(); console.log(`šŸŽµ Converting region: "${midiRegion.getName()}"`); console.log(`šŸ“ Region starts at beat: ${midiRegion.getStartFromBeat()}`); console.log(`šŸ“ Conversion starts at beat: ${effectiveStartBeat}`); console.log(`šŸŽ¼ Notes in region: ${midiRegion.getNotes().length}`); try { const abcNotation = convertRegionToABCNotation(midiRegion, effectiveStartBeat); console.log("āœ… ABC Notation conversion successful!"); console.log("šŸ“„ Result:"); console.log("─".repeat(50)); console.log(abcNotation); console.log("─".repeat(50)); // Also copy to clipboard if possible if (navigator.clipboard) { navigator.clipboard.writeText(abcNotation).then(() => { console.log("šŸ“‹ ABC notation copied to clipboard!"); }).catch(() => { console.log("šŸ“‹ Could not copy to clipboard (requires HTTPS)"); }); } } catch (error) { console.error("āŒ Error converting to ABC notation:", error); } } /** * Convert the chord progression that overlaps the currently selected MIDI region to ABC notation */ public convertSelectedRegionChordProgressionToABCNotation(): void { const core = KGCore.instance(); const selectedItems = core.getSelectedItems(); let midiRegion: KGMidiRegion | null = null; if (selectedItems.length > 0) { midiRegion = selectedItems.find(item => item.getCurrentType() === 'KGMidiRegion') as KGMidiRegion; } if (!midiRegion) { const storeState = useProjectStore.getState(); const activeRegionId = storeState.activeRegionId; const tracks = storeState.tracks; if (activeRegionId) { for (const track of tracks) { const region = track.getRegions().find(candidate => candidate.getId() === activeRegionId); if (region instanceof KGMidiRegion) { midiRegion = region; break; } } } } if (!midiRegion) { console.error('āŒ No MIDI region found.'); console.log('šŸ’” Try one of these:'); console.log(' • Select a region in the track grid'); console.log(' • Open a region in the piano roll editor'); return; } const startBeat = midiRegion.getStartFromBeat(); const endBeat = startBeat + midiRegion.getLength(); console.log(`šŸŽ¼ Reading chord progression for region: "${midiRegion.getName()}"`); console.log(`šŸ“ Range: beats ${startBeat}-${endBeat}`); try { const output = convertBeatRangeChordProgressionToABCNotation( KGCore.instance().getCurrentProject(), startBeat, endBeat, ); console.log('āœ… Chord progression conversion successful!'); console.log('šŸ“„ Result:'); console.log('─'.repeat(50)); console.log(output); console.log('─'.repeat(50)); if (navigator.clipboard) { navigator.clipboard.writeText(output).then(() => { console.log('šŸ“‹ Chord progression notation copied to clipboard!'); }).catch(() => { console.log('šŸ“‹ Could not copy to clipboard (requires HTTPS)'); }); } } catch (error) { console.error('āŒ Error converting chord progression:', error); } } /** * Test the quantization duration method with a specific duration * @param durationBeats - Duration in beats to test * @param timeSignature - Optional time signature (defaults to project time signature) */ public testQuantizeDuration(durationBeats: number, timeSignature?: TimeSignature): void { const core = KGCore.instance(); const project = core.getCurrentProject(); const effectiveTimeSignature = timeSignature ?? project.getTimeSignature(); console.log(`🧮 Testing quantization for ${durationBeats} beats...`); console.log(`ā±ļø Time signature: ${effectiveTimeSignature.numerator}/${effectiveTimeSignature.denominator}`); // Import quantization testing (we'll need to expose some internal methods) // For now, let's create a simple test const ticksPerBeat = 480 * (4 / effectiveTimeSignature.denominator); const durationTicks = Math.round(durationBeats * ticksPerBeat); console.log(`šŸŽµ Input: ${durationBeats} beats = ${durationTicks} ticks`); // Test different quantization values manually for demonstration const testValues = [ { name: '1/1', ticks: 1920 }, { name: '1/2', ticks: 960 }, { name: '1/3', ticks: 640 }, { name: '1/4', ticks: 480 }, { name: '1/6', ticks: 320 }, { name: '1/8', ticks: 240 }, { name: '1/12', ticks: 160 }, { name: '1/16', ticks: 120 } ]; console.log("šŸ“Š Quantization analysis:"); let bestMatch = { name: '1/4', error: Infinity, ticks: 480 }; testValues.forEach(val => { const remainder = durationTicks % val.ticks; const error = Math.min(remainder, val.ticks - remainder); const errorPercent = ((error / val.ticks) * 100).toFixed(1); if (error < bestMatch.error) { bestMatch = { name: val.name, error, ticks: val.ticks }; } console.log(` ${val.name}: ${error} ticks error (${errorPercent}%)`); }); const quantizedTicks = Math.round(durationTicks / bestMatch.ticks) * bestMatch.ticks; const quantizedBeats = quantizedTicks / ticksPerBeat; console.log(`āœ… Best match: ${bestMatch.name} grid`); console.log(`šŸŽÆ Quantized: ${quantizedBeats} beats = ${quantizedTicks} ticks`); console.log(`šŸ“ Difference: ${Math.abs(durationBeats - quantizedBeats).toFixed(4)} beats`); } /** * Debug currently selected items */ public debugSelectedItems(): void { const core = KGCore.instance(); const selectedItems = core.getSelectedItems(); console.log(`šŸ” Currently selected items: ${selectedItems.length}`); if (selectedItems.length === 0) { console.log("šŸ“ No items selected. Try selecting regions or notes first."); return; } selectedItems.forEach((item, index) => { const type = item.getCurrentType(); const id = item.getId(); console.log(` ${index + 1}. ${type} (ID: ${id})`); if (type === 'KGMidiRegion') { const region = item as KGMidiRegion; console.log(` šŸ“ Position: ${region.getStartFromBeat()} beats`); console.log(` šŸ“ Length: ${region.getLength()} beats`); console.log(` šŸŽµ Notes: ${region.getNotes().length}`); console.log(` šŸ“› Name: "${region.getName()}"`); } }); } /** * Create a test region with sample notes for testing (future implementation) */ public createTestRegion(): void { console.log("🚧 createTestRegion() - Not implemented yet"); console.log("šŸ’” This method would create a region with sample notes for testing"); console.log("šŸ’” For now, please create regions manually in the DAW interface"); } /** * Test the extractXMLFromString utility function with a given input * @param input - String that may contain XML blocks to extract */ public testExtractXMLFromString(input: string): void { console.log("šŸ” Testing extractXMLFromString utility..."); console.log("šŸ“ Input string:"); console.log("─".repeat(50)); console.log(input); console.log("─".repeat(50)); try { const xmlBlocks = extractXMLFromString(input); console.log(`āœ… Extraction successful! Found ${xmlBlocks.length} XML block(s):`); if (xmlBlocks.length === 0) { console.log("šŸ“­ No XML blocks found in the input string."); console.log("šŸ’” Try input with XML tags like: ..."); } else { xmlBlocks.forEach((block, index) => { console.log("\nšŸ“„ XML Block " + (index + 1) + ":"); console.log("─".repeat(30)); console.log(block); console.log("─".repeat(30)); }); // Copy all blocks to clipboard if possible if (navigator.clipboard && xmlBlocks.length > 0) { const allBlocks = xmlBlocks.join('\n\n'); navigator.clipboard.writeText(allBlocks).then(() => { console.log("šŸ“‹ XML blocks copied to clipboard!"); }).catch(() => { console.log("šŸ“‹ Could not copy to clipboard (requires HTTPS)"); }); } } } catch (error) { console.error("āŒ Error extracting XML:", error); } } /** * Test native tool calling by executing tool calls from a JSON string. * Accepts a single tool call object or an array of tool call objects. * * Usage examples in browser console: * * // Single tool call: * await KGStudio.KGDebugger.testToolCall('{"name":"read_music","arguments":{"start":0,"length":8}}') * * // Multiple tool calls: * await KGStudio.KGDebugger.testToolCall('[{"name":"remove_notes","arguments":{"start":0,"end_beat":4}},{"name":"add_notes","arguments":{"notes":[{"pitch":"C4","start":0,"length":1}]}}]') * * // Can also pass a JS object directly (no need to stringify): * await KGStudio.KGDebugger.testToolCall({name:"read_music",arguments:{start:0}}) * * @param input - JSON string, object, or array of tool call(s). * Each tool call should have: { name: string, arguments: object } */ public async testToolCall(input: string | Record | Record[]): Promise { try { // Parse input let calls: Array<{ name: string; arguments: Record }>; if (typeof input === 'string') { const parsed = JSON.parse(input); calls = Array.isArray(parsed) ? parsed : [parsed]; } else if (Array.isArray(input)) { calls = input as Array<{ name: string; arguments: Record }>; } else { calls = [input as { name: string; arguments: Record }]; } console.log(`šŸ”§ Executing ${calls.length} tool call(s)...\n`); for (let i = 0; i < calls.length; i++) { const call = calls[i]; const toolName = call.name; const toolArgs = call.arguments ?? {}; console.log(`── Tool call ${i + 1}/${calls.length}: ${toolName}`); console.log(` Arguments: ${JSON.stringify(toolArgs, null, 2)}`); const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS]; if (!ToolClass) { console.error(` āŒ Unknown tool: "${toolName}". Available tools: ${Object.keys(AVAILABLE_TOOLS).join(', ')}`); continue; } const toolInstance = new ToolClass(); const result = await toolInstance.execute(toolArgs); // Sync UI state on success if (result.success) { useProjectStore.getState().refreshProjectState(); } const icon = result.success ? 'āœ…' : 'āŒ'; console.log(` ${icon} Success: ${result.success}`); console.log(` Result: ${result.result}\n`); } console.log('šŸ”§ Tool execution complete.'); } catch (error) { console.error('āŒ Error in testToolCall:', error); console.log('šŸ’” Expected format: {"name":"tool_name","arguments":{...}}'); console.log(' Or an array: [{"name":"tool1","arguments":{...}}, ...]'); } } /** * Show help information */ public help(): void { console.log("šŸ”§ KGDebugger Help"); console.log("Available methods:"); console.log(" convertSelectedRegionToABCNotation(startFromBeat?) - Convert selected region to ABC"); console.log(" convertSelectedRegionChordProgressionToABCNotation() - Convert selected region chord progression to dual ABC views"); console.log(" testQuantizeDuration(beats, timeSignature?) - Test quantization logic"); console.log(" debugSelectedItems() - Show info about selected items"); console.log(" createTestRegion() - Create test region (not implemented)"); console.log(" testExtractXMLFromString(input) - Test XML extraction from string"); console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results"); console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter"); console.log(" inputKGOneCaption(content, interval?) - Type into KGOne Caption textarea (Full Song tab)"); console.log(" inputKGOneLyrics(content, interval?) - Type into KGOne Lyrics textarea (Full Song tab)"); console.log(" opfs(command) - OPFS file browser (pwd, ls, cd, cat, dl, rm)"); console.log(" startShell() - Start interactive OPFS shell (prompt-based loop)"); console.log(" help() - Show this help"); console.log(""); console.log("šŸ’” Usage tips:"); console.log(" - Select regions in the DAW first, then run debug methods"); console.log(" - Results are logged to console and copied to clipboard when possible"); console.log(" - Use browser developer tools for best experience"); console.log(""); console.log("šŸ’” testToolCall examples:"); console.log(' await KGStudio.KGDebugger.testToolCall(\'{"name":"read_music","arguments":{"start":0,"length":8}}\')'); console.log(' await KGStudio.KGDebugger.testToolCall({name:"add_notes",arguments:{notes:[{pitch:"C4",start:0,length:1}]}})'); console.log(' await KGStudio.KGDebugger.testToolCall([{name:"remove_notes",arguments:{start:0,end_beat:4}},{name:"read_music",arguments:{}}])'); console.log(""); console.log("šŸ’” KGOne input examples:"); console.log(' await KGStudio.KGDebugger.inputKGOneCaption("Genre: Eurodance, 90s dance-pop, upbeat electronic...", 30)'); console.log(' await KGStudio.KGDebugger.inputKGOneLyrics("[Verse 1]\\nYour lyrics here...\\n\\n[Chorus]\\n...", 30)'); } /** * Type content into ChatBox textarea character-by-character and submit with Enter. * Honors auto-resize (by dispatching 'input' events) and ChatBox's Enter-to-send behavior. * @param content - The text to type into the chat input * @param interval - Delay in ms between characters (default 30ms) */ public async inputChatBox(content: string, interval: number = 30): Promise { try { const textarea = document.querySelector('textarea.chatbox-input') as HTMLTextAreaElement | null; if (!textarea) { console.error('āŒ ChatBox textarea not found. Ensure ChatBox is mounted and visible.'); return; } // Focus to trigger ChatBox focus handlers and ensure caret/attribute setup textarea.focus(); // Use native value setter to keep React's value tracker in sync const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; const setValue = (val: string) => { if (valueSetter) { valueSetter.call(textarea, val); } else { textarea.value = val; } }; // Helper to dispatch an InputEvent so React's onChange fires const dispatchInput = (data?: string) => { const ev = typeof InputEvent !== 'undefined' ? new InputEvent('input', { bubbles: true, data, inputType: 'insertText' }) : new Event('input', { bubbles: true }); textarea.dispatchEvent(ev); }; // Start from empty content to simulate a fresh user input setValue(''); dispatchInput(''); // Helper: sleep const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); let typed = ''; for (let i = 0; i < content.length; i++) { typed += content[i]; // Update the controlled textarea and dispatch input so React onChange fires setValue(typed); dispatchInput(content[i]); // Place caret at end for realism try { textarea.selectionStart = textarea.selectionEnd = typed.length; } catch { // noop } if (interval > 0) { await sleep(interval); } } // Give React a brief moment to commit the last setState and run auto-resize effect await sleep(Math.max(30, interval)); // Simulate pressing Enter to submit (ChatBox listens on keydown) const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true } as KeyboardEventInit & { keyCode: number; which: number }); textarea.dispatchEvent(enterEvent); // Optional: follow-up keyup to mirror real typing (some UIs inspect it) const keyupEvent = new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, } as KeyboardEventInit & { keyCode: number; which: number }); textarea.dispatchEvent(keyupEvent); } catch (error) { console.error('āŒ Error in inputChatBox:', error); } } /** * Type content into K.G.One Caption textarea character-by-character. * Honors auto-resize (by dispatching 'input' events). * Note: Unlike ChatBox, this does NOT auto-submit (no Enter key dispatch). * @param content - The text to type into the caption input * @param interval - Delay in ms between characters (default 30ms) */ public async inputKGOneCaption(content: string, interval: number = 30): Promise { try { const label = Array.from(document.querySelectorAll('label.kgone-label')).find( el => el.textContent?.trim() === 'Caption' ); if (!label) { console.error('āŒ Caption label not found. Ensure KGOne panel is visible with Full Song tab selected.'); return; } const textarea = label.nextElementSibling as HTMLTextAreaElement | null; if (!textarea || textarea.tagName !== 'TEXTAREA') { console.error('āŒ Caption textarea not found after the label.'); return; } textarea.focus(); const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; const setValue = (val: string) => { if (valueSetter) { valueSetter.call(textarea, val); } else { textarea.value = val; } }; const dispatchInput = (data?: string) => { const ev = typeof InputEvent !== 'undefined' ? new InputEvent('input', { bubbles: true, data, inputType: 'insertText' }) : new Event('input', { bubbles: true }); textarea.dispatchEvent(ev); }; setValue(''); dispatchInput(''); const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); let typed = ''; for (let i = 0; i < content.length; i++) { typed += content[i]; setValue(typed); dispatchInput(content[i]); try { textarea.selectionStart = textarea.selectionEnd = typed.length; } catch { /* setting cursor position can fail in read-only or special inputs */ } textarea.scrollTop = textarea.scrollHeight; if (interval > 0) { await sleep(interval); } } await sleep(Math.max(30, interval)); } catch (error) { console.error('āŒ Error in inputKGOneCaption:', error); } } /** * Type content into K.G.One Lyrics textarea character-by-character. * Honors auto-resize (by dispatching 'input' events). * Note: Unlike ChatBox, this does NOT auto-submit (no Enter key dispatch). * @param content - The text to type into the lyrics input * @param interval - Delay in ms between characters (default 30ms) */ public async inputKGOneLyrics(content: string, interval: number = 30): Promise { try { const label = Array.from(document.querySelectorAll('label.kgone-label')).find( el => el.textContent?.trim() === 'Lyrics' ); if (!label) { console.error('āŒ Lyrics label not found. Ensure KGOne panel is visible with Full Song tab selected.'); return; } const textarea = label.nextElementSibling as HTMLTextAreaElement | null; if (!textarea || textarea.tagName !== 'TEXTAREA') { console.error('āŒ Lyrics textarea not found after the label.'); return; } textarea.focus(); const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; const setValue = (val: string) => { if (valueSetter) { valueSetter.call(textarea, val); } else { textarea.value = val; } }; const dispatchInput = (data?: string) => { const ev = typeof InputEvent !== 'undefined' ? new InputEvent('input', { bubbles: true, data, inputType: 'insertText' }) : new Event('input', { bubbles: true }); textarea.dispatchEvent(ev); }; setValue(''); dispatchInput(''); const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); let typed = ''; for (let i = 0; i < content.length; i++) { typed += content[i]; setValue(typed); dispatchInput(content[i]); try { textarea.selectionStart = textarea.selectionEnd = typed.length; } catch { /* setting cursor position can fail in read-only or special inputs */ } textarea.scrollTop = textarea.scrollHeight; if (interval > 0) { await sleep(interval); } } await sleep(Math.max(30, interval)); } catch (error) { console.error('āŒ Error in inputKGOneLyrics:', error); } } /** * Start an interactive OPFS shell using browser prompt() dialogs. * Each prompt shows the current working directory. Type commands and click OK. * Click Cancel or type 'exit'/'quit' to end the session. */ public async startShell(): Promise { console.log('šŸ“Ÿ OPFS Interactive Shell'); console.log(' Commands: pwd, ls, cd , cat , dl , rm '); console.log(' Type "exit" or click Cancel to quit.\n'); let lastOutput = ''; while (true) { const cwd = '/' + this.opfsCwd.join('/'); const promptText = lastOutput ? `${lastOutput}\n\nopfs:${cwd}$ ` : `opfs:${cwd}$ `; const input = prompt(promptText); if (input === null) break; const trimmed = input.trim(); if (trimmed === '') continue; if (trimmed === 'exit' || trimmed === 'quit') break; // Capture console output during command execution const captured: string[] = []; const origLog = console.log; const origError = console.error; console.log = (...args: unknown[]) => { origLog(...args); captured.push(args.map(String).join(' ')); }; console.error = (...args: unknown[]) => { origError(...args); captured.push(args.map(String).join(' ')); }; try { await this.opfs(trimmed); } finally { console.log = origLog; console.error = origError; } lastOutput = captured.join('\n'); } console.log('šŸ“Ÿ Shell exited.'); } // --- OPFS Shell --- /** Current working directory path segments (relative to OPFS root) */ private opfsCwd: string[] = []; /** * Simplified bash-like shell for browsing the OPFS filesystem. * * Supported commands: * pwd — print current directory * ls — list files/folders (like ls -lla) * cd — change directory (supports .., /, relative, and quoted paths) * cat — print file contents * dl — download a file to your local machine * rm — remove a file or directory (recursive) * * Usage in console: * await KGDebugger.opfs('pwd') * await KGDebugger.opfs('ls') * await KGDebugger.opfs('cd projects') * await KGDebugger.opfs('cat project.json') */ public async opfs(command: string): Promise { const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []; const cmd = parts[0]; // Strip surrounding quotes from arguments const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' '); try { switch (cmd) { case 'pwd': console.log('/' + this.opfsCwd.join('/')); break; case 'ls': await this.opfsLs(); break; case 'cd': await this.opfsCd(arg); break; case 'cat': await this.opfsCat(arg); break; case 'dl': await this.opfsDl(arg); break; case 'rm': await this.opfsRm(arg); break; default: console.log(`opfs: command not found: ${cmd}`); console.log('Available commands: pwd, ls, cd , cat , dl , rm '); } } catch (error) { console.error(`opfs: ${error}`); } } private async opfsResolveCwd(): Promise { let dir = await navigator.storage.getDirectory(); for (const segment of this.opfsCwd) { dir = await dir.getDirectoryHandle(segment); } return dir; } private async opfsLs(): Promise { const dir = await this.opfsResolveCwd(); const entries: Array<{ kind: string; name: string; size: number; modified: string }> = []; for await (const entry of dir.values()) { if (entry.kind === 'file') { const fileHandle = entry as FileSystemFileHandle; const file = await fileHandle.getFile(); entries.push({ kind: 'file', name: entry.name, size: file.size, modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19), }); } else { entries.push({ kind: 'dir', name: entry.name + '/', size: 0, modified: '-', }); } } // Sort: directories first, then files, alphabetically within each group entries.sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1; return a.name.localeCompare(b.name); }); if (entries.length === 0) { console.log('(empty directory)'); return; } // Print header const cwdPath = '/' + this.opfsCwd.join('/'); console.log(`total ${entries.length} (${cwdPath})`); // Format like ls -lla const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4); for (const e of entries) { const typeChar = e.kind === 'dir' ? 'd' : '-'; const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'; const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen); console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`); } } private async opfsCd(path: string): Promise { if (!path || path === '') { // cd with no args goes to root this.opfsCwd = []; return; } let segments: string[]; if (path === '/') { this.opfsCwd = []; return; } else if (path.startsWith('/')) { // Absolute path segments = path.split('/').filter(Boolean); } else { // Relative path segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]; } // Resolve . and .. const resolved: string[] = []; for (const seg of segments) { if (seg === '.') continue; if (seg === '..') { resolved.pop(); } else { resolved.push(seg); } } // Verify the path exists let dir = await navigator.storage.getDirectory(); for (const seg of resolved) { try { dir = await dir.getDirectoryHandle(seg); } catch { console.error(`opfs: cd: no such directory: ${path}`); return; } } this.opfsCwd = resolved; } private async opfsCat(fileName: string): Promise { if (!fileName) { console.error('opfs: cat: missing file name'); return; } const dir = await this.opfsResolveCwd(); try { const fileHandle = await dir.getFileHandle(fileName); const file = await fileHandle.getFile(); const text = await file.text(); // Pretty-print JSON files if (fileName.endsWith('.json')) { try { const parsed = JSON.parse(text); console.log(JSON.stringify(parsed, null, 2)); } catch { console.log(text); } } else { console.log(text); } } catch { console.error(`opfs: cat: ${fileName}: No such file`); } } private async opfsDl(fileName: string): Promise { if (!fileName) { console.error('opfs: dl: missing file name'); return; } const dir = await this.opfsResolveCwd(); try { const fileHandle = await dir.getFileHandle(fileName); const file = await fileHandle.getFile(); const url = URL.createObjectURL(file); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); console.log(`downloaded: ${fileName} (${file.size} bytes)`); } catch { console.error(`opfs: dl: ${fileName}: No such file`); } } private async opfsRm(name: string): Promise { if (!name) { console.error('opfs: rm: missing file or directory name'); return; } const dir = await this.opfsResolveCwd(); try { await dir.removeEntry(name, { recursive: true }); console.log(`removed: ${name}`); } catch { console.error(`opfs: rm: ${name}: No such file or directory`); } } }