dsh-message-rewind 0.0.0-stage → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +136 -0
- package/README.md +137 -2
- package/client.js +518 -0
- package/client.test.js +294 -0
- package/cordis.patch.yml +3 -0
- package/files.js +160 -0
- package/files.test.js +160 -0
- package/icon.svg +6 -0
- package/index.js +354 -0
- package/index.test.js +214 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/manifest.test.js +25 -0
- package/package.json +48 -4
- package/rewind.js +126 -0
- package/rewind.test.js +76 -0
package/client.test.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless smoke test for the client half: it installs against a stubbed Cordis
|
|
3
|
+
* context, then renders the message actions and the confirmation dialog with a
|
|
4
|
+
* minimal React stand-in. No browser and no real React are involved.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
|
|
9
|
+
const source = await readFile(new URL('./client.js', import.meta.url), 'utf8');
|
|
10
|
+
|
|
11
|
+
/** React stand-in that keeps hook state per component slot across renders. */
|
|
12
|
+
function makeReact() {
|
|
13
|
+
const states = new Map();
|
|
14
|
+
const refs = new Map();
|
|
15
|
+
let slot = 'none';
|
|
16
|
+
let index = 0;
|
|
17
|
+
return {
|
|
18
|
+
Fragment: 'Fragment',
|
|
19
|
+
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
|
|
20
|
+
__enter(name) { slot = name; index = 0; },
|
|
21
|
+
useState(initial) {
|
|
22
|
+
const key = `${slot}:${index++}`;
|
|
23
|
+
if (!states.has(key)) states.set(key, typeof initial === 'function' ? initial() : initial);
|
|
24
|
+
return [states.get(key), (next) => { states.set(key, next); }];
|
|
25
|
+
},
|
|
26
|
+
useRef(value) {
|
|
27
|
+
const key = `${slot}:${index++}`;
|
|
28
|
+
if (!refs.has(key)) refs.set(key, { current: value });
|
|
29
|
+
return refs.get(key);
|
|
30
|
+
},
|
|
31
|
+
useMemo: (factory) => { index++; return factory(); },
|
|
32
|
+
useCallback: (fn) => { index++; return fn; },
|
|
33
|
+
useEffect: (effect) => { index++; effect(); },
|
|
34
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => { index++; return getSnapshot(); },
|
|
35
|
+
useId: () => `id${index++}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const react = makeReact();
|
|
40
|
+
|
|
41
|
+
function makeElement(name) {
|
|
42
|
+
return { dataset: {}, style: {}, textContent: '', remove() {}, setAttribute() {}, tagName: name };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function queryAll(root, predicate, found = []) {
|
|
46
|
+
if (root === null || root === undefined) return found;
|
|
47
|
+
if (Array.isArray(root)) { for (const item of root) queryAll(item, predicate, found); return found; }
|
|
48
|
+
if (typeof root !== 'object') return found;
|
|
49
|
+
if (predicate(root)) found.push(root);
|
|
50
|
+
queryAll(root.children, predicate, found);
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const styleTags = [];
|
|
55
|
+
const calls = [];
|
|
56
|
+
const registrations = [];
|
|
57
|
+
globalThis.document = {
|
|
58
|
+
head: { appendChild: (node) => styleTags.push(node) },
|
|
59
|
+
createElement: (name) => makeElement(name),
|
|
60
|
+
activeElement: null,
|
|
61
|
+
addEventListener() {},
|
|
62
|
+
removeEventListener() {},
|
|
63
|
+
};
|
|
64
|
+
globalThis.window = {
|
|
65
|
+
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
66
|
+
clearTimeout: (id) => globalThis.clearTimeout(id),
|
|
67
|
+
__ModuleLoader__: { load: (definition) => { globalThis.__definition = definition; } },
|
|
68
|
+
};
|
|
69
|
+
Object.defineProperty(globalThis, 'navigator', { value: { clipboard: { writeText: () => Promise.resolve() } }, configurable: true });
|
|
70
|
+
// The Host's `inspect` answer; the dialog tests below change it to exercise the
|
|
71
|
+
// recorded-file and lost-snapshot branches.
|
|
72
|
+
let inspectResult = { files: [], incomplete: false, unfinished: false };
|
|
73
|
+
const ctx = {
|
|
74
|
+
effect: (fn) => { fn(); return () => {}; },
|
|
75
|
+
locale: {
|
|
76
|
+
register: (ns, locale, dict) => { calls.push(['locale.register', ns, locale, dict]); return () => {}; },
|
|
77
|
+
bind: (ns) => (key) => `${ns}:${key}`,
|
|
78
|
+
},
|
|
79
|
+
connection: {
|
|
80
|
+
rpc: {
|
|
81
|
+
call: (channel, endpoint, payload) => {
|
|
82
|
+
calls.push(['rpc.call', channel, endpoint, payload]);
|
|
83
|
+
const value = endpoint === 'execute'
|
|
84
|
+
? { sessionId: 'child-1', restored: 0, sourceSessionId: payload.sessionId }
|
|
85
|
+
: endpoint === 'purge'
|
|
86
|
+
? { removed: true, archivedOnly: false }
|
|
87
|
+
: inspectResult;
|
|
88
|
+
return Promise.resolve({ ok: true, value });
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
uiWorkspace: {
|
|
93
|
+
openSession: (id) => calls.push(['openSession', id]),
|
|
94
|
+
},
|
|
95
|
+
slots: {
|
|
96
|
+
inject: (name, callback) => { callback(); return () => {}; },
|
|
97
|
+
register: (options, component) => { registrations.push({ options, component }); return () => {}; },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const originalError = globalThis.console.error;
|
|
102
|
+
globalThis.console.error = (...args) => { throw args[0]; };
|
|
103
|
+
new Function('window', source)(globalThis.window);
|
|
104
|
+
const definition = globalThis.__definition;
|
|
105
|
+
assert.equal(definition.id, 'dsh-message-rewind');
|
|
106
|
+
const plugin = definition.factory((id) => {
|
|
107
|
+
if (id === 'react') return react;
|
|
108
|
+
throw new Error(`unexpected require: ${id}`);
|
|
109
|
+
});
|
|
110
|
+
assert.equal(plugin.name, 'dsh-message-rewind');
|
|
111
|
+
assert.deepEqual(plugin.inject, ['slots', 'uiWorkspace', 'locale', 'connection']);
|
|
112
|
+
plugin.apply(ctx);
|
|
113
|
+
globalThis.console.error = originalError;
|
|
114
|
+
|
|
115
|
+
assert.equal(styleTags.length, 1, 'one stylesheet is inserted');
|
|
116
|
+
assert.deepEqual(calls.filter((call) => call[0] === 'locale.register').map((call) => call[2]), ['zh', 'en']);
|
|
117
|
+
const nodeRegistration = registrations.find((entry) => entry.options.key === 'user');
|
|
118
|
+
const overlayRegistration = registrations.find((entry) => entry.options.id === 'message-rewind.dialog');
|
|
119
|
+
assert.ok(nodeRegistration, 'the user chat node renderer is registered');
|
|
120
|
+
assert.ok(overlayRegistration, 'the overlay dialog is registered');
|
|
121
|
+
|
|
122
|
+
// --- render the user message row -------------------------------------------
|
|
123
|
+
const rendered = [];
|
|
124
|
+
const renderUser = (data, running = false) => {
|
|
125
|
+
react.__enter('user');
|
|
126
|
+
return nodeRegistration.component({
|
|
127
|
+
node: { data },
|
|
128
|
+
sessionId: 'session-1',
|
|
129
|
+
renderFactorySlot: (name, props) => { rendered.push([name, props]); return { type: 'bubble' }; },
|
|
130
|
+
loadImage: () => {},
|
|
131
|
+
useSession: (selector) => selector({ running }),
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
const data = { seq: 7, time: Date.now(), content: [{ type: 'text', text: 'hello' }] };
|
|
135
|
+
const tree = renderUser(data);
|
|
136
|
+
assert.equal(tree.type, 'div');
|
|
137
|
+
assert.equal(tree.props.className, 'dmr-row');
|
|
138
|
+
assert.equal(rendered[0][0], 'conversation.chat.user.content');
|
|
139
|
+
assert.deepEqual(rendered[0][1].content, { text: 'hello', attachments: [], extraBlocks: [] });
|
|
140
|
+
assert.equal(rendered[0][1].interactive, true);
|
|
141
|
+
const actions = tree.children[1];
|
|
142
|
+
assert.equal(actions.props.className, 'dmr-actions');
|
|
143
|
+
const buttons = actions.children.filter(Boolean);
|
|
144
|
+
assert.equal(buttons.length, 4, 'clock plus edit, delete and copy');
|
|
145
|
+
const inner = buttons.slice(1);
|
|
146
|
+
const [editButton, deleteButton] = inner;
|
|
147
|
+
assert.equal(inner.length, 3, 'edit, delete and copy');
|
|
148
|
+
assert.equal(editButton.props.className, 'dmr-action');
|
|
149
|
+
assert.match(deleteButton.props.className, /dmr-danger/);
|
|
150
|
+
assert.match(actions.children[0].children[0], /^\d{2}:\d{2}$/, 'today shows a 24h clock only');
|
|
151
|
+
|
|
152
|
+
// an older message carries the localized date prefix, like the shipped row
|
|
153
|
+
const oldTree = renderUser({ seq: 9, time: Date.now() - 400 * 24 * 60 * 60 * 1000, content: [{ type: 'text', text: 'old' }] });
|
|
154
|
+
assert.match(oldTree.children[1].children[0].children[0], /^message-rewind:clock\.ymd \d{2}:\d{2}$/);
|
|
155
|
+
assert.ok(renderUser(data, true).children[1].children[1].props.disabled, 'a running turn disables edit');
|
|
156
|
+
|
|
157
|
+
// --- an attachment message disables edit just like the host does -----------
|
|
158
|
+
const imageTicket = { attachmentId: 'img-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 };
|
|
159
|
+
const imageTree = renderUser({ seq: 8, time: Date.now(), content: [{ type: 'image', attachment: imageTicket }] });
|
|
160
|
+
const imageButtons = imageTree.children[1].children.filter(Boolean).slice(1);
|
|
161
|
+
assert.equal(imageButtons[0].props.disabled, true, 'attachment messages disable edit');
|
|
162
|
+
assert.equal(imageButtons[1].props.disabled, undefined, 'attachment messages stay deletable');
|
|
163
|
+
|
|
164
|
+
// --- edit flow --------------------------------------------------------------
|
|
165
|
+
const renderDialog = () => { react.__enter('dialog'); return overlayRegistration.component({}); };
|
|
166
|
+
editButton.props.onClick();
|
|
167
|
+
renderDialog();
|
|
168
|
+
let dialog = renderDialog();
|
|
169
|
+
assert.ok(dialog, 'edit opens the dialog');
|
|
170
|
+
assert.equal(queryAll(dialog, (element) => element.type === 'textarea').at(0).props.value, 'hello');
|
|
171
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
172
|
+
assert.equal(calls.filter((call) => call[0] === 'rpc.call').at(0)[2], 'inspect', 'opening edit inspects file changes');
|
|
173
|
+
assert.deepEqual(calls.filter((call) => call[0] === 'rpc.call').at(0)[3], { sessionId: 'session-1', seq: 7 });
|
|
174
|
+
const footer = () => queryAll(renderDialog(), (element) => element.props?.className === 'dmr-footer').at(-1);
|
|
175
|
+
footer().children.filter(Boolean).at(-1).props.onClick();
|
|
176
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
177
|
+
assert.ok(!renderDialog(), 'a clean edit closes the dialog after forking');
|
|
178
|
+
assert.deepEqual(calls.filter((call) => call[0] === 'openSession'), [['openSession', 'child-1']], 'the branch is opened');
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
180
|
+
const purged = calls.filter((call) => call[0] === 'rpc.call' && call[2] === 'purge').at(-1);
|
|
181
|
+
assert.deepEqual(purged[3], { sessionId: 'session-1' }, 'the untouched source is retired');
|
|
182
|
+
|
|
183
|
+
// --- an edit that recorded file changes offers both rollback scopes ---------
|
|
184
|
+
inspectResult = { files: ['test.txt', 'src/app.js'], incomplete: false, unfinished: false };
|
|
185
|
+
editButton.props.onClick();
|
|
186
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
187
|
+
dialog = renderDialog();
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
queryAll(dialog, (element) => element.type === 'li').map((item) => item.children.join('')),
|
|
190
|
+
['test.txt', 'src/app.js'],
|
|
191
|
+
'the edit dialog lists the recorded files');
|
|
192
|
+
const scopeInputs = queryAll(dialog, (element) => element.type === 'input');
|
|
193
|
+
assert.equal(scopeInputs.length, 2, 'edit offers the shared scope control');
|
|
194
|
+
assert.equal(scopeInputs[0].props.checked, true, 'messages-only is the default');
|
|
195
|
+
assert.equal(scopeInputs[1].props.disabled, false, 'recorded files can be rolled back');
|
|
196
|
+
scopeInputs[1].props.onChange();
|
|
197
|
+
const scopeButtons = queryAll(renderDialog(), (element) => element.props?.className === 'dmr-footer').at(-1).children.filter(Boolean);
|
|
198
|
+
assert.deepEqual(scopeButtons.map((button) => button.children.join('')), [
|
|
199
|
+
'message-rewind:cancel',
|
|
200
|
+
'message-rewind:save',
|
|
201
|
+
], 'one primary action, with the scope chosen above it');
|
|
202
|
+
scopeButtons.at(-1).props.onClick();
|
|
203
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
204
|
+
assert.deepEqual(
|
|
205
|
+
calls.filter((call) => call[0] === 'rpc.call' && call[2] === 'execute').at(-1)[3],
|
|
206
|
+
{ sessionId: 'session-1', seq: 7, action: 'edit', text: 'hello', rewindFiles: true },
|
|
207
|
+
'the edited text is resent after the files roll back');
|
|
208
|
+
assert.ok(!renderDialog(), 'the dialog closes after rolling back and resending');
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
210
|
+
|
|
211
|
+
// --- a lost snapshot keeps the control, but file rollback is unavailable ----
|
|
212
|
+
inspectResult = { files: [], incomplete: true, unfinished: false };
|
|
213
|
+
editButton.props.onClick();
|
|
214
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
215
|
+
dialog = renderDialog();
|
|
216
|
+
assert.equal(queryAll(dialog, (element) => element.props?.className === 'dmr-warning').at(0).children[0], 'message-rewind:incomplete');
|
|
217
|
+
const staleInputs = queryAll(dialog, (element) => element.type === 'input');
|
|
218
|
+
assert.equal(staleInputs.length, 2, 'the scope control stays visible');
|
|
219
|
+
assert.equal(staleInputs[1].props.disabled, true, 'file rollback is unavailable');
|
|
220
|
+
const staleButtons = queryAll(renderDialog(), (element) => element.props?.className === 'dmr-footer').at(-1).children.filter(Boolean);
|
|
221
|
+
assert.equal(staleButtons.length, 2, 'cancel plus the messages-only action');
|
|
222
|
+
staleButtons[0].props.onClick();
|
|
223
|
+
assert.ok(!renderDialog(), 'cancel closes the stale-snapshot dialog');
|
|
224
|
+
inspectResult = { files: [], incomplete: false, unfinished: false };
|
|
225
|
+
|
|
226
|
+
// --- delete flow ------------------------------------------------------------
|
|
227
|
+
deleteButton.props.onClick();
|
|
228
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
229
|
+
dialog = renderDialog();
|
|
230
|
+
assert.ok(dialog, 'delete opens the dialog');
|
|
231
|
+
const deleteFooter = queryAll(dialog, (element) => element.props?.className === 'dmr-footer').at(-1);
|
|
232
|
+
const deleteButtons = deleteFooter.children.filter(Boolean);
|
|
233
|
+
assert.equal(deleteButtons.length, 2, 'cancel plus delete');
|
|
234
|
+
deleteButtons.at(-1).props.onClick();
|
|
235
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
236
|
+
const execute = calls.filter((call) => call[0] === 'rpc.call' && call[2] === 'execute').at(-1);
|
|
237
|
+
assert.deepEqual(execute[3], { sessionId: 'session-1', seq: 7, action: 'delete', text: '', rewindFiles: false });
|
|
238
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
239
|
+
assert.deepEqual(calls.filter((call) => call[0] === 'openSession').at(-1), ['openSession', 'child-1']);
|
|
240
|
+
|
|
241
|
+
// --- delete asks the same question, so it renders the same control ----------
|
|
242
|
+
inspectResult = { files: ['test.txt'], incomplete: false, unfinished: false };
|
|
243
|
+
deleteButton.props.onClick();
|
|
244
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
245
|
+
dialog = renderDialog();
|
|
246
|
+
const deleteScope = queryAll(dialog, (element) => element.type === 'input');
|
|
247
|
+
assert.equal(deleteScope.length, 2, 'delete offers the same scope control');
|
|
248
|
+
assert.equal(deleteScope[0].props.checked, true, 'messages-only is the default');
|
|
249
|
+
deleteScope[1].props.onChange();
|
|
250
|
+
const deleteActions = queryAll(renderDialog(), (element) => element.props?.className === 'dmr-footer').at(-1).children.filter(Boolean);
|
|
251
|
+
assert.deepEqual(deleteActions.map((button) => button.children.join('')), [
|
|
252
|
+
'message-rewind:cancel',
|
|
253
|
+
'message-rewind:deleteConfirm',
|
|
254
|
+
], 'the delete action follows the chosen scope');
|
|
255
|
+
deleteActions.at(-1).props.onClick();
|
|
256
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
257
|
+
assert.deepEqual(
|
|
258
|
+
calls.filter((call) => call[0] === 'rpc.call' && call[2] === 'execute').at(-1)[3],
|
|
259
|
+
{ sessionId: 'session-1', seq: 7, action: 'delete', text: '', rewindFiles: true },
|
|
260
|
+
'the chosen file scope reaches the Host');
|
|
261
|
+
inspectResult = { files: [], incomplete: false, unfinished: false };
|
|
262
|
+
|
|
263
|
+
// let the delayed purge settle before the failure-path test swaps the RPC stub
|
|
264
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
265
|
+
|
|
266
|
+
// --- host failure codes are localized --------------------------------------
|
|
267
|
+
ctx.connection.rpc.call = () => Promise.resolve({
|
|
268
|
+
ok: false,
|
|
269
|
+
error: { message: '请先停止当前运行', details: { rewindCode: 'open-turn' } },
|
|
270
|
+
});
|
|
271
|
+
deleteButton.props.onClick();
|
|
272
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
273
|
+
const failed = queryAll(renderDialog(), (element) => element.props?.className === 'dmr-error').at(0);
|
|
274
|
+
assert.equal(failed.children[0], 'message-rewind:error.open-turn');
|
|
275
|
+
|
|
276
|
+
// --- a late response cannot resurrect a closed dialog -----------------------
|
|
277
|
+
ctx.connection.rpc.call = (channel, endpoint, payload) => {
|
|
278
|
+
calls.push(['rpc.call', channel, endpoint, payload]);
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
globalThis.setTimeout(() => resolve({ ok: true, value: { files: [], incomplete: false, unfinished: false } }), 20);
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
deleteButton.props.onClick();
|
|
284
|
+
const loadingDialog = renderDialog();
|
|
285
|
+
assert.ok(loadingDialog, 'delete opens in the loading phase');
|
|
286
|
+
const loadingFooter = queryAll(loadingDialog, (element) => element.props?.className === 'dmr-footer').at(0);
|
|
287
|
+
loadingFooter.children[0].props.onClick();
|
|
288
|
+
assert.ok(!renderDialog(), 'the loading phase can be cancelled');
|
|
289
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
290
|
+
assert.ok(!renderDialog(), 'the late inspect response does not reopen the dialog');
|
|
291
|
+
|
|
292
|
+
console.log('client self-check passed');
|
|
293
|
+
|
|
294
|
+
|
package/cordis.patch.yml
ADDED
package/files.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File rewind: turn one recorded change list back into the pre-cut file state.
|
|
3
|
+
*
|
|
4
|
+
* The recorder compares each file at turn start and turn end, so a deletion is
|
|
5
|
+
* representable — `before: true, after: false` — and restoring it means writing
|
|
6
|
+
* the pre-turn text back. This module keeps that math independent of Cordis so
|
|
7
|
+
* it can be tested against a real directory.
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fsp } from 'node:fs';
|
|
10
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
11
|
+
import { inversePatch, terminated, textSide, userError } from './rewind.js';
|
|
12
|
+
|
|
13
|
+
const MAX_TEXT_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
const text = (error) => (error instanceof Error ? error.message : String(error));
|
|
16
|
+
|
|
17
|
+
export function insideRoot(root, path) {
|
|
18
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
19
|
+
return path !== root && path.startsWith(prefix);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read one file as UTF-8 text, reporting absence instead of failing. */
|
|
23
|
+
export async function readFileState(path, display) {
|
|
24
|
+
let info;
|
|
25
|
+
try {
|
|
26
|
+
info = await fsp.stat(path);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code === 'ENOENT') return { exists: false, text: null };
|
|
29
|
+
throw userError('file-unreadable', `无法读取 ${display}:${text(error)}`);
|
|
30
|
+
}
|
|
31
|
+
if (!info.isFile()) throw userError('file-unreadable', `${display} 不是普通文件,未回滚任何文件。`);
|
|
32
|
+
if (info.size > MAX_TEXT_BYTES) throw userError('file-too-large', `${display} 超过文本回滚上限,未回滚任何文件。`);
|
|
33
|
+
const bytes = await fsp.readFile(path);
|
|
34
|
+
try {
|
|
35
|
+
// The recorder reads text with `readFile(…, 'utf8')`, which keeps a BOM.
|
|
36
|
+
// TextDecoder strips it by default, so keep it here or every context line
|
|
37
|
+
// of a BOM file compares unequal and a silent rewrite drops the bytes.
|
|
38
|
+
return { exists: true, text: new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) };
|
|
39
|
+
} catch {
|
|
40
|
+
throw userError('file-binary', `${display} 不是 UTF-8 文本,未回滚任何文件。`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Apply one recorded diff backwards: current state in, the state before that turn out.
|
|
46
|
+
* @param state - what is on disk now.
|
|
47
|
+
* @param diff - the recorded text comparison.
|
|
48
|
+
* @returns the state this turn started from.
|
|
49
|
+
*/
|
|
50
|
+
export function invert(state, diff) {
|
|
51
|
+
if (state.exists !== diff.after) throw userError('file-changed', `${diff.display} 在记录之后又被修改过,已停止回滚。`);
|
|
52
|
+
// A file this turn created has nothing to invert: the pre-turn state is absence.
|
|
53
|
+
// Its current content is still compared, so a later edit is refused instead of
|
|
54
|
+
// being deleted silently.
|
|
55
|
+
if (!diff.before) {
|
|
56
|
+
const expected = textSide(true, diff.hunks, true) ?? '';
|
|
57
|
+
if (terminated(state.text ?? '') !== expected) throw userError('file-changed', `${diff.display} 在记录之后又被修改过,已停止回滚。`);
|
|
58
|
+
return { exists: false, text: null };
|
|
59
|
+
}
|
|
60
|
+
// A file this turn deleted restores from the removed lines alone.
|
|
61
|
+
if (!diff.after) return { exists: true, text: textSide(true, diff.hunks, false) };
|
|
62
|
+
return { exists: true, text: inversePatch(state.text ?? '', diff.hunks) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function pruneEmptyParents(path, root) {
|
|
66
|
+
let parent = dirname(path);
|
|
67
|
+
while (insideRoot(root, parent)) {
|
|
68
|
+
try {
|
|
69
|
+
const entries = await fsp.readdir(parent);
|
|
70
|
+
if (entries.length > 0) return;
|
|
71
|
+
await fsp.rmdir(parent);
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
parent = dirname(parent);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve every touched file to its pre-cut content without touching disk.
|
|
81
|
+
* @param readDiff - reads one recorded comparison; throws when it is unavailable.
|
|
82
|
+
* @param records - `workspace/changes` records in ascending sequence order.
|
|
83
|
+
* @returns per-path plan carrying the current state and the target state.
|
|
84
|
+
*/
|
|
85
|
+
export async function planRewind(readDiff, records) {
|
|
86
|
+
const targets = new Map();
|
|
87
|
+
for (const { seq, summary } of records) {
|
|
88
|
+
if (!summary) continue;
|
|
89
|
+
const root = resolve(summary.cwd);
|
|
90
|
+
for (let index = 0; index < summary.files.length; index += 1) {
|
|
91
|
+
const file = summary.files[index];
|
|
92
|
+
const path = resolve(root, file.path);
|
|
93
|
+
if (!insideRoot(root, path)) throw userError('path-escape', `${file.display} 不在该轮工作区内,未回滚任何文件。`);
|
|
94
|
+
if (!targets.has(path)) targets.set(path, { path, display: file.display, root, steps: [] });
|
|
95
|
+
targets.get(path).steps.push({ seq, index });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const target of targets.values()) {
|
|
99
|
+
target.actual = await readFileState(target.path, target.display);
|
|
100
|
+
let state = { ...target.actual };
|
|
101
|
+
// Newest change first: each diff expects the file as that turn left it.
|
|
102
|
+
for (let index = target.steps.length - 1; index >= 0; index -= 1) {
|
|
103
|
+
const step = target.steps[index];
|
|
104
|
+
try {
|
|
105
|
+
state = invert(state, await readDiff(step.seq, step.index));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw userError(error?.rewindCode ?? 'file-rewind', `${target.display}:${text(error)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
target.original = state;
|
|
111
|
+
}
|
|
112
|
+
return targets;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function restoreTargets(done) {
|
|
116
|
+
const failures = [];
|
|
117
|
+
for (const target of [...done].reverse()) {
|
|
118
|
+
try {
|
|
119
|
+
if (!target.actual.exists) await fsp.rm(target.path, { force: true });
|
|
120
|
+
else {
|
|
121
|
+
await fsp.mkdir(dirname(target.path), { recursive: true });
|
|
122
|
+
await fsp.writeFile(target.path, target.actual.text, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
} catch (restoreError) {
|
|
125
|
+
failures.push(`${target.display}:${text(restoreError)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return failures;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Write every planned pre-cut content and return an undo restoring the current state.
|
|
133
|
+
* @param targets - the plan from {@link planRewind}.
|
|
134
|
+
* @returns the number of files written and the undo, or throws after undoing a partial write.
|
|
135
|
+
*/
|
|
136
|
+
export async function applyRewind(targets) {
|
|
137
|
+
const done = [];
|
|
138
|
+
try {
|
|
139
|
+
for (const target of targets.values()) {
|
|
140
|
+
if (target.original.exists) {
|
|
141
|
+
await fsp.mkdir(dirname(target.path), { recursive: true });
|
|
142
|
+
await fsp.writeFile(target.path, target.original.text, 'utf8');
|
|
143
|
+
done.push(target);
|
|
144
|
+
} else if (target.actual.exists) {
|
|
145
|
+
await fsp.rm(target.path);
|
|
146
|
+
done.push(target);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const failures = await restoreTargets(done);
|
|
151
|
+
if (failures.length) throw userError('file-restore-failed', `文件回滚中断,以下文件需人工检查:${failures.join(';')}`);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
for (const target of done) if (!target.original.exists) await pruneEmptyParents(target.path, target.root);
|
|
155
|
+
const undo = async () => {
|
|
156
|
+
const failures = await restoreTargets(done);
|
|
157
|
+
if (failures.length) throw userError('file-restore-failed', `文件未能恢复原状,请人工检查:${failures.join(';')}`);
|
|
158
|
+
};
|
|
159
|
+
return { written: done.length, undo };
|
|
160
|
+
}
|
package/files.test.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-rewind self-check against a real temporary directory. The recorded
|
|
3
|
+
* diffs are handcrafted to match what `dsh-workspace-changes` produces: it diffs
|
|
4
|
+
* newline-terminated text with jsdiff, so every hunk line carries its `\n`, and a
|
|
5
|
+
* deletion is `before: true, after: false`.
|
|
6
|
+
*/
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { promises as fsp } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { planRewind, applyRewind, invert } from './files.js';
|
|
12
|
+
|
|
13
|
+
let passed = 0;
|
|
14
|
+
const check = (label, fn) => fn().then(() => { passed += 1; }, (error) => { throw new Error(`${label}: ${error.message}`); });
|
|
15
|
+
|
|
16
|
+
const workspace = await fsp.mkdtemp(join(tmpdir(), 'dmr-files-'));
|
|
17
|
+
const recordsFor = (entries) => entries.map(({ seq, files }) => ({
|
|
18
|
+
seq,
|
|
19
|
+
summary: { turn: 1, cwd: workspace, files, total: files.length, added: 0, deleted: 0 },
|
|
20
|
+
}));
|
|
21
|
+
const readDiffFrom = (table) => async (seq, index) => {
|
|
22
|
+
const found = table[`${seq}:${index}`];
|
|
23
|
+
if (!found) throw new Error(`no recorded diff for ${seq}:${index}`);
|
|
24
|
+
return found;
|
|
25
|
+
};
|
|
26
|
+
const abs = (name) => join(workspace, name);
|
|
27
|
+
const textDiff = (path, before, after, hunks) => ({ kind: 'text', path, display: path, before, after, hunks, coarse: false });
|
|
28
|
+
|
|
29
|
+
// --- a turn that deleted a file is restorable ------------------------------
|
|
30
|
+
const deletedName = 'deleted.txt';
|
|
31
|
+
const deletedBefore = 'one\ntwo\n';
|
|
32
|
+
const deletedDiff = textDiff(deletedName, true, false, [{ oldStart: 1, oldLines: 2, newStart: 0, newLines: 0, lines: ['-one\n', '-two\n'] }]);
|
|
33
|
+
await check('deleted file is recreated', async () => {
|
|
34
|
+
assert.deepEqual(await readExists(deletedName), null);
|
|
35
|
+
const targets = await planRewind(readDiffFrom({ '1:0': deletedDiff }), recordsFor([{ seq: 1, files: [{ path: deletedName, display: deletedName }] }]));
|
|
36
|
+
assert.deepEqual([...targets.values()][0].original, { exists: true, text: deletedBefore });
|
|
37
|
+
await applyRewind(targets);
|
|
38
|
+
assert.equal(await fsp.readFile(abs(deletedName), 'utf8'), deletedBefore);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// --- a turn that created a file is undone by removing it --------------------
|
|
42
|
+
const createdName = 'created.txt';
|
|
43
|
+
const createdDiff = textDiff(createdName, false, true, [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 1, lines: ['+abc'] }]);
|
|
44
|
+
await check('created file is removed', async () => {
|
|
45
|
+
await fsp.writeFile(abs(createdName), 'abc', 'utf8');
|
|
46
|
+
const targets = await planRewind(readDiffFrom({ '2:0': createdDiff }), recordsFor([{ seq: 2, files: [{ path: createdName, display: createdName }] }]));
|
|
47
|
+
assert.deepEqual([...targets.values()][0].original, { exists: false, text: null });
|
|
48
|
+
await applyRewind(targets);
|
|
49
|
+
await assert.rejects(() => fsp.stat(abs(createdName)));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// --- a turn that modified a file is reverted -------------------------------
|
|
53
|
+
const editedName = 'edited.txt';
|
|
54
|
+
const editedDiff = textDiff(editedName, true, true, [{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 3, lines: [' zero\n', ' one\n', '-two\n', '+TWO\n'] }]);
|
|
55
|
+
await check('modified file is reverted', async () => {
|
|
56
|
+
await fsp.writeFile(abs(editedName), 'zero\none\nTWO\n', 'utf8');
|
|
57
|
+
const targets = await planRewind(readDiffFrom({ '3:0': editedDiff }), recordsFor([{ seq: 3, files: [{ path: editedName, display: editedName }] }]));
|
|
58
|
+
await applyRewind(targets);
|
|
59
|
+
assert.equal(await fsp.readFile(abs(editedName), 'utf8'), 'zero\none\ntwo\n');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- a file stored without a final newline still matches the diff ----------
|
|
63
|
+
const bareName = 'bare.txt';
|
|
64
|
+
const bareDiff = textDiff(bareName, true, true, [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 2, lines: [' x\n', '+y'] }]);
|
|
65
|
+
await check('unterminated file is reverted', async () => {
|
|
66
|
+
await fsp.writeFile(abs(bareName), 'x\ny', 'utf8');
|
|
67
|
+
const targets = await planRewind(readDiffFrom({ '4:0': bareDiff }), recordsFor([{ seq: 4, files: [{ path: bareName, display: bareName }] }]));
|
|
68
|
+
await applyRewind(targets);
|
|
69
|
+
assert.equal(await fsp.readFile(abs(bareName), 'utf8'), 'x\n');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- a UTF-8 BOM survives the round trip -----------------------------------
|
|
73
|
+
const bomName = 'bom.txt';
|
|
74
|
+
const bomDiff = textDiff(bomName, true, true, [{ oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [' \uFEFFone\n', '-two\n', '+TWO\n'] }]);
|
|
75
|
+
await check('utf-8 BOM file is reverted without losing the BOM', async () => {
|
|
76
|
+
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
77
|
+
await fsp.writeFile(abs(bomName), Buffer.concat([bom, Buffer.from('one\nTWO\n', 'utf8')]));
|
|
78
|
+
const targets = await planRewind(readDiffFrom({ '10:0': bomDiff }), recordsFor([{ seq: 10, files: [{ path: bomName, display: bomName }] }]));
|
|
79
|
+
await applyRewind(targets);
|
|
80
|
+
const bytes = await fsp.readFile(abs(bomName));
|
|
81
|
+
assert.deepEqual([...bytes.subarray(0, 3)], [0xEF, 0xBB, 0xBF]);
|
|
82
|
+
assert.equal(bytes.toString('utf8'), '\uFEFFone\ntwo\n');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// --- a created file edited after its turn is refused, not deleted ----------
|
|
86
|
+
const createdLaterName = 'created-later.txt';
|
|
87
|
+
const createdLaterDiff = textDiff(createdLaterName, false, true, [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 1, lines: ['+original\n'] }]);
|
|
88
|
+
await check('created file edited later is refused', async () => {
|
|
89
|
+
await fsp.writeFile(abs(createdLaterName), 'original\nplus a manual edit\n', 'utf8');
|
|
90
|
+
await assert.rejects(
|
|
91
|
+
() => planRewind(readDiffFrom({ '11:0': createdLaterDiff }), recordsFor([{ seq: 11, files: [{ path: createdLaterName, display: createdLaterName }] }])),
|
|
92
|
+
/created-later\.txt:.*在记录之后又被修改过/,
|
|
93
|
+
);
|
|
94
|
+
await fsp.writeFile(abs(createdLaterName), 'original\n', 'utf8');
|
|
95
|
+
const targets = await planRewind(readDiffFrom({ '12:0': createdLaterDiff }), recordsFor([{ seq: 12, files: [{ path: createdLaterName, display: createdLaterName }] }]));
|
|
96
|
+
await applyRewind(targets);
|
|
97
|
+
await assert.rejects(() => fsp.stat(abs(createdLaterName)));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// --- a file changed after that turn refuses instead of guessing -------------
|
|
101
|
+
await check('conflicting later edit is refused', async () => {
|
|
102
|
+
await fsp.writeFile(abs(editedName), 'zero\none\nTHREE\n', 'utf8');
|
|
103
|
+
await assert.rejects(
|
|
104
|
+
() => planRewind(readDiffFrom({ '5:0': editedDiff }), recordsFor([{ seq: 5, files: [{ path: editedName, display: editedName }] }])),
|
|
105
|
+
/edited\.txt:.*(在记录之后又被修改过|文件版本已改变)/,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- several turns on one path unwind newest first -------------------------
|
|
110
|
+
const chainedName = 'chained.txt';
|
|
111
|
+
const chainedCreated = textDiff(chainedName, false, true, [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 1, lines: ['+abc'] }]);
|
|
112
|
+
const chainedEdited = textDiff(chainedName, true, true, [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 2, lines: [' abc\n', '+def\n'] }]);
|
|
113
|
+
await check('multi-turn path unwinds to absent', async () => {
|
|
114
|
+
await fsp.writeFile(abs(chainedName), 'abc\ndef\n', 'utf8');
|
|
115
|
+
const targets = await planRewind(
|
|
116
|
+
readDiffFrom({ '6:0': chainedCreated, '7:0': chainedEdited }),
|
|
117
|
+
recordsFor([
|
|
118
|
+
{ seq: 6, files: [{ path: chainedName, display: chainedName }] },
|
|
119
|
+
{ seq: 7, files: [{ path: chainedName, display: chainedName }] },
|
|
120
|
+
]),
|
|
121
|
+
);
|
|
122
|
+
await applyRewind(targets);
|
|
123
|
+
await assert.rejects(() => fsp.stat(abs(chainedName)));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- undo restores exactly what the rewind replaced --------------------------
|
|
127
|
+
await check('undo restores the pre-rewind state', async () => {
|
|
128
|
+
await fsp.writeFile(abs(editedName), 'zero\none\nTWO\n', 'utf8');
|
|
129
|
+
const targets = await planRewind(readDiffFrom({ '8:0': editedDiff }), recordsFor([{ seq: 8, files: [{ path: editedName, display: editedName }] }]));
|
|
130
|
+
const applied = await applyRewind(targets);
|
|
131
|
+
assert.equal(await fsp.readFile(abs(editedName), 'utf8'), 'zero\none\ntwo\n');
|
|
132
|
+
await applied.undo();
|
|
133
|
+
assert.equal(await fsp.readFile(abs(editedName), 'utf8'), 'zero\none\nTWO\n');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// --- a path outside the recorded workspace is refused ----------------------
|
|
137
|
+
await check('path outside the workspace is refused', async () => {
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
() => planRewind(readDiffFrom({}), recordsFor([{ seq: 9, files: [{ path: '../escape.txt', display: '../escape.txt' }] }])),
|
|
140
|
+
/不在该轮工作区内/,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- invert is a pure total function on absence ----------------------------
|
|
145
|
+
assert.deepEqual(invert({ exists: false, text: null }, deletedDiff), { exists: true, text: deletedBefore });
|
|
146
|
+
assert.deepEqual(invert({ exists: true, text: 'abc' }, createdDiff), { exists: false, text: null });
|
|
147
|
+
assert.throws(() => invert({ exists: true, text: 'abc changed' }, createdDiff), /在记录之后又被修改过/);
|
|
148
|
+
|
|
149
|
+
async function readExists(name) {
|
|
150
|
+
try {
|
|
151
|
+
return await fsp.readFile(abs(name), 'utf8');
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.code === 'ENOENT') return null;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await fsp.rm(workspace, { recursive: true, force: true });
|
|
159
|
+
console.log(`files self-check passed (${passed} scenarios)`);
|
|
160
|
+
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
|
2
|
+
<rect x="7" y="9" width="50" height="42" rx="13" fill="#247bbf"/>
|
|
3
|
+
<path d="M22 24h20M22 32h13M22 40h9" stroke="white" stroke-width="4" stroke-linecap="round"/>
|
|
4
|
+
<path d="M40 45a11 11 0 1 0 1-17" stroke="#bce5ff" stroke-width="4" stroke-linecap="round"/>
|
|
5
|
+
<path d="m39 23 10 5-10 5" fill="#bce5ff"/>
|
|
6
|
+
</svg>
|