dsh-plugin-term-dictionary 1.1.0 → 1.1.1

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/CHANGELOG.md CHANGED
@@ -2,7 +2,40 @@
2
2
 
3
3
  本文件记录**发布出去**的版本。每一项都是可验证的行为,不写计划。
4
4
 
5
- ## 1.1.0 — 未发布
5
+ ## 1.1.1 — 2026-10-11
6
+
7
+ ### 组可以改名、移动、删除
8
+
9
+ - **重命名与移动是同一件事**,因为组就是一条路径:`backend → 服务端` 是改名,把 `net` 从 `backend` 挪到
10
+ `infra` 是移动。组行第二行有 `[✎]`(改名/移动)与 `[🗑]`(删除)。
11
+ - 每个词条都走**单条移动那条带时间戳的决定**(`groupAt`)——批量移动不是"决定"这条规则的例外,所以它
12
+ 同样经得起与宿主的合并。
13
+ - **站在被改名的组里会跟着它走**(留在同一层、路径变了),而不是被扔回顶层。
14
+ - 改到已存在的组名 = **合并两个组**。三种情况明确拒绝:空名、名字没变、把组移到自己里面。
15
+ - **删除是软删除**:组里的词条进「已删除」并留下墓碑(可逐条撤回,也挡住路过的包把它们又放回来)。
16
+ 确认框里**带条数**——"删除这个分组?"与"删除 12 条词条?"是两个不同的提议。
17
+ - 改名表单是**内联**的,不是 `prompt()`:Electron 不实现 `prompt`。
18
+
19
+ ### 组卡片有一行正文
20
+
21
+ - 组行现在是 `[📁] 名字 N 条 ›` 加一行**里面最新的几个词条名**(超过三条接「等 N 条」)。空盒子变成了
22
+ 可读的预览,等高也就有了内容可撑。
23
+
24
+ ### 悬停/点击:段落左侧空白不再误触发
25
+
26
+ - 报告:术语在**行首**时,鼠标在它**正左侧**任意位置(直到聊天区边缘)都会弹出悬浮窗、点击也会进词条。
27
+ - 原因:`caretRangeFromPoint` **横向夹取**——左空白里的点被解析成该行起点(offset 0),而当术语正好在
28
+ 行首时,offset 0 就是它。
29
+ - 修法:量**光标自己**的位置(折叠 Range 的 `getBoundingClientRect`),只有光标真的在该点时才接受
30
+ (容差 8px ≈ 词前空格宽度)。量不到光标时选择信任,而不是拒掉所有悬浮。
31
+
32
+ ### 打包闸门不再崩在退出上(开发侧)
33
+
34
+ - `pnpm publish` 曾报 `ERR_PNPM_EXECUTOR_LIFECYCLE_SCRIPT_FAILED` / `0xc0000409`:闸门**通过了**,却在
35
+ `process.exit()` 时撞上 libuv 的 `UV_HANDLE_CLOSING` 断言(fetch 的 keep-alive socket)。现在先关
36
+ dispatcher、再用 `process.exitCode` 让事件循环自然排空;绿路径退 0、红路径退 1,两条都验过。
37
+
38
+ ## 1.1.0 — 2026-10-10
6
39
 
7
40
  ### 词条组(目录)
8
41
 
