dsh-diff-approval 0.32.0 → 0.33.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/client.js CHANGED
@@ -19810,16 +19810,27 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
19810
19810
  * `[data-produced-files-row] button` 0.1.5's produced-file card `title` = the path
19811
19811
  * `[data-presented-files-row] button` 0.1.7's presented-file card `title` = the path
19812
19812
  * `[data-changed-files] button` 0.1.7's changed-files row `aria-describedby` = the path
19813
- * `button[title]` a message's file LINK (0.1.7) `title` = the path
19814
19813
  * `button[data-ref-chip]` a `@file` chip in a message `title` = the token
19815
- * `a[href]` any other link naming a file `href` = the path
19816
- *
19817
- * Everything else is left entirely alone: a link to a URL, an in-page anchor, a chip naming something
19818
- * that is not a file, a file LINK whose file this panel does not hold, a modified press (Ctrl/Cmd/Shift/
19819
- * Alt, or a middle click — gestures the harness may give its own meaning later), and any press inside this
19820
- * plugin's own surface, whose buttons carry paths too and belong to the panel that is already showing
19821
- * the file. A file-list ROW is the one press taken over whether or not the panel holds its file (see
19822
- * `ROW_SELECTOR`); "we do not hold it" then decides what the menu offers, not whether it appears.
19814
+ * `button._fileLink_…` / `._fileMention_…` a message's file LINK `title` = the path
19815
+ *
19816
+ * A PRESS IS ADMITTED BY THE SHELL'S OWN MARK, NEVER BY THE SHAPE OF ITS TEXT. The link row's classes are
19817
+ * the shell's CSS-module names — measured on the live shell as `_fileMention_1ypvv_85 _fileLink_1ypvv_59`,
19818
+ * carrying `title="src/client/produced-diff.ts"` — and that name survives the hash and line suffix the
19819
+ * module adds per build, so the marker is the `_fileLink_` / `_fileMention_` substring rather than the
19820
+ * whole class.
19821
+ *
19822
+ * This replaced two blanket rules, `button[title]` and every `a[href]`, which admitted a press by how its
19823
+ * text was spelled and were measured wrong twice on 2026-10-09: a model picker's button carries a tooltip
19824
+ * naming the model, and a version-like name (`…v3.2`) ends in the ".extension" the text filter accepted, so
19825
+ * pressing 修改模型 raised this menu over the shell's own control; an in-app route link (`/settings`) holds a
19826
+ * separator and was admitted the same way. A file press is one the shell marks as one.
19827
+ *
19828
+ * Everything else is left entirely alone: a link to a URL, an in-page anchor, a button whose tooltip names
19829
+ * something that is not a file, a file LINK whose file this panel does not hold, a modified press
19830
+ * (Ctrl/Cmd/Shift/Alt, or a middle click — gestures the harness may give its own meaning later), and any
19831
+ * press inside this plugin's own surface, whose buttons carry paths too and belong to the panel that is
19832
+ * already showing the file. A file-list ROW is the one press taken over whether or not the panel holds its
19833
+ * file (see `ROW_SELECTOR`); "we do not hold it" then decides what the menu offers, not whether it appears.
19823
19834
  *
19824
19835
  * The name of the module (and of the event it raises) dates from when the produced-file card was the
19825
19836
  * only press of this kind; `data-presented-files-row` is that same list under 0.1.7's name for it.
@@ -19862,13 +19873,24 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
19862
19873
  *
19863
19874
  * A press this list misses is not a crash — it is one menu the reader does not get.
19864
19875
  */
19876
+ /**
19877
+ * The shell's OWN mark on a file link, as the name part of its CSS-module classes: measured on the live
19878
+ * shell as `class="_fileMention_1ypvv_85 _fileLink_1ypvv_59"` on the `<button title="src/client/
19879
+ * produced-diff.ts">` a message draws for a file. The hash and the line number after the name are per
19880
+ * build, so the marker is the `_name_` substring and not the whole class.
19881
+ *
19882
+ * A CSS-module class name is the shell telling this module what the element IS, which is why this replaced
19883
+ * the text-shape rules (see the module doc). It is a name, not an API: if the shell ever renames or
19884
+ * minifies these classes, this marker stops matching and file links fall back to the shell's own press —
19885
+ * fewer menus, never a menu over the wrong control, which is the direction a wrong guess must fail in.
19886
+ */
19887
+ const MARKED_LINK_SELECTOR = "[class*=\"_fileLink_\"], [class*=\"_fileMention_\"]";
19865
19888
  const PRESS_SELECTOR = [
19866
19889
  "[data-produced-files-row] button",
19867
19890
  "[data-presented-files-row] button",
19868
19891
  "[data-changed-files] button",
19869
- "button[title]",
19870
19892
  "button[data-ref-chip]",
19871
- "a[href]"
19893
+ MARKED_LINK_SELECTOR
19872
19894
  ].join(", ");
