dsh-gitnexus-plus 0.1.1 → 0.1.2

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.
Files changed (54) hide show
  1. package/README.md +197 -49
  2. package/lib/bin/search-helper.d.ts +22 -0
  3. package/lib/bin/search-helper.d.ts.map +1 -0
  4. package/lib/bin/search-helper.js +57 -0
  5. package/lib/bin/search-helper.js.map +1 -0
  6. package/lib/client.js +576 -87
  7. package/lib/gitnexus/analyze.d.ts +27 -2
  8. package/lib/gitnexus/analyze.d.ts.map +1 -1
  9. package/lib/gitnexus/analyze.js +31 -6
  10. package/lib/gitnexus/analyze.js.map +1 -1
  11. package/lib/gitnexus/commands.d.ts +41 -8
  12. package/lib/gitnexus/commands.d.ts.map +1 -1
  13. package/lib/gitnexus/commands.js +62 -9
  14. package/lib/gitnexus/commands.js.map +1 -1
  15. package/lib/gitnexus/coverage.d.ts.map +1 -1
  16. package/lib/gitnexus/coverage.js +3 -1
  17. package/lib/gitnexus/coverage.js.map +1 -1
  18. package/lib/gitnexus/index-meta.d.ts +15 -5
  19. package/lib/gitnexus/index-meta.d.ts.map +1 -1
  20. package/lib/gitnexus/index-meta.js +25 -5
  21. package/lib/gitnexus/index-meta.js.map +1 -1
  22. package/lib/gitnexus/languages.d.ts +7 -0
  23. package/lib/gitnexus/languages.d.ts.map +1 -1
  24. package/lib/gitnexus/languages.js +82 -2
  25. package/lib/gitnexus/languages.js.map +1 -1
  26. package/lib/gitnexus/root.d.ts +6 -0
  27. package/lib/gitnexus/root.d.ts.map +1 -1
  28. package/lib/gitnexus/root.js +6 -0
  29. package/lib/gitnexus/root.js.map +1 -1
  30. package/lib/gitnexus/scan.d.ts +1 -0
  31. package/lib/gitnexus/scan.d.ts.map +1 -1
  32. package/lib/gitnexus/scan.js +3 -1
  33. package/lib/gitnexus/scan.js.map +1 -1
  34. package/lib/gitnexus/search.d.ts +167 -0
  35. package/lib/gitnexus/search.d.ts.map +1 -0
  36. package/lib/gitnexus/search.js +517 -0
  37. package/lib/gitnexus/search.js.map +1 -0
  38. package/lib/index.d.ts +32 -13
  39. package/lib/index.d.ts.map +1 -1
  40. package/lib/index.js +62 -21
  41. package/lib/index.js.map +1 -1
  42. package/lib/routes/analyze.d.ts +8 -2
  43. package/lib/routes/analyze.d.ts.map +1 -1
  44. package/lib/routes/analyze.js +5 -1
  45. package/lib/routes/analyze.js.map +1 -1
  46. package/lib/routes/coverage.d.ts +13 -5
  47. package/lib/routes/coverage.d.ts.map +1 -1
  48. package/lib/routes/coverage.js +24 -11
  49. package/lib/routes/coverage.js.map +1 -1
  50. package/lib/routes/search.d.ts +27 -0
  51. package/lib/routes/search.d.ts.map +1 -0
  52. package/lib/routes/search.js +77 -0
  53. package/lib/routes/search.js.map +1 -0
  54. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -58,6 +58,26 @@ const h = React.createElement;
58
58
  const PLUGIN_ID = 'dsh-gitnexus-plus';
59
59
  /** The sidebar-right tab type this plugin contributes. */
60
60
  const TAB_KIND = 'gitnexus';
61
+ /** Captured from `ctx.inject(['sidebarRight'])`; null when the profile has no column. */
62
+ let openResource = null;
63
+ let openResourceIn = null;
64
+ /**
65
+ * Build the platform's file resource address.
66
+ *
67
+ * The address is `dsh-resource://file/session/<session>/<path>`, exactly what the
68
+ * built-in file strip and the chat's file links use. Relative paths resolve through
69
+ * that session's workspace, which is the project this panel is reporting on. The
70
+ * encoding keeps `:` literal so a Windows drive letter survives, and encodes each
71
+ * segment separately so `/` stays a path separator.
72
+ */
73
+ function fileResourceAddress(sessionId, filePath) {
74
+ const encodeSegment = (segment) => encodeURIComponent(segment).replace(/%3A/gi, ':');
75
+ const normalized = filePath.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
76
+ return `dsh-resource://file/session/${encodeSegment(sessionId)}/${normalized
77
+ .split('/')
78
+ .map(encodeSegment)
79
+ .join('/')}`;
80
+ }
61
81
  /**
62
82
  * How a run's state reads at a glance.
63
83
  *
@@ -113,6 +133,7 @@ const ZERO_TOTALS = {
113
133
  large: 0,
114
134
  largeBytes: 0,
115
135
  suppressedDeclarations: 0,
136
+ scannedBytes: 0,
116
137
  };
117
138
  const ZERO_CAP = { text: '—', source: 'default', maxText: '—' };
118
139
  const ZERO_ANALYZER = {
@@ -157,6 +178,23 @@ function fileSize(bytes) {
157
178
  return primitives.fileSizeText(bytes);
158
179
  return formatBytes(bytes);
159
180
  }
181
+ /**
182
+ * The cap's provenance, in the panel's language. The host reports stable English
183
+ * keys (`index` / `env` / `default`); showing those raw put an English word in the
184
+ * middle of a Chinese metric row, measured in the real panel as "512.0 KB index".
185
+ */
186
+ function capSourceLabel(source) {
187
+ switch (source) {
188
+ case 'index':
189
+ return '索引记录';
190
+ case 'env':
191
+ return '环境变量';
192
+ case 'default':
193
+ return '默认值';
194
+ default:
195
+ return source;
196
+ }
197
+ }
160
198
  function formatBytes(bytes) {
161
199
  if (!Number.isFinite(bytes) || bytes < 0)
162
200
  return '—';
@@ -428,16 +466,23 @@ const SOFT_BG = 'color-mix(in srgb, currentColor 6%, transparent)';
428
466
  * full-width cards reads as broken at the wide end — every metric row stretches
429
467
  * across a metre of empty space.
430
468
  *
431
- * Column count is content-driven rather than media-queried, because an inline-styled
432
- * plugin has no stylesheet to put a breakpoint in: `auto-fit` with a 620px floor
433
- * yields two columns once the pane can hold them and one column below that. A
434
- * card that needs the full width says so with `gridColumn: '1 / -1'`, which is
435
- * column-count agnostic — unlike `span 2`, which would overflow a single column.
469
+ * A single column is the honest layout for a docked sidebar. The previous
470
+ * `auto-fit` grid asked for two columns whenever the pane was wide enough for
471
+ * `2 × 620px + gap`; in a real narrow sidebar it therefore laid the second column —
472
+ * the entire language breakdown — past the visible edge, and the wide Git-activity
473
+ * card lost its third verdict. Tuning the floor only moves which pane width breaks;
474
+ * one column always fits. On a wide pane the card internals still distribute their
475
+ * metrics, so the space is not wasted.
476
+ *
477
+ * `minmax(0, 1fr)` also removes the min-content floor that let the grid widen its
478
+ * own container. A card that needs the full width still says so with
479
+ * `gridColumn: '1 / -1'`, which is harmless in one column and keeps the markup
480
+ * honest if the layout ever changes.
436
481
  */
437
482
  const styles = {
438
483
  root: {
439
484
  display: 'grid',
440
- gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 620px), 1fr))',
485
+ gridTemplateColumns: 'minmax(0, 1fr)',
441
486
  // No `alignItems: start`: grid items stretch to the row height by default, so
