dsh-diff-approval 0.2.0 → 0.4.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/README.md +20 -5
- package/README.zh.md +20 -5
- package/docs/images/pending-panel.png +0 -0
- package/docs/images/pending-panel.zh.png +0 -0
- package/lib/client.js +624 -275
- package/lib/index.js +302 -30
- package/lib/types/client/PendingPanel.d.ts +2 -2
- package/lib/types/client/highlight.d.ts +2 -0
- package/lib/types/client/locales.d.ts +18 -2
- package/lib/types/client/port.d.ts +3 -1
- package/lib/types/client/slots.d.ts +3 -1
- package/lib/types/client/store.d.ts +3 -0
- package/lib/types/index.d.ts +21 -9
- package/lib/types/open.d.ts +17 -0
- package/lib/types/persist.d.ts +10 -0
- package/lib/types/types.d.ts +7 -0
- package/package.json +4 -2
package/lib/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { dshHomePath, expandHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
5
5
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
6
7
|
//#region lib/types/pending.js
|
|
7
8
|
/**
|
|
8
9
|
* In-memory pending-diff store: one entry per (session, path), holding the
|
|
@@ -235,6 +236,29 @@ var PendingPersistence = class {
|
|
|
235
236
|
return entries.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
236
237
|
}
|
|
237
238
|
/**
|
|
239
|
+
* Load every session's entries for one workspace, oldest capture first.
|
|
240
|
+
* The list shows a workspace's pending changes across sessions — a session
|
|
241
|
+
* that restarted carries a fresh id while its earlier entries sit under the
|
|
242
|
+
* original session ids in the same workspace file — so hydration reads the
|
|
243
|
+
* whole workspace, not one session.
|
|
244
|
+
* @param workspaceId - the owning workspace's stable id.
|
|
245
|
+
* @returns the persisted entries across all sessions; empty when none were saved.
|
|
246
|
+
*/
|
|
247
|
+
async loadWorkspace(workspaceId) {
|
|
248
|
+
const envelope = await this.readWorkspace(this.fileOf(workspaceId));
|
|
249
|
+
if (envelope === void 0) return [];
|
|
250
|
+
const entries = [];
|
|
251
|
+
for (const sessionId of Object.keys(envelope.sessions)) {
|
|
252
|
+
const rows = envelope.sessions[sessionId];
|
|
253
|
+
if (!Array.isArray(rows)) continue;
|
|
254
|
+
for (const row of rows) {
|
|
255
|
+
const entry = pendingEntryOf(row);
|
|
256
|
+
if (entry !== void 0) entries.push(entry);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return entries.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
238
262
|
* Replace one session's entries durably. Saves to one file are serialized;
|
|
239
263
|
* a previous save's failure does not block the next one.
|
|
240
264
|
* @param workspaceId - the owning workspace's stable id.
|
|
@@ -269,17 +293,70 @@ var PendingPersistence = class {
|
|
|
269
293
|
}
|
|
270
294
|
};
|
|
271
295
|
//#endregion
|
|
296
|
+
//#region lib/types/open.js
|
|
297
|
+
/**
|
|
298
|
+
* OS-level "open file" and "reveal in folder" launches for the host half's
|
|
299
|
+
* `open` endpoint. The plugin's own tests inject a double instead of running
|
|
300
|
+
* these; real profiles get the platform commands below.
|
|
301
|
+
* @module dsh-diff-approval/open
|
|
302
|
+
*/
|
|
303
|
+
/** Run one detached command; resolves once the process spawns, never waits for exit. */
|
|
304
|
+
function launch(command, args) {
|
|
305
|
+
return new Promise((resolvePromise, reject) => {
|
|
306
|
+
const child = spawn(command, [...args], {
|
|
307
|
+
detached: true,
|
|
308
|
+
stdio: "ignore",
|
|
309
|
+
windowsHide: true
|
|
310
|
+
});
|
|
311
|
+
child.once("error", reject);
|
|
312
|
+
child.once("spawn", () => {
|
|
313
|
+
child.unref();
|
|
314
|
+
resolvePromise();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Launch a path through the current platform's default handler: `open` runs
|
|
320
|
+
* the file with its default application, `reveal` selects it in the file
|
|
321
|
+
* manager.
|
|
322
|
+
* @param path - backend execution-world path to act on.
|
|
323
|
+
* @param action - what to do with the path.
|
|
324
|
+
* @returns resolution once the launcher process has spawned.
|
|
325
|
+
*/
|
|
326
|
+
function defaultOpenPath(path, action) {
|
|
327
|
+
switch (process.platform) {
|
|
328
|
+
case "win32": return action === "open" ? launch("cmd", [
|
|
329
|
+
"/c",
|
|
330
|
+
"start",
|
|
331
|
+
"",
|
|
332
|
+
path
|
|
333
|
+
]) : launch("cmd", [
|
|
334
|
+
"/c",
|
|
335
|
+
"start",
|
|
336
|
+
"",
|
|
337
|
+
"explorer.exe",
|
|
338
|
+
`/select,${path}`
|
|
339
|
+
]);
|
|
340
|
+
case "darwin": return action === "open" ? launch("open", [path]) : launch("open", ["-R", path]);
|
|
341
|
+
default: return action === "open" ? launch("xdg-open", [path]) : launch("xdg-open", [dirname(path)]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
272
345
|
//#region lib/types/index.js
|
|
273
346
|
/**
|
|
274
347
|
* Pending-edit review, host half. Captures every successful `edit` and `write`
|
|
275
348
|
* tool result (an unscoped `tools/result` listener receives per-session tool
|
|
276
349
|
* executions because scoped emissions route through the shared root hook
|
|
277
|
-
* table)
|
|
350
|
+
* table) and every `str_replace_editor` mutation (whose result carries only a
|
|
351
|
+
* success message, so its pre-write basis is snapshotted at the
|
|
352
|
+
* `fs/edit-intent` / `fs/write-intent` seams and paired with the settle),
|
|
353
|
+
* folds each operation into its file's entry in the
|
|
278
354
|
* {@link PendingDiffStore} (one entry per path), serves the `/diff-approval`
|
|
279
|
-
* connection RPC channel (list/keep/revert), and applies a revert by
|
|
280
|
-
* the entry's `oldText` back through `ctx.fs` (a created file's
|
|
281
|
-
* removes it, and a tracked file that has since disappeared is
|
|
282
|
-
* its revert).
|
|
355
|
+
* connection RPC channel (list/keep/revert/open), and applies a revert by
|
|
356
|
+
* writing the entry's `oldText` back through `ctx.fs` (a created file's
|
|
357
|
+
* revert removes it, and a tracked file that has since disappeared is
|
|
358
|
+
* restored by its revert). `open` launches the file with its default
|
|
359
|
+
* application or reveals it in the file manager.
|
|
283
360
|
*
|
|
284
361
|
* Mount this row in any profile's `cordis.patch.yml`:
|
|
285
362
|
*
|
|
@@ -294,9 +371,10 @@ var PendingPersistence = class {
|
|
|
294
371
|
* ```
|
|
295
372
|
*
|
|
296
373
|
* Pending entries persist per (workspace, session) so an unhandled operation
|
|
297
|
-
* survives a harness restart; the list endpoint
|
|
298
|
-
*
|
|
299
|
-
* restart
|
|
374
|
+
* survives a harness restart; the list endpoint hydrates the whole workspace
|
|
375
|
+
* and merges every registered session's entries, so a fresh session after a
|
|
376
|
+
* restart still reports the earlier sessions' pending changes, live-verified
|
|
377
|
+
* exactly as it is mid-session.
|
|
300
378
|
*
|
|
301
379
|
* @module dsh-diff-approval
|
|
302
380
|
*/
|
|
@@ -363,6 +441,22 @@ function writeOutcomeOf(value) {
|
|
|
363
441
|
function errorMessage(error) {
|
|
364
442
|
return error instanceof Error ? error.message : String(error);
|
|
365
443
|
}
|
|
444
|
+
/** Narrow a tool-execution-shaped value to its name, call id, and agent. */
|
|
445
|
+
function actorOf(value) {
|
|
446
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
447
|
+
const { name, callId, agent } = value;
|
|
448
|
+
return {
|
|
449
|
+
name,
|
|
450
|
+
callId,
|
|
451
|
+
agent
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/** Narrow an agent-shaped value to its session id. */
|
|
455
|
+
function sessionOfAgent(agent) {
|
|
456
|
+
if (typeof agent !== "object" || agent === null) return void 0;
|
|
457
|
+
const id = agent.id;
|
|
458
|
+
return typeof id === "string" && id.length > 0 ? SessionId(id) : void 0;
|
|
459
|
+
}
|
|
366
460
|
/**
|
|
367
461
|
* Build one channel error in the closed RPC error vocabulary. `internal` is
|
|
368
462
|
* the catch-all: business misses ride the success branch as `outcome: 'missing'`.
|
|
@@ -389,8 +483,81 @@ function apply(ctx, config) {
|
|
|
389
483
|
if (storageDir !== void 0 && (typeof storageDir !== "string" || storageDir.trim().length === 0)) throw new Error("diff-approval: storageDir must be a non-empty string");
|
|
390
484
|
const store = new PendingDiffStore();
|
|
391
485
|
const persistence = new PendingPersistence(resolve(expandHomePath(storageDir ?? defaultStorageDir())));
|
|
392
|
-
const
|
|
393
|
-
|
|
486
|
+
const launchPath = config?.openPath ?? defaultOpenPath;
|
|
487
|
+
/** Sessions seen per workspace, so the list can merge a workspace's sessions. */
|
|
488
|
+
const sessionsByWorkspace = /* @__PURE__ */ new Map();
|
|
489
|
+
/** Workspace ids whose persisted state has been hydrated into the store. */
|
|
490
|
+
const loadedWorkspaces = /* @__PURE__ */ new Set();
|
|
491
|
+
const loadingWorkspaces = /* @__PURE__ */ new Map();
|
|
492
|
+
/** Pre-write bases captured at the intent seams, keyed by the tool call id. */
|
|
493
|
+
const editorIntents = /* @__PURE__ */ new Map();
|
|
494
|
+
/**
|
|
495
|
+
* Snapshot one str_replace_editor mutation's basis at its intent seam. A
|
|
496
|
+
* `create` has an empty basis; an edit reads the pre-write content. Any
|
|
497
|
+
* failure tracks nothing — the settle-side pairing then sees no basis.
|
|
498
|
+
* @param target - the resolved target about to be written.
|
|
499
|
+
* @param actor - the tool execution running the mutation.
|
|
500
|
+
* @param kind - whether the mutation creates or edits the file.
|
|
501
|
+
*/
|
|
502
|
+
async function stashEditorIntent(target, actor, kind) {
|
|
503
|
+
const shaped = actorOf(actor);
|
|
504
|
+
if (shaped === void 0 || shaped.name !== "str_replace_editor") return;
|
|
505
|
+
if (typeof shaped.callId !== "string") return;
|
|
506
|
+
const sessionId = sessionOfAgent(shaped.agent);
|
|
507
|
+
if (sessionId === void 0) return;
|
|
508
|
+
if (kind === "create") {
|
|
509
|
+
editorIntents.set(shaped.callId, {
|
|
510
|
+
target,
|
|
511
|
+
kind,
|
|
512
|
+
before: "",
|
|
513
|
+
sessionId
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const before = await ctx.fs.readText(target, void 0) ?? "";
|
|
519
|
+
editorIntents.set(shaped.callId, {
|
|
520
|
+
target,
|
|
521
|
+
kind,
|
|
522
|
+
before,
|
|
523
|
+
sessionId
|
|
524
|
+
});
|
|
525
|
+
} catch {}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Fold one str_replace_editor mutation into its file's entry. The tool's
|
|
529
|
+
* result carries only a success message, so the settle reads the post-write
|
|
530
|
+
* content and pairs it with the basis snapshotted at the intent seam.
|
|
531
|
+
* @param exec - the settled str_replace_editor execution.
|
|
532
|
+
* @param result - its outcome.
|
|
533
|
+
*/
|
|
534
|
+
async function captureEditorMutation(exec, result) {
|
|
535
|
+
const basis = editorIntents.get(exec.callId);
|
|
536
|
+
editorIntents.delete(exec.callId);
|
|
537
|
+
if (basis === void 0 || basis.sessionId === void 0 || result.isError) return;
|
|
538
|
+
const argumentsValue = exec.arguments;
|
|
539
|
+
const command = typeof argumentsValue === "object" && argumentsValue !== null ? argumentsValue.command : void 0;
|
|
540
|
+
if (!(basis.kind === "create" ? command === "create" : command === "str_replace" || command === "insert")) return;
|
|
541
|
+
let after;
|
|
542
|
+
try {
|
|
543
|
+
after = await ctx.fs.readText(basis.target, void 0) ?? "";
|
|
544
|
+
} catch {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (basis.kind === "edit" && after === basis.before) return;
|
|
548
|
+
const sessionId = basis.sessionId;
|
|
549
|
+
const entry = {
|
|
550
|
+
id: randomUUID(),
|
|
551
|
+
sessionId,
|
|
552
|
+
path: basis.target.displayPath,
|
|
553
|
+
kind: basis.kind,
|
|
554
|
+
oldText: basis.before,
|
|
555
|
+
newText: after,
|
|
556
|
+
updatedAt: Date.now()
|
|
557
|
+
};
|
|
558
|
+
await ensureLoaded(sessionId);
|
|
559
|
+
if (store.fold(entry)) await persistSession(sessionId);
|
|
560
|
+
}
|
|
394
561
|
/**
|
|
395
562
|
* Read one path's live state: present content, an unresolvable (missing)
|
|
396
563
|
* path, or a resolved-but-unreadable file.
|
|
@@ -462,31 +629,94 @@ function apply(ctx, config) {
|
|
|
462
629
|
for (const workspace of ctx.workspaceRegistry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace;
|
|
463
630
|
}
|
|
464
631
|
/**
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
* @
|
|
632
|
+
* Record one session in its workspace's account. Every path that touches a
|
|
633
|
+
* session registers it, so the list merges all of a workspace's sessions'
|
|
634
|
+
* entries — a fresh session after restart still sees the workspace's
|
|
635
|
+
* persisted pending changes.
|
|
636
|
+
* @param sessionId - the session to register.
|
|
637
|
+
* @returns the owning workspace, or `undefined` when none accounts it.
|
|
470
638
|
*/
|
|
471
|
-
function
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
474
|
-
const
|
|
639
|
+
function registerSession(sessionId) {
|
|
640
|
+
const workspace = workspaceOf(sessionId);
|
|
641
|
+
if (workspace === void 0) return void 0;
|
|
642
|
+
const key = String(workspace.id);
|
|
643
|
+
const sessions = sessionsByWorkspace.get(key);
|
|
644
|
+
if (sessions === void 0) sessionsByWorkspace.set(key, /* @__PURE__ */ new Set([String(sessionId)]));
|
|
645
|
+
else sessions.add(String(sessionId));
|
|
646
|
+
return workspace;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Merge one workspace's persisted entries into the store, once per
|
|
650
|
+
* workspace. Hydration is workspace-scoped: after a restart the current
|
|
651
|
+
* session has a fresh id while the persisted entries live under their
|
|
652
|
+
* original session ids in the same workspace file, so the whole workspace
|
|
653
|
+
* is loaded and every persisted session is accounted. Concurrent callers
|
|
654
|
+
* share the in-flight load, and folds arriving while the load runs stay
|
|
655
|
+
* safe: `hydrate` never overwrites a live entry.
|
|
656
|
+
* @param workspace - the workspace whose persisted state to merge.
|
|
657
|
+
* @returns resolution after the workspace's persisted state is merged (or skipped).
|
|
658
|
+
*/
|
|
659
|
+
function ensureWorkspaceLoaded(workspace) {
|
|
660
|
+
const key = String(workspace.id);
|
|
661
|
+
if (loadedWorkspaces.has(key)) return Promise.resolve();
|
|
662
|
+
const pending = loadingWorkspaces.get(key);
|
|
475
663
|
if (pending !== void 0) return pending;
|
|
476
664
|
const task = (async () => {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
665
|
+
try {
|
|
666
|
+
const persisted = await persistence.loadWorkspace(key);
|
|
667
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
668
|
+
for (const entry of persisted) {
|
|
669
|
+
const sessionKey = String(entry.sessionId);
|
|
670
|
+
const group = bySession.get(sessionKey);
|
|
671
|
+
if (group === void 0) bySession.set(sessionKey, [entry]);
|
|
672
|
+
else group.push(entry);
|
|
673
|
+
}
|
|
674
|
+
for (const [sessionKey, entries] of bySession) {
|
|
675
|
+
const sessions = sessionsByWorkspace.get(key) ?? /* @__PURE__ */ new Set();
|
|
676
|
+
sessions.add(sessionKey);
|
|
677
|
+
sessionsByWorkspace.set(key, sessions);
|
|
678
|
+
store.hydrate(SessionId(sessionKey), entries);
|
|
679
|
+
}
|
|
480
680
|
} catch (error) {
|
|
481
|
-
ctx.logger.warn(`diff-approval: loading persisted state for
|
|
681
|
+
ctx.logger.warn(`diff-approval: loading persisted state for workspace ${key} failed: ${errorMessage(error)}`);
|
|
482
682
|
}
|
|
483
|
-
|
|
484
|
-
|
|
683
|
+
loadedWorkspaces.add(key);
|
|
684
|
+
loadingWorkspaces.delete(key);
|
|
485
685
|
})();
|
|
486
|
-
|
|
686
|
+
loadingWorkspaces.set(key, task);
|
|
487
687
|
return task;
|
|
488
688
|
}
|
|
489
689
|
/**
|
|
690
|
+
* Merge the session's workspace's persisted state into the store (a session
|
|
691
|
+
* with no workspace is the memory-only edge and has nothing to load).
|
|
692
|
+
* @param sessionId - the session to hydrate for.
|
|
693
|
+
* @returns resolution after the workspace's persisted state is merged.
|
|
694
|
+
*/
|
|
695
|
+
function ensureLoaded(sessionId) {
|
|
696
|
+
const workspace = registerSession(sessionId);
|
|
697
|
+
if (workspace === void 0) return Promise.resolve();
|
|
698
|
+
return ensureWorkspaceLoaded(workspace);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* All entries visible to one session: every registered session of its
|
|
702
|
+
* workspace, merged oldest capture first. This is what makes an unhandled
|
|
703
|
+
* change survive a restart — the new session lists the workspace's whole
|
|
704
|
+
* pending set, its own live folds plus the earlier sessions' persisted
|
|
705
|
+
* entries.
|
|
706
|
+
* @param sessionId - the viewing session.
|
|
707
|
+
* @returns the merged entries; a session with no workspace lists only itself.
|
|
708
|
+
*/
|
|
709
|
+
async function workspaceEntries(sessionId) {
|
|
710
|
+
await ensureLoaded(sessionId);
|
|
711
|
+
const workspace = workspaceOf(sessionId);
|
|
712
|
+
if (workspace === void 0) return store.list(sessionId);
|
|
713
|
+
const sessions = sessionsByWorkspace.get(String(workspace.id));
|
|
714
|
+
if (sessions === void 0) return store.list(sessionId);
|
|
715
|
+
const entries = [];
|
|
716
|
+
for (const sessionKey of sessions) entries.push(...store.list(SessionId(sessionKey)));
|
|
717
|
+
return entries.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
490
720
|
* Mirror one session's entries to disk. A write fault logs a warning and
|
|
491
721
|
* leaves the in-memory view intact: the review flow must not break on a
|
|
492
722
|
* storage fault, and the next successful mutation rewrites the whole file.
|
|
@@ -504,6 +734,10 @@ function apply(ctx, config) {
|
|
|
504
734
|
}
|
|
505
735
|
}
|
|
506
736
|
ctx.on("tools/result", (exec, result) => {
|
|
737
|
+
if (exec.name === "str_replace_editor") {
|
|
738
|
+
captureEditorMutation(exec, result);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
507
741
|
if (result.isError || exec.agent === void 0) return;
|
|
508
742
|
const outcome = exec.name === "edit" ? editOutcomeOf(result.value) : exec.name === "write" ? writeOutcomeOf(result.value) : void 0;
|
|
509
743
|
if (outcome === void 0 || outcome.oldText === outcome.newText) return;
|
|
@@ -524,10 +758,9 @@ function apply(ctx, config) {
|
|
|
524
758
|
case "list": {
|
|
525
759
|
const sessionId = sessionOf(payload);
|
|
526
760
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
527
|
-
await ensureLoaded(sessionId);
|
|
528
761
|
return {
|
|
529
762
|
ok: true,
|
|
530
|
-
value: { files: await listWithState(
|
|
763
|
+
value: { files: await listWithState(await workspaceEntries(sessionId)) }
|
|
531
764
|
};
|
|
532
765
|
}
|
|
533
766
|
case "keep": {
|
|
@@ -564,10 +797,38 @@ function apply(ctx, config) {
|
|
|
564
797
|
value: { outcome: "reverted" }
|
|
565
798
|
};
|
|
566
799
|
}
|
|
800
|
+
case "open": {
|
|
801
|
+
const target = openTargetOf(payload);
|
|
802
|
+
if (target === void 0) return rpcError("sessionId, id, and action must be valid");
|
|
803
|
+
await ensureLoaded(target.sessionId);
|
|
804
|
+
const entry = store.get(target.sessionId, target.id);
|
|
805
|
+
if (entry === void 0) return {
|
|
806
|
+
ok: true,
|
|
807
|
+
value: { outcome: "missing" }
|
|
808
|
+
};
|
|
809
|
+
try {
|
|
810
|
+
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
811
|
+
await launchPath(ctx.fs.processPath(resolved), target.action);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
return rpcError(`${target.action} failed: ${errorMessage(error)}`);
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
ok: true,
|
|
817
|
+
value: { outcome: "opened" }
|
|
818
|
+
};
|
|
819
|
+
}
|
|
567
820
|
default: return rpcError(`unknown endpoint ${JSON.stringify(endpoint)}`);
|
|
568
821
|
}
|
|
569
822
|
};
|
|
570
823
|
ctx.effect(() => ctx.connection.rpc.handle(DIFF_APPROVAL_CHANNEL, handle, { authority: "trusted-host" }), "diff-approval: review channel");
|
|
824
|
+
ctx.effect(() => ctx.on("fs/edit-intent", async (target, actor, next) => {
|
|
825
|
+
await stashEditorIntent(target, actor, "edit");
|
|
826
|
+
return next();
|
|
827
|
+
}, { prepend: true }), "diff-approval: str_replace_editor edit basis");
|
|
828
|
+
ctx.effect(() => ctx.on("fs/write-intent", async (target, actor, next) => {
|
|
829
|
+
await stashEditorIntent(target, actor, "create");
|
|
830
|
+
return next();
|
|
831
|
+
}, { prepend: true }), "diff-approval: str_replace_editor create basis");
|
|
571
832
|
}
|
|
572
833
|
/** Narrow a wire payload's `sessionId` field to a branded session id. */
|
|
573
834
|
function sessionOf(payload) {
|
|
@@ -587,5 +848,16 @@ function targetOf(payload) {
|
|
|
587
848
|
id
|
|
588
849
|
};
|
|
589
850
|
}
|
|
851
|
+
/** Narrow a wire payload to one open target: the keep/revert pair plus the action. */
|
|
852
|
+
function openTargetOf(payload) {
|
|
853
|
+
const target = targetOf(payload);
|
|
854
|
+
if (target === void 0) return void 0;
|
|
855
|
+
const action = payload.action;
|
|
856
|
+
if (action !== "open" && action !== "reveal") return void 0;
|
|
857
|
+
return {
|
|
858
|
+
...target,
|
|
859
|
+
action
|
|
860
|
+
};
|
|
861
|
+
}
|
|
590
862
|
//#endregion
|
|
591
|
-
export { DIFF_APPROVAL_CHANNEL, PendingDiffStore, PendingPersistence, apply, defaultStorageDir, inject, name };
|
|
863
|
+
export { DIFF_APPROVAL_CHANNEL, PendingDiffStore, PendingPersistence, apply, defaultOpenPath, defaultStorageDir, inject, name };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Sidebar-foot pending-edit review action and the
|
|
1
|
+
/** Sidebar-foot pending-edit review action and the split review panel it opens. */
|
|
2
2
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
3
3
|
import type { PendingPanelFace } from './slots.ts';
|
|
4
4
|
/** Full panel props composed by the sidebar footer-action slot. */
|
|
5
5
|
export type PendingPanelProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<PendingPanelFace> & PropsLocale<'diff-approval'>;
|
|
6
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;
|
|
7
|
+
export declare function PendingPanel({ wide, useSessions, usePending, onRefresh, onKeep, onRevert, onOpen, t, }: PendingPanelProps): import("react").JSX.Element;
|
|
@@ -15,6 +15,8 @@ export interface HighlightSpan {
|
|
|
15
15
|
text: string;
|
|
16
16
|
style: CSSProperties;
|
|
17
17
|
}
|
|
18
|
+
/** Supported grammar ids, in picker order, for the viewer's language selector. */
|
|
19
|
+
export declare const HIGHLIGHT_LANGS: string[];
|
|
18
20
|
/**
|
|
19
21
|
* Tokenize `code` into per-line highlighted runs when `lang` names a
|
|
20
22
|
* registered grammar; `undefined` means the caller renders its plain fallback.
|
|
@@ -15,13 +15,21 @@ export declare const zh: {
|
|
|
15
15
|
'panel.missingHint': string;
|
|
16
16
|
'panel.createHint': string;
|
|
17
17
|
'row.create': string;
|
|
18
|
+
'row.added': string;
|
|
19
|
+
'row.removed': string;
|
|
18
20
|
'action.keep': string;
|
|
19
21
|
'action.revert': string;
|
|
22
|
+
'action.openFile': string;
|
|
23
|
+
'action.revealFile': string;
|
|
20
24
|
'action.busy': string;
|
|
21
25
|
'action.prevDiff': string;
|
|
22
26
|
'action.nextDiff': string;
|
|
23
|
-
'action.
|
|
27
|
+
'action.copyHint': string;
|
|
24
28
|
'action.copied': string;
|
|
29
|
+
'action.langAuto': string;
|
|
30
|
+
'action.langAutoDetected': string;
|
|
31
|
+
'action.langSelect': string;
|
|
32
|
+
'action.close': string;
|
|
25
33
|
'status.kept': string;
|
|
26
34
|
'status.reverted': string;
|
|
27
35
|
'status.missing': string;
|
|
@@ -49,13 +57,21 @@ export declare const en: {
|
|
|
49
57
|
'panel.missingHint': string;
|
|
50
58
|
'panel.createHint': string;
|
|
51
59
|
'row.create': string;
|
|
60
|
+
'row.added': string;
|
|
61
|
+
'row.removed': string;
|
|
52
62
|
'action.keep': string;
|
|
53
63
|
'action.revert': string;
|
|
64
|
+
'action.openFile': string;
|
|
65
|
+
'action.revealFile': string;
|
|
54
66
|
'action.busy': string;
|
|
55
67
|
'action.prevDiff': string;
|
|
56
68
|
'action.nextDiff': string;
|
|
57
|
-
'action.
|
|
69
|
+
'action.copyHint': string;
|
|
58
70
|
'action.copied': string;
|
|
71
|
+
'action.langAuto': string;
|
|
72
|
+
'action.langAutoDetected': string;
|
|
73
|
+
'action.langSelect': string;
|
|
74
|
+
'action.close': string;
|
|
59
75
|
'status.kept': string;
|
|
60
76
|
'status.reverted': string;
|
|
61
77
|
'status.missing': string;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @module dsh-diff-approval/client/port
|
|
6
6
|
*/
|
|
7
7
|
import type { ClientConnectionRpc, SessionId } from '@deepseek-ai/dsh-client-connection/client';
|
|
8
|
-
import type { DiffApprovalActionValue, PendingFileDiff } from '../types.ts';
|
|
8
|
+
import type { DiffApprovalActionValue, DiffApprovalOpenAction, DiffApprovalOpenValue, PendingFileDiff } from '../types.ts';
|
|
9
9
|
/** The channel the host half registers and this port calls. */
|
|
10
10
|
export declare const DIFF_APPROVAL_CHANNEL = "/diff-approval";
|
|
11
11
|
/** This package's business verbs over the review channel. */
|
|
@@ -16,6 +16,8 @@ export interface DiffApprovalPort {
|
|
|
16
16
|
keep(sessionId: SessionId, id: string): Promise<DiffApprovalActionValue>;
|
|
17
17
|
/** Revert one operation. */
|
|
18
18
|
revert(sessionId: SessionId, id: string): Promise<DiffApprovalActionValue>;
|
|
19
|
+
/** Open one file with its default application or reveal it in the folder. */
|
|
20
|
+
open(sessionId: SessionId, id: string, action: DiffApprovalOpenAction): Promise<DiffApprovalOpenValue>;
|
|
19
21
|
}
|
|
20
22
|
/** Build the port over one generic RPC caller.
|
|
21
23
|
* @param rpc - the connection's channel caller.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The panel's injected business face and its observable snapshot. */
|
|
2
2
|
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
|
|
3
3
|
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
|
|
4
|
-
import type { PendingFileDiff } from '../types.ts';
|
|
4
|
+
import type { DiffApprovalOpenAction, PendingFileDiff } from '../types.ts';
|
|
5
5
|
/** What the panel reads and drives: the pending list plus in-flight entries. */
|
|
6
6
|
export interface PendingDiffSnapshot {
|
|
7
7
|
/** Whether a list read has completed at least once. */
|
|
@@ -25,4 +25,6 @@ export interface PendingPanelFace {
|
|
|
25
25
|
onKeep: (sessionId: SessionId, id: string) => Promise<void>;
|
|
26
26
|
/** Revert one operation (restore its prior content, or remove a created file). */
|
|
27
27
|
onRevert: (sessionId: SessionId, id: string) => Promise<void>;
|
|
28
|
+
/** Open one file with its default application or reveal it in the folder. */
|
|
29
|
+
onOpen: (sessionId: SessionId, id: string, action: DiffApprovalOpenAction) => Promise<void>;
|
|
28
30
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
|
|
9
9
|
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
|
|
10
|
+
import type { DiffApprovalOpenAction } from '../types.ts';
|
|
10
11
|
import type { PendingDiffSnapshot } from './slots.ts';
|
|
11
12
|
import type { DiffApprovalPort } from './port.ts';
|
|
12
13
|
/** The observable the panel reads and the plugin body drives. */
|
|
@@ -17,6 +18,8 @@ export interface PendingDiffStore extends HostObservable<PendingDiffSnapshot> {
|
|
|
17
18
|
keep: (sessionId: SessionId, id: string) => Promise<void>;
|
|
18
19
|
/** Revert one operation. */
|
|
19
20
|
revert: (sessionId: SessionId, id: string) => Promise<void>;
|
|
21
|
+
/** Open one file with its default application or reveal it in the folder. */
|
|
22
|
+
open: (sessionId: SessionId, id: string, action: DiffApprovalOpenAction) => Promise<void>;
|
|
20
23
|
/** Drop every local fact (used on connection reset). */
|
|
21
24
|
reset: () => void;
|
|
22
25
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
* Pending-edit review, host half. Captures every successful `edit` and `write`
|
|
3
3
|
* tool result (an unscoped `tools/result` listener receives per-session tool
|
|
4
4
|
* executions because scoped emissions route through the shared root hook
|
|
5
|
-
* table)
|
|
5
|
+
* table) and every `str_replace_editor` mutation (whose result carries only a
|
|
6
|
+
* success message, so its pre-write basis is snapshotted at the
|
|
7
|
+
* `fs/edit-intent` / `fs/write-intent` seams and paired with the settle),
|
|
8
|
+
* folds each operation into its file's entry in the
|
|
6
9
|
* {@link PendingDiffStore} (one entry per path), serves the `/diff-approval`
|
|
7
|
-
* connection RPC channel (list/keep/revert), and applies a revert by
|
|
8
|
-
* the entry's `oldText` back through `ctx.fs` (a created file's
|
|
9
|
-
* removes it, and a tracked file that has since disappeared is
|
|
10
|
-
* its revert).
|
|
10
|
+
* connection RPC channel (list/keep/revert/open), and applies a revert by
|
|
11
|
+
* writing the entry's `oldText` back through `ctx.fs` (a created file's
|
|
12
|
+
* revert removes it, and a tracked file that has since disappeared is
|
|
13
|
+
* restored by its revert). `open` launches the file with its default
|
|
14
|
+
* application or reveals it in the file manager.
|
|
11
15
|
*
|
|
12
16
|
* Mount this row in any profile's `cordis.patch.yml`:
|
|
13
17
|
*
|
|
@@ -22,16 +26,19 @@
|
|
|
22
26
|
* ```
|
|
23
27
|
*
|
|
24
28
|
* Pending entries persist per (workspace, session) so an unhandled operation
|
|
25
|
-
* survives a harness restart; the list endpoint
|
|
26
|
-
*
|
|
27
|
-
* restart
|
|
29
|
+
* survives a harness restart; the list endpoint hydrates the whole workspace
|
|
30
|
+
* and merges every registered session's entries, so a fresh session after a
|
|
31
|
+
* restart still reports the earlier sessions' pending changes, live-verified
|
|
32
|
+
* exactly as it is mid-session.
|
|
28
33
|
*
|
|
29
34
|
* @module dsh-diff-approval
|
|
30
35
|
*/
|
|
31
36
|
import { Context } from '@deepseek-ai/cordis';
|
|
32
|
-
|
|
37
|
+
import type { DiffApprovalOpenAction } from './types.ts';
|
|
38
|
+
export type { DiffApprovalActionOutcome, DiffApprovalActionValue, DiffApprovalListValue, DiffApprovalOpenAction, DiffApprovalOpenValue, PendingEntry, PendingEntryKind, PendingFileDiff, } from './types.ts';
|
|
33
39
|
export { PendingDiffStore } from './pending.ts';
|
|
34
40
|
export { PendingPersistence, defaultStorageDir } from './persist.ts';
|
|
41
|
+
export { defaultOpenPath } from './open.ts';
|
|
35
42
|
/** Stable Cordis plugin name. */
|
|
36
43
|
export declare const name = "diff-approval";
|
|
37
44
|
/** Services required before the review surface activates. */
|
|
@@ -48,6 +55,11 @@ export interface DiffApprovalConfig {
|
|
|
48
55
|
* `~` prefixes expand to the OS home.
|
|
49
56
|
*/
|
|
50
57
|
storageDir?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Launcher for the `open` endpoint, defaulting to the platform commands.
|
|
60
|
+
* Injectable for tests; receives the backend execution-world path.
|
|
61
|
+
*/
|
|
62
|
+
openPath?: (path: string, action: DiffApprovalOpenAction) => Promise<void>;
|
|
51
63
|
}
|
|
52
64
|
/**
|
|
53
65
|
* Mount the pending-edit review surface.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-level "open file" and "reveal in folder" launches for the host half's
|
|
3
|
+
* `open` endpoint. The plugin's own tests inject a double instead of running
|
|
4
|
+
* these; real profiles get the platform commands below.
|
|
5
|
+
* @module dsh-diff-approval/open
|
|
6
|
+
*/
|
|
7
|
+
/** What the open endpoint asks the OS to do with a path. */
|
|
8
|
+
export type OpenAction = 'open' | 'reveal';
|
|
9
|
+
/**
|
|
10
|
+
* Launch a path through the current platform's default handler: `open` runs
|
|
11
|
+
* the file with its default application, `reveal` selects it in the file
|
|
12
|
+
* manager.
|
|
13
|
+
* @param path - backend execution-world path to act on.
|
|
14
|
+
* @param action - what to do with the path.
|
|
15
|
+
* @returns resolution once the launcher process has spawned.
|
|
16
|
+
*/
|
|
17
|
+
export declare function defaultOpenPath(path: string, action: OpenAction): Promise<void>;
|
package/lib/types/persist.d.ts
CHANGED
|
@@ -37,6 +37,16 @@ export declare class PendingPersistence {
|
|
|
37
37
|
* @returns the persisted entries; empty when none were saved.
|
|
38
38
|
*/
|
|
39
39
|
load(workspaceId: string, sessionId: string): Promise<PendingEntry[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Load every session's entries for one workspace, oldest capture first.
|
|
42
|
+
* The list shows a workspace's pending changes across sessions — a session
|
|
43
|
+
* that restarted carries a fresh id while its earlier entries sit under the
|
|
44
|
+
* original session ids in the same workspace file — so hydration reads the
|
|
45
|
+
* whole workspace, not one session.
|
|
46
|
+
* @param workspaceId - the owning workspace's stable id.
|
|
47
|
+
* @returns the persisted entries across all sessions; empty when none were saved.
|
|
48
|
+
*/
|
|
49
|
+
loadWorkspace(workspaceId: string): Promise<PendingEntry[]>;
|
|
40
50
|
/**
|
|
41
51
|
* Replace one session's entries durably. Saves to one file are serialized;
|
|
42
52
|
* a previous save's failure does not block the next one.
|
package/lib/types/types.d.ts
CHANGED
|
@@ -52,6 +52,13 @@ export interface DiffApprovalListValue {
|
|
|
52
52
|
/** Pending entries for the requested session, oldest capture first. */
|
|
53
53
|
files: PendingFileDiff[];
|
|
54
54
|
}
|
|
55
|
+
/** What the open endpoint asks the OS to do with a file. */
|
|
56
|
+
export type DiffApprovalOpenAction = 'open' | 'reveal';
|
|
57
|
+
/** Value returned by the channel's open endpoint. */
|
|
58
|
+
export interface DiffApprovalOpenValue {
|
|
59
|
+
/** What the request did; `missing` means no pending entry existed. */
|
|
60
|
+
outcome: 'opened' | 'missing';
|
|
61
|
+
}
|
|
55
62
|
/** Outcome of one keep/revert request. */
|
|
56
63
|
export type DiffApprovalActionOutcome = 'kept' | 'reverted' | 'missing';
|
|
57
64
|
/** Value returned by the channel's keep and revert endpoints. */
|