package/lib/client.js CHANGED
@@ -584,6 +584,19 @@ window.__ModuleLoader__.load({
584
584
  groupGo: "回到「{name}」",
585
585
  groupCount: "{count} 条",
586
586
  groupEmpty: "这一层只有分组,还没有词条。",
587
+ groupSampleJoin: "、",
588
+ groupSampleMore: " 等 {count} 条",
589
+ groupRename: "重命名或移动",
590
+ groupRenameTo: "改到:",
591
+ groupRenameApply: "改名 / 移动",
592
+ groupDelete: "删除分组",
593
+ groupRenamed: "已移动 {count} 条词条。",
594
+ groupDeleted: "已删除 {count} 条词条,它们都在「已删除」里,可以撤回。",
595
+ groupDeleteConfirm: "删除分组「{name}」里的 {count} 条词条?它们会进入「已删除」,之后可以逐条撤回。",
596
+ "groupError_no-group": "那个分组不存在。",
597
+ "groupError_no-name": "分组名不能为空。",
598
+ "groupError_same": "新旧名字一样,没有需要动的地方。",
599
+ "groupError_into-itself": "不能把分组移动到它自己里面。",
587
600
  groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
588
601
  termPlaceholder: "例如 event sourcing",
589
602
  glossPlaceholder: "用一两句话说明它的含义",
@@ -886,6 +899,19 @@ window.__ModuleLoader__.load({
886
899
  groupGo: "Go back to {name}",
887
900
  groupCount: "{count} entries",
888
901
  groupEmpty: "This level holds only groups so far.",
902
+ groupSampleJoin: ", ",
903
+ groupSampleMore: " and {count} more",
904
+ groupRename: "Rename or move",
905
+ groupRenameTo: "Move to:",
906
+ groupRenameApply: "Rename / move",
907
+ groupDelete: "Delete group",
908
+ groupRenamed: "Moved {count} entries.",
909
+ groupDeleted: "Deleted {count} entries. They are on the deleted list and can be restored one by one.",
910
+ groupDeleteConfirm: "Delete the {count} entries in “{name}”? They go to the deleted list, where each can be restored.",
911
+ "groupError_no-group": "There is no such group.",
912
+ "groupError_no-name": "A group needs a name.",
913
+ "groupError_same": "The name is unchanged, so there is nothing to move.",
914
+ "groupError_into-itself": "A group cannot be moved inside itself.",
889
915
  groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
890
916
  termPlaceholder: "e.g. event sourcing",
891
917
  glossPlaceholder: "Say what it means in one or two sentences",
@@ -2388,6 +2414,73 @@ window.__ModuleLoader__.load({
2388
2414
  return { total: live.length, explained, unexplained: live.length - explained, pinned };
2389
2415
  }
2390
2416
 
2417
+ /**
2418
+ * Move a group — and everything under it — to another path.
2419
+ *
2420
+ * Renaming and moving are the same act, because a group IS a path: renaming `backend` to `服务端` and moving
2421
+ * `net` out of `backend` into `infra` are both "these entries live somewhere else now". One primitive, two
2422
+ * names in the UI, so the two cannot drift apart.
2423
+ *
2424
+ * Each entry is moved through {@link editEntry}, so each carries the stamped decision (`groupAt`) a merge
2425
+ * weighs — a bulk move is not an exception to "a move is a decision".
2426
+ *
2427
+ * Refused: an empty target, a target inside the group being moved (which would put it inside itself), and a
2428
+ * target that leaves everything where it is. Renaming ONTO an existing group is allowed and merges the two,
2429
+ * which is what a reader asking for it means.
2430
+ *
2431
+ * @param state - the current document.
2432
+ * @param from - the path to move.
2433
+ * @param to - the path to move it to.
2434
+ * @param options - `now` overrides the timestamps.
2435
+ * @returns `{ state, moved, error }`.
2436
+ */
2437
+ function renameGroup(state, from, to, options) {
2438
+ const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
2439
+ const target = typeof to === "string" ? to.replace(/^\/+|\/+$/g, "") : "";
2440
+ if (source === "") return { state, moved: 0, error: "no-group" };
2441
+ if (target === "") return { state, moved: 0, error: "no-name" };
2442
+ if (target === source) return { state, moved: 0, error: "same" };
2443
+ if (target.startsWith(`${source}/`)) return { state, moved: 0, error: "into-itself" };
2444
+ const now = options?.now ?? Date.now();
2445
+ let next = state;
2446
+ let moved = 0;
2447
+ let stamp = now;
2448
+ for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
2449
+ stamp += 1;
2450
+ const rest = entry.group === source ? "" : entry.group.slice(source.length + 1);
2451
+ next = editEntry(next, entry.term, { group: rest === "" ? target : `${target}/${rest}` }, { now: stamp }).state;
2452
+ moved += 1;
2453
+ }
2454
+ return { state: next, moved, error: null };
2455
+ }
2456
+
2457
+ /**
2458
+ * Delete a group: every entry under it goes to the deleted list.
2459
+ *
2460
+ * Soft, like every other deletion here — the tombstones make it reversible from 「已删除」, and they are also
2461
+ * what stops a passing pack from putting the terms straight back. A group therefore disappears when the last
2462
+ * entry in it does, which is the same rule that made it appear.
2463
+ *
2464
+ * @param state - the current document.
2465
+ * @param from - the path to empty.
2466
+ * @param options - `now` overrides the timestamps.
2467
+ * @returns `{ state, removed, error }`.
2468
+ */
2469
+ function deleteGroup(state, from, options) {
2470
+ const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
2471
+ if (source === "") return { state, removed: 0, error: "no-group" };
2472
+ const now = options?.now ?? Date.now();
2473
+ let next = state;
2474
+ let removed = 0;
2475
+ let stamp = now;
2476
+ for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
2477
+ stamp += 1;
2478
+ next = removeEntry(next, entry.id, { now: stamp }).state;
2479
+ removed += 1;
2480
+ }
2481
+ return { state: next, removed, error: null };
2482
+ }
2483
+
2391
2484
  /**
2392
2485
  * The groups immediately inside one path, with how many entries each holds.
2393
2486
  *
@@ -2395,14 +2488,32 @@ window.__ModuleLoader__.load({
2395
2488
  * that child, however deep. A group exists as long as something is in it — there is no separate list of
2396
2489
  * empty groups to keep in step with the entries, which is the trade this makes deliberately.
2397
2490
  *
2491
+ * Each group also carries a SAMPLE of the terms inside it. A folder row with only a name and a count is a
2492
+ * row of empty space next to entries full of text — and a preview answers the question the row raises
2493
+ * without making the reader open it to find out.
2494
+ *
2398
2495
  * @param entries - every live entry.
2399
2496
  * @param prefix - the path to look inside, `""` for the top level.
2400
- * @returns `[{ group, name, count }]`, alphabetical.
2497
+ * @returns `[{ group, name, count, sample }]`, alphabetical.
2401
2498
  */
2499
+ /**
2500
+ * How many terms a group's preview names before it stops.
2501
+ *
2502
+ * Three: enough to say what kind of thing is in there, few enough that the line stays one line in a
2503
+ * multi-column layout.
2504
+ */
2505
+ const GROUP_SAMPLE = 3;
2506
+
2402
2507
  function groupsIn(entries, prefix) {
2403
2508
  const base = prefix === undefined || prefix === null ? "" : String(prefix);
2404
2509
  const counts = new Map();
2405
- for (const entry of Array.isArray(entries) ? entries : []) {
2510
+ const samples = new Map();
2511
+ // Newest first, the order the list itself uses, so the preview is the top of what you would see inside.
2512
+ const ordered = (Array.isArray(entries) ? entries : [])
2513
+ .filter((entry) => (entry?.deletedAt ?? 0) === 0 && typeof entry?.term === "string" && entry.term !== "")
2514
+ .slice()
2515
+ .sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
2516
+ for (const entry of ordered) {
2406
2517
  if ((entry?.deletedAt ?? 0) !== 0) continue;
2407
2518
  const group = typeof entry?.group === "string" ? entry.group : "";
2408
2519
  if (group === "" || group === base) continue;
@@ -2412,9 +2523,12 @@ window.__ModuleLoader__.load({
2412
2523
  if (inside === null || inside === "") continue;
2413
2524
  const name = inside.split("/")[0];
2414
2525
  counts.set(name, (counts.get(name) ?? 0) + 1);
2526
+ const sample = samples.get(name) ?? [];
2527
+ if (sample.length < GROUP_SAMPLE) sample.push(entry.term);
2528
+ samples.set(name, sample);
2415
2529
  }
2416
2530
  return [...counts.entries()]
2417
- .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count }))
2531
+ .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count, sample: samples.get(name) ?? [] }))
2418
2532
  .sort((left, right) => left.name.localeCompare(right.name));
2419
2533
  }