442
487
  // two cards sharing a row end level. With `start` each card took its natural
443
488
  // height and the row showed a ragged bottom edge.
@@ -445,7 +490,13 @@ const styles = {
445
490
  padding: '12px',
446
491
  fontSize: '12px',
447
492
  lineHeight: 1.5,
493
+ width: '100%',
494
+ maxWidth: '100%',
495
+ minWidth: 0,
448
496
  overflowY: 'auto',
497
+ // A card that overflows must be scrolled to, not painted outside the pane and
498
+ // mistaken for a neighbouring surface.
499
+ overflowX: 'hidden',
449
500
  height: '100%',
450
501
  boxSizing: 'border-box',
451
502
  },
@@ -571,6 +622,14 @@ function Histogram({ histogram, height = 54 }) {
571
622
  return null;
572
623
  const max = Math.max(...buckets.map((b) => b.commits), 1);
573
624
  const bucketDays = histogram.bucketDays ?? 1;
625
+ // One bucket is not a time series. Drawing it produced a full-width, full-height
626
+ // rectangle that read as a skeleton or a broken chart — measured on this
627
+ // repository, whose entire history fits in one week. The totals directly above
628
+ // already say the same number, so state the bucket instead of drawing it.
629
+ if (buckets.length === 1) {
630
+ const only = buckets[0];
631
+ return h('div', { style: { ...MUTED, display: 'flex', gap: '8px', flexWrap: 'wrap' } }, h('span', null, `${only.label} 起这一${bucketDays === 7 ? '周' : '天'}`), h('span', null, `${only.commits} 次提交`), h('span', null, `+${compactNumber(only.insertions)} / -${compactNumber(only.deletions)}`));
632
+ }
574
633
  return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px' } }, h('div', {
575
634
  style: { display: 'flex', alignItems: 'flex-end', gap: '1px', height: `${height}px` },
576
635
  role: 'img',
@@ -582,10 +641,18 @@ function Histogram({ histogram, height = 54 }) {
582
641
  }, h('div', {
583
642
  style: {
584
643
  flex: '1 1 0',
644
+ // Bars do not grow past a readable width on a wide pane; a two-bucket
645
+ // chart otherwise stretches each bar across half the card.
646
+ maxWidth: '56px',
585
647
  // A zero bucket keeps a visible floor so a gap reads as "no commits"
586
648
  // rather than as missing data.
587
649
  height: `${Math.max(2, (bucket.commits / max) * height)}px`,
588
- background: bucket.commits === 0 ? 'currentColor' : 'var(--dsw-color-accent, currentColor)',
650
+ // The host's real token. The first version invented `--dsw-color-accent`,
651
+ // which does not exist, so every bar fell back to `currentColor` and the
652
+ // chart rendered as a grey block in both themes.
653
+ background: bucket.commits === 0
654
+ ? 'currentColor'
655
+ : 'var(--dsw-alias-brand-primary, currentColor)',
589
656
  opacity: bucket.commits === 0 ? 0.16 : 0.75,
590
657
  borderRadius: '1px',
591
658
  alignSelf: 'flex-end',
@@ -649,7 +716,9 @@ function LanguageSection({ languages }) {
649
716
  totalBytes: languages.totalBytes,
650
717
  totalFiles: languages.totalFiles,
651
718
  metric,
652
- }), h('div', { style: { marginTop: 'auto', paddingTop: '8px', fontSize: '10px', ...MUTED } }, `按${unit}计的占比;GitNexus 只解析其中一部分语言,其余仅作为文件节点存在`));
719
+ }), h('div', { style: { marginTop: 'auto', paddingTop: '8px', fontSize: '10px', ...MUTED } }, languages.detector === 'builtin'
720
+ ? `按${unit}计的占比;GitNexus 不可用,语言由内置扩展名映射识别(仅作参考)`
721
+ : `按${unit}计的占比;GitNexus 只解析其中一部分语言,其余仅作为文件节点存在`));
653
722
  }
