dsh-diff-approval 0.24.1 → 0.26.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
@@ -3021,6 +3021,58 @@ function apply(ctx, config) {
3021
3021
  }
3022
3022
  };
3023
3023
  const isDirectory = info.type === "directory";
3024
+ if (target.exact && isDirectory) return {
3025
+ ok: true,
3026
+ value: {
3027
+ outcome: "not-a-file",
3028
+ added: 0,
3029
+ duplicates: 0
3030
+ }
3031
+ };
3032
+ /**
3033
+ * Admit what the add found and answer the wire value. Shared by the two ways in — the
3034
+ * one file a reader named, and the scan of a path — so a named file is listed, counted
3035
+ * and identified exactly like a scanned one.
3036
+ *
3037
+ * @param candidates - the entries to admit (already built, in order).
3038
+ * @param directory - whether the named path was a directory (its own outcome wording).
3039
+ * @param cut - whether the walk hit its cap.
3040
+ * @returns the channel's answer for this add.
3041
+ */
3042
+ const admitNamed = async (candidates, directory, cut) => {
3043
+ const listed = new Set(store.list(target.sessionId).map((entry) => pathIdentity(entry.path)));
3044
+ const fresh = candidates.filter((entry) => !listed.has(pathIdentity(entry.path)));
3045
+ const duplicates = candidates.length - fresh.length;
3046
+ const added = await foldBatch(target.sessionId, fresh, true);
3047
+ const outcome = added > 0 ? "added" : duplicates > 0 ? "duplicate" : directory ? "empty" : "unchanged";
3048
+ const single = directory ? void 0 : fresh[0] ?? store.list(target.sessionId).find((entry) => pathIdentity(entry.path) === pathIdentity(absolute));
3049
+ return {
3050
+ ok: true,
3051
+ value: {
3052
+ outcome,
3053
+ added,
3054
+ duplicates,
3055
+ ...single === void 0 ? {} : { id: single.id },
3056
+ ...cut ? { truncated: true } : {}
3057
+ }
3058
+ };
3059
+ };
3060
+ const now = Date.now();
3061
+ if (target.exact) {
3062
+ const existing = store.list(target.sessionId).find((entry) => pathIdentity(entry.path) === pathIdentity(absolute));
3063
+ if (existing !== void 0) return await admitNamed([existing], false, false);
3064
+ const content = await readTextOrNone(absolute, signal);
3065
+ return await admitNamed(content === void 0 ? [] : [{
3066
+ id: absolute,
3067
+ sessionId: target.sessionId,
3068
+ path: absolute,
3069
+ kind: "edit",
3070
+ oldText: content,
3071
+ newText: content,
3072
+ updatedAt: now,
3073
+ sessionIds: [target.sessionId]
3074
+ }], false, false);
3075
+ }
3024
3076
  const root = detectVcsRoot(workspace.path);
3025
3077
  if (root === void 0) return {
3026
3078
  ok: true,
@@ -3055,7 +3107,6 @@ function apply(ctx, config) {
3055
3107
  }
3056
3108
  };
3057
3109
  }
3058
- const now = Date.now();
3059
3110
  const candidates = changes.map((change) => ({
3060
3111
  id: change.path,
3061
3112
  sessionId: target.sessionId,
@@ -3093,24 +3144,7 @@ function apply(ctx, config) {
3093
3144
  });
3094
3145
  }
3095
3146
  }
3096
- const listed = new Set(store.list(target.sessionId).map((entry) => pathIdentity(entry.path)));
3097
- const fresh = candidates.filter((entry) => !listed.has(pathIdentity(entry.path)));
3098
- const duplicates = candidates.length - fresh.length;
3099
- const added = await foldBatch(target.sessionId, fresh, true);
3100
- const outcome = added > 0 ? "added" : duplicates > 0 ? "duplicate" : isDirectory ? "empty" : "unchanged";
3101
- return {
3102
- ok: true,
3103
- value: truncated ? {
3104
- outcome,
3105
- added,
3106
- duplicates,
3107
- truncated
3108
- } : {
3109
- outcome,
3110
- added,
3111
- duplicates
3112
- }
3113
- };
3147
+ return await admitNamed(candidates, isDirectory, truncated);
3114
3148
  }