2420
2534
 
@@ -2593,6 +2707,8 @@ window.__ModuleLoader__.load({
2593
2707
  upsertEntry,
2594
2708
  editEntry,
2595
2709
  restoreEntry,
2710
+ renameGroup,
2711
+ deleteGroup,
2596
2712
  recordSighting,
2597
2713
  removeEntry,
2598
2714
  reviveEntry,
@@ -4496,6 +4612,29 @@ window.__ModuleLoader__.load({
4496
4612
  return result.entry;
4497
4613
  },
4498
4614
 
4615
+ /**
4616
+ * Move a group, and everything under it, to another path.
4617
+ * @param from - the path to move.
4618
+ * @param to - the path to move it to.
4619
+ * @returns a promise resolving to `{ moved, error }`.
4620
+ */
4621
+ async renameGroup(from, to) {
4622
+ const result = dictionary.renameGroup(state, from, to, { now: now() });
4623
+ if (result.moved > 0) await commit(result.state);
4624
+ return { moved: result.moved, error: result.error };
4625
+ },
4626
+
4627
+ /**
4628
+ * Delete a group by deleting everything inside it, which is the only way a group can be deleted.
4629
+ * @param from - the path to empty.
4630
+ * @returns a promise resolving to `{ removed, error }`.
4631
+ */
4632
+ async deleteGroup(from) {
4633
+ const result = dictionary.deleteGroup(state, from, { now: now() });
4634
+ if (result.removed > 0) await commit(result.state);
4635
+ return { removed: result.removed, error: result.error };
4636
+ },
4637
+
4499
4638
  /**
4500
4639
  * Bring a deleted term back, as an explicit act.
4501
4640
  *
@@ -8401,6 +8540,45 @@ window.__ModuleLoader__.load({
8401
8540
  return null;
8402
8541
  }
8403
8542
 
8543
+ /**
8544
+ * How far left of the caret a pointer may still count as being on the text.
8545
+ *
8546
+ * The space before a word is enough: landing in it resolves the caret to the word's own start, and a
8547
+ * reader aiming at the first letter often lands there. A paragraph's left margin is tens of pixels, which
8548
+ * is what this has to reject.
8549
+ */
8550
+ const LEFT_TOLERANCE_PX = 8;
8551
+
8552
+ /**
8553
+ * Whether the caret a point resolved to actually sits at that point.
8554
+ *
8555
+ * `caretRangeFromPoint` CLAMPS horizontally: a point in the left margin of a line resolves to the line's
8556
+ * start, offset 0. When a term begins that line, offset 0 IS that term — so every point from the panel's
8557
+ * left edge to the first character read as "on the term", and the card opened from anywhere in the margin,
8558
+ * with a click to match. That is how this was reported.
8559
+ *
8560
+ * The caret's own rectangle is the live proof of where the text is: it is measured from the document at
8561
+ * this instant, so unlike a block snapshot it cannot go stale. One measurement per event, and only once a
8562
+ * caret has been found at all.
8563
+ *
8564
+ * @param x - viewport x of the pointer.
8565
+ * @param caret - the Range the point resolved to.
8566
+ * @returns true when the caret is at the pointer rather than clamped to the start of a line.
8567
+ */
8568
+ function caretIsAt(x, caret) {
8569
+ let rect = null;
8570
+ try {
8571
+ rect = typeof caret?.getBoundingClientRect === "function" ? caret.getBoundingClientRect() : null;
8572
+ } catch (error) {
8573
+ rect = null;
8574
+ }
8575
+ // A browser (or a fixture) that cannot answer is trusted: refusing every hover because one optional
8576
+ // measurement is missing would be a worse failure than the one being fixed. An all-zero rectangle says
8577
+ // the same thing in another shape — a detached or not-yet-laid-out range.
8578
+ if (rect === null || (rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0)) return true;
8579
+ return rect.left <= x + LEFT_TOLERANCE_PX;
8580
+ }
8581
+
8404
8582
  /**
8405
8583
  * The marked occurrence under a point.
8406
8584
  *
@@ -8415,6 +8593,7 @@ window.__ModuleLoader__.load({
8415
8593
  function matchAt(x, y) {
8416
8594
  const caret = caretAt(x, y);
8417
8595
  if (caret === null) return null;
8596
+ if (!caretIsAt(x, caret)) return null;
8418
8597
  const at = resolveCaret(caret, doc);
8419
8598
  if (at === null) return null;
8420
8599
  let best = null;
@@ -9175,6 +9354,8 @@ window.__ModuleLoader__.load({
9175
9354
  /** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
9176
9355
  groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
9177
9356
  groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
9357
+ /** One line saying what is inside a group, so the card is not a name over a blank space. */
9358
+ groupPreview: { margin: 0, fontSize: "12px", lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
9178
9359
  /**
9179
9360
  * The row's action toolbar: the second line, always.
9180
9361
  *
@@ -11243,6 +11424,13 @@ window.__ModuleLoader__.load({
11243
11424
  * because you looked at it — so it is panel state, next to the search box and the tab.
11244
11425
  */
11245
11426
  const [group, setGroup] = React.useState("");
11427
+ /**
11428
+ * The group being renamed or moved, as `{ group, value }`, or null.
11429
+ *
11430
+ * An inline form rather than `prompt()`: Electron does not implement `prompt`, so a rename dialog
11431
+ * built on it would do nothing at all in the app this plugin runs in.
11432
+ */
11433
+ const [renaming, setRenaming] = React.useState(null);
11246
11434
  const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
11247
11435
  /**
11248
11436
  * The groups directly inside the current one, each with how many entries it holds.
@@ -11285,6 +11473,43 @@ window.__ModuleLoader__.load({
11285
11473
  return moved;
11286
11474
  };
11287
11475
 
11476
+ /**
11477
+ * Rename or move a group, and follow it if the panel was standing inside it.
11478
+ *
11479
+ * Following is the point: a reader who renames the folder they are in must still be looking at it
11480
+ * afterwards, not dropped back at the top wondering where it went.
11481
+ */
11482
+ const commitRename = async () => {
11483
+ if (renaming === null) return;
11484
+ const result = await store.renameGroup(renaming.group, renaming.value);
11485
+ if (result.error !== null) {
11486
+ props.setToast?.(t(`groupError_${result.error}`));
11487
+ return;
11488
+ }
11489
+ const target = renaming.value.replace(/^\/+|\/+$/g, "");
11490
+ if (group === renaming.group || group.startsWith(`${renaming.group}/`)) {
11491
+ setGroup(group === renaming.group ? target : `${target}${group.slice(renaming.group.length)}`);
11492
+ }
11493
+ setRenaming(null);
11494
+ props.setToast?.(t("groupRenamed", { count: String(result.moved) }));
11495
+ };
11496
+
11497
+ /**
11498
+ * Delete a group by deleting what is in it, after saying how much that is.
11499
+ *
11500
+ * The entries go to 「已删除」 rather than away, so this is reversible — and the count is in the
11501
+ * question, because "delete this group?" and "delete 12 entries?" are different offers.
11502
+ */
11503
+ const removeGroup = async (sub) => {
11504
+ if (globalThis.confirm?.(t("groupDeleteConfirm", { name: sub.name, count: String(sub.count) })) !== true) return;
11505
+ const result = await store.deleteGroup(sub.group);
11506
+ if (result.error !== null) {
11507
+ props.setToast?.(t(`groupError_${result.error}`));
11508
+ return;
11509
+ }
11510
+ props.setToast?.(t("groupDeleted", { count: String(result.removed) }));
11511
+ };
11512
+
11288
11513
  /** Open the editor for a blank entry, prefilled from an optional term. */
11289
11514
  const startCreate = (term = "", context = "") => {
11290
11515
  setEditing({
@@ -11818,6 +12043,24 @@ window.__ModuleLoader__.load({
11818
12043
  // row. A breadcrumb says where you are and is the way back up, because a directory you can only
11819
12044
  // enter is a trap.
11820
12045
  [
12046
+ renaming === null ? null : h(
12047
+ "div",
12048
+ { style: { ...applyStyles.transferRow, gridColumn: "1 / -1" }, "data-term-group-form": renaming.group },
12049
+ h("span", { style: applyStyles.fieldLabel }, t("groupRenameTo")),
12050
+ h("input", {
12051
+ type: "text",
12052
+ value: renaming.value,
12053
+ "aria-label": t("groupRename"),
12054
+ onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
12055
+ onKeyDown: (event) => {
12056
+ if (event.key === "Enter") void commitRename();
12057
+ if (event.key === "Escape") setRenaming(null);
12058
+ },
12059
+ style: { ...applyStyles.input, maxWidth: "18em" }
12060
+ }),
12061
+ h(Button, { label: t("groupRenameApply"), onClick: () => void commitRename() }, t("groupRenameApply")),
12062
+ h(Button, { label: t("cancel"), onClick: () => setRenaming(null) }, t("cancel"))
12063
+ ),
11821
12064
  group === "" ? null : h(
11822
12065
  "div",
11823
12066
  { style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
@@ -11847,16 +12090,19 @@ window.__ModuleLoader__.load({
11847
12090
  "article",
11848
12091
  {
11849
12092
  key: `group:${sub.group}`,
11850
- // Its own height, not the track's: a group has no body text, so stretching it to match a
11851
- // three-line entry would leave an empty box the size of the entry — which is what it looked
11852
- // like. Equal KIND of row is the point; equal height only reads as equal among rows with text.
11853
- style: { ...applyStyles.row, alignSelf: "start" },
12093
+ // It fills its row again, now that the height carries something: with the preview below, the
12094
+ // space that used to be emptiness is the first thing you would see on opening it.
12095
+ style: applyStyles.row,
11854
12096
  "data-term-group": sub.group,
11855
12097
  // The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
11856
12098
  // Only the label being live is what "the group cannot be clicked into" meant: a reader
11857
12099
  // clicks the row, the same gesture that works one line below, and nothing happened.
11858
12100
  // The label stays a button so it is reachable by keyboard and named for a screen reader.
11859
- onClick: () => setGroup(sub.group)
12101
+ onClick: (event) => {
12102
+ // The buttons inside keep their own meaning, exactly as they do on an entry row.
12103
+ if (typeof event?.target?.closest === "function" && event.target.closest("button") !== null) return;
12104
+ setGroup(sub.group);
12105
+ }
11860
12106
  },
11861
12107
  h("div", { style: applyStyles.rowHead },
11862
12108
  h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
@@ -11869,6 +12115,24 @@ window.__ModuleLoader__.load({
11869
12115
  h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
11870
12116
  h("span", { style: applyStyles.spacer }),
11871
12117
  h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
12118
+ ),
12119
+ // The same shape an entry row has: a title line, a line of actions, then the body.
12120
+ h(
12121
+ "div",
12122
+ { style: applyStyles.rowActions, "data-term-group-actions": sub.group },
12123
+ h(IconButton, { title: t("groupRename"), onClick: () => setRenaming({ group: sub.group, value: sub.group }) }, Icons.edit(13)),
12124
+ h(IconButton, { title: t("groupDelete"), onClick: () => void removeGroup(sub) }, Icons.trash(13))
12125
+ ),
12126
+ // What is inside, in one line: the row stops being a name over a blank card.
12127
+ h(
12128
+ "p",
12129
+ { style: applyStyles.groupPreview, "data-term-group-sample": sub.group },
12130
+ [
12131
+ sub.sample.join(t("groupSampleJoin")),
12132
+ sub.count > sub.sample.length ? t("groupSampleMore", { count: String(sub.count - sub.sample.length) }) : ""
12133
+ ]
12134
+ .filter((part) => part !== "")
12135
+ .join("")
11872
12136
  )
11873
12137
  )
11874
12138
  ),
package/lib/core/copy.js CHANGED
@@ -238,6 +238,19 @@ const zh = {
238
238
  groupGo: "回到「{name}」",
239
239
  groupCount: "{count} 条",
240
240
  groupEmpty: "这一层只有分组,还没有词条。",
241
+ groupSampleJoin: "、",
242
+ groupSampleMore: " 等 {count} 条",
243
+ groupRename: "重命名或移动",
244
+ groupRenameTo: "改到:",
245
+ groupRenameApply: "改名 / 移动",
246
+ groupDelete: "删除分组",
247
+ groupRenamed: "已移动 {count} 条词条。",
248
+ groupDeleted: "已删除 {count} 条词条,它们都在「已删除」里,可以撤回。",
249
+ groupDeleteConfirm: "删除分组「{name}」里的 {count} 条词条?它们会进入「已删除」,之后可以逐条撤回。",
250
+ "groupError_no-group": "那个分组不存在。",
251
+ "groupError_no-name": "分组名不能为空。",
252
+ "groupError_same": "新旧名字一样,没有需要动的地方。",
253
+ "groupError_into-itself": "不能把分组移动到它自己里面。",
241
254
  groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
242
255
  termPlaceholder: "例如 event sourcing",
243
256
  glossPlaceholder: "用一两句话说明它的含义",
@@ -540,6 +553,19 @@ const en = {
540
553
  groupGo: "Go back to {name}",
541
554
  groupCount: "{count} entries",
542
555
  groupEmpty: "This level holds only groups so far.",
556
+ groupSampleJoin: ", ",
557
+ groupSampleMore: " and {count} more",
558
+ groupRename: "Rename or move",
559
+ groupRenameTo: "Move to:",
560
+ groupRenameApply: "Rename / move",
561
+ groupDelete: "Delete group",
562
+ groupRenamed: "Moved {count} entries.",
563
+ groupDeleted: "Deleted {count} entries. They are on the deleted list and can be restored one by one.",
564
+ groupDeleteConfirm: "Delete the {count} entries in “{name}”? They go to the deleted list, where each can be restored.",
565
+ "groupError_no-group": "There is no such group.",
566
+ "groupError_no-name": "A group needs a name.",
567
+ "groupError_same": "The name is unchanged, so there is nothing to move.",
568
+ "groupError_into-itself": "A group cannot be moved inside itself.",
543
569
  groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
544
570
  termPlaceholder: "e.g. event sourcing",
545
571
  glossPlaceholder: "Say what it means in one or two sentences",
@@ -966,6 +966,73 @@ function summarize(state) {
966
966
  return { total: live.length, explained, unexplained: live.length - explained, pinned };
967
967
  }
968
968
 
969
+ /**
970
+ * Move a group — and everything under it — to another path.
971
+ *
972
+ * Renaming and moving are the same act, because a group IS a path: renaming `backend` to `服务端` and moving
973
+ * `net` out of `backend` into `infra` are both "these entries live somewhere else now". One primitive, two
974
+ * names in the UI, so the two cannot drift apart.
975
+ *
976
+ * Each entry is moved through {@link editEntry}, so each carries the stamped decision (`groupAt`) a merge
977
+ * weighs — a bulk move is not an exception to "a move is a decision".
978
+ *
979
+ * Refused: an empty target, a target inside the group being moved (which would put it inside itself), and a
980
+ * target that leaves everything where it is. Renaming ONTO an existing group is allowed and merges the two,
981
+ * which is what a reader asking for it means.
982
+ *
983
+ * @param state - the current document.
984
+ * @param from - the path to move.
985
+ * @param to - the path to move it to.
986
+ * @param options - `now` overrides the timestamps.
987
+ * @returns `{ state, moved, error }`.
988
+ */
989
+ function renameGroup(state, from, to, options) {
990
+ const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
991
+ const target = typeof to === "string" ? to.replace(/^\/+|\/+$/g, "") : "";
992
+ if (source === "") return { state, moved: 0, error: "no-group" };
993
+ if (target === "") return { state, moved: 0, error: "no-name" };
994
+ if (target === source) return { state, moved: 0, error: "same" };
995
+ if (target.startsWith(`${source}/`)) return { state, moved: 0, error: "into-itself" };
996
+ const now = options?.now ?? Date.now();
997
+ let next = state;
998
+ let moved = 0;
999
+ let stamp = now;
1000
+ for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
1001
+ stamp += 1;
1002
+ const rest = entry.group === source ? "" : entry.group.slice(source.length + 1);
1003
+ next = editEntry(next, entry.term, { group: rest === "" ? target : `${target}/${rest}` }, { now: stamp }).state;
1004
+ moved += 1;
1005
+ }
1006
+ return { state: next, moved, error: null };
1007
+ }
1008
+
1009
+ /**
1010
+ * Delete a group: every entry under it goes to the deleted list.
1011
+ *
1012
+ * Soft, like every other deletion here — the tombstones make it reversible from 「已删除」, and they are also
1013
+ * what stops a passing pack from putting the terms straight back. A group therefore disappears when the last
1014
+ * entry in it does, which is the same rule that made it appear.
1015
+ *
1016
+ * @param state - the current document.
1017
+ * @param from - the path to empty.
1018
+ * @param options - `now` overrides the timestamps.
1019
+ * @returns `{ state, removed, error }`.
1020
+ */
1021
+ function deleteGroup(state, from, options) {
1022
+ const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
1023
+ if (source === "") return { state, removed: 0, error: "no-group" };
1024
+ const now = options?.now ?? Date.now();
1025
+ let next = state;
1026
+ let removed = 0;
1027
+ let stamp = now;
1028
+ for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
1029
+ stamp += 1;
1030
+ next = removeEntry(next, entry.id, { now: stamp }).state;
1031
+ removed += 1;
1032
+ }
1033
+ return { state: next, removed, error: null };
1034
+ }
1035
+
969
1036
  /**
970
1037
  * The groups immediately inside one path, with how many entries each holds.
971
1038
  *
@@ -973,14 +1040,32 @@ function summarize(state) {
973
1040
  * that child, however deep. A group exists as long as something is in it — there is no separate list of
974
1041
  * empty groups to keep in step with the entries, which is the trade this makes deliberately.
975
1042
  *
1043
+ * Each group also carries a SAMPLE of the terms inside it. A folder row with only a name and a count is a
1044
+ * row of empty space next to entries full of text — and a preview answers the question the row raises
1045
+ * without making the reader open it to find out.
1046
+ *
976
1047
  * @param entries - every live entry.
977
1048
  * @param prefix - the path to look inside, `""` for the top level.
978
- * @returns `[{ group, name, count }]`, alphabetical.
1049
+ * @returns `[{ group, name, count, sample }]`, alphabetical.
1050
+ */
1051
+ /**
1052
+ * How many terms a group's preview names before it stops.
1053
+ *
1054
+ * Three: enough to say what kind of thing is in there, few enough that the line stays one line in a
1055
+ * multi-column layout.
979
1056
  */
1057
+ const GROUP_SAMPLE = 3;
1058
+
980
1059
  function groupsIn(entries, prefix) {
981
1060
  const base = prefix === undefined || prefix === null ? "" : String(prefix);
982
1061
  const counts = new Map();
983
- for (const entry of Array.isArray(entries) ? entries : []) {
1062
+ const samples = new Map();
1063
+ // Newest first, the order the list itself uses, so the preview is the top of what you would see inside.
1064
+ const ordered = (Array.isArray(entries) ? entries : [])
1065
+ .filter((entry) => (entry?.deletedAt ?? 0) === 0 && typeof entry?.term === "string" && entry.term !== "")
1066
+ .slice()
1067
+ .sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
1068
+ for (const entry of ordered) {
984
1069
  if ((entry?.deletedAt ?? 0) !== 0) continue;
985
1070
  const group = typeof entry?.group === "string" ? entry.group : "";
986
1071
  if (group === "" || group === base) continue;
@@ -990,9 +1075,12 @@ function groupsIn(entries, prefix) {
990
1075
  if (inside === null || inside === "") continue;
991
1076
  const name = inside.split("/")[0];
992
1077
  counts.set(name, (counts.get(name) ?? 0) + 1);
1078
+ const sample = samples.get(name) ?? [];
1079
+ if (sample.length < GROUP_SAMPLE) sample.push(entry.term);
1080
+ samples.set(name, sample);
993
1081
  }
994
1082
  return [...counts.entries()]
995
- .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count }))
1083
+ .map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count, sample: samples.get(name) ?? [] }))
996
1084
  .sort((left, right) => left.name.localeCompare(right.name));
997
1085
  }
998
1086
 
@@ -1171,6 +1259,8 @@ module.exports = {
1171
1259
  upsertEntry,
1172
1260
  editEntry,
1173
1261
  restoreEntry,
1262
+ renameGroup,
1263
+ deleteGroup,
1174
1264
  recordSighting,
1175
1265
  removeEntry,
1176
1266
  reviveEntry,
package/lib/core/hover.js CHANGED
@@ -199,6 +199,45 @@ function createHoverLayer(options) {
199
199
  return null;
200
200
  }
201
201
 
202
+ /**
203
+ * How far left of the caret a pointer may still count as being on the text.
204
+ *
205
+ * The space before a word is enough: landing in it resolves the caret to the word's own start, and a
206
+ * reader aiming at the first letter often lands there. A paragraph's left margin is tens of pixels, which
207
+ * is what this has to reject.
208
+ */
209
+ const LEFT_TOLERANCE_PX = 8;
210
+
211
+ /**
212
+ * Whether the caret a point resolved to actually sits at that point.
213
+ *
214
+ * `caretRangeFromPoint` CLAMPS horizontally: a point in the left margin of a line resolves to the line's
215
+ * start, offset 0. When a term begins that line, offset 0 IS that term — so every point from the panel's
216
+ * left edge to the first character read as "on the term", and the card opened from anywhere in the margin,
217
+ * with a click to match. That is how this was reported.
218
+ *
219
+ * The caret's own rectangle is the live proof of where the text is: it is measured from the document at
220
+ * this instant, so unlike a block snapshot it cannot go stale. One measurement per event, and only once a
221
+ * caret has been found at all.
222
+ *
223
+ * @param x - viewport x of the pointer.
224
+ * @param caret - the Range the point resolved to.
225
+ * @returns true when the caret is at the pointer rather than clamped to the start of a line.
226
+ */
227
+ function caretIsAt(x, caret) {
228
+ let rect = null;
229
+ try {
230
+ rect = typeof caret?.getBoundingClientRect === "function" ? caret.getBoundingClientRect() : null;
231
+ } catch (error) {
232
+ rect = null;
233
+ }
234
+ // A browser (or a fixture) that cannot answer is trusted: refusing every hover because one optional
235
+ // measurement is missing would be a worse failure than the one being fixed. An all-zero rectangle says
236
+ // the same thing in another shape — a detached or not-yet-laid-out range.
237
+ if (rect === null || (rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0)) return true;
238
+ return rect.left <= x + LEFT_TOLERANCE_PX;
239
+ }
240
+
202
241
  /**
203
242
  * The marked occurrence under a point.
204
243
  *
@@ -213,6 +252,7 @@ function createHoverLayer(options) {
213
252
  function matchAt(x, y) {
214
253
  const caret = caretAt(x, y);
215
254
  if (caret === null) return null;
255
+ if (!caretIsAt(x, caret)) return null;
216
256
  const at = resolveCaret(caret, doc);
217
257
  if (at === null) return null;
218
258
  let best = null;
package/lib/core/store.js CHANGED
@@ -201,6 +201,29 @@ function createDictionaryStore(options) {
201
201
  return result.entry;
202
202
  },
203
203
 
204
+ /**
205
+ * Move a group, and everything under it, to another path.
206
+ * @param from - the path to move.
207
+ * @param to - the path to move it to.
208
+ * @returns a promise resolving to `{ moved, error }`.
209
+ */
210
+ async renameGroup(from, to) {
211
+ const result = dictionary.renameGroup(state, from, to, { now: now() });
212
+ if (result.moved > 0) await commit(result.state);
213
+ return { moved: result.moved, error: result.error };
214
+ },
215
+
216
+ /**
217
+ * Delete a group by deleting everything inside it, which is the only way a group can be deleted.
218
+ * @param from - the path to empty.
219
+ * @returns a promise resolving to `{ removed, error }`.
220
+ */
221
+ async deleteGroup(from) {
222
+ const result = dictionary.deleteGroup(state, from, { now: now() });
223
+ if (result.removed > 0) await commit(result.state);
224
+ return { removed: result.removed, error: result.error };
225
+ },
226
+
204
227
  /**
205
228
  * Bring a deleted term back, as an explicit act.
206
229
  *
@@ -133,6 +133,8 @@ const applyStyles = {
133
133
  /** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
134
134
  groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
135
135
  groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
136
+ /** One line saying what is inside a group, so the card is not a name over a blank space. */
137
+ groupPreview: { margin: 0, fontSize: "12px", lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
136
138
  /**
137
139
  * The row's action toolbar: the second line, always.
138
140
  *
package/lib/core/views.js CHANGED
@@ -1623,6 +1623,13 @@ function DictionaryPanel(props) {
1623
1623
  * because you looked at it — so it is panel state, next to the search box and the tab.
1624
1624
  */
1625
1625
  const [group, setGroup] = React.useState("");
1626
+ /**
1627
+ * The group being renamed or moved, as `{ group, value }`, or null.
1628
+ *
1629
+ * An inline form rather than `prompt()`: Electron does not implement `prompt`, so a rename dialog
1630
+ * built on it would do nothing at all in the app this plugin runs in.
1631
+ */
1632
+ const [renaming, setRenaming] = React.useState(null);
1626
1633
  const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
1627
1634
  /**
1628
1635
  * The groups directly inside the current one, each with how many entries it holds.
@@ -1665,6 +1672,43 @@ function DictionaryPanel(props) {
1665
1672
  return moved;
1666
1673
  };
1667
1674
 
1675
+ /**
1676
+ * Rename or move a group, and follow it if the panel was standing inside it.
1677
+ *
1678
+ * Following is the point: a reader who renames the folder they are in must still be looking at it
1679
+ * afterwards, not dropped back at the top wondering where it went.
1680
+ */
1681
+ const commitRename = async () => {
1682
+ if (renaming === null) return;
1683
+ const result = await store.renameGroup(renaming.group, renaming.value);
1684
+ if (result.error !== null) {
1685
+ props.setToast?.(t(`groupError_${result.error}`));
1686
+ return;
1687
+ }
1688
+ const target = renaming.value.replace(/^\/+|\/+$/g, "");
1689
+ if (group === renaming.group || group.startsWith(`${renaming.group}/`)) {
1690
+ setGroup(group === renaming.group ? target : `${target}${group.slice(renaming.group.length)}`);
1691
+ }
1692
+ setRenaming(null);
1693
+ props.setToast?.(t("groupRenamed", { count: String(result.moved) }));
1694
+ };
1695
+
1696
+ /**
1697
+ * Delete a group by deleting what is in it, after saying how much that is.
1698
+ *
1699
+ * The entries go to 「已删除」 rather than away, so this is reversible — and the count is in the
1700
+ * question, because "delete this group?" and "delete 12 entries?" are different offers.
1701
+ */
1702
+ const removeGroup = async (sub) => {
1703
+ if (globalThis.confirm?.(t("groupDeleteConfirm", { name: sub.name, count: String(sub.count) })) !== true) return;
1704
+ const result = await store.deleteGroup(sub.group);
1705
+ if (result.error !== null) {
1706
+ props.setToast?.(t(`groupError_${result.error}`));
1707
+ return;
1708
+ }
1709
+ props.setToast?.(t("groupDeleted", { count: String(result.removed) }));
1710
+ };
1711
+
1668
1712
  /** Open the editor for a blank entry, prefilled from an optional term. */
1669
1713
  const startCreate = (term = "", context = "") => {
1670
1714
  setEditing({
@@ -2198,6 +2242,24 @@ function DictionaryPanel(props) {
2198
2242
  // row. A breadcrumb says where you are and is the way back up, because a directory you can only
2199
2243
  // enter is a trap.
2200
2244
  [
2245
+ renaming === null ? null : h(
2246
+ "div",
2247
+ { style: { ...applyStyles.transferRow, gridColumn: "1 / -1" }, "data-term-group-form": renaming.group },
2248
+ h("span", { style: applyStyles.fieldLabel }, t("groupRenameTo")),
2249
+ h("input", {
2250
+ type: "text",
2251
+ value: renaming.value,
2252
+ "aria-label": t("groupRename"),
2253
+ onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
2254
+ onKeyDown: (event) => {
2255
+ if (event.key === "Enter") void commitRename();
2256
+ if (event.key === "Escape") setRenaming(null);
2257
+ },
2258
+ style: { ...applyStyles.input, maxWidth: "18em" }
2259
+ }),
2260
+ h(Button, { label: t("groupRenameApply"), onClick: () => void commitRename() }, t("groupRenameApply")),
2261
+ h(Button, { label: t("cancel"), onClick: () => setRenaming(null) }, t("cancel"))
2262
+ ),
2201
2263
  group === "" ? null : h(
2202
2264
  "div",
2203
2265
  { style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
@@ -2227,16 +2289,19 @@ function DictionaryPanel(props) {
2227
2289
  "article",
2228
2290
  {
2229
2291
  key: `group:${sub.group}`,
2230
- // Its own height, not the track's: a group has no body text, so stretching it to match a
2231
- // three-line entry would leave an empty box the size of the entry — which is what it looked
2232
- // like. Equal KIND of row is the point; equal height only reads as equal among rows with text.
2233
- style: { ...applyStyles.row, alignSelf: "start" },
2292
+ // It fills its row again, now that the height carries something: with the preview below, the
2293
+ // space that used to be emptiness is the first thing you would see on opening it.
2294
+ style: applyStyles.row,
2234
2295
  "data-term-group": sub.group,
2235
2296
  // The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
2236
2297
  // Only the label being live is what "the group cannot be clicked into" meant: a reader
2237
2298
  // clicks the row, the same gesture that works one line below, and nothing happened.
2238
2299
  // The label stays a button so it is reachable by keyboard and named for a screen reader.
2239
- onClick: () => setGroup(sub.group)
2300
+ onClick: (event) => {
2301
+ // The buttons inside keep their own meaning, exactly as they do on an entry row.
2302
+ if (typeof event?.target?.closest === "function" && event.target.closest("button") !== null) return;
2303
+ setGroup(sub.group);
2304
+ }
2240
2305
  },
2241
2306
  h("div", { style: applyStyles.rowHead },
2242
2307
  h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
@@ -2249,6 +2314,24 @@ function DictionaryPanel(props) {
2249
2314
  h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
2250
2315
  h("span", { style: applyStyles.spacer }),
2251
2316
  h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
2317
+ ),
2318
+ // The same shape an entry row has: a title line, a line of actions, then the body.
2319
+ h(
2320
+ "div",
2321
+ { style: applyStyles.rowActions, "data-term-group-actions": sub.group },
2322
+ h(IconButton, { title: t("groupRename"), onClick: () => setRenaming({ group: sub.group, value: sub.group }) }, Icons.edit(13)),
2323
+ h(IconButton, { title: t("groupDelete"), onClick: () => void removeGroup(sub) }, Icons.trash(13))
2324
+ ),
2325
+ // What is inside, in one line: the row stops being a name over a blank card.
2326
+ h(
2327
+ "p",
2328
+ { style: applyStyles.groupPreview, "data-term-group-sample": sub.group },
2329
+ [
2330
+ sub.sample.join(t("groupSampleJoin")),
2331
+ sub.count > sub.sample.length ? t("groupSampleMore", { count: String(sub.count - sub.sample.length) }) : ""
2332
+ ]
2333
+ .filter((part) => part !== "")
2334
+ .join("")
2252
2335
  )
2253
2336
  )
2254
2337
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-term-dictionary",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "术语词典:自动识别对话中的生僻术语并建立词条,左侧插件区提供词典面板。已收录的词在回复中被划出,鼠标靠近显示简短解释,点击进入词典对应词条;选中词句可一键添加词条。",
5
5
  "icon": "./icon.svg",
6
6
  "keywords": [