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/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), folds each operation into its file's entry in the
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 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).
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 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.
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 loaded = /* @__PURE__ */ new Set();
393
- const loading = /* @__PURE__ */ new Map();
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
- * 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).
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 ensureLoaded(sessionId) {
472
- const key = String(sessionId);
473
- if (loaded.has(key)) return Promise.resolve();
474
- const pending = loading.get(key);
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
- const workspace = workspaceOf(sessionId);
478
- if (workspace !== void 0) try {
479
- store.hydrate(sessionId, await persistence.load(String(workspace.id), key));
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 session ${key} failed: ${errorMessage(error)}`);
681
+ ctx.logger.warn(`diff-approval: loading persisted state for workspace ${key} failed: ${errorMessage(error)}`);
482
682
  }
483
- loaded.add(key);
484
- loading.delete(key);
683
+ loadedWorkspaces.add(key);
684
+ loadingWorkspaces.delete(key);
485
685
  })();
486
- loading.set(key, task);
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(store.list(sessionId)) }
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 whole-file diff list it opens. */
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.copyRange': string;
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.copyRange': string;
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
  }
@@ -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), folds each operation into its file's entry in the
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 writing
8
- * the entry's `oldText` back through `ctx.fs` (a created file's revert
9
- * removes it, and a tracked file that has since disappeared is restored by
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 re-reads the live file, so a
26
- * change or deletion made after the tracked operation is reported after
27
- * restart exactly as it is mid-session.
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
- export type { DiffApprovalActionOutcome, DiffApprovalActionValue, DiffApprovalListValue, PendingEntry, PendingEntryKind, PendingFileDiff, } from './types.ts';
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>;
@@ -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.
@@ -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. */