aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/diff.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ext/diff.js')
-rw-r--r--modules/ext/diff.js287
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');
+}