diff options
| author | Agando <roduccomm@gmail.com> | 2026-06-13 18:00:58 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-06-13 18:00:58 +0200 |
| commit | de713efd619360e29b9bfc4b99d30d2a86b96caf (patch) | |
| tree | 0cb94ba7cbeca0cd47cc77a9a31f744d28aa5e76 /modules/ext | |
| parent | 764128fbf5c5516eb26700ca3cf2f1ad523c48bd (diff) | |
auto: 2026-06-13_18:00 changes
Diffstat (limited to 'modules/ext')
| -rw-r--r-- | modules/ext/critical-ext-bundle.js | 2363 |
1 files changed, 2363 insertions, 0 deletions
diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js new file mode 100644 index 0000000..f2cefbb --- /dev/null +++ b/modules/ext/critical-ext-bundle.js @@ -0,0 +1,2363 @@ +// Critical ext module bundle — generated for v4.5.49// Loaded synchronously in main.html before module-loader.js. +/* === 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 <path>)', + }; + } + + registerCommands() { + this.commands['edit'] = (args) => this.edit(args); + } + edit(args) { + if (!args[0]) return this.terminal.print('Usage: edit <path>', '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'); + 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(); + // 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 <var>=<value> — Set a shell variable', + 'unset': 'unset <var> — Remove a shell variable', + 'script': 'script <path.sh> — Run a shell script from VFS', + '$': '$ <command> — Execute with shell parsing (default: auto-detected)', + 'sh': 'sh <path.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 + if (this._initialized) 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); + + // 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 || + const chainParts = self._splitChain(seqPart.trim()); + let lastOk = true; + for (const chainPart of chainParts) { + if (!chainPart.cmd.trim()) continue; + const { cmd: chainCmd, operator } = chainPart; + if (operator === '&&' && !lastOk) continue; + if (operator === '||' && lastOk) continue; + // Check for redirects + const redirectInfo = self._extractRedirects(chainCmd); + if (redirectInfo.hasRedirect) { + // Run, capture, write to file + await self._executeWithRedirect(redirectInfo); + lastOk = redirectInfo.exitOk; + } else { + // Normal or piped command + const pipeParts = self.terminal.splitPipe(chainCmd); + if (pipeParts.length > 1) { + await self.terminal.executePipeChain(pipeParts); + } else { + // Single command + const parts = chainCmd.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + try { + await this.security.checkAndRun(chainCmd); + } catch(e) { + this.terminal && this.terminal.print ? this.terminal.print('Error: ' + e.message, 'error') : console.error(e); + } + } + lastOk = true; + } + } + } + }; + this.terminal._shellModule = this; + + // Modify pipe chain to read _pipeInput for commands that support it + 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); + 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__'); + } + } + } + + if (i < pipeParts.length - 1) { + pipeInput = await this.capturePipeOutput(cmd, args); + } else { + await this.dispatchCommand(cmd, args); + } + + // Clean up temp file + if (this.fs.readFile('__pipebuf__') !== null) { + this.fs.root.content['__pipebuf__'] = undefined; + 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 + const parts = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + 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: '&&' }); + current = ''; i += 2; + } else if (!inQuote && input.substring(i, i+2) === '||') { + parts.push({ cmd: current.trim(), operator: '||' }); + current = ''; i += 2; + } else { + current += ch; i++; + } + } + parts.push({ cmd: current.trim(), operator: null }); + 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()); + 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()); + 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()); + 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'); + return; + } + this.terminal._pipeInput = inputContent; + // Execute the command with pipe input available + 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__'); + } + await this.terminal.dispatchCommand(cmd, newArgs); + if (fs.readFile('__pipebuf__') !== null) { + delete fs.root.content['__pipebuf__']; + } + } else { + await this.terminal.dispatchCommand(cmd, args); + } + this.terminal._pipeInput = null; + return; + } + + // > or >> — capture output, write to file + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + + // 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) { + const captPrint = this.terminal.print.bind(this.terminal); + let capt = ''; + this.terminal.print = (t, ty) => { capt += t + '\n'; }; + await this.terminal.dispatchCommand(pCmd, pArgs); + this.terminal.print = captPrint; + 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); + } + + this.terminal.print = originalPrint; + 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'); + } + } + } + + _isPipeAware(cmd) { + // Commands that read from files and should use pipe buffer + return ['grep', 'head', 'tail', 'sort', 'wc', '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 <var>=<value>', '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 <var>', '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 <path.sh>', '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++; + this.terminal.print(`Running script: ${path}`, 'info'); + const lines = content.split('\n'); + let lineNum = 0; + for (const rawLine of lines) { + lineNum++; + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + // Run each line through the shell (by calling executeCommand directly) + // We need to manually call the wrapped executeCommand + this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt'); + // Parse inline: handle pipe, redirect, chain, sequence + const substituted = this._substituteVars(line); + const seqParts = this._splitSequence(substituted); + for (const seqPart of seqParts) { + if (!seqPart.trim()) continue; + const chainParts = this._splitChain(seqPart.trim()); + let lastOk = true; + for (const chainPart of chainParts) { + if (!chainPart.cmd.trim()) continue; + if (chainPart.operator === '&&' && !lastOk) continue; + if (chainPart.operator === '||' && lastOk) continue; + const redirectInfo = this._extractRedirects(chainPart.cmd); + if (redirectInfo.hasRedirect) { + await this._executeWithRedirect(redirectInfo); + lastOk = redirectInfo.exitOk; + } else { + const pipeParts = this.terminal.splitPipe(chainPart.cmd); + if (pipeParts.length > 1) { + await this.terminal.executePipeChain(pipeParts); + } else { + const parts = chainPart.cmd.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // Check if it's our built-in command + if (this.commands[cmd]) { + this.terminal.markDirty(); + await this.commands[cmd](args); + } else { + await this.terminal.security.checkAndRun(chainPart.cmd); + } + } + lastOk = true; + } + } + } + } + this._scriptDepth--; + this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success'); + } +} +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new ShellModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/shell.js'); +} + +/* === 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 <name>)', + }; + } + + 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 <query>)', + }; + } + + registerCommands() { + this.commands['find'] = (args) => this.find(args); + } + find(args) { + if (!args[0]) return this.terminal.print('Usage: find <query> — 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 === */ +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._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 <path> — 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 <path>', '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, '<') + .replace(/>/g, '>'); + // code blocks first + html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>'); + // inline code + html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); + // headers + html = html.replace(/^### (.+)$/gm, '<h3 style="color:var(--accent-color);margin:8px 0 4px">$1</h3>'); + html = html.replace(/^## (.+)$/gm, '<h2 style="color:var(--accent-color);margin:10px 0 4px">$1</h2>'); + html = html.replace(/^# (.+)$/gm, '<h1 style="color:var(--accent-color);margin:12px 0 6px">$1</h1>'); + // bold + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); + // italic + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); + // links + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--info-color)">$1</a>'); + // horizontal rules + html = html.replace(/^---$/gm, '<hr style="border-color:var(--border-color);margin:8px 0">'); + // unordered lists + html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li style="margin-left:16px;list-style:disc">$1</li>'); + // paragraphs - double newlines + html = html.replace(/\n\n/g, '</p><p style="margin:6px 0">'); + html = '<p style="margin:6px 0">' + html + '</p>'; + return html; + } catch (e) { + return '<pre style="white-space:pre-wrap">' + text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</pre>'; + } + } + + _getPreviewHtml(path, content) { + if (content === null || content === false) { + return '<div style="color:var(--error-color);padding:20px">File not found: ' + this._escapeHtml(path) + '</div>'; + } + + 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 '<div style="padding:20px;text-align:center;color:var(--text-muted)">Cannot preview: non-string content</div>'; + } + 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 '<div class="markdown-preview" style="padding:12px;font-family:var(--font-primary);line-height:1.5">' + this._renderMarkdown(content) + '</div>'; + } + + if (type === 'html') { + // Render HTML in an inline sandbox frame + const safeHtml = content + .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') + .replace(/on\w+\s*=\s*"[^"]*"/gi, '') + .replace(/on\w+\s*=\s*'[^']*'/gi, ''); + return '<div style="padding:0;width:100%;height:60vh;border:none;overflow:hidden">' + + '<iframe srcdoc="' + this._escapeHtml(safeHtml.replace(/"/g, '"').replace(/'/g, ''')) + '" ' + + 'style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-same-origin"></iframe></div>'; + } + + if (type === 'text') { + const truncated = content.substring(0, 2000); + const extra = content.length > 2000 ? '<div style="color:var(--text-muted);margin-top:8px">(truncated to 2000 chars)</div>' : ''; + return '<pre style="white-space:pre-wrap;padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(truncated) + extra + '</pre>'; + } + + if (type === 'json') { + try { + const parsed = JSON.parse(content); + const formatted = JSON.stringify(parsed, null, 2); + const html = this._syntaxHighlightJson(formatted); + return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto;line-height:1.4">' + html + '</pre>'; + } catch (e) { + return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>'; + } + } + + if (type === 'code') { + const lines = content.split('\n'); + const totalLines = lines.length; + const maxLines = 150; + const displayLines = lines.slice(0, maxLines); + let html = '<table style="border-collapse:collapse;width:100%;font-family:var(--font-primary);font-size:0.85em;margin:0"><tbody>'; + displayLines.forEach((line, i) => { + const lineNum = i + 1; + const escaped = this._escapeHtml(line); + html += '<tr><td style="color:var(--text-muted);padding:0 8px 0 4px;text-align:right;user-select:none;border-right:1px solid var(--border-color);vertical-align:top;width:40px">' + lineNum + '</td>'; + html += '<td style="padding:0 8px;white-space:pre;width:100%">' + (escaped || ' ') + '</td></tr>'; + }); + html += '</tbody></table>'; + if (totalLines > maxLines) { + html += '<div style="color:var(--text-muted);padding:8px;border-top:1px solid var(--border-color)">(showing ' + maxLines + ' of ' + totalLines + ' lines)</div>'; + } + 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 '<div style="padding:12px;text-align:center;max-width:100%"><img src="data:' + mime + ';base64,' + content + '" style="max-width:100%;max-height:70vh;border-radius:4px;border:1px solid var(--border-color)" alt="' + this._escapeHtml(fileName) + '"></div>'; + } + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📷 Image — cannot render, file may be stored as binary</div>'; + } + + 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 '<div style="padding:12px;text-align:center;max-width:100%;overflow:auto">' + cleanSvg + '</div>'; + } catch (e) { + return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>'; + } + } + + 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 '<div style="padding:12px;text-align:center"><video controls style="max-width:90%;max-height:65vh;border-radius:4px"><source src="data:' + mime + ';base64,' + content + '" type="' + mime + '"></video></div>'; + } + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">🎬 Video — cannot render</div>'; + } + + 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 '<div style="padding:12px;text-align:center"><audio controls style="max-width:90%"><source src="data:' + mime + ';base64,' + content + '" type="' + mime + '"></audio></div>'; + } + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">🎵 Audio — cannot render</div>'; + } + + if (type === 'pdf') { + const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); + if (isBase64) { + return '<div style="padding:12px;text-align:center;height:70vh"><embed src="data:application/pdf;base64,' + content + '" type="application/pdf" style="width:100%;height:100%;border:1px solid var(--border-color);border-radius:4px"></div>'; + } + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📄 PDF — cannot render</div>'; + } + + 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 = '<table class="csv-table" style="border-collapse:collapse;width:100%;font-family:var(--font-primary);font-size:0.85em;margin:0;background:var(--bg-secondary)">'; + if (rows.length > 0) { + html += '<thead><tr>'; + rows[0].forEach(cell => { + html += '<th style="border:1px solid var(--border-color);padding:6px 8px;text-align:left;background:var(--bg-tertiary);color:var(--accent-color);font-weight:bold">' + this._escapeHtml(cell) + '</th>'; + }); + html += '</tr></thead><tbody>'; + rows.slice(1).forEach(row => { + html += '<tr>'; + row.forEach(cell => { + html += '<td style="border:1px solid var(--border-color);padding:4px 8px">' + this._escapeHtml(cell) + '</td>'; + }); + html += '</tr>'; + }); + html += '</tbody></table>'; + if (lines.length > maxRows) { + html += '<div style="color:var(--text-muted);padding:8px">(showing ' + maxRows + ' of ' + lines.length + ' rows)</div>'; + } + } + return html; + } catch (e) { + return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>'; + } + } + + // '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 = '<pre style="white-space:pre-wrap;padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">'; + displayLines.forEach((line, i) => { + html += this._escapeHtml(line) + '\n'; + }); + html += '</pre>'; + if (truncated) { + html += '<div style="color:var(--text-muted);padding:8px;border-top:1px solid var(--border-color)">(showing ' + maxLines + ' of ' + lines.length + ' lines)</div>'; + } + return html; + } + + return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📁 Preview not available for this file type</div>'; + } + + _syntaxHighlightJson(jsonStr) { + return jsonStr.replace(/("(?:\\.|[^"\\])*")\s*:/g, '<span style="color:var(--info-color)">$1</span>:') + .replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span style="color:var(--success-color)">$1</span>') + .replace(/:\s*(\d+\.?\d*)/g, ': <span style="color:var(--warning-color)">$1</span>') + .replace(/:\s*(true|false|null)/g, ': <span style="color:var(--error-color)">$1</span>'); + } + + _escapeHtml(str) { + if (typeof str !== 'string') return ''; + return str.replace(/&/g, '&').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); + + 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); + + 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); + this._navigateTo(this.currentPath); + } + + _closeOverlay() { + if (this._overlay) { + this._overlay.style.display = 'none'; + } + } + + _clearOverlay() { + if (this._overlay && this._overlay.parentNode) { + this._overlay.parentNode.removeChild(this._overlay); + } + this._overlay = null; + this._selectedFile = null; + this._searchFilter = ''; + this._rawMode = false; + } + + _navigateTo(pathParts) { + this.currentPath = pathParts || []; + this._selectedFile = null; + 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'); + + // 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)); + } + + 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._updateStatus('0 files'); + 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;'; + item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; + item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; + item.onclick = () => { + const newPath = [...this.currentPath, entry.name]; + this._navigateTo(newPath); + }; + + 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; + item.dataset.path = fullPath; + item.dataset.type = 'file'; + item.dataset.selected = isSelected ? 'true' : 'false'; + const bgColor = isSelected ? '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 + ';'; + if (isSelected) { + item.style.borderLeft = '3px solid var(--accent-color)'; + } + item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; + item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; + item.onclick = () => { + this._selectedFile = fullPath; + this._highlightSelected(); + const content = this.terminal.fs.readFile(fullPath); + this._renderPreview(fullPath, content); + }; + + 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); + }); + + const totalCount = entries.length; + this._updateStatus(totalCount + ' item' + (totalCount !== 1 ? 's' : '')); + } + + _highlightSelected() { + const sidebar = document.getElementById('filemgr-sidebar'); + if (!sidebar) return; + const items = sidebar.querySelectorAll('[data-type="file"]'); + items.forEach(item => { + const selected = item.dataset.path === this._selectedFile; + item.dataset.selected = selected ? 'true' : 'false'; + item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent'; + item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent'; + }); + } + + _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 = '<div style="padding:12px"><div style="color:var(--text-muted);font-size:0.85em;margin-bottom:8px">RAW: ' + this._escapeHtml(path) + '</div><pre style="white-space:pre-wrap;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(content.substring(0, 10000)) + '</pre></div>'; + 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._getPreviewHtml(path, content); + const truncatedMsg = document.createElement('div'); + truncatedMsg.style.cssText = 'color:var(--warning-color);padding:8px 12px;border-top:1px solid var(--border-color);font-size:0.85em;'; + truncatedMsg.textContent = '(truncated — file exceeds 50KB)'; + preview.appendChild(truncatedMsg); + this._updateStatus('Preview: ' + path); + return; + } + + preview.innerHTML = this._getPreviewHtml(path, content); + this._updateStatus('Preview: ' + path); + } + + _renderPreviewError(msg) { + const preview = document.getElementById('filemgr-preview'); + if (!preview) return; + preview.innerHTML = '<div style="color:var(--error-color);padding:20px;font-size:1em">' + this._escapeHtml(msg) + '</div>'; + this._updateStatus('Error'); + } + + _clearPreview() { + const preview = document.getElementById('filemgr-preview'); + if (preview) preview.innerHTML = ''; + } + + _updateStatus(msg) { + const status = document.getElementById('filemgr-status'); + if (status) status.textContent = msg; + } + + _actionDelete() { + if (!this._selectedFile) { + this._updateStatus('No file selected to delete'); + return; + } + if (!confirm('Delete ' + this._selectedFile + '?')) return; + const ok = this.terminal.fs.deleteFile(this._selectedFile); + if (ok) { + this._selectedFile = null; + this._refreshFileList(); + this._clearPreview(); + this._updateStatus('✅ Deleted'); + } else { + this._updateStatus('❌ Failed to delete'); + } + } + + _actionDownload() { + if (!this._selectedFile) { + this._updateStatus('No file selected to download'); + return; + } + const content = this.terminal.fs.readFile(this._selectedFile); + if (content === null || content === false) { + this._updateStatus('❌ File not found'); + return; + } + const fileName = this._selectedFile.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); + URL.revokeObjectURL(url); + this._updateStatus('⬇ Downloaded: ' + fileName); + } + + _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'); + } + } +} + +if (typeof window.webx11 !== 'undefined' && window.webx11) { + 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 <file1> <file2> or diff --module <name>)', + }; + } + + registerCommands() { + this.commands['diff'] = (args) => this.diff(args); + } + async diff(args) { + if (!args.length) return this.terminal.print('Usage: diff <file1> <file2> or diff --module <ModuleName>', 'warning'); + + if (args[0] === '--module') { + if (!args[1]) return this.terminal.print('Usage: diff --module <ModuleName>', '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 <name> — 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 <last> or seq <first> <last> — Generate sequence of numbers', + 'base64': 'base64 [-d] <string> — Base64 encode/decode', + 'printf': 'printf <format> [args...] — Formatted print (%s, %d, %f, \\n, \\t)', + 'cut': 'cut -f<N> [-d<delim>] <file> — Extract column from file', + 'uniq': 'uniq <file> — Filter adjacent duplicate lines from a file', + 'hostname': 'hostname — Show host/user-agent info', + 'whoami': 'whoami — Show current user name', + 'basename': 'basename <path> — Strip directory from path', + 'dirname': 'dirname <path> — Strip filename from path', + 'sleep': 'sleep <seconds> — Delay execution with countdown', + 'expand': 'expand <file> — 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 <name>', '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 <last> or seq <first> <last>', '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] <string>', 'warning'); + if (args[0] === '-d' || args[0] === '--decode') { + if (args.length < 2) return this.terminal.print('Usage: base64 -d <encoded>', '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 <format> [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<N> [-d<delim>] <file>', '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 <file>', '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 <path>', '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 <path>', '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 <seconds>', '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 <file>', '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'); +} |