dsh-diff-approval 0.2.0
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.md +53 -0
- package/README.zh.md +53 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +12271 -0
- package/lib/index.js +591 -0
- package/lib/types/client/PendingPanel.d.ts +7 -0
- package/lib/types/client/highlight.d.ts +28 -0
- package/lib/types/client/index.d.ts +13 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/locales.d.ts +62 -0
- package/lib/types/client/port.d.ts +24 -0
- package/lib/types/client/reference.d.ts +38 -0
- package/lib/types/client/slots.d.ts +28 -0
- package/lib/types/client/store.d.ts +28 -0
- package/lib/types/client/whole-file-diff.d.ts +37 -0
- package/lib/types/index.d.ts +57 -0
- package/lib/types/pending.d.ts +60 -0
- package/lib/types/persist.d.ts +49 -0
- package/lib/types/types.d.ts +61 -0
- package/package.json +104 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { dshHomePath, expandHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
5
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
6
|
+
//#region lib/types/pending.js
|
|
7
|
+
/**
|
|
8
|
+
* In-memory pending-diff store: one entry per (session, path), holding the
|
|
9
|
+
* file's full set of unhandled changes as one cumulative span. Every later
|
|
10
|
+
* operation to a tracked path folds into its entry — `oldText` stays the
|
|
11
|
+
* earliest basis, `newText` takes the latest content — even when the chain
|
|
12
|
+
* breaks (an outside writer changed the file between operations): the list
|
|
13
|
+
* still shows one element per file. Pure state and transitions; the plugin
|
|
14
|
+
* body owns the `tools/result` observation, the RPC surface, and the
|
|
15
|
+
* filesystem I/O.
|
|
16
|
+
* @module dsh-diff-approval/src/pending
|
|
17
|
+
*/
|
|
18
|
+
/** Map key joining one session id and one entry id. */
|
|
19
|
+
function entryKey(sessionId, id) {
|
|
20
|
+
return `${String(sessionId)}\u0000${id}`;
|
|
21
|
+
}
|
|
22
|
+
/** Map key joining one session id and one path, indexing the path's current entry. */
|
|
23
|
+
function pathKey(sessionId, path) {
|
|
24
|
+
return `${String(sessionId)}\u0000${path}`;
|
|
25
|
+
}
|
|
26
|
+
/** Whether a merge changed nothing (a repeated operation is a no-op). */
|
|
27
|
+
function sameEntry(left, right) {
|
|
28
|
+
return left.path === right.path && left.kind === right.kind && left.oldText === right.oldText && left.newText === right.newText && left.updatedAt === right.updatedAt;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The pending-diff store. Keep/Revert decides one whole file at a time.
|
|
32
|
+
*/
|
|
33
|
+
var PendingDiffStore = class {
|
|
34
|
+
entries = /* @__PURE__ */ new Map();
|
|
35
|
+
pathIndex = /* @__PURE__ */ new Map();
|
|
36
|
+
/** Merge one operation into the store, keyed by (session, path). */
|
|
37
|
+
merge(entry) {
|
|
38
|
+
const key = pathKey(entry.sessionId, entry.path);
|
|
39
|
+
const currentId = this.pathIndex.get(key);
|
|
40
|
+
const current = currentId === void 0 ? void 0 : this.entries.get(entryKey(entry.sessionId, currentId));
|
|
41
|
+
const merged = current === void 0 ? { ...entry } : {
|
|
42
|
+
...current,
|
|
43
|
+
newText: entry.newText,
|
|
44
|
+
updatedAt: entry.updatedAt
|
|
45
|
+
};
|
|
46
|
+
if (current !== void 0 && sameEntry(current, merged)) return false;
|
|
47
|
+
this.entries.set(entryKey(entry.sessionId, merged.id), merged);
|
|
48
|
+
this.pathIndex.set(key, merged.id);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Fold one captured operation into its file's entry. A no-op (equal before
|
|
53
|
+
* and after) folds nothing.
|
|
54
|
+
* @param entry - the captured operation (id assigned by the caller).
|
|
55
|
+
* @returns whether the stored entry changed.
|
|
56
|
+
*/
|
|
57
|
+
fold(entry) {
|
|
58
|
+
if (entry.oldText === entry.newText) return false;
|
|
59
|
+
return this.merge(entry);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Merge persisted entries into the store, one per path after folding. An
|
|
63
|
+
* already-keyed live entry wins over a persisted one: the live entry is
|
|
64
|
+
* newer by construction (it was captured after the persisted state was
|
|
65
|
+
* written), so the path's whole persisted run is skipped.
|
|
66
|
+
* @param sessionId - the session the entries belong to.
|
|
67
|
+
* @param entries - persisted entries, oldest capture first, to fold in.
|
|
68
|
+
*/
|
|
69
|
+
hydrate(sessionId, entries) {
|
|
70
|
+
const livePaths = new Set(this.pathIndex.keys());
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const key = pathKey(sessionId, entry.path);
|
|
73
|
+
if (livePaths.has(key)) continue;
|
|
74
|
+
this.merge({
|
|
75
|
+
...entry,
|
|
76
|
+
sessionId
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Copy the session's entries, oldest capture first.
|
|
82
|
+
* @param sessionId - the session whose entries to list.
|
|
83
|
+
* @returns detached entries in capture order.
|
|
84
|
+
*/
|
|
85
|
+
list(sessionId) {
|
|
86
|
+
const found = [];
|
|
87
|
+
for (const entry of this.entries.values()) if (entry.sessionId === sessionId) found.push(entry);
|
|
88
|
+
return found.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Read one entry without removing it.
|
|
92
|
+
* @param sessionId - the owning session.
|
|
93
|
+
* @param id - the entry id.
|
|
94
|
+
* @returns the entry, or `undefined` when the pair has none.
|
|
95
|
+
*/
|
|
96
|
+
get(sessionId, id) {
|
|
97
|
+
return this.entries.get(entryKey(sessionId, id));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Remove one entry.
|
|
101
|
+
* @param sessionId - the owning session.
|
|
102
|
+
* @param id - the entry id.
|
|
103
|
+
* @returns whether an entry was removed.
|
|
104
|
+
*/
|
|
105
|
+
remove(sessionId, id) {
|
|
106
|
+
if (!this.entries.delete(entryKey(sessionId, id))) return false;
|
|
107
|
+
for (const [key, indexedId] of this.pathIndex) if (indexedId === id) this.pathIndex.delete(key);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
/** Total entry count across all sessions. */
|
|
111
|
+
get size() {
|
|
112
|
+
return this.entries.size;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region lib/types/persist.js
|
|
117
|
+
/**
|
|
118
|
+
* Durable pending-entry persistence under the harness home. One JSON file per
|
|
119
|
+
* workspace holds every session's entries, at
|
|
120
|
+
* `<storageDir>/<workspaceId>.json`; the default root is
|
|
121
|
+
* `<dshHome>/diff-approval/workspaces` and the plugin's `storageDir` config
|
|
122
|
+
* relocates it. `save` rewrites the whole workspace file so sibling sessions
|
|
123
|
+
* survive; a session with no entries leaves the file, and a workspace with no
|
|
124
|
+
* sessions loses its file. Writes are serialized per file, staged as a
|
|
125
|
+
* sibling temp file, and atomically renamed, so a crash leaves either the old
|
|
126
|
+
* or the new file. Missing files read as empty; corrupt content and unknown
|
|
127
|
+
* versions throw so the caller can fail loud instead of silently dropping
|
|
128
|
+
* persisted data.
|
|
129
|
+
* @module dsh-diff-approval/src/persist
|
|
130
|
+
*/
|
|
131
|
+
/** Default persistence root: this plugin's own directory under the harness home. */
|
|
132
|
+
function defaultStorageDir() {
|
|
133
|
+
return dshHomePath("diff-approval", "workspaces");
|
|
134
|
+
}
|
|
135
|
+
/** On-disk envelope version; bumping it abandons older files (pre-release stance). */
|
|
136
|
+
const FILE_VERSION = 2;
|
|
137
|
+
/** Narrow one JSON value to a pending entry; malformed rows are skipped. */
|
|
138
|
+
function pendingEntryOf(value) {
|
|
139
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
140
|
+
const { id, sessionId, path, kind, oldText, newText, updatedAt } = value;
|
|
141
|
+
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
142
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
|
|
143
|
+
if (typeof path !== "string" || path.length === 0) return void 0;
|
|
144
|
+
if (kind !== "edit" && kind !== "create") return void 0;
|
|
145
|
+
if (typeof oldText !== "string" || typeof newText !== "string") return void 0;
|
|
146
|
+
if (typeof updatedAt !== "number") return void 0;
|
|
147
|
+
return {
|
|
148
|
+
id,
|
|
149
|
+
sessionId,
|
|
150
|
+
path,
|
|
151
|
+
kind,
|
|
152
|
+
oldText,
|
|
153
|
+
newText,
|
|
154
|
+
updatedAt
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Validate a parsed workspace file. Missing files yield `undefined` (empty);
|
|
159
|
+
* anything else that is not a current-version workspace file throws so a
|
|
160
|
+
* later save cannot silently overwrite unreadable persisted data.
|
|
161
|
+
* @param file - the file path, for the error message.
|
|
162
|
+
* @param value - the parsed JSON value.
|
|
163
|
+
* @returns the validated file, or `undefined` when the file does not exist.
|
|
164
|
+
*/
|
|
165
|
+
function workspaceFileOf(file, value) {
|
|
166
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`pending persistence file '${file}' is not a workspace file`);
|
|
167
|
+
const { version, sessions } = value;
|
|
168
|
+
if (version !== FILE_VERSION) throw new Error(`pending persistence file '${file}' has unsupported version ${JSON.stringify(version)}`);
|
|
169
|
+
if (typeof sessions !== "object" || sessions === null || Array.isArray(sessions)) throw new Error(`pending persistence file '${file}' has no session map`);
|
|
170
|
+
return {
|
|
171
|
+
version,
|
|
172
|
+
sessions
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* File-backed pending entries keyed by (workspace, session).
|
|
177
|
+
*/
|
|
178
|
+
var PendingPersistence = class {
|
|
179
|
+
root;
|
|
180
|
+
tails = /* @__PURE__ */ new Map();
|
|
181
|
+
/**
|
|
182
|
+
* @param root - directory holding one JSON file per workspace.
|
|
183
|
+
*/
|
|
184
|
+
constructor(root) {
|
|
185
|
+
this.root = root;
|
|
186
|
+
}
|
|
187
|
+
fileOf(workspaceId) {
|
|
188
|
+
return join(this.root, `${workspaceId}.json`);
|
|
189
|
+
}
|
|
190
|
+
/** Read one workspace file; a missing file reads as empty. */
|
|
191
|
+
async readWorkspace(file) {
|
|
192
|
+
let raw;
|
|
193
|
+
try {
|
|
194
|
+
raw = await readFile(file, "utf8");
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error.code === "ENOENT") return void 0;
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(raw);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new Error(`pending persistence file '${file}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
204
|
+
}
|
|
205
|
+
return workspaceFileOf(file, parsed);
|
|
206
|
+
}
|
|
207
|
+
/** Stage the envelope as a sibling temp file and atomically rename it into place. */
|
|
208
|
+
async writeWorkspace(file, envelope) {
|
|
209
|
+
await mkdir(this.root, { recursive: true });
|
|
210
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
211
|
+
await writeFile(tmp, JSON.stringify(envelope), "utf8");
|
|
212
|
+
try {
|
|
213
|
+
await rename(tmp, file);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Load one session's entries from its workspace file, oldest capture first.
|
|
221
|
+
* @param workspaceId - the owning workspace's stable id.
|
|
222
|
+
* @param sessionId - the session whose entries to load.
|
|
223
|
+
* @returns the persisted entries; empty when none were saved.
|
|
224
|
+
*/
|
|
225
|
+
async load(workspaceId, sessionId) {
|
|
226
|
+
const envelope = await this.readWorkspace(this.fileOf(workspaceId));
|
|
227
|
+
if (envelope === void 0) return [];
|
|
228
|
+
const rows = envelope.sessions[String(sessionId)];
|
|
229
|
+
if (!Array.isArray(rows)) return [];
|
|
230
|
+
const entries = [];
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
const entry = pendingEntryOf(row);
|
|
233
|
+
if (entry !== void 0) entries.push(entry);
|
|
234
|
+
}
|
|
235
|
+
return entries.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Replace one session's entries durably. Saves to one file are serialized;
|
|
239
|
+
* a previous save's failure does not block the next one.
|
|
240
|
+
* @param workspaceId - the owning workspace's stable id.
|
|
241
|
+
* @param sessionId - the session whose entries to replace.
|
|
242
|
+
* @param entries - the session's complete entry list, possibly empty.
|
|
243
|
+
* @returns resolution after the file is durable.
|
|
244
|
+
*/
|
|
245
|
+
save(workspaceId, sessionId, entries) {
|
|
246
|
+
const file = this.fileOf(workspaceId);
|
|
247
|
+
const task = async () => {
|
|
248
|
+
const envelope = await this.readWorkspace(file);
|
|
249
|
+
if (entries.length === 0) {
|
|
250
|
+
if (envelope === void 0) return;
|
|
251
|
+
delete envelope.sessions[String(sessionId)];
|
|
252
|
+
if (Object.keys(envelope.sessions).length === 0) {
|
|
253
|
+
await rm(file, { force: true });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
await this.writeWorkspace(file, envelope);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const next = envelope ?? {
|
|
260
|
+
version: FILE_VERSION,
|
|
261
|
+
sessions: {}
|
|
262
|
+
};
|
|
263
|
+
next.sessions[String(sessionId)] = [...entries];
|
|
264
|
+
await this.writeWorkspace(file, next);
|
|
265
|
+
};
|
|
266
|
+
const run = (this.tails.get(file) ?? Promise.resolve()).then(task, task);
|
|
267
|
+
this.tails.set(file, run.catch(() => {}));
|
|
268
|
+
return run;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region lib/types/index.js
|
|
273
|
+
/**
|
|
274
|
+
* Pending-edit review, host half. Captures every successful `edit` and `write`
|
|
275
|
+
* tool result (an unscoped `tools/result` listener receives per-session tool
|
|
276
|
+
* executions because scoped emissions route through the shared root hook
|
|
277
|
+
* table), folds each operation into its file's entry in the
|
|
278
|
+
* {@link PendingDiffStore} (one entry per path), serves the `/diff-approval`
|
|
279
|
+
* connection RPC channel (list/keep/revert), and applies a revert by writing
|
|
280
|
+
* the entry's `oldText` back through `ctx.fs` (a created file's revert
|
|
281
|
+
* removes it, and a tracked file that has since disappeared is restored by
|
|
282
|
+
* its revert).
|
|
283
|
+
*
|
|
284
|
+
* Mount this row in any profile's `cordis.patch.yml`:
|
|
285
|
+
*
|
|
286
|
+
* ```yaml
|
|
287
|
+
* - insert:
|
|
288
|
+
* - id: diff-approval
|
|
289
|
+
* name: 'dsh-diff-approval'
|
|
290
|
+
* # Optional: relocate durable pending state (defaults to
|
|
291
|
+
* # <dshHome>/diff-approval/workspaces).
|
|
292
|
+
* # config:
|
|
293
|
+
* # storageDir: ~/.dsh/diff-approval/workspaces
|
|
294
|
+
* ```
|
|
295
|
+
*
|
|
296
|
+
* Pending entries persist per (workspace, session) so an unhandled operation
|
|
297
|
+
* survives a harness restart; the list endpoint re-reads the live file, so a
|
|
298
|
+
* change or deletion made after the tracked operation is reported after
|
|
299
|
+
* restart exactly as it is mid-session.
|
|
300
|
+
*
|
|
301
|
+
* @module dsh-diff-approval
|
|
302
|
+
*/
|
|
303
|
+
/** Stable Cordis plugin name. */
|
|
304
|
+
const name = "diff-approval";
|
|
305
|
+
/** Services required before the review surface activates. */
|
|
306
|
+
const inject = [
|
|
307
|
+
"fs",
|
|
308
|
+
"connection",
|
|
309
|
+
"workspaceRegistry"
|
|
310
|
+
];
|
|
311
|
+
/** The connection RPC channel this plugin serves. */
|
|
312
|
+
const DIFF_APPROVAL_CHANNEL = "/diff-approval";
|
|
313
|
+
/**
|
|
314
|
+
* Narrow a successful `edit` result value to an operation outcome. The edit
|
|
315
|
+
* tool's output schema declares exactly `{ path, before, after }`; anything
|
|
316
|
+
* else is another tool's value or malformed data, which this recorder skips.
|
|
317
|
+
* @param value - the successful result's JSON value.
|
|
318
|
+
* @returns the outcome, or `undefined` when the value is not an edit outcome.
|
|
319
|
+
*/
|
|
320
|
+
function editOutcomeOf(value) {
|
|
321
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
322
|
+
const { path, before, after } = value;
|
|
323
|
+
if (typeof path !== "string" || path.length === 0) return void 0;
|
|
324
|
+
if (typeof before !== "string" || typeof after !== "string") return void 0;
|
|
325
|
+
return {
|
|
326
|
+
path,
|
|
327
|
+
kind: "edit",
|
|
328
|
+
oldText: before,
|
|
329
|
+
newText: after
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Narrow a successful `write` result value to an operation outcome. The write
|
|
334
|
+
* tool's output schema declares `{ path, operation, before, after }`;
|
|
335
|
+
* `operation: 'create'` becomes a `create` entry (revert removes the file),
|
|
336
|
+
* `operation: 'update'` becomes an `edit` entry. An update whose `before` is
|
|
337
|
+
* null carried no contextual basis, so it is skipped rather than tracked as
|
|
338
|
+
* an un-revertable overwrite.
|
|
339
|
+
* @param value - the successful result's JSON value.
|
|
340
|
+
* @returns the outcome, or `undefined` when the value is not a trackable write.
|
|
341
|
+
*/
|
|
342
|
+
function writeOutcomeOf(value) {
|
|
343
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
344
|
+
const { path, operation, before, after } = value;
|
|
345
|
+
if (typeof path !== "string" || path.length === 0) return void 0;
|
|
346
|
+
if (operation !== "create" && operation !== "update") return void 0;
|
|
347
|
+
if (typeof after !== "string") return void 0;
|
|
348
|
+
if (operation === "create") return {
|
|
349
|
+
path,
|
|
350
|
+
kind: "create",
|
|
351
|
+
oldText: "",
|
|
352
|
+
newText: after
|
|
353
|
+
};
|
|
354
|
+
if (typeof before !== "string") return void 0;
|
|
355
|
+
return {
|
|
356
|
+
path,
|
|
357
|
+
kind: "edit",
|
|
358
|
+
oldText: before,
|
|
359
|
+
newText: after
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/** Human-readable message from an arbitrary thrown value. */
|
|
363
|
+
function errorMessage(error) {
|
|
364
|
+
return error instanceof Error ? error.message : String(error);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Build one channel error in the closed RPC error vocabulary. `internal` is
|
|
368
|
+
* the catch-all: business misses ride the success branch as `outcome: 'missing'`.
|
|
369
|
+
* @param message - the handler-side description.
|
|
370
|
+
* @returns the error branch.
|
|
371
|
+
*/
|
|
372
|
+
function rpcError(message) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
error: {
|
|
376
|
+
code: "internal",
|
|
377
|
+
message,
|
|
378
|
+
details: {}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Mount the pending-edit review surface.
|
|
384
|
+
* @param ctx - Cordis context carrying the filesystem, connection, and workspace registry services.
|
|
385
|
+
* @param config - optional plugin configuration (`storageDir` relocates durable state).
|
|
386
|
+
*/
|
|
387
|
+
function apply(ctx, config) {
|
|
388
|
+
const storageDir = config?.storageDir;
|
|
389
|
+
if (storageDir !== void 0 && (typeof storageDir !== "string" || storageDir.trim().length === 0)) throw new Error("diff-approval: storageDir must be a non-empty string");
|
|
390
|
+
const store = new PendingDiffStore();
|
|
391
|
+
const persistence = new PendingPersistence(resolve(expandHomePath(storageDir ?? defaultStorageDir())));
|
|
392
|
+
const loaded = /* @__PURE__ */ new Set();
|
|
393
|
+
const loading = /* @__PURE__ */ new Map();
|
|
394
|
+
/**
|
|
395
|
+
* Read one path's live state: present content, an unresolvable (missing)
|
|
396
|
+
* path, or a resolved-but-unreadable file.
|
|
397
|
+
* @param path - backend display path to probe through `ctx.fs`.
|
|
398
|
+
* @returns the live state.
|
|
399
|
+
*/
|
|
400
|
+
async function liveStateOf(path) {
|
|
401
|
+
let target;
|
|
402
|
+
try {
|
|
403
|
+
target = await ctx.fs.resolve(path, {});
|
|
404
|
+
} catch {
|
|
405
|
+
return {
|
|
406
|
+
present: false,
|
|
407
|
+
kind: "missing"
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
return {
|
|
412
|
+
present: true,
|
|
413
|
+
content: await ctx.fs.readText(target, void 0)
|
|
414
|
+
};
|
|
415
|
+
} catch {
|
|
416
|
+
return {
|
|
417
|
+
present: false,
|
|
418
|
+
kind: "unreadable"
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Attach the live file state to each listed entry. Reading runs once per
|
|
424
|
+
* path (the panel polls once a second and the review set stays small).
|
|
425
|
+
* @param entries - the store's entries for one session.
|
|
426
|
+
* @returns entries with `missing` and `diverged` set from the live file.
|
|
427
|
+
*/
|
|
428
|
+
async function listWithState(entries) {
|
|
429
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
430
|
+
for (const entry of entries) {
|
|
431
|
+
const group = byPath.get(entry.path);
|
|
432
|
+
if (group === void 0) byPath.set(entry.path, [entry]);
|
|
433
|
+
else group.push(entry);
|
|
434
|
+
}
|
|
435
|
+
const listed = [];
|
|
436
|
+
for (const group of byPath.values()) {
|
|
437
|
+
const newest = group[group.length - 1];
|
|
438
|
+
if (newest === void 0) continue;
|
|
439
|
+
const live = await liveStateOf(newest.path);
|
|
440
|
+
const state = live.present ? {
|
|
441
|
+
missing: false,
|
|
442
|
+
diverged: live.content !== newest.newText
|
|
443
|
+
} : {
|
|
444
|
+
missing: live.kind === "missing",
|
|
445
|
+
diverged: live.kind === "unreadable"
|
|
446
|
+
};
|
|
447
|
+
for (const entry of group) listed.push({
|
|
448
|
+
...entry,
|
|
449
|
+
...state
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return listed;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The workspace whose session account holds `sessionId`. Web sessions are
|
|
456
|
+
* attached to a workspace at creation, so an unowned session is the
|
|
457
|
+
* memory-only edge (its entries never persist).
|
|
458
|
+
* @param sessionId - the session to locate.
|
|
459
|
+
* @returns the owning workspace, or `undefined` when none accounts it.
|
|
460
|
+
*/
|
|
461
|
+
function workspaceOf(sessionId) {
|
|
462
|
+
for (const workspace of ctx.workspaceRegistry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Merge one session's persisted entries into the store, once per session.
|
|
466
|
+
* Concurrent callers share the in-flight load, and folds arriving while the
|
|
467
|
+
* load runs stay safe: `hydrate` never overwrites a live entry.
|
|
468
|
+
* @param sessionId - the session to hydrate.
|
|
469
|
+
* @returns resolution after the session's persisted state is merged (or skipped).
|
|
470
|
+
*/
|
|
471
|
+
function ensureLoaded(sessionId) {
|
|
472
|
+
const key = String(sessionId);
|
|
473
|
+
if (loaded.has(key)) return Promise.resolve();
|
|
474
|
+
const pending = loading.get(key);
|
|
475
|
+
if (pending !== void 0) return pending;
|
|
476
|
+
const task = (async () => {
|
|
477
|
+
const workspace = workspaceOf(sessionId);
|
|
478
|
+
if (workspace !== void 0) try {
|
|
479
|
+
store.hydrate(sessionId, await persistence.load(String(workspace.id), key));
|
|
480
|
+
} catch (error) {
|
|
481
|
+
ctx.logger.warn(`diff-approval: loading persisted state for session ${key} failed: ${errorMessage(error)}`);
|
|
482
|
+
}
|
|
483
|
+
loaded.add(key);
|
|
484
|
+
loading.delete(key);
|
|
485
|
+
})();
|
|
486
|
+
loading.set(key, task);
|
|
487
|
+
return task;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Mirror one session's entries to disk. A write fault logs a warning and
|
|
491
|
+
* leaves the in-memory view intact: the review flow must not break on a
|
|
492
|
+
* storage fault, and the next successful mutation rewrites the whole file.
|
|
493
|
+
* @param sessionId - the session whose complete entry list to save.
|
|
494
|
+
* @returns resolution after the write settles (successful or logged).
|
|
495
|
+
*/
|
|
496
|
+
async function persistSession(sessionId) {
|
|
497
|
+
await ensureLoaded(sessionId);
|
|
498
|
+
const workspace = workspaceOf(sessionId);
|
|
499
|
+
if (workspace === void 0) return;
|
|
500
|
+
try {
|
|
501
|
+
await persistence.save(String(workspace.id), String(sessionId), store.list(sessionId));
|
|
502
|
+
} catch (error) {
|
|
503
|
+
ctx.logger.warn(`diff-approval: persisting session ${String(sessionId)} failed: ${errorMessage(error)}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
ctx.on("tools/result", (exec, result) => {
|
|
507
|
+
if (result.isError || exec.agent === void 0) return;
|
|
508
|
+
const outcome = exec.name === "edit" ? editOutcomeOf(result.value) : exec.name === "write" ? writeOutcomeOf(result.value) : void 0;
|
|
509
|
+
if (outcome === void 0 || outcome.oldText === outcome.newText) return;
|
|
510
|
+
const sessionId = exec.agent.id;
|
|
511
|
+
const entry = {
|
|
512
|
+
id: randomUUID(),
|
|
513
|
+
sessionId,
|
|
514
|
+
...outcome,
|
|
515
|
+
updatedAt: Date.now()
|
|
516
|
+
};
|
|
517
|
+
(async () => {
|
|
518
|
+
await ensureLoaded(sessionId);
|
|
519
|
+
if (store.fold(entry)) await persistSession(sessionId);
|
|
520
|
+
})();
|
|
521
|
+
});
|
|
522
|
+
const handle = async (endpoint, payload, signal) => {
|
|
523
|
+
switch (endpoint) {
|
|
524
|
+
case "list": {
|
|
525
|
+
const sessionId = sessionOf(payload);
|
|
526
|
+
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
527
|
+
await ensureLoaded(sessionId);
|
|
528
|
+
return {
|
|
529
|
+
ok: true,
|
|
530
|
+
value: { files: await listWithState(store.list(sessionId)) }
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
case "keep": {
|
|
534
|
+
const target = targetOf(payload);
|
|
535
|
+
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
536
|
+
await ensureLoaded(target.sessionId);
|
|
537
|
+
const removed = store.remove(target.sessionId, target.id);
|
|
538
|
+
if (removed) await persistSession(target.sessionId);
|
|
539
|
+
return {
|
|
540
|
+
ok: true,
|
|
541
|
+
value: { outcome: removed ? "kept" : "missing" }
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
case "revert": {
|
|
545
|
+
const target = targetOf(payload);
|
|
546
|
+
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
547
|
+
await ensureLoaded(target.sessionId);
|
|
548
|
+
const entry = store.get(target.sessionId, target.id);
|
|
549
|
+
if (entry === void 0) return {
|
|
550
|
+
ok: true,
|
|
551
|
+
value: { outcome: "missing" }
|
|
552
|
+
};
|
|
553
|
+
try {
|
|
554
|
+
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
555
|
+
if (entry.kind === "create") await rm(ctx.fs.processPath(resolved), { force: true });
|
|
556
|
+
else await ctx.fs.writeText(resolved, entry.oldText, void 0, signal);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
return rpcError(`revert failed: ${errorMessage(error)}`);
|
|
559
|
+
}
|
|
560
|
+
store.remove(target.sessionId, target.id);
|
|
561
|
+
await persistSession(target.sessionId);
|
|
562
|
+
return {
|
|
563
|
+
ok: true,
|
|
564
|
+
value: { outcome: "reverted" }
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
default: return rpcError(`unknown endpoint ${JSON.stringify(endpoint)}`);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
ctx.effect(() => ctx.connection.rpc.handle(DIFF_APPROVAL_CHANNEL, handle, { authority: "trusted-host" }), "diff-approval: review channel");
|
|
571
|
+
}
|
|
572
|
+
/** Narrow a wire payload's `sessionId` field to a branded session id. */
|
|
573
|
+
function sessionOf(payload) {
|
|
574
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
|
|
575
|
+
const value = payload.sessionId;
|
|
576
|
+
return typeof value === "string" && value.length > 0 ? SessionId(value) : void 0;
|
|
577
|
+
}
|
|
578
|
+
/** Narrow a wire payload to one keep/revert target. */
|
|
579
|
+
function targetOf(payload) {
|
|
580
|
+
const sessionId = sessionOf(payload);
|
|
581
|
+
if (sessionId === void 0) return void 0;
|
|
582
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
|
|
583
|
+
const id = payload.id;
|
|
584
|
+
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
585
|
+
return {
|
|
586
|
+
sessionId,
|
|
587
|
+
id
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
//#endregion
|
|
591
|
+
export { DIFF_APPROVAL_CHANNEL, PendingDiffStore, PendingPersistence, apply, defaultStorageDir, inject, name };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Sidebar-foot pending-edit review action and the whole-file diff list it opens. */
|
|
2
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
3
|
+
import type { PendingPanelFace } from './slots.ts';
|
|
4
|
+
/** Full panel props composed by the sidebar footer-action slot. */
|
|
5
|
+
export type PendingPanelProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<PendingPanelFace> & PropsLocale<'diff-approval'>;
|
|
6
|
+
/** Render the pending-edit review panel and its unified footer action. */
|
|
7
|
+
export declare function PendingPanel({ wide, useSessions, usePending, onRefresh, onKeep, onRevert, t, }: PendingPanelProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic syntax highlighting for the review viewer. A single synchronous
|
|
3
|
+
* fine-grained shiki core (JavaScript regex engine — no oniguruma WASM) with a
|
|
4
|
+
* fixed grammar set, all imported eagerly: the plugin serves one client bundle
|
|
5
|
+
* and must not code-split. Colors live in the harness theme's token sheets as
|
|
6
|
+
* `--shiki-*` custom properties via the CSS-variables theme, so token colors
|
|
7
|
+
* resolve against the web UI's existing vocabulary with no stylesheet of this
|
|
8
|
+
* package's own. An unknown or absent language falls back to plain text —
|
|
9
|
+
* never an error.
|
|
10
|
+
* @module dsh-diff-approval/client/highlight
|
|
11
|
+
*/
|
|
12
|
+
import type { CSSProperties } from 'react';
|
|
13
|
+
/** One highlighted run of a line: literal text plus a color style. */
|
|
14
|
+
export interface HighlightSpan {
|
|
15
|
+
text: string;
|
|
16
|
+
style: CSSProperties;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Tokenize `code` into per-line highlighted runs when `lang` names a
|
|
20
|
+
* registered grammar; `undefined` means the caller renders its plain fallback.
|
|
21
|
+
* Each run's color is a `--shiki-*` custom property, keeping token colors on
|
|
22
|
+
* the harness theme's sheets. The trailing newline shiki appends as a final
|
|
23
|
+
* empty line is dropped so the run count matches the caller's own line array.
|
|
24
|
+
* @param code - the source text.
|
|
25
|
+
* @param lang - the Shiki grammar id, or `undefined` for plain text.
|
|
26
|
+
* @returns one entry per source line (each an array of runs), or `undefined` when unhighlightable.
|
|
27
|
+
*/
|
|
28
|
+
export declare function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Pending-edit review panel, browser half: footer action, pending list, and whole-file diff viewer. */
|
|
2
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
3
|
+
export type { PendingPanelProps } from './PendingPanel.tsx';
|
|
4
|
+
export type { PendingDiffSnapshot, PendingPanelFace } from './slots.ts';
|
|
5
|
+
export type { DiffApprovalKey } from './locales.ts';
|
|
6
|
+
export { DIFF_APPROVAL_CHANNEL } from './port.ts';
|
|
7
|
+
/** Required services: locale, slots, the wire channel, and the current session. */
|
|
8
|
+
export declare const inject: string[];
|
|
9
|
+
/**
|
|
10
|
+
* Mount the pending-edit review panel.
|
|
11
|
+
* @param ctx - Client Cordis context carrying the wire and slot services.
|
|
12
|
+
*/
|
|
13
|
+
export declare function apply(ctx: ClientContext): void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-extension → Shiki language-id mapping for the review viewer's basic
|
|
3
|
+
* syntax highlighting. Unknown extensions fall back to plain text, so the
|
|
4
|
+
* map stays an allow-list rather than a guess machine.
|
|
5
|
+
* @module dsh-diff-approval/client/lang
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Map a file path to a Shiki language id, or `undefined` for plain text.
|
|
9
|
+
* @param path - the file path, any separator style.
|
|
10
|
+
* @returns the language id, or `undefined` when unknown.
|
|
11
|
+
*/
|
|
12
|
+
export declare function langFromPath(path: string): string | undefined;
|