3115
3149
  case "open": {
3116
3150
  const target = openTargetOf(payload);
@@ -3282,10 +3316,12 @@ function addTargetOf(payload) {
3282
3316
  if (sessionId === void 0) return void 0;
3283
3317
  const path = pathFieldOf(payload)?.trim();
3284
3318
  if (path === void 0 || path === "") return void 0;
3319
+ const record = payload;
3285
3320
  return {
3286
3321
  sessionId,
3287
3322
  path,
3288
- includeUnchanged: payload.includeUnchanged === true
3323
+ includeUnchanged: record.includeUnchanged === true,
3324
+ exact: record.exact === true
3289
3325
  };
3290
3326
  }
3291
3327
  /** Narrow a wire payload to one open target: the keep/revert pair plus the action. */
@@ -22,6 +22,30 @@ export declare function diffPathsMatch(chipPath: string, filePath: string, works
22
22
  * breakpoint so the two stay consistent. */
23
23
  export declare const SIDEBAR_AUTO_COLLAPSE_PX = 1024;
24
24
  export declare function wrapInto(text: string, widthPx: number, measure: (t: string) => number, tabPx: number): string[];
25
+ /**
26
+ * Wrap one line of a turn the way the browser lays it out: the prose by character, each inline-code
27
+ * chip as one box that cannot be broken.
28
+ *
29
+ * `wrapInto` walks characters and nothing else, which is right for the code view and wrong here:
30
+ * a turn's chip is an inline-flex box (the chat's own inline code, see `.discussionCode`), so half
31
+ * of a chip never lands on the next line — it moves over whole, and the text after it starts after
32
+ * the box rather than inside it. Measuring the chip as ordinary text therefore reserves the wrong
33
+ * number of rows on exactly the lines that carry one, and a row too few is a clipped last line.
34
+ *
35
+ * The prose segments are wrapped by `wrapInto` itself, so a line with a chip in it breaks its words
36
+ * and its spaces exactly where a line without one does; only the chips are placed as boxes.
37
+ *
38
+ * @param line - the line's text, markers and all: the runs are read the way the render reads them.
39
+ * @param room - the width the line wraps in.
40
+ * @param measure - the prose measurer.
41
+ * @param chipWidth - the width of one chip's BOX (its text in the chip's own font, plus its padding
42
+ * and its hairline).
43
+ * @param tabPx - one tab stop, in px.
44
+ * @returns the row count for the line.
45
+ */
46
+ export declare function wrapChipRows(line: string, room: number, measure: (text: string) => number, chipWidth: (text: string) => number, tabPx: number): number;
47
+ /** Create a measurer bound to `font`; falls back to a rough char estimate. */
48
+ export declare function makeMeasurer(font: string | undefined): ((text: string) => number) | undefined;
25
49
  /** Full panel props composed by the sidebar footer-action slot. */
26
50
  export type PendingPanelProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<PendingPanelFace> & PropsLocale<'diff-approval'> & PendingPanelDockProps;
27
51
  /** How the panel is hosted when it is not the footer's floating overlay: the
@@ -37,6 +37,14 @@ export interface DiscussionQuoteLine {
37
37
  old: number | undefined;
38
38
  /** New-file number — the second gutter — `undefined` on a removed line. */
39
39
  new: number | undefined;
40
+ /**
41
+ * Which side of the change the row was on when the thread quoted it: an added line, a removed
42
+ * one, or context. Kept so the quote can wear the diff's own green/red washes — the reader
43
+ * recognised those rows by their colour, and a quote that renders them as plain code loses the
44
+ * thing they were looking at. Absent on a thread quoted before this was recorded, which then
45
+ * simply draws no wash.
46
+ */
47
+ kind?: 'add' | 'del' | 'context';
40
48
  }
41
49
  /** One discussion bound to a row range. */
42
50
  export interface Discussion {
@@ -105,7 +113,9 @@ export interface Discussion {
105
113
  /**
106
114
  * How many rows the block's body occupies, measured by the caller from what it
107
115
  * actually renders (the thread plus the compose row). It is what keeps a
108
- * discussion block's height an exact multiple of the code row.
116
+ * discussion block's height an exact multiple of the thread's own row — which is fixed, not
117
+ * the code's (see `THREAD_ROW_PX`): a thread is prose and keeps its size whatever the reader
118
+ * sets the code's line height to.
109
119
  */
110
120
  bodyRows?: number;
111
121
  /** How many older messages the row cap left out of the render. */
@@ -113,32 +123,58 @@ export interface Discussion {
113
123
  }
114
124
  /** Rows a folded block occupies: its header line. */
115
125
  export declare const DISCUSSION_HEADER_ROWS = 1;
116
- /** Rows the compose area occupies: 0.3 + 1.4 + 0.3 of a code row. */
126
+ /** Rows the compose area occupies: 0.3 + 1.4 + 0.3 of the thread's own row. */
117
127
  export declare const DISCUSSION_COMPOSE_ROWS = 2;
118
128
  /**
119
- * The body budget for the turns a block shows: how many rows of older turns it is worth
120
- * carrying before the diff it annotates is swamped. It is spent in WHOLE turns — see
121
- * `discussionTail` — so a single turn that needs more than this is shown in full rather
122
- * than sliced in half (which is what a hard cap on the body's row count did).
123
- */
124
- export declare const DISCUSSION_MAX_BODY_ROWS = 12;
125
- /**
126
- * The newest turns that fit a size budget, and how many older ones were left out.
127
- * A thread grows without bound, so the block shows its tail and says what it is
128
- * hiding rather than clipping silently; the hidden note costs one row of the
129
- * budget itself. Sizes are in whatever unit the caller budgets in (the panel uses
130
- * code rows, fractions included, and rounds the total up once).
129
+ * The newest rounds of a thread, and how many older turns were left out.
130
+ *
131
+ * A thread grows without bound, so the block shows its tail and says what it is hiding. The unit
132
+ * is the round rather than a row budget: a block keeps the last `rounds` questions and
133
+ * everything said after them, however long those turns are (the count is a preference). That is possible
134
+ * because the block reserves exactly what it draws (see `discussionRows`), so a long answer is
135
+ * shown in full instead of being sliced — the row budget this replaces existed to stop an old
136
+ * thread from swamping the diff, and a round cap does that without ever cutting a turn in half.
131
137
  *
132
138
  * @param messages - the whole thread, oldest first.
139
+ * @param rounds - how many rounds to keep (at least one: something has to be visible).
133
140
  * @param sizeOf - the size one message needs when rendered (text plus its chrome).
134
- * @param capRows - the body budget.
135
141
  * @returns the messages to render, the count left out, and the size they need.
136
142
  */
137
- export declare function discussionTail(messages: readonly DiscussionMessage[], sizeOf: (message: DiscussionMessage) => number, capRows: number): {
143
+ export declare function discussionRounds(messages: readonly DiscussionMessage[], rounds: number, sizeOf: (message: DiscussionMessage) => number): {
138
144
  messages: readonly DiscussionMessage[];
139
145
  hidden: number;
140
146
  rows: number;
141
147
  };
148
+ /** One run of a turn's prose, after its inline Markdown is read. */
149
+ export interface DiscussionRun {
150
+ /** A literal run, inline `code`, or **strong** emphasis. */
151
+ kind: 'text' | 'code' | 'strong';
152
+ /** What the run draws: for `code` and `strong`, the text with its markers gone. */
153
+ text: string;
154
+ }
155
+ /**
156
+ * Read one turn's inline Markdown: `code` and **strong**.
157
+ *
158
+ * Deliberately tiny. The thread is laid out on the diff's own row grid — one line box per code
159
+ * row — so nothing here may become a block: no headings, lists, quotes or fenced code. A marker
160
+ * with no partner, or one spanning a line break, is left exactly as typed, and so is everything
161
+ * this does not know about.
162
+ *
163
+ * @param text - the turn's text.
164
+ * @returns its runs, in order.
165
+ */
166
+ export declare function discussionRuns(text: string): DiscussionRun[];
167
+ /**
168
+ * A turn's text with its inline markers gone: what the runs draw, character for character.
169
+ *
170
+ * The row measurement uses this, so the rows a block reserves and the text it draws agree. The
171
+ * markers are not drawn, and counting them would reserve room for characters nobody sees — which
172
+ * is exactly the kind of whole-row slack that leaves a blank row in the block.
173
+ *
174
+ * @param text - the turn's text.
175
+ * @returns the same text without its `code` and **strong** markers.
176
+ */
177
+ export declare function discussionPlainText(text: string): string;
142
178
  /**
143
179
  * An answer with its blank lines dropped.
144
180
  *
@@ -228,6 +264,22 @@ export declare function discussionOverlapping(discussions: readonly Discussion[]
228
264
  * @returns the re-anchored discussion (the same object when nothing moved).
229
265
  */
230
266
  export declare function remapDiscussion(discussion: Discussion, lineOf: (row: number) => number | undefined, textOf: (row: number) => string, rowCount: number): Discussion;
267
+ /**
268
+ * Re-anchor a whole file's blocks in one pass.
269
+ *
270
+ * Each block is re-anchored exactly as `remapDiscussion` does it — same rules, same identities,
271
+ * same "nothing moved, same object back" contract — but the row windows every quote is looked up
272
+ * in are joined ONCE for the whole list. A rebuild (a keep, an edit, a switched file) runs this
273
+ * over every block a file has, and joining the rows per block is what made the pass cost grow with
274
+ * the number of comments on a long file.
275
+ *
276
+ * @param discussions - the blocks to re-anchor.
277
+ * @param lineOf - the new-file line number of a row, or `undefined` for a row that has none.
278
+ * @param textOf - the text of a row, for the quote comparison.
279
+ * @param rowCount - how many rows the current model has.
280
+ * @returns the re-anchored blocks, in the order they came in.
281
+ */
282
+ export declare function remapDiscussions(discussions: readonly Discussion[], lineOf: (row: number) => number | undefined, textOf: (row: number) => string, rowCount: number): readonly Discussion[];
231
283
  /** What the selection frame shows for the current selection. */
232
284
  export interface SelectionFrameActions {
233
285
  /** Whether keep/revert apply (the selection fully covers change blocks). */
@@ -11,6 +11,12 @@ export declare const zh: {
11
11
  'panel.readFailed': string;
12
12
  'panel.group.current': string;
13
13
  'panel.group.others': string;
14
+ 'panel.tabs': string;
15
+ 'panel.tab.pending': string;
16
+ 'panel.tab.comments': string;
17
+ 'panel.commentsEmpty': string;
18
+ 'panel.commentOutdated': string;
19
+ 'panel.commentEmptyTitle': string;
14
20
  'panel.aria': string;
15
21
  'panel.stats': string;
16
22
  'panel.blockPosition': string;
@@ -50,10 +56,14 @@ export declare const zh: {
50
56
  'panel.quickSummon': string;
51
57
  'panel.commentMode': string;
52
58
  'panel.commentModeDesc': string;
59
+ 'panel.discussionRounds': string;
60
+ 'panel.discussionRoundsDesc': string;
53
61
  'panel.quickSummonDesc': string;
54
62
  'panel.recordShortcut': string;
55
63
  'settings.keybindings': string;
56
64
  'settings.keybindingsDesc': string;
65
+ 'settings.comment': string;
66
+ 'settings.commentDesc': string;
57
67
  'settings.diffView': string;
58
68
  'settings.diffViewDesc': string;
59
69
  'settings.diffPreview': string;
@@ -89,14 +99,12 @@ export declare const zh: {
89
99
  'action.discussionExpand': string;
90
100
  'action.discussionCollapse': string;
91
101
  'discussion.placeholder': string;
92
- 'discussion.placeholderOutdated': string;
93
102
  'discussion.marker': string;
94
103
  'discussion.promptRule': string;
95
104
  'discussion.promptRuleSkill': string;
96
105
  'discussion.thinking': string;
97
106
  'discussion.queued': string;
98
- 'discussion.outdated': string;
99
- 'discussion.quote': string;
107
+ 'discussion.outdatedQuote': string;
100
108
  'discussion.hidden': string;
101
109
  'discussion.failed': string;
102
110
  'discussion.stopped': string;
@@ -106,6 +114,7 @@ export declare const zh: {
106
114
  'action.keepAll': string;
107
115
  'action.revertAll': string;
108
116
  'action.openFile': string;
117
+ 'action.pathField': string;
109
118
  'action.revealFile': string;
110
119
  'action.busy': string;
111
120
  'action.prevDiff': string;
@@ -226,6 +235,12 @@ export declare const en: {
226
235
  'panel.readFailed': string;
227
236
  'panel.group.current': string;
228
237
  'panel.group.others': string;
238
+ 'panel.tabs': string;
239
+ 'panel.tab.pending': string;
240
+ 'panel.tab.comments': string;
241
+ 'panel.commentsEmpty': string;
242
+ 'panel.commentOutdated': string;
243
+ 'panel.commentEmptyTitle': string;
229
244
  'panel.aria': string;
230
245
  'panel.stats': string;
231
246
  'panel.blockPosition': string;
@@ -265,10 +280,14 @@ export declare const en: {
265
280
  'panel.quickSummon': string;
266
281
  'panel.commentMode': string;
267
282
  'panel.commentModeDesc': string;
283
+ 'panel.discussionRounds': string;
284
+ 'panel.discussionRoundsDesc': string;
268
285
  'panel.quickSummonDesc': string;
269
286
  'panel.recordShortcut': string;
270
287
  'settings.keybindings': string;
271
288
  'settings.keybindingsDesc': string;
289
+ 'settings.comment': string;
290
+ 'settings.commentDesc': string;
272
291
  'settings.diffView': string;
273
292
  'settings.diffViewDesc': string;
274
293
  'settings.diffPreview': string;
@@ -304,14 +323,12 @@ export declare const en: {
304
323
  'action.discussionExpand': string;
305
324
  'action.discussionCollapse': string;
306
325
  'discussion.placeholder': string;
307
- 'discussion.placeholderOutdated': string;
308
326
  'discussion.marker': string;
309
327
  'discussion.promptRule': string;
310
328
  'discussion.promptRuleSkill': string;
311
329
  'discussion.thinking': string;
312
330
  'discussion.queued': string;
313
- 'discussion.outdated': string;
314
- 'discussion.quote': string;
331
+ 'discussion.outdatedQuote': string;
315
332
  'discussion.hidden': string;
316
333
  'discussion.failed': string;
317
334
  'discussion.stopped': string;
@@ -321,6 +338,7 @@ export declare const en: {
321
338
  'action.keepAll': string;
322
339
  'action.revertAll': string;
323
340
  'action.openFile': string;
341
+ 'action.pathField': string;
324
342
  'action.revealFile': string;
325
343
  'action.busy': string;
326
344
  'action.prevDiff': string;
@@ -14,6 +14,13 @@
14
14
  * @module dsh-diff-approval/client/panel-memory
15
15
  */
16
16
  import type { Discussion } from './discussion.ts';
17
+ /**
18
+ * Fired on `window` whenever a session's threads change. The threads are written by the file
19
+ * detail (which owns them) and read by the list pane (whose comments tab shows them all), so the
20
+ * event is what keeps the two in step: a reader who posts a comment sees it in the list at once
21
+ * instead of on the next poll.
22
+ */
23
+ export declare const COMMENTS_CHANGED_EVENT = "dsh-diff-approval:comments-changed";
17
24
  /**
18
25
  * The comment threads a session's panel is holding.
19
26
  * @param sessionId - the session, if any.
@@ -30,8 +30,11 @@ export interface DiffApprovalPort {
30
30
  refreshVcs(sessionId: SessionId, id: string, includeUntracked: boolean): Promise<DiffApprovalRefreshValue>;
31
31
  /** List one workspace directory level (workspace-relative; `''` is the root). */
32
32
  browse(sessionId: SessionId, path?: string): Promise<DiffApprovalBrowseValue>;
33
- /** Add one named path (a file, or a directory's whole subtree) to the list. */
34
- addPath(sessionId: SessionId, path: string, includeUnchanged: boolean): Promise<DiffApprovalAddValue>;
33
+ /**
34
+ * Add one named path (a file, or a directory's whole subtree) to the list. With `exact`, the
35
+ * path must be one regular file: a directory is refused instead of being scanned.
36
+ */
37
+ addPath(sessionId: SessionId, path: string, includeUnchanged: boolean, exact?: boolean): Promise<DiffApprovalAddValue>;
35
38
  /** Open one file with its default application or reveal it in the folder. */
36
39
  open(sessionId: SessionId, id: string, action: DiffApprovalOpenAction): Promise<DiffApprovalOpenValue>;
37
40
  /** Keep every pending entry of one session in a single host call (one batch). */
@@ -20,6 +20,12 @@ export interface DiffApprovalCover {
20
20
  * composer. */
21
21
  export declare const FLOAT_COVER_DEFAULT: DiffApprovalCover;
22
22
  /** Default lead rows above a jumped-to diff block (kept small and bounded). */
23
+ /** Rounds of a comment thread a block keeps before it hides the older ones. A round is one
24
+ * question and the answer to it. One is the floor (something has to be visible) and the
25
+ * ceiling keeps a long thread from swamping the diff it annotates. */
26
+ export declare const DISCUSSION_ROUNDS_DEFAULT = 2;
27
+ export declare const DISCUSSION_ROUNDS_MIN = 1;
28
+ export declare const DISCUSSION_ROUNDS_MAX = 10;
23
29
  export declare const NAV_LEAD_ROWS_DEFAULT = 2;
24
30
  export declare const NAV_LEAD_ROWS_MIN = 0;
25
31
  export declare const NAV_LEAD_ROWS_MAX = 10;
@@ -227,6 +233,13 @@ export declare function setMdMaxWidth(value: number): void;
227
233
  * @returns the lead row count.
228
234
  */
229
235
  export declare function navLeadRows(): number;
236
+ /**
237
+ * How many rounds of a comment thread the panel keeps.
238
+ * @returns the configured round count, or the default when nothing valid is stored.
239
+ */
240
+ export declare function discussionRoundLimit(): number;
241
+ /** Persist the comment thread's round limit. */
242
+ export declare function setDiscussionRoundLimit(value: number): void;
230
243
  /** Persist the block-jump lead row count. */
231
244
  export declare function setNavLeadRows(value: number): void;
232
245
  /** Default quick-summon chord (toggle the review panel open/closed). */
@@ -90,7 +90,7 @@ export interface PendingPanelFace {
90
90
  /** List one workspace directory level for the add-path dialog. */
91
91
  onBrowse: (sessionId: SessionId, path?: string) => Promise<DiffApprovalBrowseValue>;
92
92
  /** Add one named path (a file, or a directory's whole subtree) to the list. */
93
- onAddPath: (sessionId: SessionId, path: string, includeUnchanged: boolean) => Promise<DiffApprovalAddValue>;
93
+ onAddPath: (sessionId: SessionId, path: string, includeUnchanged: boolean, exact?: boolean) => Promise<DiffApprovalAddValue>;
94
94
  /** Keep every pending entry of one session in a single host call (bulk). */
95
95
  onKeepAll: (sessionId: SessionId) => Promise<void>;
96
96
  /** Revert every pending entry of one session in a single host call (bulk). */
@@ -34,7 +34,7 @@ export interface PendingDiffStore extends HostObservable<PendingDiffSnapshot> {
34
34
  /** List one workspace directory level for the add-path dialog. */
35
35
  browse: (sessionId: SessionId, path?: string) => Promise<DiffApprovalBrowseValue>;
36
36
  /** Add one named path to the list, then refresh; resolves to what the scan did. */
37
- addPath: (sessionId: SessionId, path: string, includeUnchanged: boolean) => Promise<DiffApprovalAddValue>;
37
+ addPath: (sessionId: SessionId, path: string, includeUnchanged: boolean, exact?: boolean) => Promise<DiffApprovalAddValue>;
38
38
  /** Open one file with its default application or reveal it in the folder. */
39
39
  open: (sessionId: SessionId, id: string, action: DiffApprovalOpenAction) => Promise<void>;
40
40
  /** Keep every pending entry of one session in a single host call, then refresh. */
@@ -188,6 +188,11 @@ export type DiffApprovalAddOutcome =
188
188
  | 'empty'
189
189
  /** The path does not exist (or is neither a file nor a directory). */
190
190
  | 'missing'
191
+ /**
192
+ * The caller named one exact file and the path is a directory. Refused before anything is
193
+ * scanned, so a mistyped path cannot add a whole subtree.
194
+ */
195
+ | 'not-a-file'
191
196
  /** The path lies outside the session's workspace. */
192
197
  | 'outside'
193
198
  /** The workspace is not inside a git/svn/p4 checkout. */
@@ -201,6 +206,12 @@ export interface DiffApprovalAddValue {
201
206
  added: number;
202
207
  /** How many scanned paths were already listed (left untouched). */
203
208
  duplicates: number;
209
+ /**
210
+ * The entry a single-file add landed as (the one already listed, for a duplicate). Absent
211
+ * for a directory add, which is about many files. It is what lets a caller that named one
212
+ * path select the file without waiting for the next list poll.
213
+ */
214
+ id?: string | undefined;
204
215
  /** Whether the no-change walk hit its file cap, so some files were not added. */
205
216
  truncated?: boolean | undefined;
206
217
  /** Failure detail for `outcome: 'failed'`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-diff-approval",
3
- "version": "0.24.1",
3
+ "version": "0.26.0",
4
4
  "description": "DeepSeek Harness plugin: pending-edit review with whole-file diff and Keep/Revert",
5
5
  "packageManager": "pnpm@11.21.0",
6
6
  "author": "Wu Zhiwei",