654
723
  /**
655
724
  * The files the size cap dropped.
@@ -945,20 +1014,23 @@ function ToolchainNotice({ toolchain, run, runError, now, onInstall, onCancelIns
945
1014
  },
946
1015
  }, command), h(Button, { variant: 'ghost', onClick: copy }, copied ? '已复制' : '复制')), h('div', { style: MUTED }, '用你能访问到的源安装即可 —— 本插件不假设任何网络,也不会自行下载。')), h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } }, h('div', { style: { fontWeight: 600 } }, '② 授权本插件安装'), h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } }, installing
947
1016
  ? h(Button, { variant: 'ghost', onClick: onCancelInstall }, `取消(已进行 ${elapsed}s)`)
948
- : h(Button, { variant: 'ghost', onClick: () => setConfirming(true) }, '授权并安装'), h('span', { style: MUTED }, '装到插件自己的目录,用你已配置的 npm 源,不改全局前缀、不需要管理员权限。')), activeRun === null
1017
+ : h(Button, { variant: 'ghost', onClick: () => setConfirming(true) }, '授权并安装'), h('span', { style: MUTED }, '装到插件自己的目录,用你已配置的 npm 源,不改全局前缀、不需要管理员权限;' +
1018
+ '若当前项目还没有索引,装完会继续初始化一次。')), activeRun === null
949
1019
  ? null
950
1020
  : h('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px', paddingTop: '2px' } }, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h(StateDot, { state: runStateTone(activeRun.state) }), h('span', { style: { fontWeight: 600 } }, runStateLabel(activeRun.state)), h('span', { style: MUTED }, installing ? `已运行 ${elapsed}s` : `${elapsed}s`)), activeRun.message === null ? null : h('div', { style: MUTED }, activeRun.message), activeRun.output === ''
951
1021
  ? null
952
- : h('pre', { style: { ...styles.mono, margin: 0, whiteSpace: 'pre-wrap', maxHeight: '9em', overflow: 'auto' } }, activeRun.output)), runError === null ? null : h('div', { style: { ...MUTED, color: 'var(--dsw-color-danger, inherit)' } }, runError)), h(primitives.RiskConfirmation, {
1022
+ : h('pre', { style: { ...styles.mono, margin: 0, whiteSpace: 'pre-wrap', maxHeight: '9em', overflow: 'auto' } }, activeRun.output)), runError === null ? null : h('div', { style: { ...MUTED, color: 'var(--dsw-alias-state-error-primary, inherit)' } }, runError)), h(primitives.RiskConfirmation, {
953
1023
  open: confirming,
954
1024
  title: '授权本插件安装 GitNexus?',
955
1025
  description: '本插件将执行:\n\nnpm install --prefix <插件数据目录>/gitnexus gitnexus@^1.6.12\n\n' +
956
1026
  '· 从你已配置的 npm 源下载约 500 MB ~ 1 GB\n' +
957
1027
  '· 需要几分钟;网络慢或需要本地编译时更久\n' +
958
1028
  '· 会在本机运行这些包的安装期构建脚本(原生数据库与解析器需要本地编译)\n' +
959
- '· 安装到插件自己的目录,不改动你的全局 npm 前缀\n\n' +
1029
+ '· 安装到插件自己的目录,不改动你的全局 npm 前缀\n' +
1030
+ '· 安装成功后,若当前项目尚无索引,会继续运行一次 ' +
1031
+ '`gitnexus analyze --index-only`(只写 .gitnexus/,不改源文件)\n\n' +
960
1032
  '命令走 DSH 的 shell 执行器,沙箱与其它命令一致 —— 本插件不申请扩权。',
961
- acknowledgeLabel: '我已了解这会下载数百 MB 并在本机编译原生模块',
1033
+ acknowledgeLabel: '我已了解这会下载数百 MB、在本机编译原生模块,并在需要时初始化当前项目',
962
1034
  cancelLabel: '取消',
963
1035
  confirmLabel: '授权并开始',
964
1036
  closeLabel: '关闭',
@@ -975,19 +1047,132 @@ function ToolchainNotice({ toolchain, run, runError, now, onInstall, onCancelIns
975
1047
  },
976
1048
  })));
977
1049
  }
1050
+ /**
1051
+ * Keyword search over the graph's symbol names and file paths.
1052
+ *
1053
+ * A native `input` rather than a primitives component: the shared package ships no
1054
+ * declarations for an input, and guessing a prop name there fails at mount time in a
1055
+ * browser where no test here can see it. The styling is the same theme-agnostic
1056
+ * vocabulary the rest of the panel uses — inherited colour, borders from
1057
+ * `currentColor`.
1058
+ */
1059
+ function SearchSection({ search }) {
1060
+ const keyOf = (hit) => `${hit.kind}:${hit.filePath}:${hit.startLine ?? ''}:${hit.name}`;
1061
+ const canSubmit = search.query.trim() !== '' && !search.searching;
1062
+ return h(Section, {
1063
+ wide: true,
1064
+ title: '关键词搜索',
1065
+ meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, search.mode === 'text' ? '源码关键词(回退)' : '符号名 / 文件路径'),
1066
+ }, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', paddingTop: '4px', flexWrap: 'wrap' } }, h('input', {
1067
+ type: 'search',
1068
+ value: search.query,
1069
+ placeholder: '搜索符号或文件,例如 analyzeCoverage',
1070
+ 'aria-label': '关键词搜索',
1071
+ spellCheck: false,
1072
+ autoComplete: 'off',
1073
+ onChange: (event) => search.onQueryChange(event.target.value),
1074
+ onKeyDown: (event) => {
1075
+ if (event.key === 'Enter' && canSubmit) {
1076
+ event.preventDefault();
1077
+ search.onSubmit();
1078
+ }
1079
+ },
1080
+ style: {
1081
+ flex: '1 1 auto',
1082
+ minWidth: 0,
1083
+ padding: '5px 8px',
1084
+ border: HAIRLINE,
1085
+ borderRadius: '6px',
1086
+ background: SOFT_BG,
1087
+ color: 'inherit',
1088
+ font: 'inherit',
1089
+ },
1090
+ }), h(Button, { variant: 'primary', disabled: !canSubmit, onClick: search.onSubmit }, search.searching ? '搜索中…' : '搜索'), search.query === '' ? null : h(Button, { variant: 'ghost', onClick: search.onClear }, '清除')), search.error !== null
1091
+ ? h('div', {
1092
+ style: {
1093
+ ...MUTED,
1094
+ color: 'var(--dsw-alias-state-error-primary, inherit)',
1095
+ marginTop: '6px',
1096
+ },
1097
+ }, search.error)
1098
+ : null, h('div', { style: { ...MUTED, fontSize: '10px', marginTop: '4px' } }, search.warning ??
1099
+ (search.mode === 'text'
1100
+ ? '图查询不可用,已回退到源码关键词搜索。'
1101
+ : '按符号名与文件路径匹配;不搜索正文。')), search.results === null
1102
+ ? null
1103
+ : search.results.length === 0
1104
+ ? h('div', { style: { ...MUTED, marginTop: '6px' } }, `没有匹配“${search.query}”的符号或文件。`)
1105
+ : h('div', { style: { display: 'flex', flexDirection: 'column', marginTop: '6px' } }, h('div', { style: { ...MUTED, fontSize: '10px', paddingBottom: '2px' } }, (search.mode === 'text'
1106
+ ? `找到 ${search.total} 处源码匹配`
1107
+ : `找到 ${search.total} 个结果`) +
1108
+ `${search.truncated ? '(已截断)' : ''} · 点击结果打开文件`), search.results.map((hit) => h('div', {
1109
+ key: keyOf(hit),
1110
+ style: { ...styles.fileRow, borderTop: HAIRLINE, padding: '5px 0' },
1111
+ }, h('button', {
1112
+ type: 'button',
1113
+ onClick: () => search.onOpen(hit),
1114
+ title: hit.startLine === null
1115
+ ? `打开 ${hit.filePath}`
1116
+ : `打开 ${hit.filePath}:${hit.startLine}`,
1117
+ style: {
1118
+ display: 'flex',
1119
+ alignItems: 'center',
1120
+ gap: '6px',
1121
+ flex: '1 1 auto',
1122
+ minWidth: 0,
1123
+ padding: 0,
1124
+ border: 0,
1125
+ background: 'none',
1126
+ color: 'inherit',
1127
+ font: 'inherit',
1128
+ textAlign: 'left',
1129
+ cursor: 'pointer',
1130
+ },
1131
+ }, h(Tag, { tone: 'neutral' }, hit.kind), h('span', {
1132
+ style: {
1133
+ fontWeight: 600,
1134
+ minWidth: 0,
1135
+ overflow: 'hidden',
1136
+ textOverflow: 'ellipsis',
1137
+ whiteSpace: 'nowrap',
1138
+ },
1139
+ }, hit.name), h('span', {
1140
+ style: {
1141
+ ...MUTED,
1142
+ ...styles.mono,
1143
+ minWidth: 0,
1144
+ flex: '1 1 auto',
1145
+ overflow: 'hidden',
1146
+ textOverflow: 'ellipsis',
1147
+ whiteSpace: 'nowrap',
1148
+ },
1149
+ }, h(PathLabel, { path: hit.filePath }), hit.startLine !== null ? `:${hit.startLine}` : '')), h(Button, { variant: 'ghost', onClick: () => search.onCopy(hit) }, search.copied === keyOf(hit) ? '已复制' : '复制')))));
1150
+ }
978
1151
  function MaintenanceSection({ maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun, }) {
979
1152
  // `projectPath` is part of the prop set for the header, which builds the commands
980
1153
  // from it; the body does not read it.
981
1154
  void projectPath;
982
1155
  const [copied, setCopied] = React.useState(null);
983
- const [confirming, setConfirming] = React.useState(false);
1156
+ const [confirming, setConfirming] = React.useState(null);
984
1157
  // `RiskConfirmation`'s checkbox is controlled, so the tick has to live here. Without
985
1158
  // this the box renders but never moves, which is how it looked the first time.
986
1159
  const [acknowledged, setAcknowledged] = React.useState(false);
987
1160
  const commands = Array.isArray(maintenance?.commands) ? maintenance.commands : [];
988
1161
  if (commands.length === 0)
989
1162
  return null;
990
- const applicable = indexed ? 'full' : 'init';
1163
+ // The incremental command is the normal action on both states: it initialises a
1164
+ // missing index and refreshes a present one. The full rebuild is an escape hatch
1165
+ // for a hung refresh, never "what applies" — marking it applicable on every
1166
+ // indexed repository recommended the destructive path by default.
1167
+ const applicable = 'init';
1168
+ const fullConfirm = confirming === 'full';
1169
+ const confirmCommand = commands.find((c) => c.mode === (fullConfirm ? 'full' : 'init'));
1170
+ // `supportsForce` is also the host-generation marker: the same 0.1.2 release
1171
+ // added the adaptive FTS decision and the project-local GITNEXUS_HOME. An older
1172
+ // host's runner does not include either fix, so a refresh can fail on the FTS
1173
+ // install or the registry write. Refuse to start it and say why, rather than
1174
+ // leaving a button that is known to be wrong on this host.
1175
+ const hostCurrent = maintenance.supportsForce === true;
991
1176
  // Normalised rather than trusted. The declared type says `ClientAnalyzeRun | null`,
992
1177
  // but a React element built without the prop arrives as `undefined` — which is
993
1178
  // exactly what happened the first time this component was rendered by a caller that
@@ -1017,7 +1202,10 @@ function MaintenanceSection({ maintenance, projectPath, indexed, run, runError,
1017
1202
  // The old line said "复制后到会话执行". That stopped being the whole story once the
1018
1203
  // plugin could start the run itself, so the sentence says which parts it runs and
1019
1204
  // which it only hands over.
1020
- h('div', { style: MUTED }, '增量刷新可直接执行(走 DSH 的 shell 执行器,沙箱与其它命令一致,本插件不申请扩权);其余命令复制后到会话执行。'),
1205
+ h('div', { style: MUTED }, hostCurrent
1206
+ ? '两条命令都可由本插件执行(走 DSH 的 shell 执行器,沙箱与其它命令一致,本插件不申请扩权)。' +
1207
+ '全量重建只应在增量刷新卡死或结果反常时使用。'
1208
+ : '当前宿主是旧版本(不包含 FTS 自适应与注册表重定向修复);重启 DSH 后再执行维护。命令仍可复制。'),
1021
1209
  // The run sits above the commands rather than under one of them: the host holds a
1022
1210
  // single run per repository, and nesting it would imply the two variants have
1023
1211
  // separate ones.
@@ -1033,9 +1221,13 @@ function MaintenanceSection({ maintenance, projectPath, indexed, run, runError,
1033
1221
  borderRadius: '6px',
1034
1222
  background: SOFT_BG,
1035
1223
  },
1036
- }, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h(StateDot, { state: runStateTone(activeRun.state) }), h('span', { style: { fontWeight: 600 } }, runStateLabel(activeRun.state)), h('span', { style: MUTED }, activeRun.state === 'running' ? `已运行 ${elapsed}s` : `${elapsed}s`), h('span', { style: { flex: 1 } }), running
1224
+ }, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h(StateDot, { state: runStateTone(activeRun.state) }), h('span', { style: { fontWeight: 600 } }, runStateLabel(activeRun.state)), activeRun.force === true ? h(Tag, { tone: 'outline' }, '全量') : null, h('span', { style: MUTED }, activeRun.state === 'running' ? `已运行 ${elapsed}s` : `${elapsed}s`), h('span', { style: { flex: 1 } }), running
1037
1225
  ? h(Button, { variant: 'ghost', onClick: onCancelRun }, '取消')
1038
- : h(Button, { variant: 'ghost', onClick: onStartRun }, '再次执行')), activeRun.message === null ? null : h('div', { style: MUTED }, activeRun.message),
1226
+ // Wrapped, not passed directly: once `onStartRun` takes a `force`
1227
+ // argument, React's click event would arrive as that argument and every
1228
+ // "run again" would become a full rebuild. Repeating the last mode is
1229
+ // also the honest reading of "run again" after a force rebuild.
1230
+ : h(Button, { variant: 'ghost', disabled: !hostCurrent, onClick: () => onStartRun(activeRun.force === true) }, '再次执行')), activeRun.message === null ? null : h('div', { style: MUTED }, activeRun.message),
1039
1231
  // The output is the evidence. A run that reports success with a warning in