19873
19895
  /**
19874
19896
  * A shell press that looks like one of the above and is NOT one: the review pane's file picker.
@@ -19912,41 +19934,65 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
19912
19934
  return stripSuffix(text.replace(/^@/, "").replace(/^"|"$/g, "").trim());
19913
19935
  }
19914
19936
  /**
19915
- * Whether this text names a file, rather than a URL, an in-page anchor or a plain label.
19937
+ * Whether this text is spelled as something that is not a file at all: an in-page anchor, a query, or
19938
+ * another scheme's URL. A Windows drive letter is a file despite the colon it starts with.
19939
+ *
19940
+ * @param value - the candidate text.
19941
+ * @returns whether it is spelled as a non-file.
19942
+ */
19943
+ function isNonFileSpelling(value) {
19944
+ if (value === "" || value.startsWith("#") || value.startsWith("?")) return true;
19945
+ return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) && !/^[A-Za-z]:[\\/]/.test(value);
19946
+ }
19947
+ /**
19948
+ * Whether this text has the SHAPE of a path: a `.extension` tail or a separator.
19916
19949
  *
19917
- * A shell tooltip lives in `title` too, so "the element has a title" is not enough: a URL scheme is not
19918
- * a file (a Windows drive letter is), and a label with neither a separator nor an extension is a label.
19919
- * This is a filter, not a decision — `isPending` is what decides — so it errs towards letting a press
19920
- * through untouched rather than towards taking one over.
19950
+ * Only a press the shell did NOT mark is held to this, because its text is all there is to go on. A marked
19951
+ * press is a file link by the shell's own word, so its value is taken as written and an extension-less path
19952
+ * (`Makefile`) is read as the file it is instead of being dismissed as a label.
19921
19953
  *
19922
19954
  * @param value - the candidate text.
19923
19955
  * @returns whether it could name a file.
19924
19956
  */
19925
19957
  function looksLikePath(value) {
19926
- if (value === "" || value.startsWith("#") || value.startsWith("?")) return false;
19927
- if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) && !/^[A-Za-z]:[\\/]/.test(value)) return false;
19958
+ if (isNonFileSpelling(value)) return false;
19928
19959
  return /\.[A-Za-z0-9]{1,8}$/.test(value) || value.includes("/") || value.includes("\\");
19929
19960
  }
19961
+ /** Whether the shell itself marked this press as one of its file links (see `MARKED_LINK_SELECTOR`). */
19962
+ function isMarkedFileLink(press) {
19963
+ return press.matches(MARKED_LINK_SELECTOR);
19964
+ }
19930
19965
  /**
19931
19966
  * The path a press names, or undefined when it names no file.
19932
19967
  *
19968
+ * Every shape is read from the same three places — `title`, the text its `aria-describedby` points at, an
19969
+ * anchor's `href` — and what differs is the test the value must pass: a press the shell marked must only
19970
+ * not be spelled as a non-file, while one it did not mark must ALSO have the shape of a path.
19971
+ *
19933
19972
  * @param press - the element the reader pressed.
19934
19973
  * @returns the path, as the shell spelled it (the panel matches it against its own spelling).
19935
19974
  */
19936
19975
  function pressPathOf(press) {
19976
+ const marked = isMarkedFileLink(press);
19977
+ const accept = (value) => {
19978
+ const path = pathOfReference(value);
19979
+ if (path === "") return void 0;
19980
+ if (marked) return isNonFileSpelling(path) ? void 0 : path;
19981
+ return looksLikePath(path) ? path : void 0;
19982
+ };
19937
19983
  const title = press.getAttribute("title")?.trim();
19938
19984
  if (title !== void 0) {
19939
- const path = pathOfReference(title);
19940
- if (looksLikePath(path)) return path;
19985
+ const path = accept(title);
19986
+ if (path !== void 0) return path;
19941
19987
  }
19942
19988
  const describedBy = press.getAttribute("aria-describedby");
19943
19989
  if (describedBy !== null) {
19944
- const path = press.ownerDocument.getElementById(describedBy)?.textContent?.trim() ?? "";
19945
- if (looksLikePath(path)) return path;
19990
+ const path = accept(press.ownerDocument.getElementById(describedBy)?.textContent?.trim() ?? "");
19991
+ if (path !== void 0) return path;
19946
19992
  }
19947
19993
  if (press instanceof HTMLAnchorElement) {
19948
- const path = stripSuffix(press.getAttribute("href")?.trim() ?? "");
19949
- if (looksLikePath(path)) return path;
19994
+ const path = accept(stripSuffix(press.getAttribute("href")?.trim() ?? ""));
19995
+ if (path !== void 0) return path;
19950
19996
  }
19951
19997
  }
