Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const {
writeJsonAtomic,
formatTimestampForFileName
} = require('./lib/cli-file-utils');
const { buildLineDiff } = require('./lib/text-diff');
const {
extractModelNames,
hasModelsListPayload,
Expand Down Expand Up @@ -2251,6 +2252,80 @@ function applyAgentsFile(params = {}) {
}
}

function normalizeDiffText(input) {
const safe = typeof input === 'string' ? input : '';
return normalizeLineEnding(stripUtf8Bom(safe), '\n');
}

function countDiffLines(input) {
if (!input) return 0;
let text = input;
if (text.endsWith('\n')) {
text = text.slice(0, -1);
}
if (!text) return 0;
let count = 1;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '\n') count += 1;
}
return count;
}

function buildAgentsDiff(params = {}) {
const MAX_DIFF_LINES = 3000;
const hasBaseContent = typeof params.baseContent === 'string';
const contextRaw = typeof params.context === 'string' ? params.context.trim() : '';
const context = contextRaw || 'codex';
let readResult;
if (context === 'openclaw') {
readResult = readOpenclawAgentsFile();
} else if (context === 'openclaw-workspace') {
readResult = readOpenclawWorkspaceFile(params);
} else if (context === 'codex') {
readResult = readAgentsFile(params);
} else {
return { error: `Unsupported agents diff context: ${context}` };
}
if (readResult && readResult.error) {
return { error: readResult.error };
}

const beforeText = normalizeDiffText(
hasBaseContent ? params.baseContent : (readResult && readResult.content ? readResult.content : '')
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const afterText = normalizeDiffText(params.content);
const beforeLineCount = countDiffLines(beforeText);
const afterLineCount = countDiffLines(afterText);
if (beforeLineCount > MAX_DIFF_LINES || afterLineCount > MAX_DIFF_LINES) {
return {
diff: {
lines: [],
stats: { added: 0, removed: 0, unchanged: 0 },
oldLineCount: beforeLineCount,
newLineCount: afterLineCount,
hasChanges: beforeText !== afterText,
truncated: true
},
path: readResult && readResult.path ? readResult.path : '',
exists: !!(readResult && readResult.exists),
context,
configError: readResult && readResult.configError ? readResult.configError : ''
};
}
const diff = buildLineDiff(beforeText, afterText);
const hasChanges = diff.stats.added > 0 || diff.stats.removed > 0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return {
diff: {
...diff,
hasChanges
},
path: readResult && readResult.path ? readResult.path : '',
exists: !!(readResult && readResult.exists),
context,
configError: readResult && readResult.configError ? readResult.configError : ''
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function resolveOpenclawWorkspaceDir(config) {
const workspace = config
&& config.agents
Expand Down Expand Up @@ -8592,6 +8667,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
case 'apply-agents-file':
result = applyAgentsFile(params || {});
break;
case 'preview-agents-diff':
result = buildAgentsDiff(params || {});
break;
case 'list-codex-skills':
result = listCodexSkills();
break;
Expand Down
107 changes: 107 additions & 0 deletions lib/text-diff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
function normalizeLineFeed(input) {
if (typeof input !== 'string') {
return '';
}
return input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

function splitLines(input) {
let normalized = normalizeLineFeed(input);
if (!normalized) return [];
if (normalized.endsWith('\n')) {
normalized = normalized.slice(0, -1);
}
if (!normalized) return [];
return normalized.split('\n');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function buildLcsMatrix(beforeLines, afterLines) {
const rows = beforeLines.length + 1;
const cols = afterLines.length + 1;
const matrix = Array.from({ length: rows }, () => new Array(cols).fill(0));
for (let i = 1; i < rows; i += 1) {
const beforeLine = beforeLines[i - 1];
for (let j = 1; j < cols; j += 1) {
if (beforeLine === afterLines[j - 1]) {
matrix[i][j] = matrix[i - 1][j - 1] + 1;
} else {
const up = matrix[i - 1][j];
const left = matrix[i][j - 1];
matrix[i][j] = up >= left ? up : left;
}
}
}
return matrix;
}

function buildLineDiff(beforeText, afterText) {
const beforeLines = splitLines(beforeText);
const afterLines = splitLines(afterText);
const matrix = buildLcsMatrix(beforeLines, afterLines);
const lines = [];
let i = beforeLines.length;
let j = afterLines.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && beforeLines[i - 1] === afterLines[j - 1]) {
lines.push({
type: 'context',
value: beforeLines[i - 1],
oldNumber: i,
newNumber: j
});
i -= 1;
j -= 1;
continue;
}
const canAdd = j > 0;
const canDel = i > 0;
if (canAdd && (!canDel || matrix[i][j - 1] >= matrix[i - 1][j])) {
lines.push({
type: 'add',
value: afterLines[j - 1],
oldNumber: null,
newNumber: j
});
j -= 1;
continue;
}
if (canDel) {
lines.push({
type: 'del',
value: beforeLines[i - 1],
oldNumber: i,
newNumber: null
});
i -= 1;
}
}
lines.reverse();

let added = 0;
let removed = 0;
let unchanged = 0;
for (const line of lines) {
if (line.type === 'add') {
added += 1;
} else if (line.type === 'del') {
removed += 1;
} else {
unchanged += 1;
}
}

return {
lines,
stats: {
added,
removed,
unchanged
},
oldLineCount: beforeLines.length,
newLineCount: afterLines.length
};
}

module.exports = {
buildLineDiff
};
31 changes: 31 additions & 0 deletions tests/unit/agents-diff-ui.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import assert from 'assert';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..', '..');

function readProjectFile(relativePath) {
return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8');
}

test('agents modal exposes diff preview hooks in template and script', () => {
const template = readProjectFile('web-ui/index.html');
assert.match(template, /agentsDiffVisible/);
assert.match(template, /agentsDiffLines/);
assert.match(template, /agents-diff/);
assert.match(template, /agents-diff-editor/);
assert.match(template, /agentsDiffHasChanges/);
assert.match(template, /agents-diff-hint/);
assert.match(template, /agentsDiffTruncated/);
assert.match(template, /:readonly="agentsLoading"/);
assert.match(template, /agentsDiffVisible \? '应用'/);
assert.match(template, /应用中\.\.\./);

const script = readProjectFile('web-ui/app.js');
assert.match(script, /agentsDiffVisible:\s*false/);
assert.match(script, /prepareAgentsDiff\(/);
assert.match(script, /resetAgentsDiffState\(/);
});
2 changes: 2 additions & 0 deletions tests/unit/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ await import(pathToFileURL(path.join(__dirname, 'workflow-engine.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'skills-modal-ui.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'config-tabs-ui.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'compact-layout-ui.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'agents-diff-ui.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'text-diff.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'claude-settings-sync.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'unzip-ext.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'provider-share-command.test.mjs')));
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/text-diff.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from 'assert';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const { buildLineDiff } = require('../../lib/text-diff');

test('buildLineDiff returns add/del/context lines with stats', () => {
const before = 'alpha\nbeta\ngamma';
const after = 'alpha\nbeta 2\ngamma\nzeta';
const result = buildLineDiff(before, after);

assert.strictEqual(result.oldLineCount, 3);
assert.strictEqual(result.newLineCount, 4);
assert.deepStrictEqual(
result.lines.map(line => line.type),
['context', 'del', 'add', 'context', 'add']
);
assert.strictEqual(result.lines[0].oldNumber, 1);
assert.strictEqual(result.lines[0].newNumber, 1);
assert.strictEqual(result.lines[1].oldNumber, 2);
assert.strictEqual(result.lines[1].newNumber, null);
assert.strictEqual(result.lines[2].oldNumber, null);
assert.strictEqual(result.lines[2].newNumber, 2);
assert.strictEqual(result.stats.added, 2);
assert.strictEqual(result.stats.removed, 1);
});

test('buildLineDiff normalizes line endings', () => {
const before = 'one\r\ntwo\r\nthree';
const after = 'one\ntwo\nthree\nfour';
const result = buildLineDiff(before, after);
assert.strictEqual(result.stats.added, 1);
assert.strictEqual(result.stats.removed, 0);
});

test('buildLineDiff ignores single trailing newline', () => {
const withTrailing = buildLineDiff('a\nb\n', 'a\nb\n');
const withoutTrailing = buildLineDiff('a\nb', 'a\nb');
assert.strictEqual(withTrailing.oldLineCount, 2);
assert.strictEqual(withTrailing.newLineCount, 2);
assert.strictEqual(withTrailing.lines.length, 2);
assert.strictEqual(withoutTrailing.lines.length, 2);
});

test('buildLineDiff tolerates non-string input', () => {
const result = buildLineDiff(null, 123);
assert.strictEqual(result.oldLineCount, 0);
assert.strictEqual(result.newLineCount, 0);
assert.strictEqual(result.lines.length, 0);
});
Loading
Loading