1040
1232
  // its log is a different outcome from one that does not, and hiding the text
1041
1233
  // would leave the panel's own verdict as the only thing to check.
@@ -1051,72 +1243,108 @@ function MaintenanceSection({ maintenance, projectPath, indexed, run, runError,
1051
1243
  },
1052
1244
  }, activeRun.output)), runError === null
1053
1245
  ? null
1054
- : h('div', { style: { ...MUTED, color: 'var(--dsw-color-danger, inherit)' } }, runError), commands.map((command) => h('div', {
1055
- key: command.mode,
1056
- style: {
1057
- display: 'flex',
1058
- flexDirection: 'column',
1059
- gap: '3px',
1060
- // The inapplicable one stays visible — an operator may still want the
1061
- // text — but recedes.
1062
- opacity: command.mode === applicable ? 1 : 0.55,
1063
- },
1064
- }, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h('span', { style: { fontWeight: 600 } }, command.label), command.mode === applicable ? h(Tag, { tone: 'solid' }, '当前适用') : null, command.destructive ? h(Tag, { tone: 'outline' }, '破坏性') : null, h('span', { style: { flex: 1 } }),
1065
- // Only the incremental command gets a button, and only because it is the one
1066
- // the host route implements. A button on `--force` would quietly run
1067
- // something other than what its label says.
1068
- command.mode === 'init'
1069
- ? h(Button, { variant: 'ghost', disabled: running, onClick: () => setConfirming(true) }, running ? '运行中' : '执行增量刷新')
1070
- : null, h(Button, { variant: 'ghost', onClick: () => copy(command) }, copied === command.mode ? '已复制' : '复制')), h('code', {
1071
- style: {
1072
- ...styles.mono,
1073
- display: 'block',
1074
- padding: '6px 8px',
1075
- border: HAIRLINE,
1076
- borderRadius: '6px',
1077
- background: SOFT_BG,
1078
- wordBreak: 'break-all',
1079
- userSelect: 'all',
1080
- },
1081
- }, command.shell), h('div', { style: MUTED }, command.when), command.caveat !== null && command.caveat !== undefined
1082
- ? h('div', { style: MUTED }, command.caveat)
1083
- : null)), Array.isArray(maintenance?.rationale) && maintenance.rationale.length > 0
1246
+ : h('div', { style: { ...MUTED, color: 'var(--dsw-alias-state-error-primary, inherit)' } }, runError), commands.map((command) => {
1247
+ // The host's wording is written for the no-index state ("初始化索引", "该项目还没有
1248
+ // … 索引时使用"). Reusing it on an indexed project told the operator to initialise
1249
+ // an index that already existed. Only the label and the "when" line are stateful;
1250
+ // the command itself is identical.
1251
+ const shown = command.mode === 'init' && indexed
1252
+ ? {
1253
+ ...command,
1254
+ label: '增量刷新',
1255
+ when: '索引落后或有文件改动时使用;未变更文件的解析产物会复用。',
1256
+ }
1257
+ : command;
1258
+ return h('div', {
1259
+ key: command.mode,
1260
+ style: {
1261
+ display: 'flex',
1262
+ flexDirection: 'column',
1263
+ gap: '3px',
1264
+ // The inapplicable one stays visible — an operator may still want the
1265
+ // text — but recedes.
1266
+ opacity: command.mode === applicable ? 1 : 0.55,
1267
+ },
1268
+ }, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h('span', { style: { fontWeight: 600 } }, shown.label), command.mode === applicable ? h(Tag, { tone: 'solid' }, '当前适用') : null, command.destructive ? h(Tag, { tone: 'outline' }, '破坏性') : null, h('span', { style: { flex: 1 } }),
1269
+ // Both commands are executable now, each behind its own confirmation. The
1270
+ // force button exists because the host route implements `--force`; a button
1271
+ // labelled "全量重建" that quietly ran an incremental refresh would be the
1272
+ // exact failure the earlier revision refused to ship.
1273
+ command.mode === 'init'
1274
+ ? h(Button, { variant: 'ghost', disabled: running || !hostCurrent, onClick: () => setConfirming('init') }, running ? '运行中' : hostCurrent ? '执行增量刷新' : '执行增量刷新(需重启)')
1275
+ : hostCurrent
1276
+ ? h(Button, { variant: 'ghost', disabled: running, onClick: () => setConfirming('full') }, running ? '运行中' : '全量重建')
1277
+ : h(Button, {
1278
+ variant: 'ghost',
1279
+ disabled: true,
1280
+ title: '当前宿主版本尚未支持 force=1;重启 DSH 后再试,或复制命令执行',
1281
+ }, '全量重建(需重启)'), h(Button, { variant: 'ghost', onClick: () => copy(command) }, copied === command.mode ? '已复制' : '复制')), h('code', {
1282
+ style: {
1283
+ ...styles.mono,
1284
+ display: 'block',
1285
+ padding: '6px 8px',
1286
+ border: HAIRLINE,
1287
+ borderRadius: '6px',
1288
+ background: SOFT_BG,
1289
+ wordBreak: 'break-all',
1290
+ userSelect: 'all',
1291
+ },
1292
+ }, command.shell), h('div', { style: MUTED }, shown.when), command.caveat !== null && command.caveat !== undefined
1293
+ ? h('div', { style: MUTED }, command.caveat)
1294
+ : null);
1295
+ }), Array.isArray(maintenance?.rationale) && maintenance.rationale.length > 0
1084
1296
  ? h('div', { style: { ...MUTED, display: 'flex', flexDirection: 'column', gap: '2px' } }, maintenance.rationale.map((note, index) => h('div', { key: index }, `· ${note}`)))
