diff options
Diffstat (limited to 'modules/ext/diff.js')
| -rw-r--r-- | modules/ext/diff.js | 287 |
1 files changed, 287 insertions, 0 deletions
diff --git a/modules/ext/diff.js b/modules/ext/diff.js new file mode 100644 index 0000000..8b79b6b --- /dev/null +++ b/modules/ext/diff.js @@ -0,0 +1,287 @@ +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/chatstudio/newos4/modules/diff.js'); +} |