// Critical ext module bundle// Generated from /var/www/webx11.duckdns.org/spaces/newos4/modules/ext /* === editor.js === */ class EditorModule { constructor() { this.commands = {}; this.terminal = null; this._vfsPath = null; } requirements() { return { met: true, missing: [] }; } getHelpInfo() { return { 'edit': 'Open the code editor for a VFS file (usage: edit )', }; } registerCommands() { this.commands['edit'] = (args) => this.edit(args); } edit(args) { if (!args[0]) return this.terminal.print('Usage: edit ', 'warning'); const path = args.join(' '); const modal = document.getElementById('code-editor'); const title = document.getElementById('code-editor-title'); const textarea = document.getElementById('code-editor-textarea'); const gutter = document.getElementById('code-editor-gutter'); const statusEl = document.getElementById('code-editor-status'); if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error'); // Read existing file content or use empty for new files const content = this.terminal.fs.readFile(path); const fileContent = (content !== null && content !== false) ? content : ''; // Set up modal this._vfsPath = path; title.textContent = '๐Ÿ“ Editing: ' + path; textarea.value = fileContent; modal.style.display = 'flex'; textarea.focus(); // Editor enhancements: line numbers, Tab, Ctrl+S const updateGutter = () => { const lines = textarea.value.split('\n').length; let html = ''; for (let i = 1; i <= lines; i++) html += i + '\n'; gutter.textContent = ''; gutter.textContent = html; }; const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; }; const updateStatus = () => { const lines = textarea.value.split('\n').length; const chars = textarea.value.length; statusEl.textContent = `${lines} lines ยท ${chars} chars`; }; updateGutter(); updateStatus(); textarea.onscroll = syncScroll; textarea.oninput = () => { updateGutter(); updateStatus(); }; textarea.onkeydown = (e) => { if (e.key === 'Tab') { e.preventDefault(); insertTab(textarea, e.shiftKey); updateGutter(); } if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveBtn.click(); } }; function insertTab(el, shift) { const s = el.selectionStart, e2 = el.selectionEnd; if (shift) { const before = el.value.substring(0, s); const lineStart = before.lastIndexOf('\n') + 1; if (lineStart < s && el.value.substring(lineStart, lineStart + 1) === '\t') { el.value = el.value.substring(0, lineStart) + el.value.substring(lineStart + 1); el.selectionStart = el.selectionEnd = s - 1; } } else { el.value = el.value.substring(0, s) + '\t' + el.value.substring(e2); el.selectionStart = el.selectionEnd = s + 1; } } // Fullscreen toggle window._editorToggleFullscreen = () => { const content = modal.querySelector('.modal-content'); if (content.style.maxWidth === '100vw') { content.style.maxWidth = '90vw'; content.style.maxHeight = '90vh'; content.style.width = '900px'; content.style.height = '80vh'; } else { content.style.maxWidth = '100vw'; content.style.maxHeight = '100vh'; content.style.width = '100vw'; content.style.height = '100vh'; } }; // Override save button to write back to VFS const saveBtn = modal.querySelector('.modal-footer .btn:last-child'); const cancelBtn = modal.querySelector('.modal-footer .btn-danger'); if (saveBtn) { saveBtn._origOnclick = saveBtn.onclick; saveBtn.onclick = async () => { const code = textarea.value; const ok = this.terminal.fs.writeFile(this._vfsPath, code); if (ok) { this.terminal.print('โœ… Saved to ' + this._vfsPath, 'success'); // Mirror to _mods/ for test_module compatibility const modMatch = this._vfsPath.match(/(?:^|\/)(\w+)\.js$/); if (modMatch) { const modName = modMatch[1]; this.terminal.fs.mkdirRecursive('_mods'); this.terminal.fs.writeFile('_mods/' + modName + '.js', code); if (!this.terminal.fs.readFile('_mods/' + modName + '.orig.js')) { this.terminal.fs.writeFile('_mods/' + modName + '.orig.js', 'placeholder'); } this.terminal.print('๐Ÿ“‹ Mirrored to _mods/' + modName + '.js for live testing', 'info'); } // Notify tutorial module for quest 4 (edit + save) if (this.terminal.tutorialModule) { this.terminal.tutorialModule.onFileSaved(this._vfsPath); } } else { this.terminal.print('โŒ Failed to save ' + this._vfsPath, 'error'); } modal.style.display = 'none'; this._vfsPath = null; // Restore original onclick handler if (saveBtn._origOnclick) saveBtn.onclick = saveBtn._origOnclick; delete saveBtn._origOnclick; }; } if (cancelBtn) { cancelBtn._origOnclick = cancelBtn.onclick; cancelBtn.onclick = () => { modal.style.display = 'none'; this._vfsPath = null; if (cancelBtn._origOnclick) cancelBtn.onclick = cancelBtn._origOnclick; delete cancelBtn._origOnclick; }; } } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new EditorModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/editor.js'); } /* === shell.js === */ class ShellModule { constructor() { this.commands = {}; this.terminal = null; this._vars = { HOME: '/', USER: 'darkbot', SHELL: 'webx11-shell', TERM: 'xterm-256color', PWD: '/' }; this._scriptDepth = 0; this._maxScriptDepth = 3; } requirements() { return { met: true, missing: [] }; } getHelpInfo() { return { 'export': 'export = โ€” Set a shell variable', 'unset': 'unset โ€” Remove a shell variable', 'script': 'script โ€” Run a shell script from VFS', '$': '$ โ€” Execute with shell parsing (default: auto-detected)', 'sh': 'sh โ€” Run a shell script from VFS (alias for script)', 'set': 'set โ€” List all shell variables', }; } registerCommands() { this.commands['export'] = (args) => this._cmdExport(args); this.commands['unset'] = (args) => this._cmdUnset(args); this.commands['script'] = (args) => this._cmdScript(args); this.commands['sh'] = (args) => this._cmdScript(args); this.commands['set'] = () => this._cmdSet(); } // โ”€โ”€ Hook into terminal.executeCommand โ”€โ”€ init() { // Guard: prevent double-wrapping if init is called multiple times, and adopt the wrapper // if another instance already installed one (shell.js used to be loaded twice โ€” once in // critical-ext-bundle.js, once from main.html's extension list โ€” and the two copies could // disagree about their version). if (this._initialized) return; if (this.terminal && this.terminal._shellModule && this.terminal._shellModule !== this) { this._initialized = true; return; } this._initialized = true; // Wrap executeCommand to add shell parsing โ€” only for compound commands const origExecute = this.terminal.executeCommand.bind(this.terminal); const self = this; this.terminal.executeCommand = async function(rawInput) { if (!rawInput.trim()) return; this.markDirty(); this.printUserPrompt(rawInput); // Save to command history (preserved from original executeCommand) const cmdRaw = rawInput.trim(); if (this.history.length === 0 || this.history[this.history.length - 1] !== cmdRaw) { this.history.push(cmdRaw); } if (this.history.length > this._historyCap) { this.history = this.history.slice(-this._historyCap); } this.historyIndex = this.history.length; this._saveCommandHistory(); // For simple single-word commands without special chars, skip shell parsing if (/^[a-zA-Z0-9._\/-]+$/.test(rawInput.trim()) && !rawInput.includes(' ') && !rawInput.includes('$')) { try { return await this.security.checkAndRun(rawInput); } catch(e) { this.print('Error: ' + e.message, 'error'); return; } } // Substitute variables in the raw input const substituted = self._substituteVars(rawInput); // Split on ; first (sequence) const seqParts = self._splitSequence(substituted); for (const seqPart of seqParts) { if (!seqPart.trim()) continue; // Split on && and || โ€” operator now attached to the FOLLOWING segment const chainParts = self._splitChain(seqPart.trim()); let lastOk = true; for (const chainPart of chainParts) { if (!chainPart.cmd.trim()) continue; const { cmd: chainCmd, operator } = chainPart; // Check the operator that PRECEDES this command if (operator === '&&' && !lastOk) continue; if (operator === '||' && lastOk) continue; // Check for redirects const redirectInfo = self._extractRedirects(chainCmd); if (redirectInfo.hasRedirect) { // Same capture-and-write path the script runner uses. _executeWithRedirect // handed `>` to builtins, so `echo hi > /f` printed the operator instead of // writing the file. lastOk = await self._runCommand(chainCmd, { $: 0 }, { inScript: false }); } else { // Normal or piped command const pipeParts = this.splitPipe(chainCmd); if (pipeParts.length > 1) { await this.executePipeChain(pipeParts); } else { // Single command โ€” check for shell built-ins first const parts = chainCmd.split(/\s+/); const cmd = parts[0].toLowerCase(); // Globs expand at the prompt too โ€” the script runner did it and the // interactive path did not, so `ls *.txt` printed (empty) but the same // line inside a script worked. const args = self._expandGlobs(parts.slice(1), (this.fs && this.fs.currentPath) || []); const expandedCmd = [cmd].concat(args).join(' '); if (self.commands[cmd]) { try { this.markDirty(); await self.commands[cmd](args); lastOk = true; } catch(e) { this.print('Error: ' + e.message, 'error'); lastOk = false; } } else { try { await this.security.checkAndRun(expandedCmd); lastOk = true; } catch(e) { this.print('Error: ' + e.message, 'error'); lastOk = false; } } } } } } }; this.terminal._shellModule = this; // Modify pipe chain to add pipe-aware injection and security checks const origPipeChain = this.terminal.executePipeChain.bind(this.terminal); this.terminal.executePipeChain = async function(pipeParts) { // Write pipe input from previous segment as temp file for next segment const shellMod = this._shellModule; let pipeInput = null; for (let i = 0; i < pipeParts.length; i++) { const cmdText = pipeParts[i]; const parts = cmdText.split(/\s+/); const cmd = parts[0].toLowerCase(); let args = parts.slice(1); // Security gate โ€” replicate the original executePipeChain checks const cls = this.security._classify(cmdText); if (cls.destructive && this.security.level > 0) { this.print(`โš ๏ธ Security: Destructive command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error'); return; } if (cls.system && this.security.level > 0) { this.print(`โš ๏ธ Security: System command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error'); return; } if (!cls.known && this.security.level >= 2) { this.print(`โš ๏ธ Security: Unknown command "${cmd}" blocked in pipe (paranoid mode)`, 'error'); return; } this._pipeInput = pipeInput; if (pipeInput !== null) { // Write pipe input as temp file so file-reading commands can use it this.fs.writeFile('__pipebuf__', pipeInput); // Inject __pipebuf__ as file arg if command is pipe-aware (reads files for input) if (shellMod && shellMod._isPipeAware(cmd)) { // Don't inject if command already has enough file args if (!args.some(a => !a.startsWith('-') && a !== '|' && a !== '>')) { args = args.concat('__pipebuf__'); } } } try { if (i < pipeParts.length - 1) { pipeInput = await this.capturePipeOutput(cmd, args); } else { await this.dispatchCommand(cmd, args); } } finally { // Clean up temp file if (this.fs.readFile('__pipebuf__') !== null) { delete this.fs.root.content['__pipebuf__']; } } } this._pipeInput = null; }; } // โ”€โ”€ Tokenizer helpers โ”€โ”€ _splitSequence(input) { // Split on ; (not inside quotes) const parts = []; let current = ''; let inQuote = false; let quoteChar = ''; for (const ch of input) { if ((ch === '"' || ch === "'") && !inQuote) { inQuote = true; quoteChar = ch; current += ch; } else if (ch === quoteChar && inQuote) { inQuote = false; quoteChar = ''; current += ch; } else if (ch === ';' && !inQuote) { parts.push(current.trim()); current = ''; } else { current += ch; } } parts.push(current.trim()); return parts; } _splitChain(input) { // Split on && and ||, preserving operator info // Operator is attached to the FOLLOWING segment (the one it guards) const parts = []; let current = ''; let inQuote = false; let quoteChar = ''; let pendingOp = null; // operator from previous split, applies to next segment let i = 0; while (i < input.length) { const ch = input[i]; if ((ch === '"' || ch === "'") && !inQuote) { inQuote = true; quoteChar = ch; current += ch; i++; } else if (ch === quoteChar && inQuote) { inQuote = false; quoteChar = ''; current += ch; i++; } else if (!inQuote && input.substring(i, i+2) === '&&') { parts.push({ cmd: current.trim(), operator: pendingOp }); pendingOp = '&&'; current = ''; i += 2; } else if (!inQuote && input.substring(i, i+2) === '||') { parts.push({ cmd: current.trim(), operator: pendingOp }); pendingOp = '||'; current = ''; i += 2; } else { current += ch; i++; } } parts.push({ cmd: current.trim(), operator: pendingOp }); return parts; } _extractRedirects(input) { // Find >, >>, < outside quotes let cmd = ''; let hasRedirect = false; let redirectOp = ''; let redirectFile = ''; let append = false; let inQuote = false, quoteChar = ''; let i = 0; const parts = []; let current = ''; while (i < input.length) { const ch = input[i]; if ((ch === '"' || ch === "'") && !inQuote) { inQuote = true; quoteChar = ch; current += ch; i++; } else if (ch === quoteChar && inQuote) { inQuote = false; quoteChar = ''; current += ch; i++; } else if (!inQuote) { if (input.substring(i, i+2) === '>>') { if (current.trim()) { parts.push(current.trim()); current = ''; } hasRedirect = true; redirectOp = '>>'; append = true; i += 2; // Read until next redirect or end let file = ''; while (i < input.length && input[i] === ' ') i++; while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { file += input[i]; i++; } redirectFile = file.trim(); if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; break; } else if (input.substring(i, i+1) === '>') { if (current.trim()) { parts.push(current.trim()); current = ''; } hasRedirect = true; redirectOp = '>'; append = false; i += 1; let file = ''; while (i < input.length && input[i] === ' ') i++; while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { file += input[i]; i++; } redirectFile = file.trim(); if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; break; } else if (input.substring(i, i+1) === '<') { if (current.trim()) { parts.push(current.trim()); current = ''; } hasRedirect = true; redirectOp = '<'; i += 1; let file = ''; while (i < input.length && input[i] === ' ') i++; while (i < input.length && input[i] !== '>' && input[i] !== '<' && input[i] !== '|') { file += input[i]; i++; } redirectFile = file.trim(); if (i < input.length && (input[i] === '>' || input[i] === '<')) continue; break; } else { current += ch; i++; } } else { current += ch; i++; } } if (current.trim()) parts.push(current.trim()); const cmdText = parts.join(' '); return { hasRedirect, redirectOp, redirectFile, append, cmdText, exitOk: true }; } async _executeWithRedirect(redirectInfo) { const { redirectOp, redirectFile, append, cmdText } = redirectInfo; const fs = this.terminal.fs; if (redirectOp === '<') { // Read file content as stdin const inputContent = fs.readFile(redirectFile); if (inputContent === null) { this.terminal.print(`shell: ${redirectFile}: No such file`, 'error'); redirectInfo.exitOk = false; return; } this.terminal._pipeInput = inputContent; try { const parts = cmdText.split(/\s+/); const cmd = parts[0].toLowerCase(); const args = parts.slice(1); // If command reads files, inject pipe content as temp file if (this._isPipeAware(cmd)) { fs.writeFile('__pipebuf__', inputContent); const newArgs = [...args]; if (!newArgs.some(a => !a.startsWith('-'))) { newArgs.push('__pipebuf__'); } try { await this.terminal.dispatchCommand(cmd, newArgs); } finally { if (fs.readFile('__pipebuf__') !== null) { delete fs.root.content['__pipebuf__']; } } } else { await this.terminal.dispatchCommand(cmd, args); } redirectInfo.exitOk = true; } catch(e) { this.terminal.print('Error: ' + e.message, 'error'); redirectInfo.exitOk = false; } finally { this.terminal._pipeInput = null; } return; } // > or >> โ€” capture output, write to file let output = ''; this.terminal._pushPrintCapture(buf => { output += buf; }); let cmdOk = true; try { // Check for pipes within the command before redirect const pipeParts = this.terminal.splitPipe(cmdText); if (pipeParts.length > 1) { // Execute pipe chain manually to capture all output let pipeInput = null; for (let i = 0; i < pipeParts.length; i++) { const pText = pipeParts[i]; const pParts = pText.split(/\s+/); const pCmd = pParts[0].toLowerCase(); const pArgs = pParts.slice(1); if (pipeInput !== null) { fs.writeFile('__pipebuf__', pipeInput); } if (i < pipeParts.length - 1) { let capt = ''; this.terminal._pushPrintCapture(buf => { capt += buf; }); try { await this.terminal.dispatchCommand(pCmd, pArgs); } finally { this.terminal._popPrintCapture(); } pipeInput = capt.trim(); } else { await this.terminal.dispatchCommand(pCmd, pArgs); } if (fs.readFile('__pipebuf__') !== null) { delete fs.root.content['__pipebuf__']; } } } else { const parts = cmdText.split(/\s+/); const cmd = parts[0].toLowerCase(); const args = parts.slice(1); await this.terminal.dispatchCommand(cmd, args); } } catch(e) { cmdOk = false; // Pop capture before showing the error this.terminal._popPrintCapture(); this.terminal.print('Error: ' + e.message, 'error'); redirectInfo.exitOk = false; return; } finally { // Always pop capture โ€” critical: without this, terminal output stays captured // (empty stack = normal DOM printing, so pop is idempotent-safe when already popped) if (this.terminal._printCaptureStack && this.terminal._printCaptureStack.length > 0) { // popped in catch path for error case; success path pops here this.terminal._popPrintCapture(); } } const captured = output.trimEnd(); if (captured) { if (append) { const existing = fs.readFile(redirectFile) || ''; fs.writeFile(redirectFile, existing + (existing ? '\n' : '') + captured); } else { fs.writeFile(redirectFile, captured); } this.terminal.print(`Redirected ${captured.split('\n').length} line(s) to ${redirectFile}`, 'success'); } else { // Write empty file if (!append) { fs.writeFile(redirectFile, ''); this.terminal.print(`Redirected (empty) to ${redirectFile}`, 'success'); } } redirectInfo.exitOk = cmdOk; } _isPipeAware(cmd) { // Commands that read from files and should use the pipe buffer file. wc is NOT here any // more: it reads terminal._pipeInput natively (v4.6.111), and injecting __pipebuf__ gave // `3 __pipebuf__` instead of a bare count. // grep/head/tail/wc read terminal._pipeInput themselves (v4.6.111-116). Injecting the // __pipebuf__ file for them made `cat f | grep x` match twice (the pipe buffer AND the cwd // file of the same name) and printed "3 __pipebuf__" from wc. return ['sort', 'cut', 'uniq', 'expand'].includes(cmd); } // โ”€โ”€ Variable substitution โ”€โ”€ _substituteVars(input) { // Replace $VAR and ${VAR} (not inside single quotes) let result = ''; let inSingle = false; let i = 0; while (i < input.length) { const ch = input[i]; if (ch === "'") { inSingle = !inSingle; result += ch; i++; } else if (ch === '$' && !inSingle && i + 1 < input.length) { // Check for $( if (input[i+1] === '(') { result += ch; i++; continue; } // Check for ${VAR} if (input[i+1] === '{') { let end = input.indexOf('}', i); if (end !== -1) { const varName = input.substring(i+2, end); const val = this._getVar(varName); result += val !== undefined ? val : '$' + varName; i = end + 1; continue; } } // Check for $VAR (alphanumeric/underscore) let j = i + 1; if (j < input.length && /[A-Za-z_]/.test(input[j])) { while (j < input.length && /\w/.test(input[j])) j++; const varName = input.substring(i+1, j); const val = this._getVar(varName); result += val !== undefined ? val : ''; i = j; continue; } // $ followed by digit โ€” not a var result += ch; i++; } else { result += ch; i++; } } return result; } _getVar(name) { if (name === 'PWD') return '/' + this.terminal.fs.currentPath.join('/'); return this._vars[name] !== undefined ? this._vars[name] : undefined; } _setVar(name, value) { this._vars[name] = value; } // โ”€โ”€ Commands โ”€โ”€ _cmdExport(args) { if (!args[0] || !args[0].includes('=')) { return this.terminal.print('Usage: export =', 'warning'); } const eqIdx = args[0].indexOf('='); const name = args[0].substring(0, eqIdx); let value = args[0].substring(eqIdx + 1); // Support quoted values: export FOO="hello world" if (args.length > 1) { const rest = args.slice(1).join(' '); if (value.endsWith('"') || value.endsWith("'")) { // First part ended quote โ€” fine } else if (value.startsWith('"') || value.startsWith("'")) { value = value + (rest ? ' ' + rest : ''); } } // Strip surrounding quotes if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.substring(1, value.length - 1); } this._setVar(name, value); this.terminal.print(`Shell ${name}=${value}`, 'success'); } _cmdUnset(args) { if (!args[0]) return this.terminal.print('Usage: unset ', 'warning'); delete this._vars[args[0]]; this.terminal.print(`Shell var removed: ${args[0]}`, 'info'); } _cmdSet() { const fs = this.terminal.fs; this.terminal.print('Shell variables:', 'info'); for (const [k, v] of Object.entries(this._vars)) { this.terminal.print(`${k}=${v}`, 'info'); } this.terminal.print(`PWD=/${fs.currentPath.join('/')}`, 'info'); } async _cmdScript(args) { if (!args[0]) return this.terminal.print('Usage: script ', 'warning'); if (this._scriptDepth >= this._maxScriptDepth) { return this.terminal.print(`script: max recursion depth (${this._maxScriptDepth}) reached`, 'error'); } const path = args[0]; const content = this.terminal.fs.readFile(path); if (content === null) return this.terminal.print(`script: ${path}: No such file`, 'error'); this._scriptDepth++; try { this.terminal.print(`Running script: ${path}`, 'info'); const env = Object.assign({ $: 0 }, this._scriptEnv || {}); this._inScript = true; let result = { ran: 0 }; try { const tree = this._parseBlocks(content.split('\n')); result = await this._runInstructions(tree, env, { path, inScript: true }); } catch (e) { // A parse error (unclosed block, bad for-header) must read as a script error, not // as a crashed terminal. this.terminal.print(`Script ${path} failed: ${e.message}`, 'error'); result = { ran: 0 }; } finally { this._inScript = false; } this._scriptEnv = env; this.terminal.print(`Script ${path} completed (${result.ran} statements, exit ${env.$})`, 'success'); } finally { // Always decrement โ€” even on error or thrown exception this._scriptDepth--; } } // Parse a script into an instruction tree. Line-by-line execution cannot express a loop body, // which is why `for โ€ฆ done` used to be fed to the AI one line at a time. _parseBlocks(lines) { const root = { body: [] }; const stack = [root]; const cur = () => stack[stack.length - 1]; const push = (node) => { const top = cur(); if (top.op === 'if') { (top.inElse ? top.else : top.then).push(node); } else { top.body.push(node); } }; // `if [ -f /x ]; then echo y; fi` on one line is common enough to support: split atoms on // ';' when the line carries a block keyword, and keep quoted text intact otherwise. const atoms = []; lines.forEach((raw) => { const l = String(raw).replace(/\r$/, ''); if (l.includes(';') && /\b(then|do|done|fi|else)\b/.test(l)) l.split(';').forEach(p => atoms.push(p)); else atoms.push(l); }); atoms.forEach((raw, idx) => { const line = String(raw).replace(/\r$/, '').trim(); const lineNo = idx + 1; if (!line || line.startsWith('#')) return; const head = line.split(/\s+/)[0]; if (head === 'for') { const m = line.match(/^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.*)$/); if (!m) throw new Error('line ' + lineNo + ": expected 'for in '"); const node = { op: 'for', line: lineNo, var: m[1], items: m[2].trim().split(/\s+/).filter(Boolean), body: [] }; push(node); stack.push(node); return; } if (head === 'while') { const node = { op: 'while', line: lineNo, test: line.slice(5).trim(), body: [] }; push(node); stack.push(node); return; } if (head === 'if') { const node = { op: 'if', line: lineNo, test: line.slice(2).trim(), then: [], else: [], inElse: false }; push(node); stack.push(node); return; } if (head === 'then' || head === 'do') { // `then echo y` on the same atom still means "run echo y" const rest = line.slice(head.length).trim(); if (rest) push({ op: 'simple', line: lineNo, text: rest }); return; } if (head === 'else') { const top = cur(); if (top.op !== 'if') throw new Error('line ' + lineNo + ": 'else' without 'if'"); top.inElse = true; const rest = line.slice(4).trim(); if (rest) push({ op: 'simple', line: lineNo, text: rest }); return; } if (head === 'done' || head === 'fi') { const top = stack.pop(); const want = !top ? null : (top.op === 'if' ? 'fi' : 'done'); if (!want) throw new Error('line ' + lineNo + ": '" + head + "' without an open block"); if (head !== want) throw new Error('line ' + lineNo + ": expected '" + want + "' to close '" + top.op + "' from line " + top.line); return; } push({ op: 'simple', line: lineNo, text: line }); }); if (stack.length !== 1) { const open = stack[stack.length - 1]; throw new Error("unclosed '" + open.op + "' from line " + open.line + " โ€” expected '" + (open.op === 'if' ? 'fi' : 'done') + "'"); } const fix = (nodes) => nodes.map(n => { if (n.op === 'if') { n.body = n.then; n.elseBody = n.else; } if (n.body && (n.op === 'for' || n.op === 'while')) fix(n.body); if (n.body && n.op === 'if') fix(n.body); if (n.elseBody) fix(n.elseBody); return n; }); return fix(root.body); } // Same rules as _substituteVars, but against an explicit env so a loop body sees `$i`, and // `$?` carries the previous statement's exit status. _expandVars(input, env) { const src = String(input); let out = '', inSingle = false, inDouble = false; for (let i = 0; i < src.length; i++) { const ch = src[i]; if (ch === "'" && !inDouble) { inSingle = !inSingle; out += ch; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; out += ch; continue; } if (ch !== '$' || inSingle) { out += ch; continue; } const next = src[i + 1]; if (next === '?') { out += String(env.$ == null ? 0 : env.$); i++; continue; } let name = ''; if (next === '{') { const end = src.indexOf('}', i + 2); if (end === -1) { out += ch; continue; } name = src.slice(i + 2, end); i = end; } else { const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i + 1)); if (!m) { out += ch; continue; } name = m[0]; i += name.length; } out += (env[name] == null ? '' : String(env[name])); } return out; } // Wildcards only expand for unquoted arguments, and only against the VFS โ€” `ls *.txt` used to // return an empty list because the literal pattern was handed to ls. _expandGlobs(args, cwdSegments) { const globToRe = (pat) => new RegExp('^' + pat.replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]') + '$'); const dirOf = (p) => { const segs = String(p).split('/').filter(Boolean); segs.pop(); return segs; }; return args.flatMap((arg) => { const a = String(arg); if (/^['"]/.test(a) || !/[*?]/.test(a)) return [a]; const segs = a.split('/').filter(Boolean); const namePat = segs.pop() || '*'; const rawDir = a.startsWith('/') ? segs : cwdSegments.concat(segs); // cwd segments may arrive as ['work'] or ['/work'] โ€” normalise both const dirPath = '/' + rawDir.map(s => String(s).split('/').filter(Boolean).join('/')) .filter(Boolean).join('/'); let entries = []; try { entries = this.terminal.fs.ls(dirPath) || []; } catch (e) { entries = []; } const re = globToRe(namePat); const hits = entries.filter(e => re.test(e.name)).map(e => (dirPath === '/' ? '' : dirPath) + '/' + e.name); return hits.length ? hits : [a]; }); } // Evaluate the only condition syntaxes the runner supports: test -f/-d/-e/-z/-n, `a = b`, // and the bracket spelling of the same. Anything else is false, never a crash. _evalTest(expr, env) { const t = this._expandVars(String(expr == null ? '' : expr), env || {}) .trim().replace(/^\[/, '').replace(/\]$/, '').trim(); if (!t) return false; const fs = this.terminal.fs; const parts = t.split(/\s+/); const p = parts[1]; switch (parts[0]) { case '-f': return typeof fs.readFile(p) === 'string'; case '-d': return !!fs.readDir(p); case '-e': return typeof fs.readFile(p) === 'string' || !!fs.readDir(p); case '-z': return !p; case '-n': return !!p; case '!': return !this._evalTest(parts.slice(1).join(' '), env); } if (parts.length === 3 && (parts[1] === '=' || parts[1] === '==')) return parts[0] === parts[2]; if (parts.length === 3 && parts[1] === '!=') return parts[0] !== parts[2]; return false; } async _runInstructions(nodes, env, ctx) { let ran = 0; for (const node of nodes) { if (node.op === 'for') { for (const item of node.items) { env[node.var] = this._expandVars(item, env); const r = await this._runInstructions(node.body, env, ctx); ran += r.ran + 1; } continue; } if (node.op === 'while') { let guard = 0; const cap = this._maxLoopIterations || 1000; while (guard++ < cap && this._evalTest(node.test, env)) { const r = await this._runInstructions(node.body, env, ctx); ran += r.ran + 1; } if (guard > cap) this.terminal.print('script: while loop hit the iteration limit (' + cap + ')', 'warning'); continue; } if (node.op === 'if') { const take = this._evalTest(node.test, env); env.$ = take ? 0 : 1; const r = await this._runInstructions(take ? node.body : (node.elseBody || []), env, ctx); ran += r.ran + 1; continue; } const r = await this._runSimple(this._expandVars(node.text, env), env, Object.assign({}, ctx, { line: node.line })); ran += r.ran; } return { ran }; } async _runSimple(text, env, ctx) { const seqParts = this._splitSequence(text); let lastOk = true; for (const seqPart of seqParts) { if (!seqPart.trim()) continue; lastOk = true; for (const chainPart of this._splitChain(seqPart.trim())) { if (!chainPart.cmd.trim()) continue; if (chainPart.operator === '&&' && !lastOk) continue; if (chainPart.operator === '||' && lastOk) continue; lastOk = await this._runCommand(chainPart.cmd, env, ctx); } } env.$ = lastOk ? 0 : 1; return { ran: 1, ok: lastOk }; } async _runCommand(cmdText, env, ctx) { const redirectInfo = this._extractRedirects(cmdText); if (redirectInfo.hasRedirect) { const cap = { output: '', onChunk: null }; const stack = this.terminal._printCaptureStack || (this.terminal._printCaptureStack = []); stack.push(cap); let ok = true; try { ok = await this._runCommand(redirectInfo.cmdText, env, ctx); } catch (e) { this.terminal.print('Error: ' + e.message, 'error'); ok = false; } finally { stack.pop(); } const target = redirectInfo.redirectFile; const prev = redirectInfo.append ? (this.terminal.fs.readFile(target) || '') : ''; const wrote = this.terminal.fs.writeFile(target, prev + cap.output); if (!wrote) { this.terminal.print('script: cannot write ' + target, 'error'); return false; } return ok; } const pipeParts = this.terminal.splitPipe ? this.terminal.splitPipe(cmdText) : [cmdText]; if (pipeParts.length > 1) { await this.terminal.executePipeChain(pipeParts); return true; } return this._dispatchOne(cmdText, env, ctx); } async _dispatchOne(cmdText, env, ctx) { const parts = String(cmdText).trim().split(/\s+/); const cmd = (parts[0] || '').toLowerCase(); const cwd = (this.terminal.fs && this.terminal.fs.currentPath) || []; const args = this._expandGlobs(parts.slice(1), cwd); if (this.commands[cmd]) { try { this.terminal.markDirty(); await this.commands[cmd](args); return true; } catch (e) { this.terminal.print('Error: ' + e.message, 'error'); return false; } } const known = (this.terminal.moduleManager && this.terminal.moduleManager.getAllCommands ? this.terminal.moduleManager.getAllCommands() : {})[cmd]; if (ctx && ctx.inScript && !known) { // A script must never answer an unknown command by calling the AI. this.terminal.print('script:' + ctx.path + ':' + (ctx.line || '?') + ': ' + cmd + ': command not found', 'error'); return false; } // Dispatch the EXPANDED argument list โ€” passing cmdText here silently dropped the glob // results, so `ls /g*.txt` still reached the fs as a literal pattern. const rebuilt = [cmd].concat(args).join(' '); try { await this.terminal.security.checkAndRun(rebuilt); return true; } catch (e) { this.terminal.print('Error: ' + e.message, 'error'); return false; } } } if (typeof window.webx11 !== 'undefined' && window.webx11) { // Register with a cache-busting query: an unversioned URL let the browser keep serving a // pre-v4.6.111 shell.js after the bundle had already shipped the new runner. const _mm = window.webx11.moduleManager; const _ver = (_mm && (_mm.LATEST_VER || _mm.latestVer)) || ('t' + Date.now()); const _inst = new ShellModule(); webx11.moduleManager.registerModule(_inst, 'https://webx11.duckdns.org/spaces/newos4/modules/ext/shell.js?v=' + _ver); // registerModule() does not call init(); only loadModule() does. A module registered from the // boot bundle therefore never installed its executeCommand wrapper, so prompt-level globbing // and redirects silently did nothing. Init it here, once. if (typeof _inst.init === 'function' && !_inst._initialized) { try { _inst.init(); } catch (e) { console.warn('ShellModule init failed:', e && e.message); } } } /* === alias.js === */ class AliasModule { constructor() { this.commands = {}; this.terminal = null; this.key = 'webx11_aliases'; this.defaults = { 'll': 'ls', 'h': 'help', 'cls': 'clear', 'sh': 'script' }; this.aliases = this.load(); this._aliasDepth = 0; this._aliasMaxDepth = 10; } getHelpInfo() { return { 'alias': 'List, set, or show aliases (usage: alias [name=value])', 'unalias': 'Remove a user-defined alias (usage: unalias )', }; } load() { try { const data = localStorage.getItem(this.key); return data ? { ...this.defaults, ...JSON.parse(data) } : { ...this.defaults }; } catch (e) { return { ...this.defaults }; } } save() { const custom = {}; Object.keys(this.aliases).forEach(k => { if (!this.defaults[k]) custom[k] = this.aliases[k]; }); localStorage.setItem(this.key, JSON.stringify(custom)); } registerCommands() { const self = this; // Register management commands this.commands['alias'] = (args) => self.handleAlias(args); this.commands['unalias'] = (args) => self.handleUnalias(args); // Register each alias as a delegating command Object.keys(this.aliases).forEach(name => { if (name === 'alias' || name === 'unalias') return; const target = this.aliases[name]; this.commands[name] = (args) => { if (this._aliasDepth >= this._aliasMaxDepth) { this.terminal.print(`alias: max depth (${this._aliasMaxDepth}) reached`, 'error'); return; } this._aliasDepth++; try { this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : '')); } finally { this._aliasDepth--; } }; }); } handleAlias(args) { if (!args[0]) { // List all aliases this.terminal.print('=== Aliases ===', 'info'); const keys = Object.keys(this.aliases); if (keys.length === 0) { this.terminal.print(' No aliases defined', 'warning'); } else { keys.forEach(name => { const mark = this.defaults[name] ? ' (built-in)' : ''; this.terminal.print(` ${name}=${this.aliases[name]}${mark}`); }); } this.terminal.print('Usage: alias <name>=<value>', 'info'); return; } // Parse name=value const input = args.join(' '); const eqIdx = input.indexOf('='); if (eqIdx === -1) { // Show specific alias value const name = args[0]; if (this.aliases[name]) { const mark = this.defaults[name] ? ' (built-in)' : ''; this.terminal.print(`${name}=${this.aliases[name]}${mark}`, 'info'); } else { this.terminal.print(`Alias '${name}' not found`, 'error'); } return; } const name = input.substring(0, eqIdx).trim(); const value = input.substring(eqIdx + 1).trim(); if (!name) return this.terminal.print('Invalid alias name', 'error'); if (!value) return this.terminal.print('Invalid alias value', 'error'); if (name === 'alias' || name === 'unalias') { return this.terminal.print('Cannot alias reserved command name', 'error'); } if (this.defaults[name]) { return this.terminal.print(`Cannot alias built-in alias '${name}'`, 'warning'); } this.aliases[name] = value; this.save(); // Register the new alias as a command immediately const target = value; this.commands[name] = (args) => { if (this._aliasDepth >= this._aliasMaxDepth) { this.terminal.print(`alias: max depth (${this._aliasMaxDepth}) reached`, 'error'); return; } this._aliasDepth++; try { this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : '')); } finally { this._aliasDepth--; } }; this.terminal.print(`Alias set: ${name}=${value}`, 'success'); } handleUnalias(args) { if (!args[0]) return this.terminal.print('Usage: unalias <name>', 'warning'); const name = args[0]; if (this.defaults[name]) { return this.terminal.print(`Cannot unalias built-in alias '${name}'`, 'warning'); } if (!this.aliases[name]) { return this.terminal.print(`Alias '${name}' not found`, 'error'); } delete this.aliases[name]; delete this.commands[name]; this.save(); this.terminal.print(`Alias removed: ${name}`, 'success'); } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new AliasModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/alias.js'); } /* === finder.js === */ class FinderModule { constructor() { this.commands = {}; this.terminal = null; } requirements() { return { met: true, missing: [] }; } getHelpInfo() { return { 'find': 'Recursively search VFS files/directories by name (usage: find )', }; } registerCommands() { this.commands['find'] = (args) => this.find(args); } find(args) { if (!args[0]) return this.terminal.print('Usage: find โ€” recursively searches VFS files/dirs matching query', 'warning'); const query = args.join(' ').toLowerCase(); const results = []; this._recurse(this.terminal.fs.root, [], query, results); if (results.length === 0) return this.terminal.print(`No matches for "${query}"`, 'warning'); this.terminal.print(`=== Found ${results.length} match(es) for "${query}" ===`, 'info'); results.forEach(r => { const icon = r.type === 'dir' ? '๐Ÿ“' : '๐Ÿ“„'; this.terminal.print(` ${icon} ${r.path}`); }); } _recurse(node, pathParts, query, results) { if (!node || !node.content) return; for (const [name, child] of Object.entries(node.content)) { const fullPath = '/' + [...pathParts, name].join('/'); if (name.toLowerCase().includes(query)) { results.push({ name, path: fullPath, type: child.type || (child.content ? 'dir' : 'file') }); } if (child.content && typeof child.content === 'object') { this._recurse(child, [...pathParts, name], query, results); } } } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new FinderModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/finder.js'); } /* === filemgr.js === */ // 2026-10-24: pure URL helpers, require-able so modules/test/test-download-url.js tests the // shipped code instead of a copy of it. No DOM, no terminal, no module state. function fmDetectSpaceName(pathname) { try { const parts = String(pathname || '').split('/').filter(Boolean); const i = parts.indexOf('spaces'); return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : ''; } catch (e) { return ''; } } function fmExtractSpaceNameFromUrl(url) { if (!url) return ''; try { const parts = new URL(url).pathname.split('/').filter(Boolean); const i = parts.indexOf('spaces'); return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : ''; } catch (e) { return ''; } } // Returns null when no space can be resolved โ€” never emits /spaces//. function fmBuildShareUrl(space, path) { if (!space) return null; const clean = String(path || '').replace(/^\/+/, ''); if (!clean) return null; return 'https://webx11.duckdns.org/spaces/' + space + '/' + clean.split('/').map(encodeURIComponent).join('/'); } if (typeof module !== 'undefined' && module.exports) { module.exports = { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl }; } class FileManagerModule { constructor() { this.commands = {}; this.commandsMeta = { 'files': { argType: 'file' }, 'preview': { argType: 'file' }, }; this.terminal = null; this.currentPath = []; this._overlay = null; this._selectedFile = null; this._multiSelect = new Set(); // v4.5.78: multi-select for batch ops this._selectMode = false; // v4.6.35: tap-to-select mode (touch devices) this._itemCount = 0; // items rendered in the current directory/filter this._clipboard = { mode: null, paths: [] }; // 'copy' or 'cut' this._searchFilter = ''; this._rawMode = false; this._inlineHtml = false; } requirements() { return { met: true, missing: [] }; } getHelpInfo() { return { 'files': 'files โ€” Open the file manager overlay with VFS directory listing, preview, encrypt, zip', 'preview': 'preview โ€” Preview a specific file', }; } registerCommands() { this.commands['files'] = () => this._openOverlay(); this.commands['preview'] = (args) => this._previewCommand(args); } _previewCommand(args) { if (!args[0]) return this.terminal.print('Usage: preview ', 'warning'); const path = args.join(' '); this._openOverlay(); this._navigateToPathFromParts(path); const content = this.terminal.fs.readFile(path); if (content === null || content === false) { this._renderPreviewError('File not found: ' + path); return; } this._selectedFile = path; this._renderPreview(path, content); this._highlightSelected(); } _navigateToPathFromParts(path) { const parts = path.split('/').filter(p => p); if (parts.length <= 1) { this.currentPath = []; return; } this.currentPath = parts.slice(0, -1); } _detectType(filePath) { const ext = filePath.split('.').pop().toLowerCase(); if (!ext || ext === filePath) return 'other'; const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico']; const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv']; const audioExts = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a']; const codeExts = ['js', 'py', 'css', 'yaml', 'yml', 'toml']; if (ext === 'md') return 'md'; if (imageExts.includes(ext)) return 'image'; if (videoExts.includes(ext)) return 'video'; if (audioExts.includes(ext)) return 'audio'; if (codeExts.includes(ext)) return 'code'; if (ext === 'html') return 'html'; if (ext === 'json') return 'json'; if (ext === 'csv') return 'csv'; if (ext === 'pdf') return 'pdf'; if (ext === 'svg') return 'svg'; if (ext === 'txt') return 'text'; return 'other'; } _renderMarkdown(text) { if (!text) return ''; try { let html = text .replace(/&/g, '&') .replace(//g, '>'); // code blocks first html = html.replace(/```([\s\S]*?)```/g, '
$1
'); // inline code html = html.replace(/`([^`]+)`/g, '$1'); // headers html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); // bold html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // italic html = html.replace(/\*(.+?)\*/g, '$1'); // links โ€” v4.5.67: sanitize URL to prevent javascript: scheme injection html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { const safeUrl = /^(https?:\/\/|mailto:|\/|#)/.test(url) ? url : '#'; return `${text}`; }); // horizontal rules html = html.replace(/^---$/gm, '
'); // unordered lists html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '
  • $1
  • '); // paragraphs - double newlines html = html.replace(/\n\n/g, '

    '); html = '

    ' + html + '

    '; return html; } catch (e) { return '
    ' + text.replace(/&/g, '&').replace(//g, '>') + '
    '; } } _getPreviewHtml(path, content) { if (content === null || content === false) { return '
    File not found: ' + this._escapeHtml(path) + '
    '; } const type = this._detectType(path); const ext = path.split('.').pop().toLowerCase(); const fileName = path.split('/').pop() || path; // Handle binary detection for media types if (typeof content !== 'string') { return '
    Cannot preview: non-string content
    '; } const isLikelyBinary = content.length > 0 && !content.startsWith('\n') && !content.startsWith('\r') && !content.startsWith(' ') && !content.startsWith('<') && !content.startsWith('{') && !content.startsWith('[') && content.charCodeAt(0) < 32 && content.charCodeAt(0) !== 9 && content.charCodeAt(0) !== 10 && content.charCodeAt(0) !== 13; const isTextContent = typeof content === 'string' && (content.startsWith('{') || content.startsWith('[') || content.startsWith('<') || content.startsWith('#') || /^[\w\s\n\r\t.,;:!?()\-=+*@#$%^&"'\/\\|`~]/.test(content.substring(0, 100))); if (type === 'md' && isTextContent) { return '
    ' + this._renderMarkdown(content) + '
    '; } if (type === 'html') { // Render HTML in an inline sandbox frame const safeHtml = content .replace(/)<[^<]*)*<\/script>/gi, '') .replace(/on\w+\s*=\s*"[^"]*"/gi, '') .replace(/on\w+\s*=\s*'[^']*'/gi, ''); return '
    ' + '
    '; } if (type === 'text') { const truncated = content.substring(0, 2000); const extra = content.length > 2000 ? '
    (truncated to 2000 chars)
    ' : ''; return '
    ' + this._escapeHtml(truncated) + extra + '
    '; } if (type === 'json') { try { const parsed = JSON.parse(content); const formatted = JSON.stringify(parsed, null, 2); const html = this._syntaxHighlightJson(formatted); return '
    ' + html + '
    '; } catch (e) { return '
    ' + this._escapeHtml(content.substring(0, 5000)) + '
    '; } } if (type === 'code') { const lines = content.split('\n'); const totalLines = lines.length; const maxLines = 150; const displayLines = lines.slice(0, maxLines); let html = ''; displayLines.forEach((line, i) => { const lineNum = i + 1; const escaped = this._escapeHtml(line); html += ''; html += ''; }); html += '
    ' + lineNum + '' + (escaped || ' ') + '
    '; if (totalLines > maxLines) { html += '
    (showing ' + maxLines + ' of ' + totalLines + ' lines)
    '; } return html; } if (type === 'image') { // VFS stores images as base64 strings โ€” detect and render inline const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); if (isBase64) { const mimeTypes = {jpg:'image/jpeg',jpeg:'image/jpeg',png:'image/png',gif:'image/gif',webp:'image/webp',bmp:'image/bmp',ico:'image/x-icon'}; const mime = mimeTypes[ext] || 'image/png'; return '
    ' + this._escapeHtml(fileName) + '
    '; } return '
    ๐Ÿ“ท Image โ€” cannot render, file may be stored as binary
    '; } if (type === 'svg') { try { const parser = new DOMParser(); const doc = parser.parseFromString(content, 'text/html'); const scripts = doc.querySelectorAll('script'); scripts.forEach(s => s.remove()); // Remove event handlers from svg elements const allEls = doc.querySelectorAll('*'); allEls.forEach(el => { for (let i = el.attributes.length - 1; i >= 0; i--) { const attr = el.attributes[i].name; if (attr.startsWith('on')) el.removeAttribute(attr); } }); const cleanSvg = doc.body.innerHTML; return '
    ' + cleanSvg + '
    '; } catch (e) { return '
    ' + this._escapeHtml(content.substring(0, 5000)) + '
    '; } } if (type === 'video') { const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); if (isBase64) { const mimeTypes = {mp4:'video/mp4',webm:'video/webm',avi:'video/x-msvideo',mov:'video/quicktime',mkv:'video/x-matroska'}; const mime = mimeTypes[ext] || 'video/mp4'; return '
    '; } return '
    ๐ŸŽฌ Video โ€” cannot render
    '; } if (type === 'audio') { const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); if (isBase64) { const mimeTypes = {mp3:'audio/mpeg',wav:'audio/wav',ogg:'audio/ogg',flac:'audio/flac',aac:'audio/aac',m4a:'audio/mp4'}; const mime = mimeTypes[ext] || 'audio/mpeg'; return '
    '; } return '
    ๐ŸŽต Audio โ€” cannot render
    '; } if (type === 'pdf') { const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); if (isBase64) { return '
    '; } return '
    ๐Ÿ“„ PDF โ€” cannot render
    '; } if (type === 'csv') { try { const lines = content.split('\n').filter(l => l.trim()); const maxRows = 50; const rows = lines.slice(0, maxRows).map(l => l.split(',').map(c => c.trim().replace(/^"|"$/g, ''))); let html = ''; if (rows.length > 0) { html += ''; rows[0].forEach(cell => { html += ''; }); html += ''; rows.slice(1).forEach(row => { html += ''; row.forEach(cell => { html += ''; }); html += ''; }); html += '
    ' + this._escapeHtml(cell) + '
    ' + this._escapeHtml(cell) + '
    '; if (lines.length > maxRows) { html += '
    (showing ' + maxRows + ' of ' + lines.length + ' rows)
    '; } } return html; } catch (e) { return '
    ' + this._escapeHtml(content.substring(0, 5000)) + '
    '; } } // 'other' โ€” raw text fallback if (typeof content === 'string' && isTextContent) { const lines = content.split('\n'); const maxLines = 80; const displayLines = lines.slice(0, maxLines); const truncated = lines.length > maxLines; let html = '
    ';
                displayLines.forEach((line, i) => {
                    html += this._escapeHtml(line) + '\n';
                });
                html += '
    '; if (truncated) { html += '
    (showing ' + maxLines + ' of ' + lines.length + ' lines)
    '; } return html; } return '
    ๐Ÿ“ Preview not available for this file type
    '; } _syntaxHighlightJson(jsonStr) { return jsonStr.replace(/("(?:\\.|[^"\\])*")\s*:/g, '$1:') .replace(/:\s*("(?:\\.|[^"\\])*")/g, ': $1') .replace(/:\s*(\d+\.?\d*)/g, ': $1') .replace(/:\s*(true|false|null)/g, ': $1'); } _escapeHtml(str) { if (typeof str !== 'string') return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } _formatSize(size) { if (!size && size !== 0) return ''; if (size < 1024) return size + ' B'; if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'; return (size / (1024 * 1024)).toFixed(1) + ' MB'; } _createOverlay() { if (this._overlay) return; const overlay = document.createElement('div'); overlay.id = 'filemgr-overlay'; overlay.style.cssText = 'position:fixed;inset:0;z-index:1800;display:flex;flex-direction:column;background:var(--bg-color);color:var(--text-color);font-family:var(--font-primary);font-size:14px;height:100vh;'; // Inject responsive styles for the overlay const styleId = 'filemgr-styles'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = ` #filemgr-overlay.responsive .filemgr-main { flex-direction: column !important; } #filemgr-overlay.responsive #filemgr-sidebar { width: 100% !important; max-height: 40vh; } #filemgr-overlay.responsive #filemgr-preview { width: 100% !important; } #filemgr-overlay .filemgr-main { display: flex; flex: 1; min-height: 0; } @media (max-width: 600px) { #filemgr-overlay .filemgr-main { flex-direction: column !important; } #filemgr-overlay #filemgr-sidebar { width: 100% !important; max-height: 40vh; border-right: none !important; border-bottom: 1px solid var(--border-color); } #filemgr-overlay #filemgr-preview { width: 100% !important; flex: 1 !important; min-height: 0; } #filemgr-action-bar { flex-wrap: wrap; gap: 6px; } #filemgr-action-bar button { min-width: 44px; min-height: 44px; font-size: 0.9em; } } `; document.head.appendChild(style); } // Toolbar const toolbar = document.createElement('div'); toolbar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;'; const closeBtn = document.createElement('button'); closeBtn.textContent = 'โœ•'; closeBtn.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 10px;border-radius:4px;font-family:inherit;font-size:1em;min-height:44px;touch-action:manipulation;'; closeBtn.onclick = () => this._closeOverlay(); toolbar.appendChild(closeBtn); const titleSpan = document.createElement('span'); titleSpan.textContent = '๐Ÿ“ File Manager'; titleSpan.style.cssText = 'font-weight:bold;margin-right:8px;'; toolbar.appendChild(titleSpan); // Breadcrumbs const breadcrumbs = document.createElement('div'); breadcrumbs.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:0.9em;'; breadcrumbs.id = 'filemgr-breadcrumbs'; // Root breadcrumb const rootSeg = document.createElement('span'); rootSeg.textContent = '/'; rootSeg.style.cssText = 'color:var(--accent-color);cursor:pointer;padding:2px 4px;border-radius:3px;'; rootSeg.onmouseenter = () => { rootSeg.style.background = 'var(--bg-tertiary)'; }; rootSeg.onmouseleave = () => { rootSeg.style.background = 'transparent'; }; rootSeg.onclick = () => this._navigateTo([]); breadcrumbs.appendChild(rootSeg); toolbar.appendChild(breadcrumbs); // Search input const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.placeholder = '๐Ÿ” Filter files...'; searchInput.style.cssText = 'flex:1;min-width:120px;max-width:300px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);font-family:var(--font-primary);font-size:0.9em;outline:none;'; searchInput.oninput = (e) => { this._searchFilter = e.target.value.toLowerCase(); this._refreshFileList(); }; toolbar.appendChild(searchInput); // v4.5.78: Batch operation buttons const batchBar = document.createElement('div'); batchBar.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;'; const mkBtn = (label, title, action) => { const b = document.createElement('button'); b.textContent = label; b.title = title; b.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 8px;border-radius:4px;font-family:inherit;font-size:0.85em;min-height:32px;touch-action:manipulation;'; b.onclick = () => action(); return b; }; batchBar.appendChild(mkBtn('๐Ÿ“‹ Copy', 'Copy selected files', () => this._batchCopy())); batchBar.appendChild(mkBtn('โœ‚๏ธ Cut', 'Cut selected files', () => this._batchCut())); batchBar.appendChild(mkBtn('๐Ÿ“ฅ Paste', 'Paste from clipboard', () => this._batchPaste())); batchBar.appendChild(mkBtn('๐Ÿ—‘๏ธ Del', 'Delete selected files', () => this._batchDelete())); batchBar.appendChild(mkBtn('๐Ÿ“ Rename', 'Rename selected file', () => this._batchRename())); batchBar.appendChild(mkBtn('โ˜ All', 'Toggle select all', () => this._toggleSelectAll())); // v4.6.35: tap-to-select mode โ€” the only usable multi-select path on touch devices const selModeBtn = mkBtn('โ˜‘ Select', 'Toggle tap-to-select mode (touch)', () => { this._selectMode = !this._selectMode; selModeBtn.style.background = this._selectMode ? 'var(--accent-color)' : 'none'; selModeBtn.style.color = this._selectMode ? '#fff' : 'var(--text-color)'; this._updateStatus(this._selectMode ? 'Select mode ON โ€” tap rows to select' : 'Select mode OFF'); }); selModeBtn.setAttribute('aria-pressed', 'false'); batchBar.appendChild(selModeBtn); toolbar.appendChild(batchBar); // 2026-10-21: publish a VFS file to the user's WebDAV space was terminal-only. const wdavBtn = document.createElement('button'); wdavBtn.textContent = 'โ˜ WebDAV'; wdavBtn.title = 'Upload the selected/current file to your WebDAV space and get its public link'; wdavBtn.style.cssText = 'background:var(--bg-tertiary);border:1px solid var(--border-color);' + 'color:var(--text-color);cursor:pointer;padding:4px 10px;border-radius:4px;' + 'font-family:inherit;font-size:0.9em;min-height:32px;touch-action:manipulation;'; wdavBtn.onclick = () => this._uploadSelectionToWebdav(); toolbar.appendChild(wdavBtn); overlay.appendChild(toolbar); // Main area โ€” wraps sidebar + preview in a responsive flex container const mainArea = document.createElement('div'); mainArea.id = 'filemgr-main-container'; mainArea.className = 'filemgr-main'; mainArea.style.cssText = 'flex:1;overflow:hidden;display:flex;min-height:0;'; // Sidebar const sidebar = document.createElement('div'); sidebar.id = 'filemgr-sidebar'; sidebar.style.cssText = 'width:25%;min-width:150px;overflow-y:auto;background:var(--bg-secondary);border-right:1px solid var(--border-color);'; mainArea.appendChild(sidebar); // Preview pane const preview = document.createElement('div'); preview.id = 'filemgr-preview'; preview.style.cssText = 'flex:1;overflow-y:auto;padding:0;'; mainArea.appendChild(preview); overlay.appendChild(mainArea); // Action bar const actionBar = document.createElement('div'); actionBar.id = 'filemgr-action-bar'; actionBar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-top:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;'; const statusSpan = document.createElement('span'); statusSpan.id = 'filemgr-status'; statusSpan.style.cssText = 'flex:1;color:var(--text-muted);font-size:0.85em;'; statusSpan.textContent = 'No file selected'; actionBar.appendChild(statusSpan); const encryptBtn = document.createElement('button'); encryptBtn.textContent = '๐Ÿ” Encrypt'; encryptBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--warning-color);color:var(--warning-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; encryptBtn.onclick = () => this._actionEncrypt(); actionBar.appendChild(encryptBtn); const compressBtn = document.createElement('button'); compressBtn.textContent = '๐Ÿ“ฆ Zip'; compressBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--info-color);color:var(--info-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; compressBtn.onclick = () => this._actionCompress(); actionBar.appendChild(compressBtn); const deleteBtn = document.createElement('button'); deleteBtn.textContent = '๐Ÿ—‘ Delete'; deleteBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--error-color);color:var(--error-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; deleteBtn.onclick = () => this._actionDelete(); actionBar.appendChild(deleteBtn); const downloadBtn = document.createElement('button'); downloadBtn.textContent = 'โฌ‡ Download'; downloadBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; downloadBtn.onclick = () => this._actionDownload(); actionBar.appendChild(downloadBtn); // 2026-10-26 (phone test): share the whole selection โ€” the preview toolbar could only ever // share the one previewed file, so three selected files produced one link. const shareSelBtn = document.createElement('button'); shareSelBtn.textContent = '๐Ÿ”— Share'; shareSelBtn.title = 'Share or copy the selected files'; shareSelBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; shareSelBtn.onclick = () => { let t = this._getSelectedPaths ? this._getSelectedPaths() : []; if (!t.length && this._selectedFile) t = [this._selectedFile]; if (!t.length) { this._updateStatus('๐Ÿ”— Nothing selected โ€” tap โ˜ Select then tap files'); return; } this._sharePaths(t); }; actionBar.appendChild(shareSelBtn); const editBtn = document.createElement('button'); editBtn.textContent = 'โœ Edit'; editBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--accent-color);color:var(--accent-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; editBtn.onclick = () => this._actionEdit(); actionBar.appendChild(editBtn); const newFileBtn = document.createElement('button'); newFileBtn.textContent = '๐Ÿ“„ New'; newFileBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--success-color);color:var(--success-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; newFileBtn.onclick = () => this._actionNewFile(); actionBar.appendChild(newFileBtn); const previewToggleBtn = document.createElement('button'); previewToggleBtn.id = 'filemgr-toggle-raw'; previewToggleBtn.textContent = '๐Ÿ“„ Raw'; previewToggleBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-muted);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; previewToggleBtn.onclick = () => this._togglePreviewMode(); actionBar.appendChild(previewToggleBtn); overlay.appendChild(actionBar); this._overlay = overlay; return overlay; } _openOverlay() { if (this._overlay) { this._overlay.style.display = 'flex'; this._refreshFileList(); return; } const overlay = this._createOverlay(); document.body.appendChild(overlay); // 2026-10-26 (Issue D2): the WebDAV pane lives in another module and needs a read-only view of // this clipboard to implement "cut/copy here, paste there". try { if (typeof window !== 'undefined' && window.webx11) { window.webx11.fileClipboard = () => ({ mode: this._clipboard.mode, paths: [...this._clipboard.paths] }); window.webx11.fileClipboardClear = () => { this._clipboard = { mode: null, paths: [] }; }; window.webx11.fileClipboardSet = (paths, mode) => { this._clipboard = { mode: mode || 'copy', paths: [...(paths || [])] }; }; } } catch (_e) {} this._navigateTo(this.currentPath); } _closeOverlay() { if (this._overlay) { this._overlay.style.display = 'none'; } this._multiSelect.clear(); this._selectedFile = null; this._selectMode = false; } _clearOverlay() { if (this._overlay && this._overlay.parentNode) { this._overlay.parentNode.removeChild(this._overlay); } this._overlay = null; this._selectedFile = null; this._multiSelect.clear(); this._selectMode = false; this._searchFilter = ''; this._rawMode = false; } _navigateTo(pathParts) { this.currentPath = pathParts || []; this._selectedFile = null; this._multiSelect.clear(); this._searchFilter = ''; const searchInput = this._overlay?.querySelector('input[type="text"]'); if (searchInput) searchInput.value = ''; this._updateBreadcrumbs(); this._refreshFileList(); this._clearPreview(); this._updateStatus('No file selected'); } _updateBreadcrumbs() { const breadcrumbs = document.getElementById('filemgr-breadcrumbs'); if (!breadcrumbs) return; // Keep root segment, remove rest while (breadcrumbs.children.length > 1) { breadcrumbs.removeChild(breadcrumbs.lastChild); } let accumulated = ''; this.currentPath.forEach((part, i) => { const sep = document.createElement('span'); sep.textContent = ' โ€บ '; sep.style.cssText = 'color:var(--text-muted);margin:0 2px;'; breadcrumbs.appendChild(sep); const seg = document.createElement('span'); seg.textContent = part; seg.style.cssText = 'cursor:pointer;padding:2px 4px;border-radius:3px;color:var(--text-color);'; if (i === this.currentPath.length - 1) { seg.style.color = 'var(--accent-color)'; seg.style.fontWeight = 'bold'; } seg.onmouseenter = () => { seg.style.background = 'var(--bg-tertiary)'; }; seg.onmouseleave = () => { seg.style.background = 'transparent'; }; const pathUpTo = this.currentPath.slice(0, i + 1); seg.onclick = () => this._navigateTo(pathUpTo); breadcrumbs.appendChild(seg); }); } _refreshFileList() { const sidebar = document.getElementById('filemgr-sidebar'); if (!sidebar) return; // Read directory const pathStr = '/' + this.currentPath.join('/'); const entries = this.terminal.fs.ls(pathStr) || []; // Separate dirs and files const dirs = entries.filter(e => e.type === 'dir'); const files = entries.filter(e => e.type !== 'dir'); // Prune selections that no longer exist here (deleted/moved elsewhere) try { const _live = new Set(); entries.forEach(e => _live.add(pathStr === '/' ? '/' + e.name : pathStr + '/' + e.name)); [...this._multiSelect].forEach(p => { if (!_live.has(p)) this._multiSelect.delete(p); }); } catch (_e) {} // Sort alphabetically dirs.sort((a, b) => a.name.localeCompare(b.name)); files.sort((a, b) => a.name.localeCompare(b.name)); // Apply search filter let filteredDirs = dirs; let filteredFiles = files; if (this._searchFilter) { const q = this._searchFilter; filteredDirs = dirs.filter(e => e.name.toLowerCase().includes(q)); filteredFiles = files.filter(e => e.name.toLowerCase().includes(q)); } const _scrollTop = sidebar.scrollTop; sidebar.innerHTML = ''; if (filteredDirs.length === 0 && filteredFiles.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.style.cssText = 'padding:20px;text-align:center;color:var(--text-muted);'; emptyMsg.textContent = this._searchFilter ? 'No matching files' : '(empty)'; sidebar.appendChild(emptyMsg); this._itemCount = 0; this._updateSelectionStatus(); return; } // Add directory items filteredDirs.forEach(entry => { const item = document.createElement('div'); const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name; item.dataset.path = fullPath; item.dataset.type = 'dir'; item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;border-left:3px solid transparent;'; item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; item.onclick = (e) => { if (e.ctrlKey || e.metaKey || this._selectMode) { e.preventDefault(); this._toggleMulti(fullPath); return; } const newPath = [...this.currentPath, entry.name]; this._navigateTo(newPath); }; item.appendChild(this._makeCheckbox(fullPath, entry.name + '/')); const icon = document.createElement('span'); icon.textContent = '๐Ÿ“'; item.appendChild(icon); const nameSpan = document.createElement('span'); nameSpan.textContent = entry.name + '/'; nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; item.appendChild(nameSpan); sidebar.appendChild(item); }); // Add file items filteredFiles.forEach(entry => { const item = document.createElement('div'); const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name; const isSelected = this._selectedFile === fullPath; const isMulti = this._multiSelect.has(fullPath); item.dataset.path = fullPath; item.dataset.type = 'file'; item.dataset.selected = (isSelected || isMulti) ? 'true' : 'false'; const bgColor = isMulti ? 'var(--bg-tertiary)' : 'transparent'; item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';border-left:3px solid ' + (isSelected ? 'var(--accent-color)' : 'transparent') + ';'; item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; item.onclick = (e) => { // Ctrl/Cmd or Select-mode โ†’ toggle in the batch set (no full re-render) if (e.ctrlKey || e.metaKey || this._selectMode) { e.preventDefault(); this._toggleMulti(fullPath); return; } // Plain click โ†’ preview AND make it the single selected item this._multiSelect.clear(); this._multiSelect.add(fullPath); this._selectedFile = fullPath; this._highlightSelected(); this._renderPreview(fullPath, this.terminal.fs.readFile(fullPath)); this._updateSelectionStatus(); }; item.appendChild(this._makeCheckbox(fullPath, entry.name)); const icon = document.createElement('span'); icon.textContent = '๐Ÿ“„'; item.appendChild(icon); const nameSpan = document.createElement('span'); nameSpan.textContent = entry.name; nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; item.appendChild(nameSpan); const sizeSpan = document.createElement('span'); sizeSpan.textContent = this._formatSize(entry.size); sizeSpan.style.cssText = 'color:var(--text-muted);font-size:0.8em;white-space:nowrap;margin-left:4px;'; item.appendChild(sizeSpan); sidebar.appendChild(item); }); this._itemCount = filteredDirs.length + filteredFiles.length; sidebar.scrollTop = _scrollTop; this._highlightSelected(); this._updateSelectionStatus(); } _makeCheckbox(path, label) { const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = this._multiSelect.has(path); cb.setAttribute('aria-label', 'Select ' + label); cb.style.cssText = 'width:20px;height:20px;min-width:20px;margin:0 6px 0 0;accent-color:var(--accent-color);cursor:pointer;touch-action:manipulation;'; cb.onclick = (e) => e.stopPropagation(); cb.onchange = () => this._toggleMulti(path); return cb; } _toggleMulti(path) { if (this._multiSelect.has(path)) this._multiSelect.delete(path); else this._multiSelect.add(path); this._highlightSelected(); this._updateSelectionStatus(); } _updateSelectionStatus() { const n = this._multiSelect.size; if (n) { this._updateStatus(n + ' selected'); return; } if (this._selectedFile) { this._updateStatus('Preview: ' + this._selectedFile.split('/').pop()); return; } const total = this._itemCount || 0; this._updateStatus(total + ' item' + (total === 1 ? '' : 's')); } _exists(path) { const fs = this.terminal.fs; try { if (fs.readFile(path) !== null) return true; if (typeof fs.readDir === 'function' && fs.readDir(path) !== null) return true; if (typeof fs.stat === 'function' && fs.stat(path)) return true; } catch (_e) {} return false; } _highlightSelected() { const sidebar = document.getElementById('filemgr-sidebar'); if (!sidebar) return; const items = sidebar.querySelectorAll('[data-path]'); items.forEach(item => { const path = item.dataset.path; const inBatch = this._multiSelect.has(path); const preview = this._selectedFile === path; item.dataset.selected = (inBatch || preview) ? 'true' : 'false'; item.style.background = inBatch ? 'var(--bg-tertiary)' : 'transparent'; item.style.borderLeft = '3px solid ' + (preview ? 'var(--accent-color)' : 'transparent'); const cb = item.querySelector('input[type="checkbox"]'); if (cb) cb.checked = inBatch; }); } // v4.5.78: Batch operations _getSelectedPaths() { return [...this._multiSelect]; } _batchCopy() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return; } this._clipboard = { mode: 'copy', paths: [...paths] }; this._updateStatus('๐Ÿ“‹ Copied ' + paths.length + ' item(s)'); } _batchCut() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return; } this._clipboard = { mode: 'cut', paths: [...paths] }; this._updateStatus('โœ‚๏ธ Cut ' + paths.length + ' item(s)'); } _batchPaste() { if (!this._clipboard || !this._clipboard.paths.length) { this._updateStatus('Clipboard empty'); return; } const fs = this.terminal.fs; const dest = this.currentPath.length ? '/' + this.currentPath.join('/') : '/'; let count = 0, skipped = 0; const failed = []; this._clipboard.paths.forEach(src => { const name = src.split('/').pop(); const destPath = dest === '/' ? '/' + name : dest + '/' + name; if (destPath === src || destPath.startsWith(src + '/')) { skipped++; return; } // Directory โ†’ existing directory would REPLACE the whole subtree silently; skip it. let srcIsDir = false; try { srcIsDir = typeof fs.readDir === 'function' && fs.readDir(src) !== null; } catch (_e) {} if (this._exists(destPath) && srcIsDir) { skipped++; return; } if (this._exists(destPath) && !confirm('Overwrite ' + destPath + '?')) { skipped++; return; } try { const ok = this._clipboard.mode === 'copy' ? fs.copy(src, destPath) : fs.move(src, destPath); if (ok) count++; else failed.push(src); } catch (_e) { failed.push(src); } }); if (this._clipboard.mode === 'cut') { this._clipboard = failed.length ? { mode: 'cut', paths: failed } : { mode: null, paths: [] }; } this._multiSelect.clear(); this._selectedFile = null; this._refreshFileList(); this._updateStatus('๐Ÿ“ฅ Pasted ' + count + ' item(s)' + (skipped ? ' โ€” ' + skipped + ' skipped' : '') + (failed.length ? ' โ€” ' + failed.length + ' failed' : '')); } _batchDelete() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return false; } if (!confirm('Delete ' + paths.length + ' item(s)?')) return false; const fs = this.terminal.fs; let count = 0, failed = 0; paths.forEach(p => { try { const ok = typeof fs.rm === 'function' ? fs.rm(p) : fs.deleteFile(p); if (ok) count++; else failed++; } catch (_e) { failed++; } }); this._multiSelect.clear(); this._selectedFile = null; this._refreshFileList(); this._updateStatus('๐Ÿ—‘๏ธ Deleted ' + count + ' item(s)' + (failed ? ' โ€” ' + failed + ' failed' : '')); return count > 0; } _batchRename() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No item selected'); return; } const fs = this.terminal.fs; if (paths.length === 1) { const path = paths[0]; const oldName = path.split('/').pop(); const newName = (prompt('Rename to:', oldName) || '').trim(); if (!newName || newName === oldName) return; if (newName === '.' || newName === '..' || newName.includes('/') || newName.includes('\\')) { this._updateStatus('โŒ Invalid file name'); return; } const dir = path.substring(0, path.lastIndexOf('/')) || '/'; const newPath = dir === '/' ? '/' + newName : dir + '/' + newName; if (this._exists(newPath)) { this._updateStatus('โš  Destination already exists'); return; } if (!fs.rename(path, newPath)) { this._updateStatus('โŒ Rename failed'); return; } this._selectedFile = newPath; this._multiSelect.clear(); this._multiSelect.add(newPath); this._refreshFileList(); this._updateStatus('๐Ÿ“ Renamed to ' + newName); return; } const prefix = (prompt('Rename ' + paths.length + ' items โ€” new prefix:', 'file') || '').trim(); if (!prefix) return; let i = 0, ok = 0, skip = 0; paths.forEach(p => { const ext = (p.match(/\.[^./]+$/) || [''])[0]; const dir = p.substring(0, p.lastIndexOf('/')) || '/'; const dest = (dir === '/' ? '' : dir) + '/' + prefix + (++i) + ext; if (dest === p || this._exists(dest)) { skip++; return; } try { if (fs.rename(p, dest)) ok++; else skip++; } catch (_e) { skip++; } }); this._multiSelect.clear(); this._selectedFile = null; this._refreshFileList(); this._updateStatus('๐Ÿ“ Renamed ' + ok + ' item(s)' + (skip ? ' โ€” ' + skip + ' skipped' : '')); } _toggleSelectAll() { const sidebar = document.getElementById('filemgr-sidebar'); if (!sidebar) return; const paths = [...sidebar.querySelectorAll('[data-path]')].map(i => i.dataset.path); if (!paths.length) { this._updateSelectionStatus(); return; } const all = paths.every(p => this._multiSelect.has(p)); if (all) { paths.forEach(p => this._multiSelect.delete(p)); if (this._selectedFile && paths.includes(this._selectedFile)) this._selectedFile = null; } else { paths.forEach(p => this._multiSelect.add(p)); } this._refreshFileList(); } _renderPreview(path, content) { const preview = document.getElementById('filemgr-preview'); if (!preview) return; // Re-read content on each preview call if (content === undefined) { content = this.terminal.fs.readFile(path); } // For raw mode, show raw text regardless of type if (this._rawMode && typeof content === 'string') { preview.innerHTML = this._previewShell(path, '
    RAW: ' + this._escapeHtml(path) + '
    ' + this._escapeHtml(content.substring(0, 10000)) + '
    '); this._wirePreviewToolbar(path); this._updateStatus('Preview: ' + path); return; } // Check file size for text files const type = this._detectType(path); const textTypes = ['md', 'text', 'json', 'code', 'csv', 'other']; if (textTypes.includes(type) && typeof content === 'string' && content.length > 51200) { content = content.substring(0, 51200); preview.innerHTML = this._previewShell(path, this._getPreviewHtml(path, content) + '
    (truncated โ€” file exceeds 50KB)
    '); this._wirePreviewToolbar(path); this._updateStatus('Preview: ' + path); return; } preview.innerHTML = this._previewShell(path, this._getPreviewHtml(path, content)); this._wirePreviewToolbar(path); this._updateStatus('Preview: ' + path); } // 2026-10-21: preview pane had no fullscreen and no share action. _previewShell(path, innerHtml) { // 2026-10-26 (phone test): the pane had no close control at all โ€” the only โœ• belonged to the // whole file manager and wiped _multiSelect on the way out, so "select 3 files โ†’ Download" // ended in "No file selected to download". const btn = (cls, label, title) => ''; return '
    ' + btn('fm-btn-close', 'โœ•', 'Close preview') + '
    ' + btn('fm-btn-fs', 'โ›ถ', 'Fullscreen preview') + btn('fm-btn-copy', '๐Ÿ“‹', 'Copy link') + btn('fm-btn-share', '๐Ÿ”—', 'Share link') + '
    ' + innerHtml + '
    '; } // 2026-10-26: one entry point for both the preview toolbar (1 path) and the action bar (N paths). _sharePaths(paths, mode) { if (!paths || !paths.length) { this._updateStatus('๐Ÿ”— Nothing selected โ€” tap โ˜ Select then tap files'); return; } const urls = (paths || []).map(p => this._previewShareUrl(p)).filter(Boolean); if (!urls.length) { this._updateStatus('๐Ÿ”— Share unavailable โ€” no space resolved'); return; } const n = urls.length, text = urls.join('\n'); const copy = () => { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text) .then(() => this._updateStatus('๐Ÿ“‹ Copied ' + n + (n === 1 ? ' link' : ' links'))) .catch(() => this._inlineLinkBox(text, n)); } else { this._inlineLinkBox(text, n); } }; if (mode === 'copy') { copy(); return; } if (navigator.share) { const payload = n === 1 ? { url: urls[0] } : { title: n + ' newOS4 files', text }; navigator.share(payload) .then(() => this._updateStatus('๐Ÿ”— Shared ' + n + (n === 1 ? ' link' : ' links'))) .catch(copy); return; } copy(); } // Visible, selectable fallback: prompt() cannot appear in iOS standalone PWAs or most WebViews. _inlineLinkBox(text, n) { const pane = document.getElementById('filemgr-preview'); const bar = pane ? pane.querySelector('.fm-preview-toolbar') : null; let box = document.querySelector('.fm-share-input'); if (!box) { box = document.createElement(n > 1 ? 'textarea' : 'input'); box.className = 'fm-share-input'; box.readOnly = true; box.setAttribute('aria-label', n > 1 ? 'Share links' : 'Share link'); box.style.cssText = 'flex:1;min-width:0;min-height:44px;padding:6px 10px;font-size:1em;box-sizing:border-box;' + 'border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-color)'; if (bar) { bar.insertBefore(box, bar.firstChild); } else { // 2026-10-26: sharing from the action bar happens with no preview open โ€” the fallback // box needs its own row above the buttons, otherwise the URL is only in the status line. const anchor = document.getElementById('filemgr-action-bar'); const host = anchor && anchor.parentNode ? anchor.parentNode : document.getElementById('filemgr-main-container'); if (!host) { this._updateStatus('๐Ÿ“‹ ' + text); return; } const row = document.createElement('div'); row.className = 'fm-share-row'; row.style.cssText = 'display:flex;gap:8px;padding:6px 8px;flex-shrink:0'; row.appendChild(box); host.insertBefore(row, anchor || null); } } if (box) { box.value = text; box.focus(); box.select(); } this._updateStatus('๐Ÿ“‹ Select and copy ' + n + (n === 1 ? ' link' : ' links')); } _previewShareUrl(path) { const wdav = this.terminal && this.terminal.moduleManager && this.terminal.moduleManager.modules ? this.terminal.moduleManager.modules.WebDAVModule : null; let space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || ''; // T5: the fallback detector already shipped in modules/ext/webdav.js:22 โ€” use it. if (!space && wdav && wdav._detectSpaceName) space = wdav._detectSpaceName() || ''; if (!space) space = fmDetectSpaceName(window.location.pathname); return fmBuildShareUrl(space, path); // null when nothing resolves โ€” caller reports it } // 2026-10-21: publish VFS file(s) to WebDAV (was terminal-only: webdav-push) async _uploadSelectionToWebdav() { let targets = this._getSelectedPaths ? this._getSelectedPaths() : []; if (!targets || !targets.length) targets = this._selectedFile ? [this._selectedFile] : []; if (!targets.length) { this._updateStatus('โ˜ Select a file first'); return; } const wdav = this.terminal && this.terminal.moduleManager && this.terminal.moduleManager.modules ? this.terminal.moduleManager.modules.WebDAVModule : null; if (!wdav) { this._updateStatus('โ˜ WebDAV module not loaded'); return; } if (!wdav.config || !wdav.config.url) { try { await wdav._autoBootstrap(); } catch (e) {} } if (!wdav.config || !wdav.config.url) { this._updateStatus('โ˜ WebDAV not configured โ€” run webdav-config'); return; } for (const p of targets) { const clean = String(p).replace(/^\/+/, ''); try { await this.terminal.security.checkAndRun('webdav-push ' + clean); const shareUrl = this._previewShareUrl(clean); this._updateStatus('โ˜ Uploaded ' + clean + ' โ†’ ' + shareUrl); if (this.terminal.print) this.terminal.print('โ˜ WebDAV: ' + shareUrl, 'success'); } catch (e) { this._updateStatus('โ˜ Upload failed: ' + (e && e.message ? e.message : e)); } } } _wirePreviewToolbar(path) { const pane = document.getElementById('filemgr-preview'); if (!pane) return; const fsBtn = pane.querySelector('.fm-btn-fs'); const shBtn = pane.querySelector('.fm-btn-share'); if (fsBtn) fsBtn.onclick = () => { // 2026-10-26 (phone test): fullscreening .fm-preview-body hid the toolbar, so the only way // out was the OS back gesture. Fullscreen the pane instead โ€” โœ• and โ›ถ stay on screen. if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); return; } const target = pane; if (target.requestFullscreen) target.requestFullscreen().catch(() => this._updateStatus('โ›ถ Fullscreen blocked by this browser')); else if (target.webkitRequestFullscreen) target.webkitRequestFullscreen(); else this._updateStatus('โ›ถ Fullscreen not supported here'); }; const closeBtn = pane.querySelector('.fm-btn-close'); if (closeBtn) closeBtn.onclick = () => { this._clearPreview(); this._updateStatus('Preview closed โ€” selection kept'); }; const copyBtn = pane.querySelector('.fm-btn-copy'); if (copyBtn) copyBtn.onclick = () => this._sharePaths([path], 'copy'); if (shBtn) shBtn.onclick = () => this._sharePaths([path]); } _renderPreviewError(msg) { const preview = document.getElementById('filemgr-preview'); if (!preview) return; preview.innerHTML = '
    ' + this._escapeHtml(msg) + '
    '; this._updateStatus('Error'); } _clearPreview() { const preview = document.getElementById('filemgr-preview'); if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); } if (preview) preview.innerHTML = ''; this._updateSelectionStatus(); // selection survives closing the preview } _updateStatus(msg) { const status = document.getElementById('filemgr-status'); if (status) status.textContent = msg; } _actionDelete() { // Delegate to the batch path so checkbox selections are honoured, not just the previewed file. if (!this._getSelectedPaths().length && this._selectedFile) { this._multiSelect.add(this._selectedFile); } // Only clear the preview when something was actually deleted (not on cancel). if (this._batchDelete()) this._clearPreview(); } _actionDownload() { // T2: honour the multi-selection the way every sibling action does (_getSelectedPaths). let targets = this._getSelectedPaths ? this._getSelectedPaths() : []; if (!targets || !targets.length) targets = this._selectedFile ? [this._selectedFile] : []; if (!targets.length) { this._updateStatus('โฌ‡ Nothing selected โ€” tap โ˜ Select then tap files, or tap one file first'); return; } const saved = [], skipped = []; for (const p of targets) { const content = this.terminal.fs.readFile(p); if (content === null || content === false) { skipped.push(p); continue; } const fileName = p.split('/').pop() || 'file'; const blob = new Blob([content], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); // T1: iOS Safari / Android Chrome hand blob: to the download manager asynchronously. // Revoking in the same tick aborts the transfer while the UI still said "Downloaded". setTimeout(() => URL.revokeObjectURL(url), 30000); saved.push(fileName); } if (saved.length > 1) this._updateStatus('โฌ‡ Downloaded ' + saved.length + ' files'); else if (saved.length === 1) this._updateStatus('โฌ‡ Downloaded: ' + saved[0]); if (skipped.length) this._updateStatus('โŒ Skipped ' + skipped.join(', ')); } _actionEdit() { if (!this._selectedFile) { this._updateStatus('No file selected to edit'); return; } const path = this._selectedFile; // Close file manager first so code editor modal (z-index:1000) is visible above this._closeOverlay(); if (typeof webx11 !== 'undefined' && webx11 && webx11.dispatchCommand) { setTimeout(() => { webx11.dispatchCommand('edit', [path]); }, 100); this._updateStatus('โœ Opened in editor: ' + path); } else { this._updateStatus('โŒ Editor not available'); } } _togglePreviewMode() { this._rawMode = !this._rawMode; const btn = document.getElementById('filemgr-toggle-raw'); if (btn) { btn.textContent = this._rawMode ? '๐Ÿ“„ Rendered' : '๐Ÿ“„ Raw'; btn.style.borderColor = this._rawMode ? 'var(--accent-color)' : 'var(--border-color)'; btn.style.color = this._rawMode ? 'var(--accent-color)' : 'var(--text-muted)'; } if (this._selectedFile) { const content = this.terminal.fs.readFile(this._selectedFile); this._renderPreview(this._selectedFile, content); } } _actionEncrypt() { if (!this._selectedFile) { this._updateStatus('No file selected'); return; } if (this._selectedFile.endsWith('.enc')) { this._updateStatus('โš  Already encrypted (.enc)'); return; } const passphrase = prompt('Enter passphrase for AES-256-GCM encryption:'); if (!passphrase || passphrase.length < 4) { this._updateStatus('โŒ Passphrase too short (min 4 chars)'); return; } // Delegate to cryptogzip module's encrypt command if (typeof webx11 !== 'undefined' && webx11.executeCommand) { webx11.executeCommand('encrypt ' + this._selectedFile + ' ' + passphrase); this._updateStatus('๐Ÿ” Encrypted: ' + this._selectedFile + '.enc'); this._refreshFileList(); } } _actionCompress() { if (!this._selectedFile) { this._updateStatus('No file selected'); return; } if (this._selectedFile.endsWith('.gz')) { this._updateStatus('โš  Already compressed (.gz)'); return; } if (typeof webx11 !== 'undefined' && webx11.executeCommand) { webx11.executeCommand('gzip ' + this._selectedFile); this._updateStatus('๐Ÿ“ฆ Compressed: ' + this._selectedFile + '.gz'); this._refreshFileList(); } } _actionNewFile() { const name = prompt('New file name (e.g. myfile.txt):'); if (!name) return; const pathStr = '/' + this.currentPath.join('/'); const cleanPath = pathStr === '/' ? '/' + name : pathStr + '/' + name; const content = prompt('Enter file content (optional):') || ''; const ok = this.terminal.fs.writeFile(cleanPath, content); if (ok) { this._updateStatus('โœ… Created: ' + cleanPath + ' (' + content.length + ' chars)'); this._refreshFileList(); } else { this._updateStatus('โŒ Failed to create file'); } } } // 2026-10-24: `typeof window.webx11` throws under Node (member access on an undefined global), // which would make the module un-require-able by modules/test/test-download-url.js. if (typeof window !== 'undefined' && window.webx11 && window.webx11.moduleManager) { window.webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js'); } /* === diff.js === */ class DiffModule { constructor() { this.commands = {}; this.terminal = null; } requirements() { return { met: true, missing: [] }; } getHelpInfo() { return { 'diff': 'Show differences between two VFS files or compare module source (usage: diff or diff --module )', }; } registerCommands() { this.commands['diff'] = (args) => this.diff(args); } async diff(args) { if (!args.length) return this.terminal.print('Usage: diff or diff --module ', 'warning'); if (args[0] === '--module') { if (!args[1]) return this.terminal.print('Usage: diff --module ', 'warning'); return this._diffModule(args[1]); } return this._diffFiles(args[0], args[1]); } _diffFiles(path1, path2) { let content1, content2; try { content1 = this.terminal.fs.readFile(path1); content2 = this.terminal.fs.readFile(path2); } catch (e) { return this.terminal.print(`Error reading files: ${e.message}`, 'error'); } if (content1 === null || content1 === undefined) { return this.terminal.print(`File not found: ${path1}`, 'error'); } if (content2 === null || content2 === undefined) { return this.terminal.print(`File not found: ${path2}`, 'error'); } const lines1 = String(content1).split('\n'); const lines2 = String(content2).split('\n'); // Strip trailing empty line if file ends with newline if (lines1.length > 0 && lines1[lines1.length - 1] === '') lines1.pop(); if (lines2.length > 0 && lines2[lines2.length - 1] === '') lines2.pop(); if (lines1.length === 0 && lines2.length === 0) { return this.terminal.print('Both files are empty โ€” no differences', 'info'); } // Compute LCS-based diff const result = this._computeDiff(lines1, lines2); // Render output this.terminal.print(`--- ${path1}`, 'info'); this.terminal.print(`+++ ${path2}`, 'info'); if (result.length === 0) { return this.terminal.print('Files are identical', 'success'); } const maxLineNum = Math.max(lines1.length, lines2.length); const lineNumWidth = String(maxLineNum).length; result.forEach(part => { if (part.type === 'same') { // Print up to 3 unchanged lines for context, then collapse if (part.lines.length <= 3) { part.lines.forEach((line, i) => { const ln = String(part.aStart + i + 1).padStart(lineNumWidth); this.terminal.print(` ${ln} ${line}`, 'dim'); }); } else { // Show first line this.terminal.print(` ${String(part.aStart + 1).padStart(lineNumWidth)} ${part.lines[0]}`, 'dim'); this.terminal.print(` ${' '.repeat(lineNumWidth)} ... ${part.lines.length - 2} more unchanged lines`, 'dim'); // Show last line const lastIdx = part.lines.length - 1; const ln = String(part.aStart + lastIdx + 1).padStart(lineNumWidth); this.terminal.print(` ${ln} ${part.lines[lastIdx]}`, 'dim'); } } else if (part.type === 'removed') { part.lines.forEach((line, i) => { const ln = String(part.aStart + i + 1).padStart(lineNumWidth); this.terminal.print(`-${ln} ${line}`, 'error'); }); } else if (part.type === 'added') { part.lines.forEach((line, i) => { const ln = String(part.bStart + i + 1).padStart(lineNumWidth); this.terminal.print(`+${ln} ${line}`, 'success'); }); } }); } _computeDiff(a, b) { // Build LCS table const m = a.length; const n = b.length; const dp = Array.from({ length: m + 1 }, () => new Int32Array(n + 1)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (a[i - 1] === b[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } } // Backtrack to build diff parts const parts = []; let i = m, j = n; let removed = [], added = [], same = []; const aStart = 0, bStart = 0; const flush = () => { if (same.length > 0) { parts.push({ type: 'same', aStart: aStart + (parts.length > 0 ? parts.reduce((sum, p) => { if (p.type === 'same') return sum + p.lines.length; if (p.type === 'removed') return sum + p.lines.length; return sum; }, 0) : 0), lines: same.splice(0) }); } if (removed.length > 0 || added.length > 0) { if (removed.length > 0) { const aIdx = parts.reduce((sum, p) => { if (p.type === 'same') return sum + p.lines.length; if (p.type === 'removed') return sum + p.lines.length; return sum; }, 0); parts.push({ type: 'removed', aStart: aIdx, lines: removed.splice(0) }); } if (added.length > 0) { const bIdx = parts.reduce((sum, p) => { if (p.type === 'same') return sum; if (p.type === 'added') return sum + p.lines.length; return sum; }, 0); parts.push({ type: 'added', bStart: bIdx, lines: added.splice(0) }); } } }; // Trace back through DP table const trace = []; while (i > 0 || j > 0) { if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { trace.push({ type: 'same', lineA: a[i - 1], lineB: b[j - 1] }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { trace.push({ type: 'added', lineB: b[j - 1] }); j--; } else { trace.push({ type: 'removed', lineA: a[i - 1] }); i--; } } // Reverse trace to get forward order trace.reverse(); // Build parts from trace let aIdx = 0, bIdx = 0; for (const entry of trace) { if (entry.type === 'same') { same.push(entry.lineA); aIdx++; bIdx++; } else if (entry.type === 'removed') { flush(); removed.push(entry.lineA); aIdx++; } else if (entry.type === 'added') { flush(); added.push(entry.lineB); bIdx++; } } flush(); return parts; } async _diffModule(modName) { // Find module in registry const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); if (!entry) { return this.terminal.print(`Module "${modName}" not found in registry`, 'error'); } const url = entry.url; if (!url) { return this.terminal.print(`No source URL for module "${modName}"`, 'warning'); } // Fetch original source let originalSource; try { const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); originalSource = await resp.text(); } catch (e) { return this.terminal.print(`Failed to fetch original source: ${e.message}`, 'error'); } // Read local edited version const localPath = `_mods/${modName}.js`; let localSource; let hasLocalEdit = false; try { localSource = this.terminal.fs.readFile(localPath); if (localSource === null || localSource === undefined) { hasLocalEdit = false; } else { hasLocalEdit = true; } } catch (e) { hasLocalEdit = false; } if (!hasLocalEdit) { // Try to read the module's currently loaded source const loadedModule = this.terminal.moduleManager.modules[modName]; if (!loadedModule) { return this.terminal.print(`Module "${modName}" is not loaded and has no local edits`, 'info'); } return this.terminal.print(`Module "${modName}" has no local edits โ€” source matches original`, 'success'); } // Compare original source vs local edit const lines1 = String(originalSource).split('\n'); const lines2 = String(localSource).split('\n'); // Strip trailing empty line if file ends with newline if (lines1.length > 0 && lines1[lines1.length - 1] === '') lines1.pop(); if (lines2.length > 0 && lines2[lines2.length - 1] === '') lines2.pop(); this.terminal.print(`--- ${url}`, 'info'); this.terminal.print(`+++ ${localPath}`, 'info'); if (lines1.length === lines2.length && lines1.every((l, i) => l === lines2[i])) { return this.terminal.print('No differences โ€” local edit matches original', 'success'); } const result = this._computeDiff(lines1, lines2); const maxLineNum = Math.max(lines1.length, lines2.length); const lineNumWidth = String(maxLineNum).length; result.forEach(part => { if (part.type === 'same') { if (part.lines.length <= 3) { part.lines.forEach((line, i) => { const ln = String(part.aStart + i + 1).padStart(lineNumWidth); this.terminal.print(` ${ln} ${line}`, 'dim'); }); } else { this.terminal.print(` ${String(part.aStart + 1).padStart(lineNumWidth)} ${part.lines[0]}`, 'dim'); this.terminal.print(` ${' '.repeat(lineNumWidth)} ... ${part.lines.length - 2} more unchanged lines`, 'dim'); const lastIdx = part.lines.length - 1; const ln = String(part.aStart + lastIdx + 1).padStart(lineNumWidth); this.terminal.print(` ${ln} ${part.lines[lastIdx]}`, 'dim'); } } else if (part.type === 'removed') { part.lines.forEach((line, i) => { const ln = String(part.aStart + i + 1).padStart(lineNumWidth); this.terminal.print(`-${ln} ${line}`, 'error'); }); } else if (part.type === 'added') { part.lines.forEach((line, i) => { const ln = String(part.bStart + i + 1).padStart(lineNumWidth); this.terminal.print(`+${ln} ${line}`, 'success'); }); } }); } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new DiffModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/diff.js'); } /* === busybox.js === */ class BusyBoxModule { constructor() { this.commands = {}; this.terminal = null; } requirements() { return { met: true, missing: [] }; } getHelpInfo() { return { 'which': 'which โ€” Locate a command in the module registry', 'env': 'env โ€” List environment variables (webx11_ keys + user profile)', 'date': 'date โ€” Show current date/time (ISO, local, UTC, timestamp)', 'cal': 'cal [month] [year] โ€” Display an ASCII calendar', 'yes': 'yes [string] โ€” Output repeated string (default "y", capped at 50)', 'seq': 'seq or seq โ€” Generate sequence of numbers', 'base64': 'base64 [-d] โ€” Base64 encode/decode', 'printf': 'printf [args...] โ€” Formatted print (%s, %d, %f, \\n, \\t)', 'cut': 'cut -f [-d] โ€” Extract column from file', 'uniq': 'uniq โ€” Filter adjacent duplicate lines from a file', 'hostname': 'hostname โ€” Show host/user-agent info', 'whoami': 'whoami โ€” Show current user name', 'basename': 'basename โ€” Strip directory from path', 'dirname': 'dirname โ€” Strip filename from path', 'sleep': 'sleep โ€” Delay execution with countdown', 'expand': 'expand โ€” Convert tabs to spaces (default 4) in file', }; } registerCommands() { this.commands['which'] = (args) => this._cmdWhich(args); this.commands['env'] = () => this._cmdEnv(); this.commands['date'] = (args) => this._cmdDate(args); this.commands['cal'] = (args) => this._cmdCal(args); this.commands['yes'] = (args) => this._cmdYes(args); this.commands['seq'] = (args) => this._cmdSeq(args); this.commands['base64'] = (args) => this._cmdBase64(args); this.commands['printf'] = (args) => this._cmdPrintf(args); this.commands['cut'] = (args) => this._cmdCut(args); this.commands['uniq'] = (args) => this._cmdUniq(args); this.commands['hostname'] = () => this._cmdHostname(); this.commands['whoami'] = () => this._cmdWhoami(); this.commands['basename'] = (args) => this._cmdBasename(args); this.commands['dirname'] = (args) => this._cmdDirname(args); this.commands['sleep'] = (args) => this._cmdSleep(args); this.commands['expand'] = (args) => this._cmdExpand(args); } // โ”€โ”€ which โ”€โ”€ _cmdWhich(args) { if (!args[0]) return this.terminal.print('Usage: which ', 'warning'); const query = args[0].toLowerCase(); const all = this.terminal.moduleManager.getAllCommands(); const keys = Object.keys(all); if (all[query]) { let foundMod = null; for (const modName in this.terminal.moduleManager.modules) { const mod = this.terminal.moduleManager.modules[modName]; if (mod.commands && mod.commands[query]) { foundMod = mod; break; } } const src = foundMod ? ` (from ${foundMod.constructor.name})` : ''; return this.terminal.print(`${query}${src}`, 'info'); } // Fuzzy match: check Levenshtein distance up to 2 let best = null, bestDist = 3; for (const k of keys) { const d = this._levenshtein(query, k); if (d < bestDist) { best = k; bestDist = d; } } if (best) { this.terminal.print(`which: no '${query}' in registry`, 'error'); this.terminal.print(`Did you mean '${best}'?`, 'info'); } else { this.terminal.print(`which: no '${query}' in registry`, 'error'); } } _levenshtein(a, b) { const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]); } } return dp[m][n]; } // โ”€โ”€ env โ”€โ”€ _cmdEnv() { const lines = []; // webx11_ localStorage keys for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith('webx11_')) { let v = localStorage.getItem(k) || ''; if (v.length > 80) v = v.substring(0, 77) + '...'; lines.push(`${k}=${v}`); } } // Runtime info if (this.terminal.ollamaManager) { lines.push(`OLLAMA_MODEL=${this.terminal.ollamaManager.currentModel || 'none'}`); lines.push(`OLLAMA_CONNECTED=${this.terminal.ollamaManager.connected}`); } lines.push(`PWD=/${this.terminal.fs.currentPath.join('/')}`); lines.push(`SHELL=webx11-terminal`); lines.push(`USER=darkbot`); lines.push(`HOME=/`); lines.push(`TERM=xterm-256color`); const prof = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; if (prof && prof._userProfile) { const facts = prof._userProfile.knownFacts; if (facts) { for (const [k, v] of Object.entries(facts)) { lines.push(`USER_${k.toUpperCase()}=${v}`); } } } this.terminal.print(lines.join('\n'), 'info'); } // โ”€โ”€ date โ”€โ”€ _cmdDate(args) { const n = new Date(); if (args.length > 0) { const flag = args[0].toLowerCase(); if (flag === '-u' || flag === '--utc' || flag === 'utc') return this.terminal.print(n.toUTCString(), 'info'); if (flag === '-i' || flag === '--iso' || flag === 'iso') return this.terminal.print(n.toISOString(), 'info'); if (flag === '-t' || flag === '--ts' || flag === 'timestamp') return this.terminal.print(String(n.getTime()), 'info'); if (flag === '-r' || flag === '--rfc' || flag === 'rfc') return this.terminal.print(n.toUTCString(), 'info'); } const local = n.toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short' }); this.terminal.print(local, 'info'); this.terminal.print(`ISO: ${n.toISOString()}`, 'info'); this.terminal.print(`UTC: ${n.toUTCString()}`, 'info'); this.terminal.print(`Unix: ${Math.floor(n.getTime() / 1000)}`, 'info'); } // โ”€โ”€ cal โ”€โ”€ _cmdCal(args) { const now = new Date(); let year = now.getFullYear(); let month = now.getMonth() + 1; if (args.length >= 2) { month = parseInt(args[0]); year = parseInt(args[1]); } else if (args.length === 1) { // If only one arg, could be year (4 digits) or month const v = parseInt(args[0]); if (args[0].length === 4 || v > 12) { year = v; } else { month = v; } } if (isNaN(month) || isNaN(year) || month < 1 || month > 12) { return this.terminal.print('cal: invalid month or year', 'error'); } const monthNames = ['January','February','March','April','May','June', 'July','August','September','October','November','December']; const first = new Date(year, month - 1, 1); const last = new Date(year, month, 0); const daysInMonth = last.getDate(); const startDay = first.getDay(); // 0=Sun const header = ` ${monthNames[month-1]} ${year}`; const weekdays = 'Su Mo Tu We Th Fr Sa'; let cal = header + '\n' + weekdays + '\n'; let line = ' '.repeat(startDay); for (let d = 1; d <= daysInMonth; d++) { line += String(d).padStart(2) + ' '; if ((startDay + d) % 7 === 0 || d === daysInMonth) { cal += line.trimEnd() + '\n'; line = ''; } } this.terminal.print(cal, 'info'); } // โ”€โ”€ yes โ”€โ”€ _cmdYes(args) { const text = args.length > 0 ? args.join(' ') : 'y'; const count = 50; for (let i = 0; i < count; i++) { this.terminal.print(text); } this.terminal.print(`(repeated ${count} times, capped)`, 'text-muted'); } // โ”€โ”€ seq โ”€โ”€ _cmdSeq(args) { if (args.length === 0) return this.terminal.print('Usage: seq or seq ', 'warning'); let first = 1, last; if (args.length === 1) { last = parseInt(args[0]); } else { first = parseInt(args[0]); last = parseInt(args[1]); } if (isNaN(first) || isNaN(last)) return this.terminal.print('seq: non-integer argument', 'error'); const max = 1000; if (last - first > max) return this.terminal.print(`seq: range too large (max ${max})`, 'error'); const output = []; if (first <= last) { for (let i = first; i <= last; i++) output.push(String(i)); } else { for (let i = first; i >= last; i--) output.push(String(i)); } this.terminal.print(output.join('\n'), 'info'); } // โ”€โ”€ base64 โ”€โ”€ _cmdBase64(args) { if (args.length === 0) return this.terminal.print('Usage: base64 [-d] ', 'warning'); if (args[0] === '-d' || args[0] === '--decode') { if (args.length < 2) return this.terminal.print('Usage: base64 -d ', 'warning'); const encoded = args.slice(1).join(' '); try { const decoded = atob(encoded); this.terminal.print(decoded, 'info'); } catch (e) { this.terminal.print('base64: invalid input (not valid base64)', 'error'); } } else { const input = args.join(' '); this.terminal.print(btoa(input), 'info'); } } // โ”€โ”€ printf โ”€โ”€ _cmdPrintf(args) { if (args.length === 0) return this.terminal.print('Usage: printf [args...]', 'warning'); let fmt = args[0]; const vals = args.slice(1); let vi = 0; // Replace format specifiers step by step const result = fmt .replace(/\\n/g, '\n') .replace(/\\t/g, '\t') .replace(/\\\\/g, '\\') .replace(/%s/g, () => vals[vi++] || '') .replace(/%d/g, () => { const v = parseInt(vals[vi++]); return isNaN(v) ? '0' : String(v); }) .replace(/%f/g, () => { const v = parseFloat(vals[vi++]); return isNaN(v) ? '0.000000' : v.toFixed(6); }) .replace(/%[^sdf%]/g, (m) => `(unsupported: ${m})`); this.terminal.print(result, 'info'); } // โ”€โ”€ cut โ”€โ”€ _cmdCut(args) { if (args.length < 2) return this.terminal.print('Usage: cut -f [-d] ', 'warning'); let field = null, delim = '\t', file = ''; for (const a of args) { if (a.startsWith('-f')) field = parseInt(a.substring(2)); else if (a.startsWith('-d')) delim = a.substring(2); else file = a; } if (!field || isNaN(field)) return this.terminal.print('cut: -f flag required', 'error'); if (!file) return this.terminal.print('cut: file argument required', 'error'); const content = this.terminal.fs.readFile(file); if (content === null) return this.terminal.print(`cut: ${file}: No such file`, 'error'); const lines = content.split('\n').filter(l => l); const output = []; for (const line of lines) { const parts = line.split(delim); const idx = field - 1; if (idx >= 0 && idx < parts.length) { output.push(parts[idx]); } } this.terminal.print(output.join('\n'), 'info'); } // โ”€โ”€ uniq โ”€โ”€ _cmdUniq(args) { if (!args[0]) return this.terminal.print('Usage: uniq ', 'warning'); const content = this.terminal.fs.readFile(args[0]); if (content === null) return this.terminal.print(`uniq: ${args[0]}: No such file`, 'error'); const lines = content.split('\n'); let last = null; const output = []; for (const line of lines) { if (line !== last) { output.push(line); last = line; } } this.terminal.print(output.join('\n'), 'info'); } // โ”€โ”€ hostname โ”€โ”€ _cmdHostname() { const ua = navigator.userAgent; const space = 'newos4'; const platform = navigator.platform || 'unknown'; const version = 'WebX11 ' + (window.NEWOS4_VERSION || 'v4.0'); this.terminal.print(`Hostname: ${space}`, 'info'); this.terminal.print(`Platform: ${version} on ${platform}`, 'info'); this.terminal.print(`User Agent: ${ua}`, 'info'); } // โ”€โ”€ whoami โ”€โ”€ _cmdWhoami() { // Try to get from user profile let name = 'darkbot'; try { const prof = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; if (prof && prof._userProfile && prof._userProfile.knownFacts && prof._userProfile.knownFacts.name) { name = prof._userProfile.knownFacts.name; } } catch (e) {} this.terminal.print(name, 'info'); } // โ”€โ”€ basename โ”€โ”€ _cmdBasename(args) { if (!args[0]) return this.terminal.print('Usage: basename ', 'warning'); const parts = args[0].split('/').filter(p => p); const result = parts.pop() || '/'; this.terminal.print(result, 'info'); } // โ”€โ”€ dirname โ”€โ”€ _cmdDirname(args) { if (!args[0]) return this.terminal.print('Usage: dirname ', 'warning'); const parts = args[0].split('/').filter(p => p); if (parts.length <= 1) { this.terminal.print('.', 'info'); return; } parts.pop(); const result = args[0].startsWith('/') ? '/' + parts.join('/') : parts.join('/'); this.terminal.print(result || '.', 'info'); } // โ”€โ”€ sleep โ”€โ”€ _cmdSleep(args) { if (!args[0]) return this.terminal.print('Usage: sleep ', 'warning'); const secs = parseInt(args[0]); if (isNaN(secs) || secs < 1) return this.terminal.print('sleep: invalid duration', 'error'); const max = 30; const actual = Math.min(secs, max); if (actual !== secs) this.terminal.print(`sleep: capped to ${max}s (from ${secs}s)`, 'warning'); this.terminal.print(`Sleeping ${actual}s...`, 'info'); let remaining = actual; const interval = setInterval(() => { remaining--; if (remaining > 0) { this.terminal.print(`${remaining}s...`, 'info'); } else { clearInterval(interval); this.terminal.print('Done', 'success'); } }, 1000); } // โ”€โ”€ expand โ”€โ”€ _cmdExpand(args) { if (!args[0]) return this.terminal.print('Usage: expand ', 'warning'); const content = this.terminal.fs.readFile(args[0]); if (content === null) return this.terminal.print(`expand: ${args[0]}: No such file`, 'error'); const expanded = content.replace(/\t/g, ' '); this.terminal.print(expanded, 'info'); } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new BusyBoxModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/busybox.js'); } /* === end critical ext module bundle === */