1085
1297
  : null,
1086
1298
  // The confirmation. Everything the operator needs before a process touches their
1087
1299
  // working tree goes in `description`, because that is the prop the modal renders:
1088
1300
  // an earlier revision passed this as children and the dialog opened with an empty
1089
1301
  // body and two unlabelled buttons.
1302
+ //
1303
+ // One dialog, two modes. The full rebuild gets its own title, wording and
1304
+ // acknowledgement because it is a different risk: it discards the graph and FTS
1305
+ // and can take minutes, while the incremental path reuses unchanged files.
1090
1306
  h(primitives.RiskConfirmation, {
1091
- open: confirming,
1092
- title: '执行增量刷新?',
1093
- description: '将在项目目录下运行:\n\n' +
1094
- (commands.find((c) => c.mode === 'init')?.shell ?? 'gitnexus analyze --index-only') +
1095
- '\n\n' +
1096
- '只写入 .gitnexus/,不改动任何源文件,也不会重写 AGENTS.md / CLAUDE.md。' +
1097
- '耗时取决于改动量:无改动时约 7 秒,改动几个文件时实测可到 3 分钟,' +
1098
- '大仓库的全量重建可能更久(上限 10 分钟,可随时取消)。' +
1099
- '命令由 DSH 的 shell 执行器运行,沙箱与其它命令一致 —— 本插件不申请扩权。',
1100
- acknowledgeLabel: '我了解这会运行一个进程并写入 .gitnexus/',
1307
+ open: confirming !== null,
1308
+ title: fullConfirm ? '执行全量重建?' : '执行增量刷新?',
1309
+ description: fullConfirm
1310
+ ? '将执行强制全量重建:\n\n' +
1311
+ (confirmCommand?.shell ?? 'gitnexus analyze --index-only --force') +
1312
+ '\n\n' +
1313
+ '这会重建图与 FTS;未变更文件的解析产物仍会复用。' +
1314
+ '但耗时以分钟计,索引很大时更久(上限 10 分钟,可随时取消)。' +
1315
+ '重建期间索引可能短暂不可用。' +
1316
+ '只写入 .gitnexus/,不改动源文件,也不会重写 AGENTS.md / CLAUDE.md。' +
1317
+ '命令由 DSH 的 shell 执行器运行,沙箱与其它命令一致 —— 本插件不申请扩权。'
1318
+ : '将在项目目录下运行:\n\n' +
1319
+ (confirmCommand?.shell ?? 'gitnexus analyze --index-only') +
1320
+ '\n\n' +
1321
+ '只写入 .gitnexus/,不改动任何源文件,也不会重写 AGENTS.md / CLAUDE.md。' +
1322
+ '耗时取决于改动量:无改动时约 7 秒,改动几个文件时实测可到 3 分钟,' +
1323
+ '大仓库的初始化或刷新可能更久(上限 10 分钟,可随时取消)。' +
1324
+ '命令由 DSH 的 shell 执行器运行,沙箱与其它命令一致 —— 本插件不申请扩权。',
1325
+ acknowledgeLabel: fullConfirm
1326
+ ? '我了解这会重建图与 FTS、耗时数分钟,并可能让索引短暂不可用'
1327
+ : '我了解这会运行一个进程并写入 .gitnexus/',
1101
1328
  cancelLabel: '取消',
1102
- confirmLabel: '执行',
1329
+ confirmLabel: fullConfirm ? '开始重建' : '执行',
1103
1330
  closeLabel: '关闭',
1104
1331
  // The checkbox is controlled, so it needs state of its own, and it is reset on
1105
1332
  // close: the acknowledgement is per-action rather than remembered.
1106
1333
  acknowledged,
1107
1334
  onAcknowledgedChange: setAcknowledged,
1108
1335
  onCancel: () => {
1109
- setConfirming(false);
1336
+ setConfirming(null);
1110
1337
  setAcknowledged(false);
1111
1338
  },
1112
1339
  onConfirm: () => {
1113
- setConfirming(false);
1340
+ const force = confirming === 'full';
1341
+ setConfirming(null);
1114
1342
  setAcknowledged(false);
1115
- onStartRun();
1343
+ onStartRun(force);
1116
1344
  },
1117
1345
  }));
1118
1346
  }
1119
- function CoverageView({ report, maintenance, projectPath, context, busy, error, readAt, run, runError, now, onRefresh, onStartRun, onCancelRun, installRun, installError, onInstall, onCancelInstall, }) {
1347
+ function CoverageView({ report, maintenance, projectPath, context, busy, error, readAt, run, runError, now, onRefresh, onStartRun, onCancelRun, installRun, installError, onInstall, onCancelInstall, search, }) {
1120
1348
  // Whether an index exists decides which maintenance command is applicable. When
1121
1349
  // the project could not even be identified there is nothing to claim, so it is
1122
1350
  // false rather than assumed.
@@ -1159,28 +1387,46 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
1159
1387
  if (report === null) {
1160
1388
  return h('div', { style: styles.root }, header, h('div', { style: MUTED }, '正在读取索引…'));
1161
1389
  }
1162
- // No index yet. This is the state the initialize command exists for, so the
1163
- // panel leads with the command rather than an error.
1390
+ // Defaults are resolved before the no-index branch: a scan-only report still has
1391
+ // totals, a cap and languages even though it has no graph. Building them only for
1392
+ // an indexed report is what made "no index" mean "nothing to show".
1393
+ const totals = report.totals ?? ZERO_TOTALS;
1394
+ const cap = report.cap ?? ZERO_CAP;
1395
+ const analyzer = report.analyzer ?? ZERO_ANALYZER;
1396
+ const largeFiles = report.largeFiles ?? [];
1397
+ const languages = report.languages ?? null;
1398
+ const { notable, context: contextWarnings } = splitWarnings(report.warnings);
1399
+ const ratio = coverageRatio(report);
1400
+ // No index yet. This is the state the initialize command exists for, but it is
1401
+ // also a state in which the working tree is still readable: file counts, bytes,
1402
+ // the cap and the language split do not need GitNexus. The panel used to show
1403
+ // only "没有索引" and a command, which made an otherwise healthy repository look
1404
+ // empty. The scan is the "at least something to look at" floor.
1164
1405
  if (report.repo?.indexed !== true) {
1165
- return h('div', { style: styles.root }, header, h(Section, { wide: true, style: { display: 'flex', gap: '8px', alignItems: 'flex-start' } }, h(StateDot, { state: 'idle' }), h('span', null, '该项目还没有 GitNexus 索引。')), maintenance === null || maintenance === undefined
1406
+ return h('div', { style: styles.root }, h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header), h(ToolchainNotice, {
1407
+ toolchain: report.toolchain,
1408
+ run: installRun,
1409
+ runError: installError,
1410
+ now,
1411
+ onInstall,
1412
+ onCancelInstall,
1413
+ }), h('div', { style: { gridColumn: '1 / -1', display: 'flex', gap: '8px', alignItems: 'flex-start' } }, h(StateDot, { state: 'idle' }), h('span', null, '该项目还没有 GitNexus 索引。下面是工作区扫描结果;初始化后可看到图谱、查询能力与 Git 活动对账。')), h(Section, { wide: true, title: '扫描概览 · 尚未索引' }, h(MetricGrid, {
1414
+ items: [
1415
+ { label: '扫描文件', value: compactNumber(totals.scanned) },
1416
+ { label: '选中文件', value: compactNumber(totals.selected) },
1417
+ { label: '扫描字节', value: fileSize(totals.scannedBytes ?? 0) },
1418
+ { label: '大小上限', value: cap.text, hint: capSourceLabel(cap.source) },
1419
+ ],
1420
+ }), h('div', { style: { ...MUTED, marginTop: '6px' } }, '这里的数字来自工作区,不是索引;索引里有什么,只有建立后才能回答。')), languages !== null && languages.rows.length > 0 ? h(LanguageSection, { languages }) : null, maintenance === null || maintenance === undefined
1166
1421
  ? null
1167
1422
  : h(Section, {
1168
1423
  wide: true,
1169
1424
  title: '初始化索引',
1170
- meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, '复制后到会话执行(沙箱与提权由 DSH 处理)'),
1425
+ meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, maintenance.supportsForce === true
1426
+ ? '均可直接执行,也可复制到会话(沙箱与提权由 DSH 处理)'
1427
+ : '重启 DSH 后可执行;当前仅可复制(沙箱与提权由 DSH 处理)'),
1171
1428
  }, h(MaintenanceSection, { maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun })));
1172
1429
  }
