Skip to content

Commit 76a3b92

Browse files
committed
fix(web/shell): #107 review — a command no longer replaces a file with unsaved changes in the editor
shellCommit.js refuses a change to a file with unsaved changes in the editor — but only when its io has an isDirty, and agent.js's commit hook never passed one; only the unit test injected it. So a command's change to a dirty file went to the review, which replaces the whole document (reviewSession.applyEdit) and saves it, and whose Undo restores the file as it was on disk: what the user had typed was lost. The commit hook now passes isDirty: hasUnsavedChanges finds the document by URI, as the review finds the file (host.uriFor), with the path's letter case and Unicode form folded — on a disk that ignores case (the usual folder from the computer: htmlFileSystemProvider has no PathCaseSensitive off Linux, so openTextDocument canonicalises the URI) the editor keeps one document for README.md and readme.md, and the review would replace it whichever spelling the command used. A modify and a delete are refused; a file the command created has no buffer to lose, as before. Test (test/shellSeam.test.js, through the real runAgent -> run_command -> the commit hook agent.js hands the shell): "a file with unsaved changes in the editor is not replaced by a command" — a dirty document refuses a modify (and the other file of the set), a delete, and the dirty buffer of another spelling; an open, saved document does not. It fails on 187fd21 (the dirty file reached the review) and passes here; removing the wiring, the fold, the isDirty test or the URI lookup each fails it.
1 parent 82e6cea commit 76a3b92

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,12 +325,27 @@ function shellSpawnOpts(ctx, info, cwd) {
325325
const r = await commitShellChanges(changes, ctx, {
326326
folders: shellFolders, // the paths of the changes are made of the roots the shell was given
327327
readNow: async (abs) => { try { return await host.readBytes(abs); } catch { return null; } },
328+
isDirty: hasUnsavedChanges,
328329
cancelled: () => !!(control && typeof control.cancelled === 'function' && control.cancelled())
329330
});
330331
return { rest: r.rest, notes: r.notes, failed: r.failed.length, refused: r.refused };
331332
}
332333
};
333334
}
335+
/**
336+
* Has the editor got unsaved changes for this workspace file? A command's change goes through the review, which
337+
* replaces the whole document and saves it, and whose Undo restores the file as it was on disk: what was typed would
338+
* be gone. The document is found by URI, as the review finds the file (host.uriFor), with the path's letter case and
339+
* Unicode form folded: on a disk that ignores case (the usual folder from the computer) the editor keeps ONE document
340+
* for `README.md` and `readme.md`, and the review would replace it whichever spelling the command used. (On a disk
341+
* that tells them apart, a dirty document of another spelling refuses the command too; running it again is the cost.)
342+
* @param {string} abs
343+
*/
344+
function hasUnsavedChanges(abs) {
345+
const key = (/** @type {vscode.Uri} */ u) => u.scheme + ':' + u.authority + ':' + String(u.path).normalize('NFC').toLowerCase();
346+
const want = key(host.uriFor(abs));
347+
return (vscode.workspace.textDocuments || []).some((d) => d.isDirty && key(d.uri) === want);
348+
}
334349
function workspaceRoot() {
335350
const f = vscode.workspace.workspaceFolders;
336351
return f && f.length ? f[0].uri.fsPath : null;

‎extensions/levelcode-ai/test/shellSeam.test.js‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,44 @@ function ctxFor(over) {
224224
await p;
225225
});
226226

227+
await test('BROWSER, shell registered: a file with unsaved changes in the editor is not replaced by a command — the set is refused whole, nothing reaches the review', async () => {
228+
const { agent, host } = load(true);
229+
const calls = registerFake(host);
230+
const edits = [], deletes = [];
231+
const enc = (s) => new TextEncoder().encode(s);
232+
// The review replaces the whole buffer and saves it; its Undo restores the file as it was on disk, not what was typed.
233+
const doc = (p, isDirty) => ({ uri: { fsPath: p, path: p, toString: () => 'file://' + p }, isDirty });
234+
fs.writeFileSync(path.join(root, 'notes.txt'), 'saved\n');
235+
script = [tool('t1', 'run_command', { command: 'sed -i s/saved/x/ notes.txt', explanation: 'x' }), say('Done: x')];
236+
const ctx = ctxFor({ applyEdit: async (r) => { edits.push(r); return true; }, applyDelete: async (r) => { deletes.push(r); return true; } });
237+
const p = agent.runAgent(ctx);
238+
await settle(); await settle();
239+
const commit = calls[0].opts.commit;
240+
const modify = () => ({ kind: 'write', path: root + '/notes.txt', existed: true, before: enc('saved\n'), after: enc('x\n') });
241+
try {
242+
vscodeMock.workspace.textDocuments = [doc(root + '/notes.txt', true)];
243+
let out = await commit([modify(), { kind: 'write', path: root + '/other.txt', existed: false, before: null, after: enc('o\n') }]);
244+
assert.strictEqual(out.refused, true);
245+
assert.deepStrictEqual(edits, [], 'not even the other file: one refusal refuses the set');
246+
assert.match(out.notes.join('\n'), /refused: notes\.txt — it has unsaved changes in the editor/);
247+
out = await commit([{ kind: 'delete', path: root + '/notes.txt', before: enc('saved\n') }]);
248+
assert.strictEqual(out.refused, true);
249+
assert.deepStrictEqual(deletes, []);
250+
// On a disk that ignores case the editor has ONE document for both spellings, and the review would replace it.
251+
vscodeMock.workspace.textDocuments = [doc(root + '/NOTES.txt', true)];
252+
assert.strictEqual((await commit([modify()])).refused, true, 'the dirty buffer of another spelling of the name');
253+
// Open and saved is no reason to refuse.
254+
vscodeMock.workspace.textDocuments = [doc(root + '/notes.txt', false)];
255+
out = await commit([modify()]);
256+
assert.strictEqual(out.refused, false);
257+
assert.deepStrictEqual(edits.map((e) => e.path), ['notes.txt']);
258+
} finally {
259+
delete vscodeMock.workspace.textDocuments;
260+
}
261+
calls[0].child.emit('close', 0);
262+
await p;
263+
});
264+
227265
await test('BROWSER, the shell says the run was stopped while its changes were being checked: the commit hook applies nothing', async () => {
228266
const { agent, host } = load(true);
229267
const calls = registerFake(host);

0 commit comments

Comments
 (0)