dsh-gitnexus-plus 0.1.1 → 0.1.3
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/README-CN.md +174 -0
- package/README.md +184 -447
- package/lib/bin/search-helper.d.ts +22 -0
- package/lib/bin/search-helper.d.ts.map +1 -0
- package/lib/bin/search-helper.js +57 -0
- package/lib/bin/search-helper.js.map +1 -0
- package/lib/client.js +978 -122
- package/lib/gitnexus/analyze.d.ts +27 -2
- package/lib/gitnexus/analyze.d.ts.map +1 -1
- package/lib/gitnexus/analyze.js +71 -6
- package/lib/gitnexus/analyze.js.map +1 -1
- package/lib/gitnexus/commands.d.ts +41 -8
- package/lib/gitnexus/commands.d.ts.map +1 -1
- package/lib/gitnexus/commands.js +62 -9
- package/lib/gitnexus/commands.js.map +1 -1
- package/lib/gitnexus/coverage.d.ts +16 -0
- package/lib/gitnexus/coverage.d.ts.map +1 -1
- package/lib/gitnexus/coverage.js +61 -2
- package/lib/gitnexus/coverage.js.map +1 -1
- package/lib/gitnexus/git-activity.d.ts +85 -0
- package/lib/gitnexus/git-activity.d.ts.map +1 -1
- package/lib/gitnexus/git-activity.js +286 -1
- package/lib/gitnexus/git-activity.js.map +1 -1
- package/lib/gitnexus/index-meta.d.ts +15 -5
- package/lib/gitnexus/index-meta.d.ts.map +1 -1
- package/lib/gitnexus/index-meta.js +25 -5
- package/lib/gitnexus/index-meta.js.map +1 -1
- package/lib/gitnexus/languages.d.ts +7 -0
- package/lib/gitnexus/languages.d.ts.map +1 -1
- package/lib/gitnexus/languages.js +82 -2
- package/lib/gitnexus/languages.js.map +1 -1
- package/lib/gitnexus/root.d.ts +6 -0
- package/lib/gitnexus/root.d.ts.map +1 -1
- package/lib/gitnexus/root.js +6 -0
- package/lib/gitnexus/root.js.map +1 -1
- package/lib/gitnexus/scan.d.ts +3 -0
- package/lib/gitnexus/scan.d.ts.map +1 -1
- package/lib/gitnexus/scan.js +6 -3
- package/lib/gitnexus/scan.js.map +1 -1
- package/lib/gitnexus/search.d.ts +167 -0
- package/lib/gitnexus/search.d.ts.map +1 -0
- package/lib/gitnexus/search.js +517 -0
- package/lib/gitnexus/search.js.map +1 -0
- package/lib/index.d.ts +32 -13
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +68 -21
- package/lib/index.js.map +1 -1
- package/lib/routes/analyze.d.ts +8 -2
- package/lib/routes/analyze.d.ts.map +1 -1
- package/lib/routes/analyze.js +5 -1
- package/lib/routes/analyze.js.map +1 -1
- package/lib/routes/coverage.d.ts +15 -6
- package/lib/routes/coverage.d.ts.map +1 -1
- package/lib/routes/coverage.js +77 -12
- package/lib/routes/coverage.js.map +1 -1
- package/lib/routes/file-history.d.ts +21 -0
- package/lib/routes/file-history.d.ts.map +1 -0
- package/lib/routes/file-history.js +69 -0
- package/lib/routes/file-history.js.map +1 -0
- package/lib/routes/search.d.ts +27 -0
- package/lib/routes/search.d.ts.map +1 -0
- package/lib/routes/search.js +77 -0
- package/lib/routes/search.js.map +1 -0
- package/package.json +3 -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
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
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: '
|
|
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
|
-
|
|
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 } },
|
|
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.
|
|
@@ -749,10 +818,160 @@ function activityView(activity) {
|
|
|
749
818
|
* whether the graph knows about it.
|
|
750
819
|
* @param {{file: any}} props
|
|
751
820
|
*/
|
|
752
|
-
function ActivityFileRow({ file }) {
|
|
821
|
+
function ActivityFileRow({ file, onSelect, }) {
|
|
753
822
|
const tone = file.verdict === 'indexed' ? 'solid' : file.verdict === 'missing' ? 'outline' : 'neutral';
|
|
754
823
|
const label = file.verdict === 'indexed' ? '已索引' : file.verdict === 'missing' ? '缺失' : '排除';
|
|
755
|
-
|
|
824
|
+
const interactive = typeof onSelect === 'function';
|
|
825
|
+
return h('div', {
|
|
826
|
+
role: interactive ? 'button' : undefined,
|
|
827
|
+
tabIndex: interactive ? 0 : undefined,
|
|
828
|
+
title: interactive ? '查看该文件的提交时间线' : undefined,
|
|
829
|
+
onClick: interactive ? () => onSelect?.(file.path) : undefined,
|
|
830
|
+
onKeyDown: interactive
|
|
831
|
+
? (event) => {
|
|
832
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
833
|
+
event.preventDefault();
|
|
834
|
+
onSelect?.(file.path);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
: undefined,
|
|
838
|
+
style: { ...styles.fileRow, cursor: interactive ? 'pointer' : undefined },
|
|
839
|
+
}, h(FileTypeIcon, { path: file.path, size: 16 }), h('span', { style: { minWidth: 0, flex: '1 1 auto' } }, h(PathLabel, { path: file.path })), h('span', { style: styles.fileMeta }, interactive
|
|
840
|
+
? h('span', { style: { ...MUTED, fontSize: '10px', whiteSpace: 'nowrap' } }, '时间线')
|
|
841
|
+
: null, h(Tag, { tone }, label), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `${file.changes}×`)));
|
|
842
|
+
}
|
|
843
|
+
/** Shared labels/tones for the three activity verdicts. */
|
|
844
|
+
function activityVerdict(verdict) {
|
|
845
|
+
if (verdict === 'indexed')
|
|
846
|
+
return { label: '已索引', tone: 'solid', dot: 'done' };
|
|
847
|
+
if (verdict === 'missing')
|
|
848
|
+
return { label: '索引缺失', tone: 'outline', dot: 'warning' };
|
|
849
|
+
return { label: '设计排除', tone: 'neutral', dot: 'idle' };
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* The three-way verdict with the volume behind it.
|
|
853
|
+
*
|
|
854
|
+
* The old UI had counts only, which made "2 missing" look as urgent as "2 excluded"
|
|
855
|
+
* even when the missing files accounted for 40 commits. This table adds the volume
|
|
856
|
+
* without changing the verdict definition.
|
|
857
|
+
*/
|
|
858
|
+
function ActivityCrossTable({ cross }) {
|
|
859
|
+
const rows = Array.isArray(cross.rows) ? cross.rows : [];
|
|
860
|
+
if (rows.length === 0)
|
|
861
|
+
return null;
|
|
862
|
+
const cell = { padding: '3px 0', textAlign: 'right' };
|
|
863
|
+
return h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, `活动 × 索引交叉对比 · ${compactNumber(cross.totalFiles ?? 0)} 文件 / ` +
|
|
864
|
+
`${compactNumber(cross.totalChanges ?? 0)} 次变更`), h('div', {
|
|
865
|
+
style: {
|
|
866
|
+
display: 'grid',
|
|
867
|
+
gridTemplateColumns: 'minmax(0, 1.5fr) repeat(4, minmax(0, 0.65fr))',
|
|
868
|
+
gap: '4px',
|
|
869
|
+
alignItems: 'center',
|
|
870
|
+
fontSize: '10px',
|
|
871
|
+
},
|
|
872
|
+
}, h('span', { style: MUTED }, '分类'), h('span', { style: { ...MUTED, ...cell } }, '文件'), h('span', { style: { ...MUTED, ...cell } }, '变更'), h('span', { style: { ...MUTED, ...cell } }, '+行'), h('span', { style: { ...MUTED, ...cell } }, '−行'), rows.map((row) => {
|
|
873
|
+
const verdict = activityVerdict(row.verdict);
|
|
874
|
+
return [
|
|
875
|
+
h('span', { key: `${row.verdict}-label`, style: { display: 'flex', alignItems: 'center', gap: '5px' } }, h(StateDot, { state: verdict.dot }), verdict.label),
|
|
876
|
+
h('span', { key: `${row.verdict}-files`, style: cell }, compactNumber(row.files)),
|
|
877
|
+
h('span', { key: `${row.verdict}-changes`, style: cell }, compactNumber(row.changes)),
|
|
878
|
+
h('span', { key: `${row.verdict}-ins`, style: cell }, `+${compactNumber(row.insertions)}`),
|
|
879
|
+
h('span', { key: `${row.verdict}-del`, style: cell }, `−${compactNumber(row.deletions)}`),
|
|
880
|
+
];
|
|
881
|
+
})));
|
|
882
|
+
}
|
|
883
|
+
/** HEAD against the commit the index was built from. */
|
|
884
|
+
function ActivityBaseline({ baseline, onSelect, }) {
|
|
885
|
+
const changedFiles = Array.isArray(baseline.changedFiles) ? baseline.changedFiles : [];
|
|
886
|
+
const short = baseline.shortSha ?? baseline.ref?.slice(0, 8) ?? '未知';
|
|
887
|
+
const date = typeof baseline.date === 'string' ? baseline.date.slice(0, 10) : '';
|
|
888
|
+
const windowBaseline = baseline.source === 'window';
|
|
889
|
+
return h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, `${windowBaseline ? '活动窗口基线审查' : '索引基线审查'} · ${short}` +
|
|
890
|
+
(date === '' ? '' : ` · ${date}`)), windowBaseline
|
|
891
|
+
? h('div', { style: { ...MUTED, fontSize: '10px', marginBottom: '3px' } }, '索引没有记录基线提交,使用活动窗口最早的提交作为基线。')
|
|
892
|
+
: null, baseline.available === false
|
|
893
|
+
? h('div', { style: { display: 'flex', gap: '6px', alignItems: 'flex-start' } }, h(StateDot, { state: 'warning' }), h('span', null, baseline.reason ?? '基线提交不可读'))
|
|
894
|
+
: h('div', { style: { ...MUTED } }, (baseline.commits ?? 0) === 0 && (baseline.files ?? 0) === 0
|
|
895
|
+
? '工作区与索引基线一致,没有新增变更。'
|
|
896
|
+
: `自基线以来 ${compactNumber(baseline.commits ?? 0)} 个提交 · ` +
|
|
897
|
+
`${compactNumber(baseline.files ?? 0)} 个文件 · ` +
|
|
898
|
+
`+${compactNumber(baseline.insertions ?? 0)} / −${compactNumber(baseline.deletions ?? 0)}`), changedFiles.length > 0
|
|
899
|
+
? h('div', { style: { marginTop: '4px' } }, changedFiles
|
|
900
|
+
.slice(0, 8)
|
|
901
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file, onSelect })))
|
|
902
|
+
: null);
|
|
903
|
+
}
|
|
904
|
+
/** Newest-first commit list with per-commit index verdict. */
|
|
905
|
+
function ActivityTimeline({ timeline, indexKnown, }) {
|
|
906
|
+
if (timeline.length === 0) {
|
|
907
|
+
return h('div', { style: { ...MUTED } }, '该时间范围内没有提交。');
|
|
908
|
+
}
|
|
909
|
+
return h('div', null, timeline.slice(0, 15).map((commit) => {
|
|
910
|
+
const verdict = indexKnown
|
|
911
|
+
? activityVerdict(commit.verdict)
|
|
912
|
+
: { label: '索引未知', tone: 'neutral', dot: 'idle' };
|
|
913
|
+
return h('div', {
|
|
914
|
+
key: commit.sha,
|
|
915
|
+
style: {
|
|
916
|
+
display: 'flex',
|
|
917
|
+
flexDirection: 'column',
|
|
918
|
+
gap: '2px',
|
|
919
|
+
padding: '4px 0',
|
|
920
|
+
borderTop: HAIRLINE,
|
|
921
|
+
minWidth: 0,
|
|
922
|
+
},
|
|
923
|
+
}, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } }, h(StateDot, { state: verdict.dot }), h('span', { style: { ...styles.mono, color: 'inherit', whiteSpace: 'nowrap' } }, commit.shortSha ?? commit.sha.slice(0, 8)), h('span', {
|
|
924
|
+
style: {
|
|
925
|
+
minWidth: 0,
|
|
926
|
+
flex: '1 1 auto',
|
|
927
|
+
overflow: 'hidden',
|
|
928
|
+
textOverflow: 'ellipsis',
|
|
929
|
+
whiteSpace: 'nowrap',
|
|
930
|
+
},
|
|
931
|
+
title: commit.subject,
|
|
932
|
+
}, commit.subject === '' ? '(无提交说明)' : commit.subject)), h('div', {
|
|
933
|
+
style: {
|
|
934
|
+
...MUTED,
|
|
935
|
+
fontSize: '10px',
|
|
936
|
+
display: 'flex',
|
|
937
|
+
flexWrap: 'wrap',
|
|
938
|
+
alignItems: 'center',
|
|
939
|
+
gap: '6px',
|
|
940
|
+
marginLeft: '14px',
|
|
941
|
+
},
|
|
942
|
+
}, h(Tag, { tone: verdict.tone }, verdict.label), h('span', { style: { whiteSpace: 'nowrap' } }, commit.date.slice(0, 10)), h('span', { style: { whiteSpace: 'nowrap' } }, commit.author), h('span', { style: { whiteSpace: 'nowrap' } }, `+${compactNumber(commit.insertions)}/−${compactNumber(commit.deletions)}`), h('span', { style: { whiteSpace: 'nowrap' } }, `${compactNumber(commit.files)} 文件`)));
|
|
943
|
+
}));
|
|
944
|
+
}
|
|
945
|
+
/** On-demand commit timeline for one file selected from the activity lists. */
|
|
946
|
+
function FileHistorySection({ filePath, history, busy, error, onClose, }) {
|
|
947
|
+
if (filePath === null)
|
|
948
|
+
return null;
|
|
949
|
+
return h('div', { style: { borderTop: HAIRLINE, paddingTop: '6px', marginTop: '2px' } }, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } }, h('span', { style: { ...MUTED, flexShrink: 0 } }, '文件时间线'), h('span', { style: { minWidth: 0, flex: '1 1 auto' } }, h(PathLabel, { path: filePath })), h(Button, { variant: 'ghost', onClick: onClose }, '关闭')), busy
|
|
950
|
+
? h('div', { style: { ...MUTED, marginTop: '4px' } }, '读取文件历史…')
|
|
951
|
+
: error !== null
|
|
952
|
+
? h('div', { style: { ...MUTED, color: 'var(--dsw-alias-state-error-primary, inherit)' } }, error)
|
|
953
|
+
: history === null
|
|
954
|
+
? null
|
|
955
|
+
: history.available === false
|
|
956
|
+
? h('div', { style: { ...MUTED, marginTop: '4px' } }, history.reason ?? '文件历史不可用')
|
|
957
|
+
: h('div', { style: { marginTop: '4px' } }, h('div', { style: { ...MUTED, fontSize: '10px', marginBottom: '3px' } }, `近 ${history.days} 天 · ${history.commits} 提交 · ` +
|
|
958
|
+
`+${compactNumber(history.insertions)} / −${compactNumber(history.deletions)}`), history.timeline.length === 0
|
|
959
|
+
? h('div', { style: { ...MUTED } }, '该时间范围内没有提交。')
|
|
960
|
+
: history.timeline.length > 30
|
|
961
|
+
? h('div', { style: { ...MUTED, fontSize: '10px', marginBottom: '2px' } }, `仅显示最近 30 个提交(共 ${history.timeline.length} 个)`)
|
|
962
|
+
: null, history.timeline.slice(0, 30).map((commit) => h('div', {
|
|
963
|
+
key: commit.sha,
|
|
964
|
+
style: { ...styles.fileRow, alignItems: 'flex-start' },
|
|
965
|
+
}, h('span', { style: { ...styles.mono, color: 'inherit', whiteSpace: 'nowrap' } }, commit.shortSha ?? commit.sha.slice(0, 8)), h('span', {
|
|
966
|
+
style: {
|
|
967
|
+
minWidth: 0,
|
|
968
|
+
flex: '1 1 auto',
|
|
969
|
+
overflow: 'hidden',
|
|
970
|
+
textOverflow: 'ellipsis',
|
|
971
|
+
whiteSpace: 'nowrap',
|
|
972
|
+
},
|
|
973
|
+
title: commit.subject,
|
|
974
|
+
}, commit.subject === '' ? '(无提交说明)' : commit.subject), h('span', { style: { ...styles.fileMeta, flexWrap: 'wrap' } }, h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, commit.date.slice(0, 10)), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, commit.author), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `+${compactNumber(commit.insertions)}/−${compactNumber(commit.deletions)}`))))));
|
|
756
975
|
}
|
|
757
976
|
/**
|
|
758
977
|
* Git activity, crossed with the index.
|
|
@@ -762,7 +981,16 @@ function ActivityFileRow({ file }) {
|
|
|
762
981
|
* never expands it learns whether recent work is visible to the graph.
|
|
763
982
|
* @param {{activity: any}} props
|
|
764
983
|
*/
|
|
765
|
-
function GitActivitySection({ activity: rawActivity }) {
|
|
984
|
+
function GitActivitySection({ activity: rawActivity, projectPath, }) {
|
|
985
|
+
// The project timeline defaults to 7 days and caps at 30; the file timeline
|
|
986
|
+
// reuses the same range. Hooks are before the "activity unavailable" return so
|
|
987
|
+
// the hook order cannot change with the data.
|
|
988
|
+
const [timelineDays, setTimelineDays] = React.useState(7);
|
|
989
|
+
const [selectedFile, setSelectedFile] = React.useState(null);
|
|
990
|
+
const [fileHistory, setFileHistory] = React.useState(null);
|
|
991
|
+
const [fileHistoryBusy, setFileHistoryBusy] = React.useState(false);
|
|
992
|
+
const [fileHistoryError, setFileHistoryError] = React.useState(null);
|
|
993
|
+
const fileHistorySeq = React.useRef(0);
|
|
766
994
|
// Narrowed to a local const before use. `activityView` returns null for an absent
|
|
767
995
|
// activity, but TypeScript cannot carry that back to the parameter, so every later
|
|
768
996
|
// read would need its own guard — and a guard per read is how one gets forgotten.
|
|
@@ -772,6 +1000,62 @@ function GitActivitySection({ activity: rawActivity }) {
|
|
|
772
1000
|
return h('div', { style: { ...MUTED } }, 'Git 活动不可用');
|
|
773
1001
|
}
|
|
774
1002
|
const missing = view.verdicts.find((v) => v.key === 'missing')?.value ?? 0;
|
|
1003
|
+
const requestFileHistory = (path, days) => {
|
|
1004
|
+
if (projectPath === null)
|
|
1005
|
+
return;
|
|
1006
|
+
const seq = fileHistorySeq.current + 1;
|
|
1007
|
+
fileHistorySeq.current = seq;
|
|
1008
|
+
setSelectedFile(path);
|
|
1009
|
+
setFileHistory(null);
|
|
1010
|
+
setFileHistoryError(null);
|
|
1011
|
+
setFileHistoryBusy(true);
|
|
1012
|
+
const query = `/api/gitnexus/file-history?repo=${encodeURIComponent(projectPath)}` +
|
|
1013
|
+
`&path=${encodeURIComponent(path)}&days=${days}`;
|
|
1014
|
+
fetch(routeUrl(query))
|
|
1015
|
+
.then(async (res) => ({ status: res.status, body: await res.json().catch(() => null) }))
|
|
1016
|
+
.then(({ status, body }) => {
|
|
1017
|
+
if (seq !== fileHistorySeq.current)
|
|
1018
|
+
return;
|
|
1019
|
+
// The client bundle can hot-reload before the host does. An unregistered
|
|
1020
|
+
// route comes back as the desktop carrier's 401/404, not the route's own
|
|
1021
|
+
// not-a-project JSON; say that instead of a generic status.
|
|
1022
|
+
if ((status === 404 && body?.code !== 'not-a-project') ||
|
|
1023
|
+
status === 401) {
|
|
1024
|
+
throw new Error('当前宿主没有文件时间线路由;重启 DSH 后再试。');
|
|
1025
|
+
}
|
|
1026
|
+
if (!body?.ok) {
|
|
1027
|
+
throw new Error(typeof body?.message === 'string' ? body.message : `请求失败 (${status})`);
|
|
1028
|
+
}
|
|
1029
|
+
setFileHistory(body);
|
|
1030
|
+
})
|
|
1031
|
+
.catch((cause) => {
|
|
1032
|
+
if (seq !== fileHistorySeq.current)
|
|
1033
|
+
return;
|
|
1034
|
+
setFileHistoryError(String(cause?.message ?? cause));
|
|
1035
|
+
})
|
|
1036
|
+
.finally(() => {
|
|
1037
|
+
if (seq === fileHistorySeq.current)
|
|
1038
|
+
setFileHistoryBusy(false);
|
|
1039
|
+
});
|
|
1040
|
+
};
|
|
1041
|
+
const openFileHistory = (path) => requestFileHistory(path, timelineDays);
|
|
1042
|
+
const closeFileHistory = () => {
|
|
1043
|
+
fileHistorySeq.current += 1;
|
|
1044
|
+
setSelectedFile(null);
|
|
1045
|
+
setFileHistory(null);
|
|
1046
|
+
setFileHistoryError(null);
|
|
1047
|
+
setFileHistoryBusy(false);
|
|
1048
|
+
};
|
|
1049
|
+
const changeTimelineDays = (days) => {
|
|
1050
|
+
setTimelineDays(days);
|
|
1051
|
+
if (selectedFile !== null)
|
|
1052
|
+
requestFileHistory(selectedFile, days);
|
|
1053
|
+
};
|
|
1054
|
+
const cutoff = Date.now() - timelineDays * 86_400_000;
|
|
1055
|
+
const projectTimeline = (Array.isArray(activity.timeline) ? activity.timeline : []).filter((commit) => {
|
|
1056
|
+
const at = Date.parse(commit.date);
|
|
1057
|
+
return !Number.isFinite(at) || at >= cutoff;
|
|
1058
|
+
});
|
|
775
1059
|
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '4px' } }, h('div', { style: MUTED }, view.totals), view.truncated
|
|
776
1060
|
? h('div', { style: MUTED }, `活动窗口达到提交上限,更早的改动未统计`)
|
|
777
1061
|
: null, h(MetricGrid, {
|
|
@@ -783,27 +1067,61 @@ function GitActivitySection({ activity: rawActivity }) {
|
|
|
783
1067
|
activity.histogram !== undefined && activity.histogram !== null
|
|
784
1068
|
? h(Histogram, { histogram: activity.histogram })
|
|
785
1069
|
: null,
|
|
1070
|
+
// Three views over the same window: what the index sees, what changed since the
|
|
1071
|
+
// index baseline, and the individual commits in order. Old hosts omit these
|
|
1072
|
+
// fields; each block disappears rather than rendering a false empty state.
|
|
1073
|
+
activity.totals?.indexKnown === false
|
|
1074
|
+
? h('div', { style: { ...MUTED } }, '项目尚未建立索引:无法把活动文件与图谱交叉,也没有索引基线可审查。')
|
|
1075
|
+
: activity.cross !== undefined && activity.cross !== null
|
|
1076
|
+
? h(ActivityCrossTable, { cross: activity.cross })
|
|
1077
|
+
: null, activity.baseline !== undefined && activity.baseline !== null
|
|
1078
|
+
? h(ActivityBaseline, { baseline: activity.baseline, onSelect: openFileHistory })
|
|
1079
|
+
: null, h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } }, h('span', { style: { ...MUTED } }, `提交时间线 · 近 ${timelineDays} 天 · ${projectTimeline.length} 个`), h('span', { style: { flex: 1 } }), h('select', {
|
|
1080
|
+
value: String(timelineDays),
|
|
1081
|
+
'aria-label': '项目提交时间线范围',
|
|
1082
|
+
onChange: (event) => changeTimelineDays(Number(event.target.value)),
|
|
1083
|
+
style: {
|
|
1084
|
+
font: 'inherit',
|
|
1085
|
+
fontSize: '10px',
|
|
1086
|
+
padding: '2px 4px',
|
|
1087
|
+
border: HAIRLINE,
|
|
1088
|
+
borderRadius: '4px',
|
|
1089
|
+
background: SOFT_BG,
|
|
1090
|
+
color: 'inherit',
|
|
1091
|
+
},
|
|
1092
|
+
}, [7, 14, 30].map((days) => h('option', { key: days, value: String(days) }, `${days} 天`)))), h(ActivityTimeline, {
|
|
1093
|
+
timeline: projectTimeline,
|
|
1094
|
+
indexKnown: activity.totals?.indexKnown === true,
|
|
1095
|
+
}), activity.timelineTruncated === true
|
|
1096
|
+
? h('div', { style: { ...MUTED, fontSize: '10px' } }, '时间线最多显示最近 100 个提交;缩短范围可看更早记录。')
|
|
1097
|
+
: null,
|
|
786
1098
|
// Missing files come from their own list, not from the display-capped
|
|
787
1099
|
// by-changes slice — otherwise the summary can report a count with no rows.
|
|
788
1100
|
missing > 0 && Array.isArray(activity.missingFiles) && activity.missingFiles.length > 0
|
|
789
1101
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '应被索引却缺失'), activity.missingFiles
|
|
790
1102
|
.slice(0, 8)
|
|
791
|
-
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
1103
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file, onSelect: openFileHistory })))
|
|
792
1104
|
: null, Array.isArray(activity.byChanges) && activity.byChanges.length > 0
|
|
793
1105
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '改动最频繁'), activity.byChanges
|
|
794
1106
|
.filter((f) => f.verdict !== 'excluded')
|
|
795
1107
|
.slice(0, 8)
|
|
796
|
-
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
1108
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file, onSelect: openFileHistory })))
|
|
797
1109
|
: null, Array.isArray(activity.byRecency) && activity.byRecency.length > 0
|
|
798
1110
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '最近改动'), activity.byRecency
|
|
799
1111
|
.filter((f) => f.verdict !== 'excluded')
|
|
800
1112
|
.slice(0, 5)
|
|
801
|
-
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
1113
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file, onSelect: openFileHistory })))
|
|
802
1114
|
: null, Array.isArray(activity.authors) && activity.authors.length > 0
|
|
803
1115
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '提交者'), activity.authors.slice(0, 6).map((author) => h('div', { key: `${author.name}-${author.email}`, style: styles.fileRow }, h('span', { style: { minWidth: 0, flex: '1 1 auto' } }, author.name), h('span', { style: styles.fileMeta }, author.bot ? h(Tag, { tone: 'neutral' }, 'bot') : null, h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `${author.commits} 提交`), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `${author.files} 文件`)))))
|
|
804
1116
|
: null, Array.isArray(activity.branches) && activity.branches.length > 0
|
|
805
1117
|
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, `分支 · ${activity.branches.length}`), activity.branches.slice(0, 6).map((branch) => h('div', { key: branch.name, style: styles.fileRow }, h('span', { style: { minWidth: 0, flex: '1 1 auto', ...styles.mono } }, branch.name), h('span', { style: styles.fileMeta }, branch.current ? h(Tag, { tone: 'solid' }, '当前') : null, h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, branch.lastCommitDate ? branch.lastCommitDate.slice(0, 10) : '')))))
|
|
806
|
-
: null
|
|
1118
|
+
: null, h(FileHistorySection, {
|
|
1119
|
+
filePath: selectedFile,
|
|
1120
|
+
history: fileHistory,
|
|
1121
|
+
busy: fileHistoryBusy,
|
|
1122
|
+
error: fileHistoryError,
|
|
1123
|
+
onClose: closeFileHistory,
|
|
1124
|
+
}));
|
|
807
1125
|
}
|
|
808
1126
|
/**
|
|
809
1127
|
* Resolve the project this panel is about.
|
|
@@ -945,20 +1263,23 @@ function ToolchainNotice({ toolchain, run, runError, now, onInstall, onCancelIns
|
|
|
945
1263
|
},
|
|
946
1264
|
}, 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
1265
|
? h(Button, { variant: 'ghost', onClick: onCancelInstall }, `取消(已进行 ${elapsed}s)`)
|
|
948
|
-
: h(Button, { variant: 'ghost', onClick: () => setConfirming(true) }, '授权并安装'), h('span', { style: MUTED }, '装到插件自己的目录,用你已配置的 npm
|
|
1266
|
+
: h(Button, { variant: 'ghost', onClick: () => setConfirming(true) }, '授权并安装'), h('span', { style: MUTED }, '装到插件自己的目录,用你已配置的 npm 源,不改全局前缀、不需要管理员权限;' +
|
|
1267
|
+
'若当前项目还没有索引,装完会继续初始化一次。')), activeRun === null
|
|
949
1268
|
? null
|
|
950
1269
|
: 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
1270
|
? 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-
|
|
1271
|
+
: 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
1272
|
open: confirming,
|
|
954
1273
|
title: '授权本插件安装 GitNexus?',
|
|
955
1274
|
description: '本插件将执行:\n\nnpm install --prefix <插件数据目录>/gitnexus gitnexus@^1.6.12\n\n' +
|
|
956
1275
|
'· 从你已配置的 npm 源下载约 500 MB ~ 1 GB\n' +
|
|
957
1276
|
'· 需要几分钟;网络慢或需要本地编译时更久\n' +
|
|
958
1277
|
'· 会在本机运行这些包的安装期构建脚本(原生数据库与解析器需要本地编译)\n' +
|
|
959
|
-
'· 安装到插件自己的目录,不改动你的全局 npm 前缀\n
|
|
1278
|
+
'· 安装到插件自己的目录,不改动你的全局 npm 前缀\n' +
|
|
1279
|
+
'· 安装成功后,若当前项目尚无索引,会继续运行一次 ' +
|
|
1280
|
+
'`gitnexus analyze --index-only`(只写 .gitnexus/,不改源文件)\n\n' +
|
|
960
1281
|
'命令走 DSH 的 shell 执行器,沙箱与其它命令一致 —— 本插件不申请扩权。',
|
|
961
|
-
acknowledgeLabel: '我已了解这会下载数百 MB
|
|
1282
|
+
acknowledgeLabel: '我已了解这会下载数百 MB、在本机编译原生模块,并在需要时初始化当前项目',
|
|
962
1283
|
cancelLabel: '取消',
|
|
963
1284
|
confirmLabel: '授权并开始',
|
|
964
1285
|
closeLabel: '关闭',
|
|
@@ -975,19 +1296,132 @@ function ToolchainNotice({ toolchain, run, runError, now, onInstall, onCancelIns
|
|
|
975
1296
|
},
|
|
976
1297
|
})));
|
|
977
1298
|
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Keyword search over the graph's symbol names and file paths.
|
|
1301
|
+
*
|
|
1302
|
+
* A native `input` rather than a primitives component: the shared package ships no
|
|
1303
|
+
* declarations for an input, and guessing a prop name there fails at mount time in a
|
|
1304
|
+
* browser where no test here can see it. The styling is the same theme-agnostic
|
|
1305
|
+
* vocabulary the rest of the panel uses — inherited colour, borders from
|
|
1306
|
+
* `currentColor`.
|
|
1307
|
+
*/
|
|
1308
|
+
function SearchSection({ search }) {
|
|
1309
|
+
const keyOf = (hit) => `${hit.kind}:${hit.filePath}:${hit.startLine ?? ''}:${hit.name}`;
|
|
1310
|
+
const canSubmit = search.query.trim() !== '' && !search.searching;
|
|
1311
|
+
return h(Section, {
|
|
1312
|
+
wide: true,
|
|
1313
|
+
title: '关键词搜索',
|
|
1314
|
+
meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, search.mode === 'text' ? '源码关键词(回退)' : '符号名 / 文件路径'),
|
|
1315
|
+
}, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', paddingTop: '4px', flexWrap: 'wrap' } }, h('input', {
|
|
1316
|
+
type: 'search',
|
|
1317
|
+
value: search.query,
|
|
1318
|
+
placeholder: '搜索符号或文件,例如 analyzeCoverage',
|
|
1319
|
+
'aria-label': '关键词搜索',
|
|
1320
|
+
spellCheck: false,
|
|
1321
|
+
autoComplete: 'off',
|
|
1322
|
+
onChange: (event) => search.onQueryChange(event.target.value),
|
|
1323
|
+
onKeyDown: (event) => {
|
|
1324
|
+
if (event.key === 'Enter' && canSubmit) {
|
|
1325
|
+
event.preventDefault();
|
|
1326
|
+
search.onSubmit();
|
|
1327
|
+
}
|
|
1328
|
+
},
|
|
1329
|
+
style: {
|
|
1330
|
+
flex: '1 1 auto',
|
|
1331
|
+
minWidth: 0,
|
|
1332
|
+
padding: '5px 8px',
|
|
1333
|
+
border: HAIRLINE,
|
|
1334
|
+
borderRadius: '6px',
|
|
1335
|
+
background: SOFT_BG,
|
|
1336
|
+
color: 'inherit',
|
|
1337
|
+
font: 'inherit',
|
|
1338
|
+
},
|
|
1339
|
+
}), h(Button, { variant: 'primary', disabled: !canSubmit, onClick: search.onSubmit }, search.searching ? '搜索中…' : '搜索'), search.query === '' ? null : h(Button, { variant: 'ghost', onClick: search.onClear }, '清除')), search.error !== null
|
|
1340
|
+
? h('div', {
|
|
1341
|
+
style: {
|
|
1342
|
+
...MUTED,
|
|
1343
|
+
color: 'var(--dsw-alias-state-error-primary, inherit)',
|
|
1344
|
+
marginTop: '6px',
|
|
1345
|
+
},
|
|
1346
|
+
}, search.error)
|
|
1347
|
+
: null, h('div', { style: { ...MUTED, fontSize: '10px', marginTop: '4px' } }, search.warning ??
|
|
1348
|
+
(search.mode === 'text'
|
|
1349
|
+
? '图查询不可用,已回退到源码关键词搜索。'
|
|
1350
|
+
: '按符号名与文件路径匹配;不搜索正文。')), search.results === null
|
|
1351
|
+
? null
|
|
1352
|
+
: search.results.length === 0
|
|
1353
|
+
? h('div', { style: { ...MUTED, marginTop: '6px' } }, `没有匹配“${search.query}”的符号或文件。`)
|
|
1354
|
+
: h('div', { style: { display: 'flex', flexDirection: 'column', marginTop: '6px' } }, h('div', { style: { ...MUTED, fontSize: '10px', paddingBottom: '2px' } }, (search.mode === 'text'
|
|
1355
|
+
? `找到 ${search.total} 处源码匹配`
|
|
1356
|
+
: `找到 ${search.total} 个结果`) +
|
|
1357
|
+
`${search.truncated ? '(已截断)' : ''} · 点击结果打开文件`), search.results.map((hit) => h('div', {
|
|
1358
|
+
key: keyOf(hit),
|
|
1359
|
+
style: { ...styles.fileRow, borderTop: HAIRLINE, padding: '5px 0' },
|
|
1360
|
+
}, h('button', {
|
|
1361
|
+
type: 'button',
|
|
1362
|
+
onClick: () => search.onOpen(hit),
|
|
1363
|
+
title: hit.startLine === null
|
|
1364
|
+
? `打开 ${hit.filePath}`
|
|
1365
|
+
: `打开 ${hit.filePath}:${hit.startLine}`,
|
|
1366
|
+
style: {
|
|
1367
|
+
display: 'flex',
|
|
1368
|
+
alignItems: 'center',
|
|
1369
|
+
gap: '6px',
|
|
1370
|
+
flex: '1 1 auto',
|
|
1371
|
+
minWidth: 0,
|
|
1372
|
+
padding: 0,
|
|
1373
|
+
border: 0,
|
|
1374
|
+
background: 'none',
|
|
1375
|
+
color: 'inherit',
|
|
1376
|
+
font: 'inherit',
|
|
1377
|
+
textAlign: 'left',
|
|
1378
|
+
cursor: 'pointer',
|
|
1379
|
+
},
|
|
1380
|
+
}, h(Tag, { tone: 'neutral' }, hit.kind), h('span', {
|
|
1381
|
+
style: {
|
|
1382
|
+
fontWeight: 600,
|
|
1383
|
+
minWidth: 0,
|
|
1384
|
+
overflow: 'hidden',
|
|
1385
|
+
textOverflow: 'ellipsis',
|
|
1386
|
+
whiteSpace: 'nowrap',
|
|
1387
|
+
},
|
|
1388
|
+
}, hit.name), h('span', {
|
|
1389
|
+
style: {
|
|
1390
|
+
...MUTED,
|
|
1391
|
+
...styles.mono,
|
|
1392
|
+
minWidth: 0,
|
|
1393
|
+
flex: '1 1 auto',
|
|
1394
|
+
overflow: 'hidden',
|
|
1395
|
+
textOverflow: 'ellipsis',
|
|
1396
|
+
whiteSpace: 'nowrap',
|
|
1397
|
+
},
|
|
1398
|
+
}, h(PathLabel, { path: hit.filePath }), hit.startLine !== null ? `:${hit.startLine}` : '')), h(Button, { variant: 'ghost', onClick: () => search.onCopy(hit) }, search.copied === keyOf(hit) ? '已复制' : '复制')))));
|
|
1399
|
+
}
|
|
978
1400
|
function MaintenanceSection({ maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun, }) {
|
|
979
1401
|
// `projectPath` is part of the prop set for the header, which builds the commands
|
|
980
1402
|
// from it; the body does not read it.
|
|
981
1403
|
void projectPath;
|
|
982
1404
|
const [copied, setCopied] = React.useState(null);
|
|
983
|
-
const [confirming, setConfirming] = React.useState(
|
|
1405
|
+
const [confirming, setConfirming] = React.useState(null);
|
|
984
1406
|
// `RiskConfirmation`'s checkbox is controlled, so the tick has to live here. Without
|
|
985
1407
|
// this the box renders but never moves, which is how it looked the first time.
|
|
986
1408
|
const [acknowledged, setAcknowledged] = React.useState(false);
|
|
987
1409
|
const commands = Array.isArray(maintenance?.commands) ? maintenance.commands : [];
|
|
988
1410
|
if (commands.length === 0)
|
|
989
1411
|
return null;
|
|
990
|
-
|
|
1412
|
+
// The incremental command is the normal action on both states: it initialises a
|
|
1413
|
+
// missing index and refreshes a present one. The full rebuild is an escape hatch
|
|
1414
|
+
// for a hung refresh, never "what applies" — marking it applicable on every
|
|
1415
|
+
// indexed repository recommended the destructive path by default.
|
|
1416
|
+
const applicable = 'init';
|
|
1417
|
+
const fullConfirm = confirming === 'full';
|
|
1418
|
+
const confirmCommand = commands.find((c) => c.mode === (fullConfirm ? 'full' : 'init'));
|
|
1419
|
+
// `supportsForce` is also the host-generation marker: the same 0.1.2 release
|
|
1420
|
+
// added the adaptive FTS decision and the project-local GITNEXUS_HOME. An older
|
|
1421
|
+
// host's runner does not include either fix, so a refresh can fail on the FTS
|
|
1422
|
+
// install or the registry write. Refuse to start it and say why, rather than
|
|
1423
|
+
// leaving a button that is known to be wrong on this host.
|
|
1424
|
+
const hostCurrent = maintenance.supportsForce === true;
|
|
991
1425
|
// Normalised rather than trusted. The declared type says `ClientAnalyzeRun | null`,
|
|
992
1426
|
// but a React element built without the prop arrives as `undefined` — which is
|
|
993
1427
|
// exactly what happened the first time this component was rendered by a caller that
|
|
@@ -1017,7 +1451,10 @@ function MaintenanceSection({ maintenance, projectPath, indexed, run, runError,
|
|
|
1017
1451
|
// The old line said "复制后到会话执行". That stopped being the whole story once the
|
|
1018
1452
|
// plugin could start the run itself, so the sentence says which parts it runs and
|
|
1019
1453
|
// which it only hands over.
|
|
1020
|
-
h('div', { style: MUTED },
|
|
1454
|
+
h('div', { style: MUTED }, hostCurrent
|
|
1455
|
+
? '两条命令都可由本插件执行(走 DSH 的 shell 执行器,沙箱与其它命令一致,本插件不申请扩权)。' +
|
|
1456
|
+
'全量重建只应在增量刷新卡死或结果反常时使用。'
|
|
1457
|
+
: '当前宿主是旧版本(不包含 FTS 自适应与注册表重定向修复);重启 DSH 后再执行维护。命令仍可复制。'),
|
|
1021
1458
|
// The run sits above the commands rather than under one of them: the host holds a
|
|
1022
1459
|
// single run per repository, and nesting it would imply the two variants have
|
|
1023
1460
|
// separate ones.
|
|
@@ -1033,9 +1470,13 @@ function MaintenanceSection({ maintenance, projectPath, indexed, run, runError,
|
|
|
1033
1470
|
borderRadius: '6px',
|
|
1034
1471
|
background: SOFT_BG,
|
|
1035
1472
|
},
|
|
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
|
|
1473
|
+
}, 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
1474
|
? h(Button, { variant: 'ghost', onClick: onCancelRun }, '取消')
|
|
1038
|
-
|
|
1475
|
+
// Wrapped, not passed directly: once `onStartRun` takes a `force`
|
|
1476
|
+
// argument, React's click event would arrive as that argument and every
|
|
1477
|
+
// "run again" would become a full rebuild. Repeating the last mode is
|
|
1478
|
+
// also the honest reading of "run again" after a force rebuild.
|
|
1479
|
+
: h(Button, { variant: 'ghost', disabled: !hostCurrent, onClick: () => onStartRun(activeRun.force === true) }, '再次执行')), activeRun.message === null ? null : h('div', { style: MUTED }, activeRun.message),
|
|
1039
1480
|
// The output is the evidence. A run that reports success with a warning in
|
|
1040
1481
|
// its log is a different outcome from one that does not, and hiding the text
|
|
1041
1482
|
// would leave the panel's own verdict as the only thing to check.
|
|
@@ -1051,72 +1492,138 @@ function MaintenanceSection({ maintenance, projectPath, indexed, run, runError,
|
|
|
1051
1492
|
},
|
|
1052
1493
|
}, activeRun.output)), runError === null
|
|
1053
1494
|
? null
|
|
1054
|
-
: h('div', { style: { ...MUTED, color: 'var(--dsw-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1495
|
+
: h('div', { style: { ...MUTED, color: 'var(--dsw-alias-state-error-primary, inherit)' } }, runError), commands.map((command) => {
|
|
1496
|
+
// The host's wording is written for the no-index state ("初始化索引", "该项目还没有
|
|
1497
|
+
// … 索引时使用"). Reusing it on an indexed project told the operator to initialise
|
|
1498
|
+
// an index that already existed. Only the label and the "when" line are stateful;
|
|
1499
|
+
// the command itself is identical.
|
|
1500
|
+
const shown = command.mode === 'init' && indexed
|
|
1501
|
+
? {
|
|
1502
|
+
...command,
|
|
1503
|
+
label: '增量刷新',
|
|
1504
|
+
when: '索引落后或有文件改动时使用;未变更文件的解析产物会复用。',
|
|
1505
|
+
}
|
|
1506
|
+
: command;
|
|
1507
|
+
return h('div', {
|
|
1508
|
+
key: command.mode,
|
|
1509
|
+
style: {
|
|
1510
|
+
display: 'flex',
|
|
1511
|
+
flexDirection: 'column',
|
|
1512
|
+
gap: '3px',
|
|
1513
|
+
// The inapplicable one stays visible — an operator may still want the
|
|
1514
|
+
// text — but recedes.
|
|
1515
|
+
opacity: command.mode === applicable ? 1 : 0.55,
|
|
1516
|
+
},
|
|
1517
|
+
}, 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 } }),
|
|
1518
|
+
// Both commands are executable now, each behind its own confirmation. The
|
|
1519
|
+
// force button exists because the host route implements `--force`; a button
|
|
1520
|
+
// labelled "全量重建" that quietly ran an incremental refresh would be the
|
|
1521
|
+
// exact failure the earlier revision refused to ship.
|
|
1522
|
+
command.mode === 'init'
|
|
1523
|
+
? h(Button, { variant: 'ghost', disabled: running || !hostCurrent, onClick: () => setConfirming('init') }, running ? '运行中' : hostCurrent ? '执行增量刷新' : '执行增量刷新(需重启)')
|
|
1524
|
+
: hostCurrent
|
|
1525
|
+
? h(Button, { variant: 'ghost', disabled: running, onClick: () => setConfirming('full') }, running ? '运行中' : '全量重建')
|
|
1526
|
+
: h(Button, {
|
|
1527
|
+
variant: 'ghost',
|
|
1528
|
+
disabled: true,
|
|
1529
|
+
title: '当前宿主版本尚未支持 force=1;重启 DSH 后再试,或复制命令执行',
|
|
1530
|
+
}, '全量重建(需重启)'), h(Button, { variant: 'ghost', onClick: () => copy(command) }, copied === command.mode ? '已复制' : '复制')), h('code', {
|
|
1531
|
+
style: {
|
|
1532
|
+
...styles.mono,
|
|
1533
|
+
display: 'block',
|
|
1534
|
+
padding: '6px 8px',
|
|
1535
|
+
border: HAIRLINE,
|
|
1536
|
+
borderRadius: '6px',
|
|
1537
|
+
background: SOFT_BG,
|
|
1538
|
+
wordBreak: 'break-all',
|
|
1539
|
+
userSelect: 'all',
|
|
1540
|
+
},
|
|
1541
|
+
}, command.shell), h('div', { style: MUTED }, shown.when), command.caveat !== null && command.caveat !== undefined
|
|
1542
|
+
? h('div', { style: MUTED }, command.caveat)
|
|
1543
|
+
: null);
|
|
1544
|
+
}), Array.isArray(maintenance?.rationale) && maintenance.rationale.length > 0
|
|
1084
1545
|
? h('div', { style: { ...MUTED, display: 'flex', flexDirection: 'column', gap: '2px' } }, maintenance.rationale.map((note, index) => h('div', { key: index }, `· ${note}`)))
|
|
1085
1546
|
: null,
|
|
1086
1547
|
// The confirmation. Everything the operator needs before a process touches their
|
|
1087
1548
|
// working tree goes in `description`, because that is the prop the modal renders:
|
|
1088
1549
|
// an earlier revision passed this as children and the dialog opened with an empty
|
|
1089
1550
|
// body and two unlabelled buttons.
|
|
1551
|
+
//
|
|
1552
|
+
// One dialog, two modes. The full rebuild gets its own title, wording and
|
|
1553
|
+
// acknowledgement because it is a different risk: it discards the graph and FTS
|
|
1554
|
+
// and can take minutes, while the incremental path reuses unchanged files.
|
|
1090
1555
|
h(primitives.RiskConfirmation, {
|
|
1091
|
-
open: confirming,
|
|
1092
|
-
title: '执行增量刷新?',
|
|
1093
|
-
description:
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1556
|
+
open: confirming !== null,
|
|
1557
|
+
title: fullConfirm ? '执行全量重建?' : '执行增量刷新?',
|
|
1558
|
+
description: fullConfirm
|
|
1559
|
+
? '将执行强制全量重建:\n\n' +
|
|
1560
|
+
(confirmCommand?.shell ?? 'gitnexus analyze --index-only --force') +
|
|
1561
|
+
'\n\n' +
|
|
1562
|
+
'这会重建图与 FTS;未变更文件的解析产物仍会复用。' +
|
|
1563
|
+
'但耗时以分钟计,索引很大时更久(上限 10 分钟,可随时取消)。' +
|
|
1564
|
+
'重建期间索引可能短暂不可用。' +
|
|
1565
|
+
'只写入 .gitnexus/,不改动源文件,也不会重写 AGENTS.md / CLAUDE.md。' +
|
|
1566
|
+
'命令由 DSH 的 shell 执行器运行,沙箱与其它命令一致 —— 本插件不申请扩权。'
|
|
1567
|
+
: '将在项目目录下运行:\n\n' +
|
|
1568
|
+
(confirmCommand?.shell ?? 'gitnexus analyze --index-only') +
|
|
1569
|
+
'\n\n' +
|
|
1570
|
+
'只写入 .gitnexus/,不改动任何源文件,也不会重写 AGENTS.md / CLAUDE.md。' +
|
|
1571
|
+
'耗时取决于改动量:无改动时约 7 秒,改动几个文件时实测可到 3 分钟,' +
|
|
1572
|
+
'大仓库的初始化或刷新可能更久(上限 10 分钟,可随时取消)。' +
|
|
1573
|
+
'命令由 DSH 的 shell 执行器运行,沙箱与其它命令一致 —— 本插件不申请扩权。',
|
|
1574
|
+
acknowledgeLabel: fullConfirm
|
|
1575
|
+
? '我了解这会重建图与 FTS、耗时数分钟,并可能让索引短暂不可用'
|
|
1576
|
+
: '我了解这会运行一个进程并写入 .gitnexus/',
|
|
1101
1577
|
cancelLabel: '取消',
|
|
1102
|
-
confirmLabel: '执行',
|
|
1578
|
+
confirmLabel: fullConfirm ? '开始重建' : '执行',
|
|
1103
1579
|
closeLabel: '关闭',
|
|
1104
1580
|
// The checkbox is controlled, so it needs state of its own, and it is reset on
|
|
1105
1581
|
// close: the acknowledgement is per-action rather than remembered.
|
|
1106
1582
|
acknowledged,
|
|
1107
1583
|
onAcknowledgedChange: setAcknowledged,
|
|
1108
1584
|
onCancel: () => {
|
|
1109
|
-
setConfirming(
|
|
1585
|
+
setConfirming(null);
|
|
1110
1586
|
setAcknowledged(false);
|
|
1111
1587
|
},
|
|
1112
1588
|
onConfirm: () => {
|
|
1113
|
-
|
|
1589
|
+
const force = confirming === 'full';
|
|
1590
|
+
setConfirming(null);
|
|
1114
1591
|
setAcknowledged(false);
|
|
1115
|
-
onStartRun();
|
|
1592
|
+
onStartRun(force);
|
|
1116
1593
|
},
|
|
1117
1594
|
}));
|
|
1118
1595
|
}
|
|
1119
|
-
function
|
|
1596
|
+
function LoadingBar({ label, percent }) {
|
|
1597
|
+
const value = percent === null || percent === undefined
|
|
1598
|
+
? undefined
|
|
1599
|
+
: Math.max(0, Math.min(100, Math.round(percent)));
|
|
1600
|
+
const text = value === undefined ? label : `${label} · ${value}%`;
|
|
1601
|
+
return h('div', {
|
|
1602
|
+
style: {
|
|
1603
|
+
gridColumn: '1 / -1',
|
|
1604
|
+
display: 'flex',
|
|
1605
|
+
flexDirection: 'column',
|
|
1606
|
+
gap: '4px',
|
|
1607
|
+
},
|
|
1608
|
+
}, h('progress', {
|
|
1609
|
+
value,
|
|
1610
|
+
max: 100,
|
|
1611
|
+
'aria-label': label,
|
|
1612
|
+
'aria-valuetext': text,
|
|
1613
|
+
'aria-valuenow': value,
|
|
1614
|
+
style: {
|
|
1615
|
+
width: '100%',
|
|
1616
|
+
height: '4px',
|
|
1617
|
+
border: 0,
|
|
1618
|
+
borderRadius: '999px',
|
|
1619
|
+
overflow: 'hidden',
|
|
1620
|
+
background: 'var(--dsw-alias-bg-layer-3, rgba(127, 127, 127, 0.16))',
|
|
1621
|
+
accentColor: 'var(--dsw-alias-brand-primary, #4f8cff)',
|
|
1622
|
+
color: 'var(--dsw-alias-brand-primary, #4f8cff)',
|
|
1623
|
+
},
|
|
1624
|
+
}), h('div', { style: { ...MUTED, fontSize: '10px' } }, text));
|
|
1625
|
+
}
|
|
1626
|
+
function CoverageView({ report, maintenance, projectPath, context, busy, progress, error, readAt, run, runError, now, onRefresh, onStartRun, onCancelRun, installRun, installError, onInstall, onCancelInstall, search, }) {
|
|
1120
1627
|
// Whether an index exists decides which maintenance command is applicable. When
|
|
1121
1628
|
// the project could not even be identified there is nothing to claim, so it is
|
|
1122
1629
|
// false rather than assumed.
|
|
@@ -1137,6 +1644,16 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1137
1644
|
title: '重新读取报告(不会重建索引;重建见下方「维护命令」)',
|
|
1138
1645
|
'aria-label': '重新读取报告',
|
|
1139
1646
|
}, h(IconRefreshOutlineRegular, { size: 14 })));
|
|
1647
|
+
// A report already on screen stays readable while a manual refresh runs; the bar
|
|
1648
|
+
// rides above it. Before the first report exists, the bar is the whole body.
|
|
1649
|
+
// `percent` is null when the host does not stream (old host, or a cached read):
|
|
1650
|
+
// the native progress element is then indeterminate, not a made-up number.
|
|
1651
|
+
const progressBar = report === null || busy
|
|
1652
|
+
? h(LoadingBar, {
|
|
1653
|
+
label: progress?.label ?? (report === null ? '正在读取索引…' : '正在刷新索引…'),
|
|
1654
|
+
percent: progress?.percent ?? null,
|
|
1655
|
+
})
|
|
1656
|
+
: null;
|
|
1140
1657
|
// "I cannot tell which project this is" and "this project has no index" are
|
|
1141
1658
|
// different facts, and the first revision conflated them: an unresolved project
|
|
1142
1659
|
// fell through to the no-index branch and told the operator to initialise an
|
|
@@ -1157,34 +1674,52 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1157
1674
|
}, h(MaintenanceSection, { maintenance, projectPath, indexed: false, run, runError, now, onStartRun, onCancelRun })));
|
|
1158
1675
|
}
|
|
1159
1676
|
if (report === null) {
|
|
1160
|
-
return h('div', { style: styles.root }, header,
|
|
1677
|
+
return h('div', { style: styles.root }, header, progressBar);
|
|
1161
1678
|
}
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1679
|
+
// Defaults are resolved before the no-index branch: a scan-only report still has
|
|
1680
|
+
// totals, a cap and languages even though it has no graph. Building them only for
|
|
1681
|
+
// an indexed report is what made "no index" mean "nothing to show".
|
|
1682
|
+
const totals = report.totals ?? ZERO_TOTALS;
|
|
1683
|
+
const cap = report.cap ?? ZERO_CAP;
|
|
1684
|
+
const analyzer = report.analyzer ?? ZERO_ANALYZER;
|
|
1685
|
+
const largeFiles = report.largeFiles ?? [];
|
|
1686
|
+
const languages = report.languages ?? null;
|
|
1687
|
+
const { notable, context: contextWarnings } = splitWarnings(report.warnings);
|
|
1688
|
+
const ratio = coverageRatio(report);
|
|
1689
|
+
// No index yet. This is the state the initialize command exists for, but it is
|
|
1690
|
+
// also a state in which the working tree is still readable: file counts, bytes,
|
|
1691
|
+
// the cap and the language split do not need GitNexus. The panel used to show
|
|
1692
|
+
// only "没有索引" and a command, which made an otherwise healthy repository look
|
|
1693
|
+
// empty. The scan is the "at least something to look at" floor.
|
|
1164
1694
|
if (report.repo?.indexed !== true) {
|
|
1165
|
-
return h('div', { style: styles.root },
|
|
1695
|
+
return h('div', { style: styles.root }, h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header), progressBar, h(ToolchainNotice, {
|
|
1696
|
+
toolchain: report.toolchain,
|
|
1697
|
+
run: installRun,
|
|
1698
|
+
runError: installError,
|
|
1699
|
+
now,
|
|
1700
|
+
onInstall,
|
|
1701
|
+
onCancelInstall,
|
|
1702
|
+
}), 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, {
|
|
1703
|
+
items: [
|
|
1704
|
+
{ label: '扫描文件', value: compactNumber(totals.scanned) },
|
|
1705
|
+
{ label: '选中文件', value: compactNumber(totals.selected) },
|
|
1706
|
+
{ label: '扫描字节', value: fileSize(totals.scannedBytes ?? 0) },
|
|
1707
|
+
{ label: '大小上限', value: cap.text, hint: capSourceLabel(cap.source) },
|
|
1708
|
+
],
|
|
1709
|
+
}), h('div', { style: { ...MUTED, marginTop: '6px' } }, '这里的数字来自工作区,不是索引;索引里有什么,只有建立后才能回答。')), languages !== null && languages.rows.length > 0 ? h(LanguageSection, { languages }) : null, maintenance === null || maintenance === undefined
|
|
1166
1710
|
? null
|
|
1167
1711
|
: h(Section, {
|
|
1168
1712
|
wide: true,
|
|
1169
1713
|
title: '初始化索引',
|
|
1170
|
-
meta: h('span', { style: { ...MUTED, fontSize: '11px' } },
|
|
1714
|
+
meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, maintenance.supportsForce === true
|
|
1715
|
+
? '均可直接执行,也可复制到会话(沙箱与提权由 DSH 处理)'
|
|
1716
|
+
: '重启 DSH 后可执行;当前仅可复制(沙箱与提权由 DSH 处理)'),
|
|
1171
1717
|
}, h(MaintenanceSection, { maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun })));
|
|
1172
1718
|
}
|
|
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
1719
|
return h('div', { style: styles.root },
|
|
1185
1720
|
// The header and the two status lines span everything: they describe the whole
|
|
1186
1721
|
// panel, so giving them a column would make them look like one card's heading.
|
|
1187
|
-
h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header),
|
|
1722
|
+
h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header), progressBar,
|
|
1188
1723
|
// Immediately after the header, before any index content. If GitNexus is missing,
|
|
1189
1724
|
// that single fact explains everything the panel is about to show or fail to show,
|
|
1190
1725
|
// so it belongs above the numbers rather than beside them.
|
|
@@ -1208,6 +1743,9 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1208
1743
|
const s = stalenessView(report.staleness);
|
|
1209
1744
|
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h(StateDot, { state: s.state }), h('span', null, s.text));
|
|
1210
1745
|
})()),
|
|
1746
|
+
// Search sits above the report cards: it is the action a reader reaches for
|
|
1747
|
+
// before reading, not an annotation on the numbers below it.
|
|
1748
|
+
search === undefined ? null : h(SearchSection, { search }),
|
|
1211
1749
|
// One card for the whole "what is this index" question — file coverage and
|
|
1212
1750
|
// graph contents together.
|
|
1213
1751
|
//
|
|
@@ -1225,13 +1763,15 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1225
1763
|
: h(Ring, {
|
|
1226
1764
|
ratio,
|
|
1227
1765
|
caption: '覆盖',
|
|
1228
|
-
tone: ratio >= 0.9
|
|
1766
|
+
tone: ratio >= 0.9
|
|
1767
|
+
? 'var(--dsw-alias-state-success-primary, #3fb950)'
|
|
1768
|
+
: 'var(--dsw-alias-state-warn-primary, #d9a441)',
|
|
1229
1769
|
}), h('div', { style: { display: 'flex', flexDirection: 'column', gap: '9px', minWidth: 0, flex: '1 1 auto' } }, h('div', { style: MUTED }, summaryLine(report)),
|
|
1230
1770
|
// Files in, graph out — the two halves sit side by side so the wide card
|
|
1231
1771
|
// is used rather than merely filled.
|
|
1232
1772
|
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
1773
|
items: [
|
|
1234
|
-
{ label: '大小上限', value: cap.text, hint: cap.source },
|
|
1774
|
+
{ label: '大小上限', value: cap.text, hint: capSourceLabel(cap.source) },
|
|
1235
1775
|
{ label: '跳过合计', value: fileSize(totals.largeBytes) },
|
|
1236
1776
|
{ label: '超限文件', value: compactNumber(totals.large) },
|
|
1237
1777
|
],
|
|
@@ -1245,7 +1785,9 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1245
1785
|
{ label: '流程', value: compactNumber(report.graph.processes) },
|
|
1246
1786
|
{ label: '向量', value: compactNumber(report.graph.embeddings) },
|
|
1247
1787
|
],
|
|
1248
|
-
}))), h('div', { style: { borderTop: HAIRLINE, paddingTop: '8px' } }, h('div', { style: { ...MUTED, marginBottom: '5px' } }, '查询能力'), h(CapabilityStrip, { capabilities: report.capabilities })
|
|
1788
|
+
}))), h('div', { style: { borderTop: HAIRLINE, paddingTop: '8px' } }, h('div', { style: { ...MUTED, marginBottom: '5px' } }, '查询能力'), h(CapabilityStrip, { capabilities: report.capabilities }), report.capabilities?.fts?.status === 'available'
|
|
1789
|
+
? null
|
|
1790
|
+
: h('div', { style: { ...MUTED, marginTop: '4px', fontSize: '10px' } }, '顶部「关键词搜索」会先查图;图查询不可用时回退到源码关键词。'))))),
|
|
1249
1791
|
// Shares a row with the overview card: a bar plus its legend is a proportional
|
|
1250
1792
|
// reading, and pairing it with the coverage figure puts the two "how much of
|
|
1251
1793
|
// this repository" answers side by side. Order matters — it must directly
|
|
@@ -1313,14 +1855,18 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1313
1855
|
meta: h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '5px', ...MUTED, fontSize: '11px' } }, h(StateDot, {
|
|
1314
1856
|
state: activityView(report.activity)?.verdicts.find((v) => v.key === 'missing')?.tone ?? 'done',
|
|
1315
1857
|
}), activityCollapsed(report.activity)),
|
|
1316
|
-
}, h(GitActivitySection, { activity: report.activity })),
|
|
1858
|
+
}, h(GitActivitySection, { activity: report.activity, projectPath })),
|
|
1317
1859
|
// Wide, and always open: the commands are the action this panel offers.
|
|
1318
1860
|
maintenance === null || maintenance === undefined
|
|
1319
1861
|
? null
|
|
1320
1862
|
: h(Section, {
|
|
1321
1863
|
wide: true,
|
|
1322
1864
|
title: '维护命令',
|
|
1323
|
-
meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, `${maintenance.commands?.length ?? 0} 条 ·
|
|
1865
|
+
meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, `${maintenance.commands?.length ?? 0} 条 · ` +
|
|
1866
|
+
(maintenance.supportsForce === true
|
|
1867
|
+
? '均可直接执行,也可复制到会话'
|
|
1868
|
+
: '重启 DSH 后可执行;当前仅可复制') +
|
|
1869
|
+
'(沙箱与提权由 DSH 处理)'),
|
|
1324
1870
|
}, h(MaintenanceSection, { maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun })));
|
|
1325
1871
|
}
|
|
1326
1872
|
/**
|
|
@@ -1334,10 +1880,12 @@ function CoverageView({ report, maintenance, projectPath, context, busy, error,
|
|
|
1334
1880
|
*/
|
|
1335
1881
|
function CoveragePanel(props) {
|
|
1336
1882
|
const projectPath = useProjectPath(props);
|
|
1883
|
+
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
|
|
1337
1884
|
const [report, setReport] = React.useState(null);
|
|
1338
1885
|
const [maintenance, setMaintenance] = React.useState(null);
|
|
1339
1886
|
const [error, setError] = React.useState(null);
|
|
1340
1887
|
const [busy, setBusy] = React.useState(false);
|
|
1888
|
+
const [progress, setProgress] = React.useState(null);
|
|
1341
1889
|
const [reloadToken, setReloadToken] = React.useState(0);
|
|
1342
1890
|
// When the report on screen was read. Without it a refresh of an unchanged
|
|
1343
1891
|
// repository produces a byte-identical panel, so the control looks inert even
|
|
@@ -1345,6 +1893,19 @@ function CoveragePanel(props) {
|
|
|
1345
1893
|
const [readAt, setReadAt] = React.useState(null);
|
|
1346
1894
|
// Only a manual refresh bypasses the host's cache; the initial load does not.
|
|
1347
1895
|
const wantsFresh = reloadToken > 0;
|
|
1896
|
+
// Keyword search. Its own state because it is an on-demand action, not part of
|
|
1897
|
+
// the report the panel loads: an idle panel makes no search requests at all.
|
|
1898
|
+
const [searchQuery, setSearchQuery] = React.useState('');
|
|
1899
|
+
const [searchResults, setSearchResults] = React.useState(null);
|
|
1900
|
+
const [searchMode, setSearchMode] = React.useState(null);
|
|
1901
|
+
const [searchTotal, setSearchTotal] = React.useState(0);
|
|
1902
|
+
const [searchTruncated, setSearchTruncated] = React.useState(false);
|
|
1903
|
+
const [searchWarning, setSearchWarning] = React.useState(null);
|
|
1904
|
+
const [searching, setSearching] = React.useState(false);
|
|
1905
|
+
const [searchError, setSearchError] = React.useState(null);
|
|
1906
|
+
const [searchCopied, setSearchCopied] = React.useState(null);
|
|
1907
|
+
// Monotonic request id: a slower earlier search must not overwrite a newer one.
|
|
1908
|
+
const searchSeq = React.useRef(0);
|
|
1348
1909
|
// The refresh run, and the tick that makes its elapsed time advance. `run` is null
|
|
1349
1910
|
// until something is started in this host's lifetime — a state the card renders as
|
|
1350
1911
|
// "not run yet" rather than as a failure.
|
|
@@ -1389,14 +1950,13 @@ function CoveragePanel(props) {
|
|
|
1389
1950
|
clearInterval(id);
|
|
1390
1951
|
};
|
|
1391
1952
|
}, [projectPath, running]);
|
|
1392
|
-
const startRun = React.useCallback(async () => {
|
|
1953
|
+
const startRun = React.useCallback(async (force = false) => {
|
|
1393
1954
|
if (projectPath === null)
|
|
1394
1955
|
return;
|
|
1395
1956
|
setRunError(null);
|
|
1396
1957
|
try {
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
});
|
|
1958
|
+
const suffix = force ? '&force=1' : '';
|
|
1959
|
+
const res = await fetch(routeUrl(`/api/gitnexus/analyze?repo=${encodeURIComponent(projectPath)}${suffix}`), { method: 'POST' });
|
|
1400
1960
|
const body = await res.json().catch(() => null);
|
|
1401
1961
|
if (!body?.ok)
|
|
1402
1962
|
throw new Error(body?.message ?? `请求失败 (${res.status})`);
|
|
@@ -1422,12 +1982,155 @@ function CoveragePanel(props) {
|
|
|
1422
1982
|
setRunError(String(cause?.message ?? cause));
|
|
1423
1983
|
}
|
|
1424
1984
|
}, [projectPath]);
|
|
1985
|
+
/**
|
|
1986
|
+
* Run one keyword search.
|
|
1987
|
+
*
|
|
1988
|
+
* No request on mount and no request per keystroke: a graph query is cheap but not
|
|
1989
|
+
* free, and the operator's typing is not a search intent until Enter or the button.
|
|
1990
|
+
* `searchSeq` makes an older in-flight response a no-op, which is the difference
|
|
1991
|
+
* between "the last search wins" and a result list that flickers backwards.
|
|
1992
|
+
*/
|
|
1993
|
+
const runSearch = React.useCallback(async () => {
|
|
1994
|
+
if (projectPath === null)
|
|
1995
|
+
return;
|
|
1996
|
+
const query = searchQuery.trim();
|
|
1997
|
+
if (query === '') {
|
|
1998
|
+
setSearchResults(null);
|
|
1999
|
+
setSearchError(null);
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
const seq = ++searchSeq.current;
|
|
2003
|
+
setSearching(true);
|
|
2004
|
+
setSearchError(null);
|
|
2005
|
+
setSearchCopied(null);
|
|
2006
|
+
try {
|
|
2007
|
+
const res = await fetch(routeUrl(`/api/gitnexus/search?repo=${encodeURIComponent(projectPath)}` +
|
|
2008
|
+
`&q=${encodeURIComponent(query)}&limit=20`));
|
|
2009
|
+
const body = await res.json().catch(() => null);
|
|
2010
|
+
if (seq !== searchSeq.current)
|
|
2011
|
+
return;
|
|
2012
|
+
if (!body?.ok) {
|
|
2013
|
+
// A 404 without the route's own code means the host predates the search
|
|
2014
|
+
// route, not that the project is wrong: the panel's own project path is a
|
|
2015
|
+
// project root by construction.
|
|
2016
|
+
if (res.status === 404 && body?.code !== 'not-a-project') {
|
|
2017
|
+
throw new Error('当前宿主没有搜索路由;重启 DSH 后再试。');
|
|
2018
|
+
}
|
|
2019
|
+
throw new Error(typeof body?.message === 'string' ? body.message : `请求失败 (${res.status})`);
|
|
2020
|
+
}
|
|
2021
|
+
const results = Array.isArray(body.results)
|
|
2022
|
+
? body.results.filter((hit) => hit !== null &&
|
|
2023
|
+
typeof hit === 'object' &&
|
|
2024
|
+
typeof hit.kind === 'string' &&
|
|
2025
|
+
typeof hit.name === 'string' &&
|
|
2026
|
+
typeof hit.filePath === 'string')
|
|
2027
|
+
: [];
|
|
2028
|
+
setSearchResults(results);
|
|
2029
|
+
setSearchMode(body.mode === 'text' ? 'text' : body.mode === 'graph' ? 'graph' : null);
|
|
2030
|
+
setSearchTotal(typeof body.total === 'number' ? body.total : results.length);
|
|
2031
|
+
setSearchTruncated(body.truncated === true);
|
|
2032
|
+
setSearchWarning(typeof body.warning === 'string' ? body.warning : null);
|
|
2033
|
+
}
|
|
2034
|
+
catch (cause) {
|
|
2035
|
+
if (seq !== searchSeq.current)
|
|
2036
|
+
return;
|
|
2037
|
+
setSearchResults(null);
|
|
2038
|
+
setSearchError(String(cause?.message ?? cause));
|
|
2039
|
+
}
|
|
2040
|
+
finally {
|
|
2041
|
+
if (seq === searchSeq.current)
|
|
2042
|
+
setSearching(false);
|
|
2043
|
+
}
|
|
2044
|
+
}, [projectPath, searchQuery]);
|
|
2045
|
+
const clearSearch = React.useCallback(() => {
|
|
2046
|
+
searchSeq.current += 1;
|
|
2047
|
+
setSearchQuery('');
|
|
2048
|
+
setSearchResults(null);
|
|
2049
|
+
setSearchMode(null);
|
|
2050
|
+
setSearchTotal(0);
|
|
2051
|
+
setSearchTruncated(false);
|
|
2052
|
+
setSearchWarning(null);
|
|
2053
|
+
setSearchError(null);
|
|
2054
|
+
setSearching(false);
|
|
2055
|
+
setSearchCopied(null);
|
|
2056
|
+
}, []);
|
|
2057
|
+
const copySearchHit = React.useCallback((hit) => {
|
|
2058
|
+
const text = `${hit.filePath}${hit.startLine !== null ? `:${hit.startLine}` : ''}`;
|
|
2059
|
+
const key = `${hit.kind}:${hit.filePath}:${hit.startLine ?? ''}:${hit.name}`;
|
|
2060
|
+
const write = typeof primitives.writeClipboard === 'function'
|
|
2061
|
+
? primitives.writeClipboard
|
|
2062
|
+
: (value) => navigator.clipboard?.writeText(value);
|
|
2063
|
+
try {
|
|
2064
|
+
const outcome = write(text);
|
|
2065
|
+
if (outcome && typeof outcome.then === 'function') {
|
|
2066
|
+
outcome
|
|
2067
|
+
.then(() => {
|
|
2068
|
+
setSearchCopied(key);
|
|
2069
|
+
setTimeout(() => setSearchCopied(null), 1500);
|
|
2070
|
+
})
|
|
2071
|
+
.catch(() => setSearchCopied(null));
|
|
2072
|
+
}
|
|
2073
|
+
else {
|
|
2074
|
+
setSearchCopied(key);
|
|
2075
|
+
setTimeout(() => setSearchCopied(null), 1500);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
catch {
|
|
2079
|
+
setSearchCopied(null);
|
|
2080
|
+
}
|
|
2081
|
+
}, []);
|
|
2082
|
+
/**
|
|
2083
|
+
* Open a hit in the platform's file viewer at its line.
|
|
2084
|
+
*
|
|
2085
|
+
* The resource address is the same `dsh-resource://file/session/...` the built-in
|
|
2086
|
+
* file strip uses; `params.line` is what the chat's file links pass. If the
|
|
2087
|
+
* deployment has no right-sidebar controller (a headless profile, or a host older
|
|
2088
|
+
* than the service), opening is impossible — copy the `path:line` instead of
|
|
2089
|
+
* silently doing nothing, and say why.
|
|
2090
|
+
*/
|
|
2091
|
+
const openSearchHit = React.useCallback((hit) => {
|
|
2092
|
+
setSearchError(null);
|
|
2093
|
+
if (sessionId === null) {
|
|
2094
|
+
setSearchError('未能定位当前会话,已复制路径。');
|
|
2095
|
+
copySearchHit(hit);
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
const address = fileResourceAddress(sessionId, hit.filePath);
|
|
2099
|
+
const options = hit.startLine === null
|
|
2100
|
+
? undefined
|
|
2101
|
+
: { params: { line: hit.startLine } };
|
|
2102
|
+
try {
|
|
2103
|
+
if (openResourceIn !== null) {
|
|
2104
|
+
openResourceIn(sessionId, address, options);
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
if (openResource !== null) {
|
|
2108
|
+
openResource(address, options);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
catch {
|
|
2113
|
+
/* fall through to the copy path */
|
|
2114
|
+
}
|
|
2115
|
+
setSearchError('当前宿主没有可用的文件打开能力,已复制路径。');
|
|
2116
|
+
copySearchHit(hit);
|
|
2117
|
+
}, [sessionId, copySearchHit]);
|
|
2118
|
+
// A different project means the previous hits are another repository's symbols.
|
|
2119
|
+
React.useEffect(() => {
|
|
2120
|
+
clearSearch();
|
|
2121
|
+
}, [projectPath, clearSearch]);
|
|
1425
2122
|
// The GitNexus install. Its own state because it is not tied to a repository: the
|
|
1426
2123
|
// install target is one directory per machine, and the operator may have several
|
|
1427
2124
|
// projects open across a session.
|
|
1428
2125
|
const [installRun, setInstallRun] = React.useState(null);
|
|
1429
2126
|
const [installError, setInstallError] = React.useState(null);
|
|
1430
2127
|
const installing = installRun?.state === 'running';
|
|
2128
|
+
// Only an install the operator authorised *in this panel* may chain into an
|
|
2129
|
+
// analyze. A successful install from an earlier session is still visible in the
|
|
2130
|
+
// host's run state, and starting a write from that would be acting on consent the
|
|
2131
|
+
// operator gave for a different action.
|
|
2132
|
+
const autoInitArmed = React.useRef(false);
|
|
2133
|
+
const autoInitDone = React.useRef(null);
|
|
1431
2134
|
React.useEffect(() => {
|
|
1432
2135
|
if (!installing)
|
|
1433
2136
|
return undefined;
|
|
@@ -1460,6 +2163,7 @@ function CoveragePanel(props) {
|
|
|
1460
2163
|
}, [installing]);
|
|
1461
2164
|
const startInstall = React.useCallback(async () => {
|
|
1462
2165
|
setInstallError(null);
|
|
2166
|
+
autoInitArmed.current = true;
|
|
1463
2167
|
try {
|
|
1464
2168
|
const res = await fetch(routeUrl('/api/gitnexus/install'), { method: 'POST' });
|
|
1465
2169
|
const body = await res.json().catch(() => null);
|
|
@@ -1469,6 +2173,7 @@ function CoveragePanel(props) {
|
|
|
1469
2173
|
setTick(0);
|
|
1470
2174
|
}
|
|
1471
2175
|
catch (cause) {
|
|
2176
|
+
autoInitArmed.current = false;
|
|
1472
2177
|
setInstallError(String(cause?.message ?? cause));
|
|
1473
2178
|
}
|
|
1474
2179
|
}, []);
|
|
@@ -1483,38 +2188,142 @@ function CoveragePanel(props) {
|
|
|
1483
2188
|
setInstallError(String(cause?.message ?? cause));
|
|
1484
2189
|
}
|
|
1485
2190
|
}, []);
|
|
2191
|
+
/**
|
|
2192
|
+
* Install → initialize, in one authorised step.
|
|
2193
|
+
*
|
|
2194
|
+
* The install is machine-wide; the index is per-project. Chaining them is the
|
|
2195
|
+
* difference between "you now have a CLI" and "you can see the index", which is
|
|
2196
|
+
* what the operator asked for. Three conditions keep it honest: the install must
|
|
2197
|
+
* have finished successfully, the project must still have no index (otherwise
|
|
2198
|
+
* this would silently refresh an index nobody asked to refresh), and the operator
|
|
2199
|
+
* must have started *this* install from this panel.
|
|
2200
|
+
*
|
|
2201
|
+
* The key includes the install's start time, so a retry after a cancellation is a
|
|
2202
|
+
* new run and may chain again, while the poll ticks of one run cannot start two
|
|
2203
|
+
* analyses.
|
|
2204
|
+
*/
|
|
2205
|
+
React.useEffect(() => {
|
|
2206
|
+
if (!autoInitArmed.current)
|
|
2207
|
+
return;
|
|
2208
|
+
if (installRun?.state !== 'done')
|
|
2209
|
+
return;
|
|
2210
|
+
// Wait for a report rather than treating "not loaded" as "not indexed".
|
|
2211
|
+
if (report === null || projectPath === null || report.repo?.indexed === true)
|
|
2212
|
+
return;
|
|
2213
|
+
const key = `${projectPath}:${installRun.startedAt}`;
|
|
2214
|
+
if (autoInitDone.current === key)
|
|
2215
|
+
return;
|
|
2216
|
+
autoInitDone.current = key;
|
|
2217
|
+
autoInitArmed.current = false;
|
|
2218
|
+
void startRun(false);
|
|
2219
|
+
}, [installRun, projectPath, report, startRun]);
|
|
1486
2220
|
React.useEffect(() => {
|
|
1487
2221
|
if (projectPath === null)
|
|
1488
2222
|
return undefined;
|
|
1489
2223
|
const controller = new AbortController();
|
|
1490
2224
|
setBusy(true);
|
|
1491
2225
|
setError(null);
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
2226
|
+
setProgress({ percent: 0, label: '连接索引服务…' });
|
|
2227
|
+
const query = `/api/gitnexus/coverage?repo=${encodeURIComponent(projectPath)}` +
|
|
2228
|
+
(wantsFresh ? '&refresh=1' : '') +
|
|
2229
|
+
'&stream=1';
|
|
2230
|
+
const load = async () => {
|
|
2231
|
+
try {
|
|
2232
|
+
const res = await fetch(routeUrl(query), { signal: controller.signal });
|
|
2233
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
2234
|
+
if (contentType.includes('application/x-ndjson') && res.body !== null) {
|
|
2235
|
+
const reader = res.body.getReader();
|
|
2236
|
+
const decoder = new TextDecoder();
|
|
2237
|
+
const reports = [];
|
|
2238
|
+
let buffer = '';
|
|
2239
|
+
const consume = (line) => {
|
|
2240
|
+
if (line.trim() === '' || controller.signal.aborted)
|
|
2241
|
+
return;
|
|
2242
|
+
let message;
|
|
2243
|
+
try {
|
|
2244
|
+
message = JSON.parse(line);
|
|
2245
|
+
}
|
|
2246
|
+
catch {
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (message.type === 'progress') {
|
|
2250
|
+
const raw = typeof message.percent === 'number' ? message.percent : 0;
|
|
2251
|
+
const percent = Math.max(0, Math.min(100, Math.round(raw)));
|
|
2252
|
+
const label = typeof message.label === 'string' ? message.label : '读取索引…';
|
|
2253
|
+
setProgress({ percent, label });
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (message.type === 'report') {
|
|
2257
|
+
reports.push(message);
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
if (message.type === 'error') {
|
|
2261
|
+
throw new Error(typeof message.message === 'string' ? message.message : '读取索引失败。');
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
for (;;) {
|
|
2265
|
+
const chunk = await reader.read();
|
|
2266
|
+
if (chunk.done)
|
|
2267
|
+
break;
|
|
2268
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
2269
|
+
let newline = buffer.indexOf('\n');
|
|
2270
|
+
while (newline >= 0) {
|
|
2271
|
+
const line = buffer.slice(0, newline);
|
|
2272
|
+
buffer = buffer.slice(newline + 1);
|
|
2273
|
+
consume(line);
|
|
2274
|
+
newline = buffer.indexOf('\n');
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
buffer += decoder.decode();
|
|
2278
|
+
if (buffer.trim() !== '')
|
|
2279
|
+
consume(buffer);
|
|
2280
|
+
const payload = reports[0];
|
|
2281
|
+
if (payload === undefined)
|
|
2282
|
+
throw new Error('读取中断:没有收到完整报告。');
|
|
2283
|
+
if (controller.signal.aborted)
|
|
2284
|
+
return;
|
|
2285
|
+
setReport(payload.report);
|
|
2286
|
+
setMaintenance(payload.maintenance ?? null);
|
|
2287
|
+
setReadAt(typeof payload.readAt === 'string' ? payload.readAt : new Date().toISOString());
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
// An older host ignores `stream=1` and answers plain JSON. Drop the numeric
|
|
2291
|
+
// channel and let the native progress element go indeterminate rather than
|
|
2292
|
+
// leaving a frozen 0%.
|
|
2293
|
+
setProgress(null);
|
|
2294
|
+
const body = (await res.json().catch(() => null));
|
|
2295
|
+
// `not-indexed` is a state the panel renders, not a failure: the whole
|
|
2296
|
+
// point of leading with the initialize command is that reaching it is
|
|
2297
|
+
// expected on a project that has never been analysed.
|
|
2298
|
+
if (res.status === 404 && body?.code === 'not-indexed') {
|
|
2299
|
+
setReport({ repo: { indexed: false, path: projectPath } });
|
|
2300
|
+
setMaintenance(null);
|
|
2301
|
+
setReadAt(body?.readAt ?? new Date().toISOString());
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
if (!body?.ok || body.report === undefined) {
|
|
2305
|
+
throw new Error(body?.message ?? `请求失败 (${res.status})`);
|
|
2306
|
+
}
|
|
2307
|
+
setReport(body.report);
|
|
2308
|
+
setMaintenance(body.maintenance ?? null);
|
|
2309
|
+
setReadAt(typeof body.readAt === 'string' ? body.readAt : new Date().toISOString());
|
|
1506
2310
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
2311
|
+
catch (cause) {
|
|
2312
|
+
if (controller.signal.aborted)
|
|
2313
|
+
return;
|
|
2314
|
+
const error = cause;
|
|
2315
|
+
if (error?.name === 'AbortError')
|
|
2316
|
+
return;
|
|
2317
|
+
setError(String(error?.message ?? cause));
|
|
2318
|
+
}
|
|
2319
|
+
finally {
|
|
2320
|
+
if (!controller.signal.aborted) {
|
|
2321
|
+
setBusy(false);
|
|
2322
|
+
setProgress(null);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
void load();
|
|
1518
2327
|
return () => controller.abort();
|
|
1519
2328
|
}, [projectPath, reloadToken]);
|
|
1520
2329
|
const fallbackMaintenance = React.useMemo(() => (maintenance === null && projectPath !== null ? { commands: [], rationale: [] } : maintenance), [maintenance, projectPath]);
|
|
@@ -1524,6 +2333,7 @@ function CoveragePanel(props) {
|
|
|
1524
2333
|
projectPath,
|
|
1525
2334
|
context: projectPath === null ? contextDiagnostic(props) : null,
|
|
1526
2335
|
busy,
|
|
2336
|
+
progress,
|
|
1527
2337
|
error,
|
|
1528
2338
|
readAt,
|
|
1529
2339
|
run,
|
|
@@ -1533,12 +2343,39 @@ function CoveragePanel(props) {
|
|
|
1533
2343
|
// otherwise looks frozen.
|
|
1534
2344
|
now: Date.now() + tick * 0,
|
|
1535
2345
|
onRefresh: () => setReloadToken((n) => n + 1),
|
|
1536
|
-
onStartRun: () => void startRun(),
|
|
2346
|
+
onStartRun: (force) => void startRun(force),
|
|
1537
2347
|
onCancelRun: () => void cancelRun(),
|
|
1538
2348
|
installRun,
|
|
1539
2349
|
installError,
|
|
1540
2350
|
onInstall: () => void startInstall(),
|
|
1541
2351
|
onCancelInstall: () => void cancelInstall(),
|
|
2352
|
+
search: {
|
|
2353
|
+
query: searchQuery,
|
|
2354
|
+
onQueryChange: (value) => {
|
|
2355
|
+
// Invalidate an in-flight search for the previous text: otherwise its
|
|
2356
|
+
// response can land after the edit and repopulate a list that no longer
|
|
2357
|
+
// matches the box. The sequence bump also makes its `finally` a no-op, so
|
|
2358
|
+
// the spinner is cleared here.
|
|
2359
|
+
searchSeq.current += 1;
|
|
2360
|
+
setSearching(false);
|
|
2361
|
+
setSearchQuery(value);
|
|
2362
|
+
setSearchError(null);
|
|
2363
|
+
setSearchResults(null);
|
|
2364
|
+
setSearchMode(null);
|
|
2365
|
+
},
|
|
2366
|
+
onSubmit: () => void runSearch(),
|
|
2367
|
+
onClear: clearSearch,
|
|
2368
|
+
searching,
|
|
2369
|
+
error: searchError,
|
|
2370
|
+
mode: searchMode,
|
|
2371
|
+
results: searchResults,
|
|
2372
|
+
total: searchTotal,
|
|
2373
|
+
truncated: searchTruncated,
|
|
2374
|
+
warning: searchWarning,
|
|
2375
|
+
copied: searchCopied,
|
|
2376
|
+
onOpen: openSearchHit,
|
|
2377
|
+
onCopy: copySearchHit,
|
|
2378
|
+
},
|
|
1542
2379
|
});
|
|
1543
2380
|
}
|
|
1544
2381
|
/** The tab chip's live title. */
|
|
@@ -1570,7 +2407,10 @@ function apply(ctx) {
|
|
|
1570
2407
|
};
|
|
1571
2408
|
safe('sidebarRightTabs', () => {
|
|
1572
2409
|
ctx.inject(['sidebarRightTabs'], (svcCtx) => {
|
|
1573
|
-
|
|
2410
|
+
const tabs = svcCtx.sidebarRightTabs;
|
|
2411
|
+
if (tabs === undefined)
|
|
2412
|
+
return;
|
|
2413
|
+
ctx.effect(() => tabs.register({
|
|
1574
2414
|
id: PLUGIN_ID,
|
|
1575
2415
|
kind: TAB_KIND,
|
|
1576
2416
|
title: () => 'GitNexus',
|
|
@@ -1589,6 +2429,20 @@ function apply(ctx) {
|
|
|
1589
2429
|
}), `${PLUGIN_ID}: tab type`);
|
|
1590
2430
|
});
|
|
1591
2431
|
});
|
|
2432
|
+
// The navigation controller the chat and the file strip already use. Optional:
|
|
2433
|
+
// a profile without a right column simply leaves the module-level openers null,
|
|
2434
|
+
// and a search-result click falls back to copying `path:line`.
|
|
2435
|
+
safe('sidebarRight', () => {
|
|
2436
|
+
ctx.inject(['sidebarRight'], (svcCtx) => {
|
|
2437
|
+
const service = svcCtx.sidebarRight;
|
|
2438
|
+
if (service === undefined || typeof service.openResource !== 'function')
|
|
2439
|
+
return;
|
|
2440
|
+
openResource = (address, options) => service.openResource(address, options);
|
|
2441
|
+
if (typeof service.openResourceIn === 'function') {
|
|
2442
|
+
openResourceIn = (sessionId, address, options) => service.openResourceIn?.(sessionId, address, options);
|
|
2443
|
+
}
|
|
2444
|
+
});
|
|
2445
|
+
});
|
|
1592
2446
|
safe('sidebar.right.pane.tab', () => {
|
|
1593
2447
|
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: PLUGIN_ID }, CoveragePanel));
|
|
1594
2448
|
});
|
|
@@ -1603,6 +2457,7 @@ module.exports = {
|
|
|
1603
2457
|
PLUGIN_ID,
|
|
1604
2458
|
TAB_KIND,
|
|
1605
2459
|
routeUrl,
|
|
2460
|
+
fileResourceAddress,
|
|
1606
2461
|
formatBytes,
|
|
1607
2462
|
fileSize,
|
|
1608
2463
|
percent,
|
|
@@ -1628,6 +2483,7 @@ module.exports = {
|
|
|
1628
2483
|
MetricGrid,
|
|
1629
2484
|
Histogram,
|
|
1630
2485
|
GitActivitySection,
|
|
2486
|
+
FileHistorySection,
|
|
1631
2487
|
ProvenanceBlock,
|
|
1632
2488
|
MaintenanceSection,
|
|
1633
2489
|
CapabilityStrip,
|