1173
- // Past this point an index exists, and the host builds these blocks
1174
- // unconditionally for one. Defaulting them once here beats a `?? []` at each of
1175
- // twenty read sites — and a default repeated twenty times is one that will
1176
- // eventually be forgotten at the twenty-first.
1177
- const totals = report.totals ?? ZERO_TOTALS;
1178
- const cap = report.cap ?? ZERO_CAP;
1179
- const analyzer = report.analyzer ?? ZERO_ANALYZER;
1180
- const largeFiles = report.largeFiles ?? [];
1181
- const { notable, context: contextWarnings } = splitWarnings(report.warnings);
1182
- const ratio = coverageRatio(report);
1183
- const languages = report.languages ?? null;
1184
1430
  return h('div', { style: styles.root },
1185
1431
  // The header and the two status lines span everything: they describe the whole
1186
1432
  // panel, so giving them a column would make them look like one card's heading.
@@ -1208,6 +1454,9 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
1208
1454
  const s = stalenessView(report.staleness);
1209
1455
  return h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h(StateDot, { state: s.state }), h('span', null, s.text));
1210
1456
  })()),
1457
+ // Search sits above the report cards: it is the action a reader reaches for
1458
+ // before reading, not an annotation on the numbers below it.
1459
+ search === undefined ? null : h(SearchSection, { search }),
1211
1460
  // One card for the whole "what is this index" question — file coverage and
1212
1461
  // graph contents together.
1213
1462
  //
@@ -1225,13 +1474,15 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
1225
1474
  : h(Ring, {
1226
1475
  ratio,
1227
1476
  caption: '覆盖',
1228
- tone: ratio >= 0.9 ? 'var(--dsw-color-success, #3fb950)' : 'var(--dsw-color-warning, #d9a441)',
1477
+ tone: ratio >= 0.9
1478
+ ? 'var(--dsw-alias-state-success-primary, #3fb950)'
1479
+ : 'var(--dsw-alias-state-warn-primary, #d9a441)',
1229
1480
  }), h('div', { style: { display: 'flex', flexDirection: 'column', gap: '9px', minWidth: 0, flex: '1 1 auto' } }, h('div', { style: MUTED }, summaryLine(report)),
1230
1481
  // Files in, graph out — the two halves sit side by side so the wide card
1231
1482
  // is used rather than merely filled.
1232
1483
  h('div', { style: { display: 'flex', gap: '24px', flexWrap: 'wrap', alignItems: 'flex-start' } }, h('div', { style: { minWidth: '200px', flex: '1 1 auto' } }, h('div', { style: { ...MUTED, marginBottom: '5px' } }, '文件'), h(MetricGrid, {
1233
1484
  items: [
1234
- { label: '大小上限', value: cap.text, hint: cap.source },
1485
+ { label: '大小上限', value: cap.text, hint: capSourceLabel(cap.source) },
1235
1486
  { label: '跳过合计', value: fileSize(totals.largeBytes) },
1236
1487
  { label: '超限文件', value: compactNumber(totals.large) },
1237
1488
  ],
@@ -1245,7 +1496,9 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
1245
1496
  { label: '流程', value: compactNumber(report.graph.processes) },
1246
1497
  { label: '向量', value: compactNumber(report.graph.embeddings) },
1247
1498
  ],
1248
- }))), h('div', { style: { borderTop: HAIRLINE, paddingTop: '8px' } }, h('div', { style: { ...MUTED, marginBottom: '5px' } }, '查询能力'), h(CapabilityStrip, { capabilities: report.capabilities }))))),
1499
+ }))), h('div', { style: { borderTop: HAIRLINE, paddingTop: '8px' } }, h('div', { style: { ...MUTED, marginBottom: '5px' } }, '查询能力'), h(CapabilityStrip, { capabilities: report.capabilities }), report.capabilities?.fts?.status === 'available'
1500
+ ? null
1501
+ : h('div', { style: { ...MUTED, marginTop: '4px', fontSize: '10px' } }, '顶部「关键词搜索」会先查图;图查询不可用时回退到源码关键词。'))))),
1249
1502
  // Shares a row with the overview card: a bar plus its legend is a proportional
1250
1503
  // reading, and pairing it with the coverage figure puts the two "how much of
1251
1504
  // this repository" answers side by side. Order matters — it must directly
@@ -1320,7 +1573,11 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
1320
1573
  : h(Section, {
1321
1574
  wide: true,
1322
1575
  title: '维护命令',
1323
- meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, `${maintenance.commands?.length ?? 0} 条 · 复制后到会话执行(沙箱与提权由 DSH 处理)`),
1576
+ meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, `${maintenance.commands?.length ?? 0} 条 · ` +
1577
+ (maintenance.supportsForce === true
1578
+ ? '均可直接执行,也可复制到会话'
1579
+ : '重启 DSH 后可执行;当前仅可复制') +
1580
+ '(沙箱与提权由 DSH 处理)'),
1324
1581
  }, h(MaintenanceSection, { maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun })));
1325
1582
  }
1326
1583
  /**
@@ -1334,6 +1591,7 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
1334
1591
  */