19952
19998
  /** The press `replayFilePress` is replaying, if any: that press is the shell's, not the menu's. */
@@ -23084,14 +23130,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
23084
23130
  const body = bodyRef.current;
23085
23131
  if (body === null) return;
23086
23132
  if (body.clientHeight <= 0) return;
23087
- const viewTop = body.scrollTop;
23088
- const viewBottom = viewTop + body.clientHeight;
23089
- const pairTop = off(pairIndex);
23090
- const pairBottom = pairTop + ROW_HEIGHT_PX;
23091
- let target;
23092
- if (pairTop < viewTop) target = pairTop;
23093
- else if (pairBottom > viewBottom) target = pairBottom - body.clientHeight;
23094
- if (target === void 0) return;
23133
+ const target = off(pairIndex) + ROW_HEIGHT_PX / 2 - body.clientHeight / 2;
23095
23134
  const clamped = Math.max(0, Math.min(target, body.scrollHeight - body.clientHeight));
23096
23135
  if (body.scrollTop !== clamped) body.scrollTop = clamped;
23097
23136
  setScrollTop(clamped);
@@ -24964,25 +25003,17 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
24964
25003
  applyPreviewFrames();
24965
25004
  };
24966
25005
  /**
24967
- * Bring one preview search occurrence into view. The code view's search rule is
24968
- * reused: a hit that is already fully visible is left alone (a search must not
24969
- * yank the pane off what the user can see), one above the viewport lands with
24970
- * the lead rows above it — the block-jump rule — and one below lands at the
24971
- * bottom edge. The settled offset is mirrored into the frames, as for a jump.
25006
+ * Put one preview search occurrence in the middle of the pane. Its only caller is the search JUMP — the
25007
+ * bar's next / previous, Enter, F3 — and the point of that jump is to read the occurrence it landed on,
25008
+ * which is the rule the code view and the side-by-side view now follow too. The settled offset is
25009
+ * mirrored into the frames, as for a jump.
24972
25010
  * @param hit - the highlighted occurrence to reveal.
24973
25011
  */
24974
25012
  const scrollPreviewHitIntoView = (hit) => {
24975
25013
  const body = mdPreviewBodyRef.current;
24976
25014
  if (body === null) return;
24977
25015
  const bodyRect = body.getBoundingClientRect();
24978
- const hitTop = hit.getBoundingClientRect().top - bodyRect.top + body.scrollTop;
24979
- const hitBottom = hit.getBoundingClientRect().bottom - bodyRect.top + body.scrollTop;
24980
- const viewTop = body.scrollTop;
24981
- const viewBottom = viewTop + body.clientHeight;
24982
- let target;
24983
- if (hitTop - leadRows * ROW_HEIGHT_PX < viewTop) target = hitTop - leadRows * ROW_HEIGHT_PX;
24984
- else if (hitBottom > viewBottom) target = hitBottom - body.clientHeight;
24985
- if (target === void 0) return;
25016
+ const target = (hit.getBoundingClientRect().top - bodyRect.top + body.scrollTop + (hit.getBoundingClientRect().bottom - bodyRect.top + body.scrollTop)) / 2 - body.clientHeight / 2;
24986
25017
  const maxTop = Math.max(0, body.scrollHeight - body.clientHeight);
24987
25018
  const clamped = Math.max(0, Math.min(target, maxTop));
24988
25019
  if (body.scrollTop !== clamped) body.scrollTop = clamped;
@@ -25078,9 +25109,13 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
25078
25109
  }
25079
25110
  if (searchMatches.length === 0) return;
25080
25111
  if (cursorPosRef.current !== void 0) {
25112
+ centerSearchRef.current = true;
25113
+ setSearchJump((jump) => jump + 1);
25081
25114
  setSearchIndex(startIndexFor(searchQuery));
25082
25115
  return;
25083
25116
  }
25117
+ centerSearchRef.current = true;
25118
+ setSearchJump((jump) => jump + 1);
25084
25119
  setSearchIndex((current) => (current + direction + searchMatches.length) % searchMatches.length);
25085
25120
  };
25086
25121
  /**
@@ -25102,6 +25137,11 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
25102
25137
  return at === -1 ? 0 : at;
25103
25138
  };
25104
25139
  const cursorPosRef = (0, react.useRef)(void 0);
25140
+ /** Set by a search JUMP: the next match is centred, where a typed query only brings it into view. */
25141
+ const centerSearchRef = (0, react.useRef)(false);
25142
+ /** Bumped by every JUMP, so the centring effect runs even when the match itself does not change (a single
25143
+ * hit, or a wrap back onto the same row). */
25144
+ const [searchJump, setSearchJump] = (0, react.useState)(0);
25105
25145
  const lastRecordedCursorRef = (0, react.useRef)(void 0);