1335
1592
  function CoveragePanel(props) {
1336
1593
  const projectPath = useProjectPath(props);
1594
+ const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
1337
1595
  const [report, setReport] = React.useState(null);
1338
1596
  const [maintenance, setMaintenance] = React.useState(null);
1339
1597
  const [error, setError] = React.useState(null);
@@ -1345,6 +1603,19 @@ function CoveragePanel(props) {
1345
1603
  const [readAt, setReadAt] = React.useState(null);
1346
1604
  // Only a manual refresh bypasses the host's cache; the initial load does not.
1347
1605
  const wantsFresh = reloadToken > 0;
1606
+ // Keyword search. Its own state because it is an on-demand action, not part of
1607
+ // the report the panel loads: an idle panel makes no search requests at all.
1608
+ const [searchQuery, setSearchQuery] = React.useState('');
1609
+ const [searchResults, setSearchResults] = React.useState(null);
1610
+ const [searchMode, setSearchMode] = React.useState(null);
1611
+ const [searchTotal, setSearchTotal] = React.useState(0);
1612
+ const [searchTruncated, setSearchTruncated] = React.useState(false);
1613
+ const [searchWarning, setSearchWarning] = React.useState(null);
1614
+ const [searching, setSearching] = React.useState(false);
1615
+ const [searchError, setSearchError] = React.useState(null);
1616
+ const [searchCopied, setSearchCopied] = React.useState(null);
1617
+ // Monotonic request id: a slower earlier search must not overwrite a newer one.
1618
+ const searchSeq = React.useRef(0);
1348
1619
  // The refresh run, and the tick that makes its elapsed time advance. `run` is null
1349
1620
  // until something is started in this host's lifetime — a state the card renders as
1350
1621
  // "not run yet" rather than as a failure.
@@ -1389,14 +1660,13 @@ function CoveragePanel(props) {
1389
1660
  clearInterval(id);
1390
1661
  };
1391
1662
  }, [projectPath, running]);
1392
- const startRun = React.useCallback(async () => {
1663
+ const startRun = React.useCallback(async (force = false) => {
1393
1664
  if (projectPath === null)
1394
1665
  return;
1395
1666
  setRunError(null);
1396
1667
  try {
1397
- const res = await fetch(routeUrl(`/api/gitnexus/analyze?repo=${encodeURIComponent(projectPath)}`), {
1398
- method: 'POST',
1399
- });
1668
+ const suffix = force ? '&force=1' : '';
1669
+ const res = await fetch(routeUrl(`/api/gitnexus/analyze?repo=${encodeURIComponent(projectPath)}${suffix}`), { method: 'POST' });
1400
1670
  const body = await res.json().catch(() => null);
1401
1671
  if (!body?.ok)
1402
1672
  throw new Error(body?.message ?? `请求失败 (${res.status})`);
@@ -1422,12 +1692,155 @@ function CoveragePanel(props) {
1422
1692
  setRunError(String(cause?.message ?? cause));
1423
1693
  }
1424
1694
  }, [projectPath]);
1695
+ /**
1696
+ * Run one keyword search.
1697
+ *
1698
+ * No request on mount and no request per keystroke: a graph query is cheap but not
1699
+ * free, and the operator's typing is not a search intent until Enter or the button.
1700
+ * `searchSeq` makes an older in-flight response a no-op, which is the difference
1701
+ * between "the last search wins" and a result list that flickers backwards.
1702
+ */
1703
+ const runSearch = React.useCallback(async () => {
1704
+ if (projectPath === null)
1705
+ return;
1706
+ const query = searchQuery.trim();
1707
+ if (query === '') {
1708
+ setSearchResults(null);
1709
+ setSearchError(null);
1710
+ return;
1711
+ }
1712
+ const seq = ++searchSeq.current;
1713
+ setSearching(true);
1714
+ setSearchError(null);
1715
+ setSearchCopied(null);
1716
+ try {
1717
+ const res = await fetch(routeUrl(`/api/gitnexus/search?repo=${encodeURIComponent(projectPath)}` +
1718
+ `&q=${encodeURIComponent(query)}&limit=20`));
1719
+ const body = await res.json().catch(() => null);
1720
+ if (seq !== searchSeq.current)
1721
+ return;
1722
+ if (!body?.ok) {
1723
+ // A 404 without the route's own code means the host predates the search
1724
+ // route, not that the project is wrong: the panel's own project path is a
1725
+ // project root by construction.
1726
+ if (res.status === 404 && body?.code !== 'not-a-project') {
1727
+ throw new Error('当前宿主没有搜索路由;重启 DSH 后再试。');
1728
+ }
1729
+ throw new Error(typeof body?.message === 'string' ? body.message : `请求失败 (${res.status})`);
1730
+ }
1731
+ const results = Array.isArray(body.results)
1732
+ ? body.results.filter((hit) => hit !== null &&
1733
+ typeof hit === 'object' &&
1734
+ typeof hit.kind === 'string' &&
1735
+ typeof hit.name === 'string' &&
1736
+ typeof hit.filePath === 'string')
1737
+ : [];
1738
+ setSearchResults(results);
1739
+ setSearchMode(body.mode === 'text' ? 'text' : body.mode === 'graph' ? 'graph' : null);
1740
+ setSearchTotal(typeof body.total === 'number' ? body.total : results.length);
1741
+ setSearchTruncated(body.truncated === true);
1742
+ setSearchWarning(typeof body.warning === 'string' ? body.warning : null);
1743
+ }
1744
+ catch (cause) {
1745
+ if (seq !== searchSeq.current)
1746
+ return;
1747
+ setSearchResults(null);
1748
+ setSearchError(String(cause?.message ?? cause));
1749
+ }
1750
+ finally {
1751
+ if (seq === searchSeq.current)
1752
+ setSearching(false);
1753
+ }
1754
+ }, [projectPath, searchQuery]);
1755
+ const clearSearch = React.useCallback(() => {
1756
+ searchSeq.current += 1;
1757
+ setSearchQuery('');
1758
+ setSearchResults(null);
1759
+ setSearchMode(null);
1760
+ setSearchTotal(0);
1761
+ setSearchTruncated(false);
1762
+ setSearchWarning(null);
1763
+ setSearchError(null);
1764
+ setSearching(false);
1765
+ setSearchCopied(null);
1766
+ }, []);
1767
+ const copySearchHit = React.useCallback((hit) => {
1768
+ const text = `${hit.filePath}${hit.startLine !== null ? `:${hit.startLine}` : ''}`;
1769
+ const key = `${hit.kind}:${hit.filePath}:${hit.startLine ?? ''}:${hit.name}`;
1770
+ const write = typeof primitives.writeClipboard === 'function'
1771
+ ? primitives.writeClipboard
1772
+ : (value) => navigator.clipboard?.writeText(value);
1773
+ try {
1774
+ const outcome = write(text);
1775
+ if (outcome && typeof outcome.then === 'function') {
1776
+ outcome
1777
+ .then(() => {
1778
+ setSearchCopied(key);
1779
+ setTimeout(() => setSearchCopied(null), 1500);
1780
+ })
1781
+ .catch(() => setSearchCopied(null));
1782
+ }
1783
+ else {
1784
+ setSearchCopied(key);
1785
+ setTimeout(() => setSearchCopied(null), 1500);
1786
+ }
1787
+ }
1788
+ catch {
1789
+ setSearchCopied(null);
1790
+ }
1791
+ }, []);
1792
+ /**
1793
+ * Open a hit in the platform's file viewer at its line.
1794
+ *
1795
+ * The resource address is the same `dsh-resource://file/session/...` the built-in
1796
+ * file strip uses; `params.line` is what the chat's file links pass. If the
1797
+ * deployment has no right-sidebar controller (a headless profile, or a host older
1798
+ * than the service), opening is impossible — copy the `path:line` instead of
1799
+ * silently doing nothing, and say why.
1800
+ */
1801
+ const openSearchHit = React.useCallback((hit) => {
1802
+ setSearchError(null);
1803
+ if (sessionId === null) {
1804
+ setSearchError('未能定位当前会话,已复制路径。');
1805
+ copySearchHit(hit);
1806
+ return;
1807
+ }
1808
+ const address = fileResourceAddress(sessionId, hit.filePath);
1809
+ const options = hit.startLine === null
1810
+ ? undefined
1811
+ : { params: { line: hit.startLine } };
1812
+ try {
1813
+ if (openResourceIn !== null) {
1814
+ openResourceIn(sessionId, address, options);
1815
+ return;
1816
+ }
1817
+ if (openResource !== null) {
1818
+ openResource(address, options);
1819
+ return;
1820
+ }
1821
+ }
1822
+ catch {
1823
+ /* fall through to the copy path */
1824
+ }
1825
+ setSearchError('当前宿主没有可用的文件打开能力,已复制路径。');
1826
+ copySearchHit(hit);
1827
+ }, [sessionId, copySearchHit]);
1828
+ // A different project means the previous hits are another repository's symbols.
1829
+ React.useEffect(() => {
1830
+ clearSearch();
1831
+ }, [projectPath, clearSearch]);
1425
1832
  // The GitNexus install. Its own state because it is not tied to a repository: the
1426
1833
  // install target is one directory per machine, and the operator may have several
1427
1834
  // projects open across a session.
1428
1835
  const [installRun, setInstallRun] = React.useState(null);
1429
1836
  const [installError, setInstallError] = React.useState(null);
1430
1837
  const installing = installRun?.state === 'running';
1838
+ // Only an install the operator authorised *in this panel* may chain into an
1839
+ // analyze. A successful install from an earlier session is still visible in the
1840
+ // host's run state, and starting a write from that would be acting on consent the
1841
+ // operator gave for a different action.
1842
+ const autoInitArmed = React.useRef(false);
1843
+ const autoInitDone = React.useRef(null);
1431
1844
  React.useEffect(() => {
1432
1845
  if (!installing)
1433
1846
  return undefined;
@@ -1460,6 +1873,7 @@ function CoveragePanel(props) {
1460
1873
  }, [installing]);
1461
1874
  const startInstall = React.useCallback(async () => {
1462
1875
  setInstallError(null);
1876
+ autoInitArmed.current = true;
1463
1877
  try {
1464
1878
  const res = await fetch(routeUrl('/api/gitnexus/install'), { method: 'POST' });
1465
1879
  const body = await res.json().catch(() => null);
@@ -1469,6 +1883,7 @@ function CoveragePanel(props) {
1469
1883
  setTick(0);
1470
1884
  }
1471
1885
  catch (cause) {
1886
+ autoInitArmed.current = false;
1472
1887
  setInstallError(String(cause?.message ?? cause));
1473
1888
  }
1474
1889
  }, []);
@@ -1483,6 +1898,35 @@ function CoveragePanel(props) {
1483
1898
  setInstallError(String(cause?.message ?? cause));
1484
1899
  }
1485
1900
  }, []);
1901
+ /**
1902
+ * Install → initialize, in one authorised step.
1903
+ *
1904
+ * The install is machine-wide; the index is per-project. Chaining them is the
1905
+ * difference between "you now have a CLI" and "you can see the index", which is
1906
+ * what the operator asked for. Three conditions keep it honest: the install must
1907
+ * have finished successfully, the project must still have no index (otherwise
1908
+ * this would silently refresh an index nobody asked to refresh), and the operator
1909
+ * must have started *this* install from this panel.
1910
+ *
1911
+ * The key includes the install's start time, so a retry after a cancellation is a
1912
+ * new run and may chain again, while the poll ticks of one run cannot start two
1913
+ * analyses.
1914
+ */
1915
+ React.useEffect(() => {
1916
+ if (!autoInitArmed.current)
1917
+ return;
1918
+ if (installRun?.state !== 'done')
1919
+ return;
1920
+ // Wait for a report rather than treating "not loaded" as "not indexed".
1921
+ if (report === null || projectPath === null || report.repo?.indexed === true)
1922
+ return;
1923
+ const key = `${projectPath}:${installRun.startedAt}`;
1924
+ if (autoInitDone.current === key)
1925
+ return;
1926
+ autoInitDone.current = key;
1927
+ autoInitArmed.current = false;
1928
+ void startRun(false);
1929
+ }, [installRun, projectPath, report, startRun]);
1486
1930
  React.useEffect(() => {
1487
1931
  if (projectPath === null)
1488
1932
  return undefined;
@@ -1533,12 +1977,39 @@ function CoveragePanel(props) {
1533
1977
  // otherwise looks frozen.
1534
1978
  now: Date.now() + tick * 0,
1535
1979
  onRefresh: () => setReloadToken((n) => n + 1),
1536
- onStartRun: () => void startRun(),
1980
+ onStartRun: (force) => void startRun(force),
1537
1981
  onCancelRun: () => void cancelRun(),
1538
1982
  installRun,
1539
1983
  installError,
1540
1984
  onInstall: () => void startInstall(),
1541
1985
  onCancelInstall: () => void cancelInstall(),
1986
+ search: {
1987
+ query: searchQuery,
1988
+ onQueryChange: (value) => {
1989
+ // Invalidate an in-flight search for the previous text: otherwise its
1990
+ // response can land after the edit and repopulate a list that no longer
1991
+ // matches the box. The sequence bump also makes its `finally` a no-op, so
1992
+ // the spinner is cleared here.
1993
+ searchSeq.current += 1;
1994
+ setSearching(false);
1995
+ setSearchQuery(value);
1996
+ setSearchError(null);
1997
+ setSearchResults(null);
1998
+ setSearchMode(null);
1999
+ },
2000
+ onSubmit: () => void runSearch(),
2001
+ onClear: clearSearch,
2002
+ searching,
2003
+ error: searchError,
2004
+ mode: searchMode,
2005
+ results: searchResults,
2006
+ total: searchTotal,
2007
+ truncated: searchTruncated,
2008
+ warning: searchWarning,
2009
+ copied: searchCopied,
2010
+ onOpen: openSearchHit,
2011
+ onCopy: copySearchHit,
2012
+ },
1542
2013
  });
1543
2014
  }
1544
2015
  /** The tab chip's live title. */
@@ -1570,7 +2041,10 @@ function apply(ctx) {
1570
2041
  };
1571
2042
  safe('sidebarRightTabs', () => {
1572
2043
  ctx.inject(['sidebarRightTabs'], (svcCtx) => {
1573
- ctx.effect(() => svcCtx.sidebarRightTabs.register({
2044
+ const tabs = svcCtx.sidebarRightTabs;
2045
+ if (tabs === undefined)
2046
+ return;
2047
+ ctx.effect(() => tabs.register({
1574
2048
  id: PLUGIN_ID,
1575
2049
  kind: TAB_KIND,
1576
2050
  title: () => 'GitNexus',
@@ -1589,6 +2063,20 @@ function apply(ctx) {
1589
2063
  }), `${PLUGIN_ID}: tab type`);
1590
2064
  });
1591
2065
  });
2066
+ // The navigation controller the chat and the file strip already use. Optional:
2067
+ // a profile without a right column simply leaves the module-level openers null,
2068
+ // and a search-result click falls back to copying `path:line`.
2069
+ safe('sidebarRight', () => {
2070
+ ctx.inject(['sidebarRight'], (svcCtx) => {
2071
+ const service = svcCtx.sidebarRight;
2072
+ if (service === undefined || typeof service.openResource !== 'function')
2073
+ return;
2074
+ openResource = (address, options) => service.openResource(address, options);
2075
+ if (typeof service.openResourceIn === 'function') {
2076
+ openResourceIn = (sessionId, address, options) => service.openResourceIn?.(sessionId, address, options);
2077
+ }
2078
+ });
2079
+ });
1592
2080
  safe('sidebar.right.pane.tab', () => {
1593
2081
  ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: PLUGIN_ID }, CoveragePanel));
1594
2082
  });
@@ -1603,6 +2091,7 @@ module.exports = {
1603
2091
  PLUGIN_ID,
1604
2092
  TAB_KIND,
1605
2093
  routeUrl,
2094
+ fileResourceAddress,
1606
2095
  formatBytes,
1607
2096
  fileSize,
1608
2097
  percent,