25106
25146
  const openSearchWithSelection = () => {
25107
25147
  if (searchOpen) {
@@ -25168,18 +25208,25 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
25168
25208
  const body = bodyRef.current;
25169
25209
  if (body === null) return;
25170
25210
  if (body.clientHeight <= 0) return;
25211
+ const centring = centerSearchRef.current;
25212
+ centerSearchRef.current = false;
25171
25213
  const viewTop = body.scrollTop;
25172
25214
  const viewBottom = viewTop + body.clientHeight;
25173
25215
  const rowTop = offsetOf(row);
25174
25216
  const rowBottom = rowTop + extentOf(row, row);
25175
25217
  let target;
25176
- if (rowTop < viewTop) target = rowTop;
25218
+ if (centring) target = (rowTop + rowBottom) / 2 - body.clientHeight / 2;
25219
+ else if (rowTop < viewTop) target = rowTop;
25177
25220
  else if (rowBottom > viewBottom) target = rowBottom - body.clientHeight;
25178
25221
  if (target === void 0) return;
25179
25222
  const clamped = Math.max(0, Math.min(target, body.scrollHeight - body.clientHeight));
25180
25223
  if (body.scrollTop !== clamped) body.scrollTop = clamped;
25181
25224
  setScrollTop(clamped);
25182
- }, [currentSearchRow, searchMatches]);
25225
+ }, [
25226
+ currentSearchRow,
25227
+ searchMatches,
25228
+ searchJump
25229
+ ]);
25183
25230
  const blockIndexByRow = (0, react.useMemo)(() => {
25184
25231
  const map = /* @__PURE__ */ new Map();
25185
25232
  model.blocks.forEach((block, blockIndex) => {
@@ -8,16 +8,27 @@
8
8
  * `[data-produced-files-row] button` 0.1.5's produced-file card `title` = the path
9
9
  * `[data-presented-files-row] button` 0.1.7's presented-file card `title` = the path
10
10
  * `[data-changed-files] button` 0.1.7's changed-files row `aria-describedby` = the path
11
- * `button[title]` a message's file LINK (0.1.7) `title` = the path
12
11
  * `button[data-ref-chip]` a `@file` chip in a message `title` = the token
13
- * `a[href]` any other link naming a file `href` = the path
12
+ * `button._fileLink_…` / `._fileMention_…` a message's file LINK `title` = the path
14
13
  *
15
- * Everything else is left entirely alone: a link to a URL, an in-page anchor, a chip naming something
16
- * that is not a file, a file LINK whose file this panel does not hold, a modified press (Ctrl/Cmd/Shift/
17
- * Alt, or a middle click — gestures the harness may give its own meaning later), and any press inside this
18
- * plugin's own surface, whose buttons carry paths too and belong to the panel that is already showing
19
- * the file. A file-list ROW is the one press taken over whether or not the panel holds its file (see
20
- * `ROW_SELECTOR`); "we do not hold it" then decides what the menu offers, not whether it appears.
14
+ * A PRESS IS ADMITTED BY THE SHELL'S OWN MARK, NEVER BY THE SHAPE OF ITS TEXT. The link row's classes are
15
+ * the shell's CSS-module names — measured on the live shell as `_fileMention_1ypvv_85 _fileLink_1ypvv_59`,
16
+ * carrying `title="src/client/produced-diff.ts"` — and that name survives the hash and line suffix the
17
+ * module adds per build, so the marker is the `_fileLink_` / `_fileMention_` substring rather than the
18
+ * whole class.
19
+ *
20
+ * This replaced two blanket rules, `button[title]` and every `a[href]`, which admitted a press by how its
21
+ * text was spelled and were measured wrong twice on 2026-10-09: a model picker's button carries a tooltip
22
+ * naming the model, and a version-like name (`…v3.2`) ends in the ".extension" the text filter accepted, so
23
+ * pressing 修改模型 raised this menu over the shell's own control; an in-app route link (`/settings`) holds a
24
+ * separator and was admitted the same way. A file press is one the shell marks as one.
25
+ *
26
+ * Everything else is left entirely alone: a link to a URL, an in-page anchor, a button whose tooltip names
27
+ * something that is not a file, a file LINK whose file this panel does not hold, a modified press
28
+ * (Ctrl/Cmd/Shift/Alt, or a middle click — gestures the harness may give its own meaning later), and any
29
+ * press inside this plugin's own surface, whose buttons carry paths too and belong to the panel that is
30
+ * already showing the file. A file-list ROW is the one press taken over whether or not the panel holds its
31
+ * file (see `ROW_SELECTOR`); "we do not hold it" then decides what the menu offers, not whether it appears.
21
32
  *
22
33
  * The name of the module (and of the event it raises) dates from when the produced-file card was the
23
34
  * only press of this kind; `data-presented-files-row` is that same list under 0.1.7's name for it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-diff-approval",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",