dsh-gitnexus-plus 0.0.0-stage → 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.
- package/LICENSE +38 -0
- package/README.md +631 -2
- package/cordis.patch.yml +18 -0
- package/lib/bin/gitnexus-coverage.d.ts +14 -0
- package/lib/bin/gitnexus-coverage.d.ts.map +1 -0
- package/lib/bin/gitnexus-coverage.js +171 -0
- package/lib/bin/gitnexus-coverage.js.map +1 -0
- 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 +2131 -0
- package/lib/gitnexus/analyze.d.ts +173 -0
- package/lib/gitnexus/analyze.d.ts.map +1 -0
- package/lib/gitnexus/analyze.js +308 -0
- package/lib/gitnexus/analyze.js.map +1 -0
- package/lib/gitnexus/commands.d.ts +129 -0
- package/lib/gitnexus/commands.d.ts.map +1 -0
- package/lib/gitnexus/commands.js +185 -0
- package/lib/gitnexus/commands.js.map +1 -0
- package/lib/gitnexus/coverage.d.ts +340 -0
- package/lib/gitnexus/coverage.d.ts.map +1 -0
- package/lib/gitnexus/coverage.js +554 -0
- package/lib/gitnexus/coverage.js.map +1 -0
- package/lib/gitnexus/format.d.ts +16 -0
- package/lib/gitnexus/format.d.ts.map +1 -0
- package/lib/gitnexus/format.js +178 -0
- package/lib/gitnexus/format.js.map +1 -0
- package/lib/gitnexus/git-activity.d.ts +136 -0
- package/lib/gitnexus/git-activity.d.ts.map +1 -0
- package/lib/gitnexus/git-activity.js +345 -0
- package/lib/gitnexus/git-activity.js.map +1 -0
- package/lib/gitnexus/git.d.ts +70 -0
- package/lib/gitnexus/git.d.ts.map +1 -0
- package/lib/gitnexus/git.js +121 -0
- package/lib/gitnexus/git.js.map +1 -0
- package/lib/gitnexus/index-meta.d.ts +160 -0
- package/lib/gitnexus/index-meta.d.ts.map +1 -0
- package/lib/gitnexus/index-meta.js +176 -0
- package/lib/gitnexus/index-meta.js.map +1 -0
- package/lib/gitnexus/install.d.ts +97 -0
- package/lib/gitnexus/install.d.ts.map +1 -0
- package/lib/gitnexus/install.js +228 -0
- package/lib/gitnexus/install.js.map +1 -0
- package/lib/gitnexus/languages.d.ts +80 -0
- package/lib/gitnexus/languages.d.ts.map +1 -0
- package/lib/gitnexus/languages.js +297 -0
- package/lib/gitnexus/languages.js.map +1 -0
- package/lib/gitnexus/root.d.ts +74 -0
- package/lib/gitnexus/root.d.ts.map +1 -0
- package/lib/gitnexus/root.js +168 -0
- package/lib/gitnexus/root.js.map +1 -0
- package/lib/gitnexus/scan.d.ts +68 -0
- package/lib/gitnexus/scan.d.ts.map +1 -0
- package/lib/gitnexus/scan.js +249 -0
- package/lib/gitnexus/scan.js.map +1 -0
- 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/gitnexus/toolchain.d.ts +136 -0
- package/lib/gitnexus/toolchain.d.ts.map +1 -0
- package/lib/gitnexus/toolchain.js +251 -0
- package/lib/gitnexus/toolchain.js.map +1 -0
- package/lib/index.d.ts +98 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +252 -0
- package/lib/index.js.map +1 -0
- package/lib/routes/analyze.d.ts +41 -0
- package/lib/routes/analyze.d.ts.map +1 -0
- package/lib/routes/analyze.js +75 -0
- package/lib/routes/analyze.js.map +1 -0
- package/lib/routes/coverage.d.ts +52 -0
- package/lib/routes/coverage.d.ts.map +1 -0
- package/lib/routes/coverage.js +81 -0
- package/lib/routes/coverage.js.map +1 -0
- package/lib/routes/guard.d.ts +92 -0
- package/lib/routes/guard.d.ts.map +1 -0
- package/lib/routes/guard.js +187 -0
- package/lib/routes/guard.js.map +1 -0
- package/lib/routes/install.d.ts +27 -0
- package/lib/routes/install.d.ts.map +1 -0
- package/lib/routes/install.js +46 -0
- package/lib/routes/install.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 +87 -4
- package/scripts/link-runtime.mjs +146 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,2131 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-gitnexus-plus",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
"use strict";
|
|
7
|
+
/**
|
|
8
|
+
* dsh-gitnexus-plus — browser half.
|
|
9
|
+
*
|
|
10
|
+
* Plain CommonJS on purpose. The in-tree client preset (`packages/client/tsdown.client.ts`)
|
|
11
|
+
* is unpublished, so an out-of-tree plugin must reproduce its output format with
|
|
12
|
+
* its own toolchain — and the honest minimum here is no toolchain at all: this
|
|
13
|
+
* module's only imports are platform baseline modules, so it can be wrapped into
|
|
14
|
+
* the loader's factory verbatim by `scripts/build-client.mjs`, with no bundler and
|
|
15
|
+
* no node_modules in the loop.
|
|
16
|
+
*
|
|
17
|
+
* UI COMES FROM THE SHARED PRIMITIVES PACKAGE
|
|
18
|
+
* -------------------------------------------
|
|
19
|
+
* An earlier revision hand-rolled every control with inline styles, on the
|
|
20
|
+
* reasoning that `@deepseek-ai/dsh-client-ui-primitives` ships no JS in the
|
|
21
|
+
* installed runtime and its exports therefore could not be verified. That
|
|
22
|
+
* reasoning was wrong twice over. The package's own README states the rule —
|
|
23
|
+
* "plugins cannot import another plugin's components, so this package is the one
|
|
24
|
+
* place controls can be shared; reuse them" — and the installed plugin
|
|
25
|
+
* `dsh-context` declares it as a peer dependency, which is exactly how a plugin is
|
|
26
|
+
* meant to get a native look.
|
|
27
|
+
*
|
|
28
|
+
* Every prop used below was read off a real call site in an installed plugin
|
|
29
|
+
* bundle rather than inferred from prose, because a wrong prop name fails
|
|
30
|
+
* silently: React renders the component with its defaults and nothing throws.
|
|
31
|
+
*
|
|
32
|
+
* Button variant: "ghost" | "outline" | "primary" | "toolbar"
|
|
33
|
+
* StateDot state: "done" | "warning" | "error" | "idle" | "ongoing"
|
|
34
|
+
* Tag tone: "neutral" | "solid" | "outline" | "info"
|
|
35
|
+
* PathLabel path
|
|
36
|
+
* FileTypeIcon path | kind (from classifyFileType) , size, className
|
|
37
|
+
* Tooltip label, side, delayMs, portal — wraps a single child
|
|
38
|
+
*
|
|
39
|
+
* WHAT THE PANEL SHOWS, AND WHY
|
|
40
|
+
* -----------------------------
|
|
41
|
+
* GitNexus prints skipped files once, to stderr, and never exposes them again. The
|
|
42
|
+
* panel keeps that visible: the bar answers "how much of this repository does the
|
|
43
|
+
* index actually represent?", the warning block says what was dropped, and the
|
|
44
|
+
* file list says whether what was dropped is source or build output.
|
|
45
|
+
*/
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
// Namespace imports, not `require` and not default imports. `tsconfig.client.json`
|
|
48
|
+
// runs with `esModuleInterop: false`, under which `import * as React from 'react'`
|
|
49
|
+
// compiles to a bare `const React = require("react")` — exactly the form the loader
|
|
50
|
+
// factory provides, with no `__importStar` helper riding along into every page load.
|
|
51
|
+
// It is also the only form that gives `React.ReactNode` and `React.CSSProperties`
|
|
52
|
+
// as usable type namespaces.
|
|
53
|
+
const React = require("react");
|
|
54
|
+
const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
55
|
+
const { Button, FileTypeIcon, PathLabel, SegmentedControl, StateDot, Tag, Tooltip, IconRefreshOutlineRegular, classifyFileType, } = primitives;
|
|
56
|
+
const h = React.createElement;
|
|
57
|
+
/** Must match the `id` in the loader factory written by scripts/build-client.mjs. */
|
|
58
|
+
const PLUGIN_ID = 'dsh-gitnexus-plus';
|
|
59
|
+
/** The sidebar-right tab type this plugin contributes. */
|
|
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
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* How a run's state reads at a glance.
|
|
83
|
+
*
|
|
84
|
+
* Typed to the primitive's own union rather than `string`, so a state added to
|
|
85
|
+
* `ClientAnalyzeRun` without a mapping here fails to compile instead of rendering a
|
|
86
|
+
* dot the primitive silently falls back on.
|
|
87
|
+
*/
|
|
88
|
+
function runStateTone(state) {
|
|
89
|
+
switch (state) {
|
|
90
|
+
case 'done':
|
|
91
|
+
return 'done';
|
|
92
|
+
case 'running':
|
|
93
|
+
// The primitive spells this `ongoing`; the host calls it `running`.
|
|
94
|
+
return 'ongoing';
|
|
95
|
+
case 'cancelled':
|
|
96
|
+
return 'idle';
|
|
97
|
+
// Not errors: the sandbox did its job, and the operator's next step is to run the
|
|
98
|
+
// command themselves rather than to investigate a failure.
|
|
99
|
+
case 'denied':
|
|
100
|
+
case 'unavailable':
|
|
101
|
+
return 'warning';
|
|
102
|
+
default:
|
|
103
|
+
return 'error';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function runStateLabel(state) {
|
|
107
|
+
switch (state) {
|
|
108
|
+
case 'unavailable':
|
|
109
|
+
return '无法执行';
|
|
110
|
+
case 'running':
|
|
111
|
+
return '刷新中';
|
|
112
|
+
case 'done':
|
|
113
|
+
return '已完成';
|
|
114
|
+
case 'failed':
|
|
115
|
+
return '失败';
|
|
116
|
+
case 'timed-out':
|
|
117
|
+
return '超时终止';
|
|
118
|
+
case 'cancelled':
|
|
119
|
+
return '已取消';
|
|
120
|
+
case 'denied':
|
|
121
|
+
return '沙箱拒绝';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Empty blocks used to narrow a report past the point where an index is known to
|
|
126
|
+
* exist. Zeros rather than absent fields, so a partially-populated report from an
|
|
127
|
+
* older index still renders numbers instead of `undefined`.
|
|
128
|
+
*/
|
|
129
|
+
const ZERO_TOTALS = {
|
|
130
|
+
scanned: 0,
|
|
131
|
+
selected: 0,
|
|
132
|
+
indexedInGraph: 0,
|
|
133
|
+
large: 0,
|
|
134
|
+
largeBytes: 0,
|
|
135
|
+
suppressedDeclarations: 0,
|
|
136
|
+
scannedBytes: 0,
|
|
137
|
+
};
|
|
138
|
+
const ZERO_CAP = { text: '—', source: 'default', maxText: '—' };
|
|
139
|
+
const ZERO_ANALYZER = {
|
|
140
|
+
gitnexusVersion: null,
|
|
141
|
+
primitives: 'unknown',
|
|
142
|
+
};
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Routes
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
/**
|
|
147
|
+
* Resolve one of this plugin's host routes against the PAGE's own base.
|
|
148
|
+
*
|
|
149
|
+
* Two failures are avoided here. A leading slash pins a URL to the origin root,
|
|
150
|
+
* which walks out of any reverse-proxy directory prefix. And resolving against
|
|
151
|
+
* `location.host` breaks on the desktop shell, whose page origin is `dsh-app://app`
|
|
152
|
+
* — a hostname that never completes a DNS lookup.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} path
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
function routeUrl(path) {
|
|
158
|
+
const raw = typeof document !== 'undefined' && typeof document.baseURI === 'string' ? document.baseURI : '';
|
|
159
|
+
const placeholder = 'http://dsh.invalid/';
|
|
160
|
+
const url = new URL(raw === '' ? placeholder : raw, placeholder);
|
|
161
|
+
url.search = '';
|
|
162
|
+
url.hash = '';
|
|
163
|
+
if (!url.pathname.endsWith('/'))
|
|
164
|
+
url.pathname += '/';
|
|
165
|
+
return new URL(path.replace(/^\/+/, ''), url.href).href;
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Pure view-model helpers (exported for tests)
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
/**
|
|
171
|
+
* Fall back to local formatting unless the primitives package exposes its own.
|
|
172
|
+
* `fileSizeText` was present in the export surface of installed bundles but never
|
|
173
|
+
* observed at a call site, so it is used opportunistically rather than assumed.
|
|
174
|
+
* @param {number} bytes
|
|
175
|
+
*/
|
|
176
|
+
function fileSize(bytes) {
|
|
177
|
+
if (typeof primitives.fileSizeText === 'function')
|
|
178
|
+
return primitives.fileSizeText(bytes);
|
|
179
|
+
return formatBytes(bytes);
|
|
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
|
+
}
|
|
198
|
+
function formatBytes(bytes) {
|
|
199
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
200
|
+
return '—';
|
|
201
|
+
if (bytes < 1024)
|
|
202
|
+
return `${bytes} B`;
|
|
203
|
+
if (bytes < 1024 * 1024)
|
|
204
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
205
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
206
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
207
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Percentage of a whole, as a number in [0, 100].
|
|
211
|
+
* @param {number} part
|
|
212
|
+
* @param {number} whole
|
|
213
|
+
*/
|
|
214
|
+
function percent(part, whole) {
|
|
215
|
+
if (!Number.isFinite(part) || !Number.isFinite(whole) || whole <= 0)
|
|
216
|
+
return 0;
|
|
217
|
+
return (part / whole) * 100;
|
|
218
|
+
}
|
|
219
|
+
function fixed(value, digits) {
|
|
220
|
+
return Number.isFinite(value) ? value.toFixed(digits) : '0.0';
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Turn language rows into stack segments whose widths sum to exactly 100.
|
|
224
|
+
*
|
|
225
|
+
* Rounding each share independently leaves a visible gap or overflow in a stacked
|
|
226
|
+
* bar, so the largest segment absorbs the residual. The metric is a parameter
|
|
227
|
+
* because "what is this repository written in" has two defensible answers — bytes
|
|
228
|
+
* for weight of code, files for breadth of surface — and they disagree often enough
|
|
229
|
+
* that showing only one invites the wrong conclusion.
|
|
230
|
+
*/
|
|
231
|
+
function stackSegments(rows, totalBytes, totalFiles, metric = 'bytes') {
|
|
232
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
233
|
+
return [];
|
|
234
|
+
const whole = metric === 'files' ? totalFiles : totalBytes;
|
|
235
|
+
const valueOf = (row) => (metric === 'files' ? row.files : row.bytes);
|
|
236
|
+
const shares = rows.map((row) => ({
|
|
237
|
+
key: row.key,
|
|
238
|
+
label: row.label,
|
|
239
|
+
color: row.color,
|
|
240
|
+
percent: percent(valueOf(row), whole),
|
|
241
|
+
}));
|
|
242
|
+
const sum = shares.reduce((acc, s) => acc + s.percent, 0);
|
|
243
|
+
if (sum > 0 && Math.abs(sum - 100) > 0.005) {
|
|
244
|
+
let biggest = 0;
|
|
245
|
+
for (let i = 1; i < shares.length; i += 1) {
|
|
246
|
+
if (shares[i].percent > shares[biggest].percent)
|
|
247
|
+
biggest = i;
|
|
248
|
+
}
|
|
249
|
+
shares[biggest].percent += 100 - sum;
|
|
250
|
+
}
|
|
251
|
+
return shares;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Split the report's warnings by severity.
|
|
255
|
+
*
|
|
256
|
+
* Warnings arrive as structured records rather than prose so that a caller (the
|
|
257
|
+
* model, via the tool) can act on `code`. The panel only renders them, but keeps
|
|
258
|
+
* the split: `warn` is what the reader must not miss, `info` is context.
|
|
259
|
+
*
|
|
260
|
+
* @param {Array<{level:string,code:string,message:string}>} warnings
|
|
261
|
+
*/
|
|
262
|
+
function splitWarnings(warnings) {
|
|
263
|
+
const list = Array.isArray(warnings) ? warnings : [];
|
|
264
|
+
return {
|
|
265
|
+
notable: list.filter((w) => w.level === 'warn'),
|
|
266
|
+
context: list.filter((w) => w.level !== 'warn'),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Derive the header's one-line summary from a report.
|
|
271
|
+
* @param {any} report
|
|
272
|
+
* @returns {string}
|
|
273
|
+
*/
|
|
274
|
+
function summaryLine(report) {
|
|
275
|
+
if (report === null || typeof report !== 'object')
|
|
276
|
+
return '';
|
|
277
|
+
// Optional-chained per read rather than `report.totals ?? {}`: an empty object
|
|
278
|
+
// literal would erase the field types and every read below would be `unknown`.
|
|
279
|
+
const totals = report.totals;
|
|
280
|
+
const parts = [
|
|
281
|
+
`扫描 ${totals?.scanned ?? 0}`,
|
|
282
|
+
`选中 ${totals?.selected ?? 0}`,
|
|
283
|
+
`图内 ${totals?.indexedInGraph ?? 0}`,
|
|
284
|
+
];
|
|
285
|
+
const large = totals?.large ?? 0;
|
|
286
|
+
if (large > 0)
|
|
287
|
+
parts.push(`跳过 ${large}`);
|
|
288
|
+
const suppressed = totals?.suppressedDeclarations ?? 0;
|
|
289
|
+
if (suppressed > 0)
|
|
290
|
+
parts.push(`声明抑制 ${suppressed}`);
|
|
291
|
+
return parts.join(' · ');
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Fraction of scanned files the index covers, in [0, 1], or `null` when there is
|
|
295
|
+
* nothing to divide by — so the UI can omit rather than print a fabricated 0/100.
|
|
296
|
+
* @param {any} report
|
|
297
|
+
* @returns {number | null}
|
|
298
|
+
*/
|
|
299
|
+
function coverageRatio(report) {
|
|
300
|
+
const scanned = report?.totals?.scanned;
|
|
301
|
+
const indexed = report?.totals?.indexedInGraph;
|
|
302
|
+
// Explicit `typeof` guards rather than `Number.isFinite` alone: `isFinite` takes
|
|
303
|
+
// `unknown` and does not narrow, so the division below would still be typed
|
|
304
|
+
// against a possibly-absent value.
|
|
305
|
+
if (typeof scanned !== 'number' || typeof indexed !== 'number')
|
|
306
|
+
return null;
|
|
307
|
+
if (!Number.isFinite(scanned) || !Number.isFinite(indexed) || scanned <= 0)
|
|
308
|
+
return null;
|
|
309
|
+
return Math.max(0, Math.min(1, indexed / scanned));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Index health as a `StateDot` state.
|
|
313
|
+
*
|
|
314
|
+
* `error` is reserved for an index that is absent or badly behind; `warning` for
|
|
315
|
+
* one that is usable but has dropped files worth knowing about.
|
|
316
|
+
* @param {any} report
|
|
317
|
+
* @returns {'done' | 'warning' | 'error' | 'idle'}
|
|
318
|
+
*/
|
|
319
|
+
function healthState(report) {
|
|
320
|
+
if (report === null || report?.repo?.indexed !== true)
|
|
321
|
+
return 'error';
|
|
322
|
+
const ratio = coverageRatio(report);
|
|
323
|
+
const skipped = report?.totals?.large ?? 0;
|
|
324
|
+
if (skipped > 0)
|
|
325
|
+
return 'warning';
|
|
326
|
+
if (ratio !== null && ratio < 0.9)
|
|
327
|
+
return 'warning';
|
|
328
|
+
return 'done';
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Staleness as a dot state plus a sentence.
|
|
332
|
+
*
|
|
333
|
+
* `diverged` is deliberately not folded into `behind`: when HEAD has moved off the
|
|
334
|
+
* indexed commit, the distance between them does not measure how much of the index
|
|
335
|
+
* is wrong, so the UI must not imply a count it cannot give. Measured on a real
|
|
336
|
+
* repository, the panel had been showing the indexed commit as though it were
|
|
337
|
+
* current while HEAD was already one commit ahead on a different line.
|
|
338
|
+
*
|
|
339
|
+
* @param {any} staleness
|
|
340
|
+
* @returns {{ state: 'done' | 'warning' | 'error' | 'idle', text: string }}
|
|
341
|
+
*/
|
|
342
|
+
function stalenessView(staleness) {
|
|
343
|
+
if (!staleness || staleness.gitAvailable !== true) {
|
|
344
|
+
return { state: 'idle', text: 'git 不可用,无法判断新鲜度' };
|
|
345
|
+
}
|
|
346
|
+
const short = (sha) => (typeof sha === 'string' && sha !== '' ? sha.slice(0, 8) : '?');
|
|
347
|
+
const head = short(staleness.headCommit);
|
|
348
|
+
const indexed = short(staleness.indexedCommit);
|
|
349
|
+
const dirtyCount = typeof staleness.dirtyCount === 'number' ? staleness.dirtyCount : 0;
|
|
350
|
+
const dirty = dirtyCount > 0 ? `,工作区 ${dirtyCount} 处未提交改动` : '';
|
|
351
|
+
switch (staleness.state) {
|
|
352
|
+
case 'current':
|
|
353
|
+
return { state: 'done', text: `current · HEAD ${head} 与索引一致${dirty}` };
|
|
354
|
+
case 'behind': {
|
|
355
|
+
const ahead = staleness.aheadBehind?.ahead ?? 0;
|
|
356
|
+
// Says where the action lives, not that one is available nearby. The previous
|
|
357
|
+
// wording — "可直接增量刷新" — sat directly under the panel's refresh button and
|
|
358
|
+
// read as an instruction to press it, while that control only re-reads the
|
|
359
|
+
// report and never touches the index. Pointing at the maintenance commands
|
|
360
|
+
// costs one clause and stops the panel promising something it does not do.
|
|
361
|
+
return {
|
|
362
|
+
state: 'warning',
|
|
363
|
+
text: `behind · 索引落后 ${ahead} 个提交(${indexed} → ${head})· 重建见下方「维护命令」${dirty}`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
case 'diverged': {
|
|
367
|
+
// Diverged means the *indexed* commit holds work HEAD no longer reaches —
|
|
368
|
+
// not merely that HEAD is ahead. The distinction decides whether an
|
|
369
|
+
// incremental refresh is enough or a rebuild is required, so the wording
|
|
370
|
+
// must not blur it.
|
|
371
|
+
const indexedSide = staleness.aheadBehind?.behind ?? 0;
|
|
372
|
+
return {
|
|
373
|
+
state: 'warning',
|
|
374
|
+
text: `diverged · HEAD ${head} 已偏离索引提交 ${indexed}(索引侧另有 ${indexedSide} 个提交不在 HEAD 上)` +
|
|
375
|
+
`${dirty}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
default:
|
|
379
|
+
return { state: 'idle', text: `unknown · 无法比对 ${indexed} 与 ${head}${dirty}` };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Capability rows for the query surfaces, in a fixed order so the panel does not
|
|
384
|
+
* reshuffle between repositories.
|
|
385
|
+
*
|
|
386
|
+
* A capability whose provider reports `unavailable` still gets a row: "no keyword
|
|
387
|
+
* search" is information, and hiding it is how a reader concludes the graph can do
|
|
388
|
+
* something it cannot.
|
|
389
|
+
*
|
|
390
|
+
* @param {any} capabilities
|
|
391
|
+
* @returns {Array<{ key: string, label: string, ok: boolean, provider: string, note: string }>}
|
|
392
|
+
*/
|
|
393
|
+
const CAPABILITY_KEYS = ['graph', 'fts', 'vectorSearch'];
|
|
394
|
+
function capabilityRows(capabilities) {
|
|
395
|
+
const caps = capabilities ?? {};
|
|
396
|
+
// Keyed by the same literal union the loop walks, so the lookup is checked rather
|
|
397
|
+
// than an index-signature read that would silently yield `undefined`.
|
|
398
|
+
const labels = {
|
|
399
|
+
graph: '图查询',
|
|
400
|
+
fts: '关键词搜索',
|
|
401
|
+
vectorSearch: '语义搜索',
|
|
402
|
+
};
|
|
403
|
+
return CAPABILITY_KEYS.map((key) => {
|
|
404
|
+
const entry = caps[key] ?? {};
|
|
405
|
+
const ok = entry.status === 'available';
|
|
406
|
+
return {
|
|
407
|
+
key,
|
|
408
|
+
label: labels[key],
|
|
409
|
+
ok,
|
|
410
|
+
provider: typeof entry.provider === 'string' ? entry.provider : 'unknown',
|
|
411
|
+
note: ok ? '' : String(entry.skipReason ?? entry.status ?? 'unavailable'),
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Summarise the resolver's untyped receiver sites.
|
|
417
|
+
*
|
|
418
|
+
* GitNexus publishes this so `impact` and `context` can report `exact` versus
|
|
419
|
+
* `lower-bound`; the panel surfaces it because it is the honest caveat on every
|
|
420
|
+
* other number here.
|
|
421
|
+
*/
|
|
422
|
+
function graphQualitySummary(graphQuality) {
|
|
423
|
+
const counts = graphQuality?.counts;
|
|
424
|
+
if (counts === null || counts === undefined || typeof counts !== 'object')
|
|
425
|
+
return null;
|
|
426
|
+
const names = Object.entries(counts)
|
|
427
|
+
// Narrow to numbers here rather than at the `.sort` below: the counts come from
|
|
428
|
+
// an external manifest, so a non-numeric entry is possible and would otherwise
|
|
429
|
+
// reach the comparator as `unknown`.
|
|
430
|
+
.filter((pair) => typeof pair[1] === 'number' && Number.isFinite(pair[1]))
|
|
431
|
+
.map(([name, count]) => ({ name, count }))
|
|
432
|
+
.sort((a, b) => b.count - a.count);
|
|
433
|
+
const total = typeof graphQuality?.totalSites === 'number' && Number.isFinite(graphQuality.totalSites)
|
|
434
|
+
? graphQuality.totalSites
|
|
435
|
+
: names.reduce((sum, entry) => sum + entry.count, 0);
|
|
436
|
+
return { total, names };
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Compact a count for a dense row: 26788 → "26.8k", 2366932 → "2.4M".
|
|
440
|
+
*
|
|
441
|
+
* One decimal is kept all the way up, matching how the product's own dashboards
|
|
442
|
+
* render counts. Rounding 26 788 to a bare "27k" discards the digits a reader is
|
|
443
|
+
* actually comparing between repositories.
|
|
444
|
+
*/
|
|
445
|
+
function compactNumber(value) {
|
|
446
|
+
if (!Number.isFinite(value))
|
|
447
|
+
return '—';
|
|
448
|
+
if (value < 1000)
|
|
449
|
+
return String(value);
|
|
450
|
+
if (value < 1_000_000)
|
|
451
|
+
return `${(value / 1000).toFixed(1)}k`;
|
|
452
|
+
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
453
|
+
}
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// Presentation
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
const MUTED = { opacity: 0.62 };
|
|
458
|
+
const HAIRLINE = '1px solid color-mix(in srgb, currentColor 16%, transparent)';
|
|
459
|
+
const SOFT_BG = 'color-mix(in srgb, currentColor 6%, transparent)';
|
|
460
|
+
/** Layout only. Appearance belongs to the primitives, which read `--dsw-*` tokens. */
|
|
461
|
+
/**
|
|
462
|
+
* Layout.
|
|
463
|
+
*
|
|
464
|
+
* The panel is a CARD GRID, not a vertical stack: the surface it lives in can be
|
|
465
|
+
* narrow (a docked sidebar) or very wide (a maximised pane), and a single column of
|
|
466
|
+
* full-width cards reads as broken at the wide end — every metric row stretches
|
|
467
|
+
* across a metre of empty space.
|
|
468
|
+
*
|
|
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.
|
|
481
|
+
*/
|
|
482
|
+
const styles = {
|
|
483
|
+
root: {
|
|
484
|
+
display: 'grid',
|
|
485
|
+
gridTemplateColumns: 'minmax(0, 1fr)',
|
|
486
|
+
// No `alignItems: start`: grid items stretch to the row height by default, so
|
|
487
|
+
// two cards sharing a row end level. With `start` each card took its natural
|
|
488
|
+
// height and the row showed a ragged bottom edge.
|
|
489
|
+
gap: '10px',
|
|
490
|
+
padding: '12px',
|
|
491
|
+
fontSize: '12px',
|
|
492
|
+
lineHeight: 1.5,
|
|
493
|
+
width: '100%',
|
|
494
|
+
maxWidth: '100%',
|
|
495
|
+
minWidth: 0,
|
|
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',
|
|
500
|
+
height: '100%',
|
|
501
|
+
boxSizing: 'border-box',
|
|
502
|
+
},
|
|
503
|
+
/**
|
|
504
|
+
* A card that takes one grid column.
|
|
505
|
+
*
|
|
506
|
+
* Flex column, not block. A card stretched by the grid has a definite height, and
|
|
507
|
+
* its body needs to grow into that height — which `height: 100%` on the body gets
|
|
508
|
+
* wrong: a percentage height is resolved from the parent, so the body's own
|
|
509
|
+
* content stops contributing to the row's intrinsic height, the row is sized too
|
|
510
|
+
* short, and the body spills past the border. `flex: 1` on the body grows it
|
|
511
|
+
* without breaking the content-based sizing.
|
|
512
|
+
*/
|
|
513
|
+
card: {
|
|
514
|
+
border: HAIRLINE,
|
|
515
|
+
borderRadius: '8px',
|
|
516
|
+
padding: '10px',
|
|
517
|
+
minWidth: 0,
|
|
518
|
+
display: 'flex',
|
|
519
|
+
flexDirection: 'column',
|
|
520
|
+
},
|
|
521
|
+
/** A card that spans every column. */
|
|
522
|
+
cardWide: {
|
|
523
|
+
border: HAIRLINE,
|
|
524
|
+
borderRadius: '8px',
|
|
525
|
+
padding: '10px',
|
|
526
|
+
minWidth: 0,
|
|
527
|
+
gridColumn: '1 / -1',
|
|
528
|
+
display: 'flex',
|
|
529
|
+
flexDirection: 'column',
|
|
530
|
+
},
|
|
531
|
+
row: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
|
532
|
+
bar: { display: 'flex', height: '10px', borderRadius: '5px', overflow: 'hidden', background: SOFT_BG },
|
|
533
|
+
mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' },
|
|
534
|
+
fileRow: { display: 'flex', alignItems: 'center', gap: '8px', padding: '3px 0' },
|
|
535
|
+
fileMeta: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 },
|
|
536
|
+
};
|
|
537
|
+
function Section({ title, meta, wide = false, children, style }) {
|
|
538
|
+
return h('section', { style: { ...(wide ? styles.cardWide : styles.card), ...style } }, title === undefined
|
|
539
|
+
? null
|
|
540
|
+
: h('div', { style: { marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 } }, typeof title === 'string' ? h('span', { style: MUTED }, title) : title, h('span', { style: { flex: 1 } }), meta ?? null), children);
|
|
541
|
+
}
|
|
542
|
+
function Row({ children, style }) {
|
|
543
|
+
return h('div', { style: { ...styles.row, ...style } }, children);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* One file's flags, as native tags.
|
|
547
|
+
*/
|
|
548
|
+
function FileFlags({ file }) {
|
|
549
|
+
const flags = [];
|
|
550
|
+
if (file.minified?.binary) {
|
|
551
|
+
flags.push(h(Tag, { key: 'bin', tone: 'neutral' }, '二进制'));
|
|
552
|
+
}
|
|
553
|
+
else if (file.minified?.suspect) {
|
|
554
|
+
flags.push(h(Tag, { key: 'min', tone: 'info' }, `单行 ~${Math.round(file.minified.avgLineLength)}`));
|
|
555
|
+
}
|
|
556
|
+
if (file.generatedHint) {
|
|
557
|
+
flags.push(h(Tag, { key: 'gen', tone: 'outline' }, '构建产物'));
|
|
558
|
+
}
|
|
559
|
+
if (flags.length === 0)
|
|
560
|
+
return null;
|
|
561
|
+
return h('span', { style: { display: 'inline-flex', gap: '4px', flexShrink: 0 } }, flags);
|
|
562
|
+
}
|
|
563
|
+
function Ring({ ratio, caption, tone = 'currentColor', size = 76 }) {
|
|
564
|
+
const clamped = Math.max(0, Math.min(1, Number.isFinite(ratio) ? ratio : 0));
|
|
565
|
+
const stroke = 8;
|
|
566
|
+
const radius = (size - stroke) / 2;
|
|
567
|
+
const circumference = 2 * Math.PI * radius;
|
|
568
|
+
const dash = circumference * clamped;
|
|
569
|
+
return h('div', { style: { position: 'relative', width: `${size}px`, height: `${size}px`, flexShrink: 0 } }, h('svg', { width: size, height: size, viewBox: `0 0 ${size} ${size}`, role: 'img', 'aria-label': caption }, h('circle', {
|
|
570
|
+
cx: size / 2,
|
|
571
|
+
cy: size / 2,
|
|
572
|
+
r: radius,
|
|
573
|
+
fill: 'none',
|
|
574
|
+
stroke: 'currentColor',
|
|
575
|
+
strokeOpacity: 0.15,
|
|
576
|
+
strokeWidth: stroke,
|
|
577
|
+
}), h('circle', {
|
|
578
|
+
cx: size / 2,
|
|
579
|
+
cy: size / 2,
|
|
580
|
+
r: radius,
|
|
581
|
+
fill: 'none',
|
|
582
|
+
stroke: tone,
|
|
583
|
+
strokeWidth: stroke,
|
|
584
|
+
strokeLinecap: 'round',
|
|
585
|
+
strokeDasharray: `${dash} ${circumference - dash}`,
|
|
586
|
+
// Start the arc at twelve o'clock rather than three.
|
|
587
|
+
transform: `rotate(-90 ${size / 2} ${size / 2})`,
|
|
588
|
+
})), h('div', {
|
|
589
|
+
style: {
|
|
590
|
+
position: 'absolute',
|
|
591
|
+
inset: 0,
|
|
592
|
+
display: 'flex',
|
|
593
|
+
flexDirection: 'column',
|
|
594
|
+
alignItems: 'center',
|
|
595
|
+
justifyContent: 'center',
|
|
596
|
+
gap: '1px',
|
|
597
|
+
},
|
|
598
|
+
}, h('span', { style: { fontSize: '15px', fontWeight: 600, ...styles.mono } }, `${fixed(clamped * 100, 1)}%`), h('span', { style: { fontSize: '10px', ...MUTED } }, caption)));
|
|
599
|
+
}
|
|
600
|
+
function MetricGrid({ items }) {
|
|
601
|
+
return h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '4px 16px' } }, items.map((item) => h('div', { key: item.label, style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } }, item.state !== undefined
|
|
602
|
+
? h(StateDot, { state: item.state })
|
|
603
|
+
: h('span', {
|
|
604
|
+
style: {
|
|
605
|
+
width: '8px',
|
|
606
|
+
height: '8px',
|
|
607
|
+
borderRadius: '50%',
|
|
608
|
+
background: item.color ?? 'currentColor',
|
|
609
|
+
opacity: item.color === undefined ? 0.4 : 1,
|
|
610
|
+
flexShrink: 0,
|
|
611
|
+
},
|
|
612
|
+
}),
|
|
613
|
+
// Label and value sit ADJACENT. A flex spacer between them was a bug that
|
|
614
|
+
// only showed on a wide pane: the cell stretched and pushed the two ends
|
|
615
|
+
// hundreds of pixels apart, so a row read as two unrelated columns of text
|
|
616
|
+
// rather than one metric.
|
|
617
|
+
h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, item.label), h('span', { style: { fontWeight: 600, ...styles.mono, whiteSpace: 'nowrap' } }, item.value), item.hint !== undefined ? h('span', { style: { ...MUTED, fontSize: '10px' } }, item.hint) : null)));
|
|
618
|
+
}
|
|
619
|
+
function Histogram({ histogram, height = 54 }) {
|
|
620
|
+
const buckets = histogram?.buckets ?? [];
|
|
621
|
+
if (buckets.length === 0)
|
|
622
|
+
return null;
|
|
623
|
+
const max = Math.max(...buckets.map((b) => b.commits), 1);
|
|
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
|
+
}
|
|
633
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px' } }, h('div', {
|
|
634
|
+
style: { display: 'flex', alignItems: 'flex-end', gap: '1px', height: `${height}px` },
|
|
635
|
+
role: 'img',
|
|
636
|
+
'aria-label': `提交活动,每${bucketDays === 7 ? '周' : '天'}一柱`,
|
|
637
|
+
}, buckets.map((bucket, index) => h(Tooltip, {
|
|
638
|
+
key: `${bucket.label}-${index}`,
|
|
639
|
+
label: `${bucket.label} · ${bucket.commits} 提交 · +${bucket.insertions}/-${bucket.deletions}`,
|
|
640
|
+
side: 'bottom',
|
|
641
|
+
}, h('div', {
|
|
642
|
+
style: {
|
|
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',
|
|
647
|
+
// A zero bucket keeps a visible floor so a gap reads as "no commits"
|
|
648
|
+
// rather than as missing data.
|
|
649
|
+
height: `${Math.max(2, (bucket.commits / max) * height)}px`,
|
|
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)',
|
|
656
|
+
opacity: bucket.commits === 0 ? 0.16 : 0.75,
|
|
657
|
+
borderRadius: '1px',
|
|
658
|
+
alignSelf: 'flex-end',
|
|
659
|
+
},
|
|
660
|
+
})))), h('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: '10px', ...MUTED } }, h('span', null, buckets[0]?.label ?? ''), h('span', null, `每${bucketDays === 7 ? '周' : '天'} · 峰值 ${max}`), h('span', null, buckets[buckets.length - 1]?.label ?? '')));
|
|
661
|
+
}
|
|
662
|
+
function LanguageBar({ rows, totalBytes, totalFiles = 0, metric = 'bytes' }) {
|
|
663
|
+
const segments = stackSegments(rows, totalBytes, totalFiles, metric);
|
|
664
|
+
if (segments.length === 0)
|
|
665
|
+
return null;
|
|
666
|
+
const visible = segments.filter((s) => s.percent >= 0.05);
|
|
667
|
+
const hidden = segments.length - visible.length;
|
|
668
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } }, h('div', { style: styles.bar, role: 'img', 'aria-label': '语言分布' }, segments.map((segment, index) => h(Tooltip, { key: `${segment.key}-${index}`, label: `${segment.label} ${fixed(segment.percent, 1)}%`, side: 'bottom' }, h('div', {
|
|
669
|
+
// A floor keeps a 0.01% language visible; the flex ratio keeps the
|
|
670
|
+
// major segments proportional.
|
|
671
|
+
style: { width: `${Math.max(0.4, segment.percent)}%`, background: segment.color, flexShrink: 0 },
|
|
672
|
+
})))), h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px 12px' } }, visible.map((segment) => h('span', { key: segment.key, style: { display: 'inline-flex', gap: '5px', alignItems: 'center' } }, h('span', {
|
|
673
|
+
style: {
|
|
674
|
+
width: '8px',
|
|
675
|
+
height: '8px',
|
|
676
|
+
borderRadius: '50%',
|
|
677
|
+
background: segment.color,
|
|
678
|
+
display: 'inline-block',
|
|
679
|
+
flexShrink: 0,
|
|
680
|
+
},
|
|
681
|
+
}), h('span', null, segment.label), h('span', { style: MUTED }, `${fixed(segment.percent, 1)}%`))), hidden > 0 ? h('span', { style: MUTED }, `…另有 ${hidden} 种`) : null));
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Language distribution, with the metric switchable.
|
|
685
|
+
*
|
|
686
|
+
* The toggle earns its place because bytes and files disagree in a way that
|
|
687
|
+
* changes the answer: a repository can be 70 % TypeScript by bytes while being
|
|
688
|
+
* mostly Markdown by file count, and either statement alone is misleading. The
|
|
689
|
+
* control is a stateful wrapper so `CoverageView` stays a pure function of its
|
|
690
|
+
* props and can still be rendered against a fixture.
|
|
691
|
+
*
|
|
692
|
+
* @param {{languages: {rows: any[], totalBytes: number, totalFiles: number}}} props
|
|
693
|
+
*/
|
|
694
|
+
function LanguageSection({ languages }) {
|
|
695
|
+
const [metric, setMetric] = React.useState('bytes');
|
|
696
|
+
const unit = metric === 'files' ? '文件' : '字节';
|
|
697
|
+
return h(Section, {
|
|
698
|
+
title: `语言分布 · ${languages.rows.length} 种`,
|
|
699
|
+
meta: h(SegmentedControl, {
|
|
700
|
+
id: 'gitnexus-language-metric',
|
|
701
|
+
label: '语言分布统计口径',
|
|
702
|
+
value: metric,
|
|
703
|
+
// The control's API is string-valued; narrowing here rather than widening the
|
|
704
|
+
// state keeps the union intact downstream, where `stackSegments` switches on
|
|
705
|
+
// it — a widened state would let a typo like `'bytse'` compile.
|
|
706
|
+
onChange: (next) => setMetric(next === 'files' ? 'files' : 'bytes'),
|
|
707
|
+
options: [
|
|
708
|
+
{ value: 'bytes', label: '按字节' },
|
|
709
|
+
{ value: 'files', label: '按文件' },
|
|
710
|
+
],
|
|
711
|
+
}),
|
|
712
|
+
// The card stretches to the row height beside the overview card; the footnote
|
|
713
|
+
// below uses `marginTop: auto` to sit at the foot of that height.
|
|
714
|
+
}, h(LanguageBar, {
|
|
715
|
+
rows: languages.rows,
|
|
716
|
+
totalBytes: languages.totalBytes,
|
|
717
|
+
totalFiles: languages.totalFiles,
|
|
718
|
+
metric,
|
|
719
|
+
}), h('div', { style: { marginTop: 'auto', paddingTop: '8px', fontSize: '10px', ...MUTED } }, languages.detector === 'builtin'
|
|
720
|
+
? `按${unit}计的占比;GitNexus 不可用,语言由内置扩展名映射识别(仅作参考)`
|
|
721
|
+
: `按${unit}计的占比;GitNexus 只解析其中一部分语言,其余仅作为文件节点存在`));
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* The files the size cap dropped.
|
|
725
|
+
*
|
|
726
|
+
* `FileTypeIcon` + `PathLabel` replace a hand-rolled row: the icon makes a `.glb`
|
|
727
|
+
* or a `.ts` readable at a glance, and `PathLabel` dims the directories, keeps the
|
|
728
|
+
* filename at full strength, and preserves the tail on overflow — which is what a
|
|
729
|
+
* path needs and what raw truncation destroys.
|
|
730
|
+
* @param {{files: any[]}} props
|
|
731
|
+
*/
|
|
732
|
+
function LargeFileList({ files }) {
|
|
733
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column' } }, files.map((file) => h('div', { key: file.path, style: styles.fileRow }, 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 }, h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, file.sizeText), h(FileFlags, { file }), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `${fixed((file.shareOfLanguage ?? 0) * 100, 0)}% ${file.language?.label ?? ''}`)))));
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Which query surfaces are live.
|
|
737
|
+
* @param {{capabilities: any}} props
|
|
738
|
+
*/
|
|
739
|
+
function CapabilityStrip({ capabilities }) {
|
|
740
|
+
const rows = capabilityRows(capabilities);
|
|
741
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px' } }, rows.map((row) => h('div', { key: row.key, style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h(StateDot, { state: row.ok ? 'done' : 'idle' }), h('span', { style: { minWidth: '68px' } }, row.label), row.ok
|
|
742
|
+
? h('span', { style: MUTED }, row.provider)
|
|
743
|
+
: h(Tag, { tone: 'outline' }, row.note))));
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Resolver confidence and analysis provenance.
|
|
747
|
+
*
|
|
748
|
+
* Body only — the surrounding `Section` card supplies the border and the heading.
|
|
749
|
+
*
|
|
750
|
+
* @param {{quality: any, provenance: any}} props
|
|
751
|
+
*/
|
|
752
|
+
function ProvenanceBlock({ quality, provenance }) {
|
|
753
|
+
const summary = graphQualitySummary(quality);
|
|
754
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', paddingTop: '4px', flex: '1 1 auto' } }, summary === null
|
|
755
|
+
? h('div', { style: MUTED }, '该索引未记录未解析接收者统计(旧版本写入)')
|
|
756
|
+
: h('div', null, h('div', null, `未解析接收者 ${compactNumber(summary.total)} 处——存在这些位置时,impact / context 的结果是下界而非精确值`), h('div', { style: { ...MUTED, marginTop: '3px' } }, summary.names
|
|
757
|
+
.slice(0, 6)
|
|
758
|
+
.map((entry) => `${entry.name} ${entry.count}`)
|
|
759
|
+
.join(' · '))), provenance === null
|
|
760
|
+
? null
|
|
761
|
+
: h('div',
|
|
762
|
+
// Pushed to the bottom: this card shares a row with the caveats card and
|
|
763
|
+
// stretches to its height, so anchoring the metrics at the foot turns the
|
|
764
|
+
// leftover space into a gap between two groups rather than a void below.
|
|
765
|
+
{ style: { marginTop: 'auto', paddingTop: '8px' } }, h(MetricGrid, {
|
|
766
|
+
items: [
|
|
767
|
+
{ label: 'schema', value: provenance.schemaFingerprint ?? '—' },
|
|
768
|
+
{ label: '内容保留', value: provenance.contentRetention ?? '—' },
|
|
769
|
+
{ label: 'FTS 配置', value: provenance.ftsProfile ?? '—' },
|
|
770
|
+
{ label: '跨语言属性', value: compactNumber(provenance.crossLanguageProperties ?? 0) },
|
|
771
|
+
{ label: '分析器', value: provenance.runnerCliVersion ?? '—' },
|
|
772
|
+
],
|
|
773
|
+
})));
|
|
774
|
+
}
|
|
775
|
+
/** Collapsed summary for the provenance card's header. */
|
|
776
|
+
function provenanceCollapsed(quality) {
|
|
777
|
+
const summary = graphQualitySummary(quality);
|
|
778
|
+
return summary === null ? '无未解析接收者记录' : `未解析接收者 ${compactNumber(summary.total)} 处`;
|
|
779
|
+
}
|
|
780
|
+
/** Collapsed summary for the activity card's header. */
|
|
781
|
+
function activityCollapsed(activity) {
|
|
782
|
+
const view = activityView(activity);
|
|
783
|
+
if (view === null)
|
|
784
|
+
return '不可用';
|
|
785
|
+
const missing = view.verdicts.find((v) => v.key === 'missing')?.value ?? 0;
|
|
786
|
+
if (missing > 0)
|
|
787
|
+
return `${missing} 个活跃文件不在索引中`;
|
|
788
|
+
return `活跃文件均在索引中${view.truncated ? ' · 窗口已截断' : ''}`;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* All activity numbers in one place, so the view stays a layout decision.
|
|
792
|
+
* @param {any} activity
|
|
793
|
+
* @returns {null | { window: string, totals: string, verdicts: Array<{key:string,label:string,value:number,tone:string}> }}
|
|
794
|
+
*/
|
|
795
|
+
function activityView(activity) {
|
|
796
|
+
if (activity === null || typeof activity !== 'object')
|
|
797
|
+
return null;
|
|
798
|
+
const t = activity.totals ?? {};
|
|
799
|
+
const v = t.verdicts ?? { indexed: 0, excluded: 0, missing: 0 };
|
|
800
|
+
return {
|
|
801
|
+
window: `近 ${activity.window?.sinceDays ?? '?'} 天`,
|
|
802
|
+
truncated: activity.window?.truncated === true,
|
|
803
|
+
totals: `${t.commits ?? 0} 提交 · ${t.authors ?? 0} 作者 · ${compactNumber(t.insertions ?? 0)} 增 / ${compactNumber(t.deletions ?? 0)} 删`,
|
|
804
|
+
// Ordered so the actionable verdict is first: "missing" is the only one that
|
|
805
|
+
// means the index is behind. "excluded" next to it, because a reader who sees
|
|
806
|
+
// only "not indexed" will otherwise read it as a defect.
|
|
807
|
+
verdicts: [
|
|
808
|
+
{ key: 'indexed', label: '已索引', value: v.indexed, tone: 'done' },
|
|
809
|
+
{ key: 'missing', label: '索引缺失', value: v.missing, tone: v.missing > 0 ? 'warning' : 'idle' },
|
|
810
|
+
{ key: 'excluded', label: '有意排除', value: v.excluded, tone: 'idle' },
|
|
811
|
+
],
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* One touched file, with its verdict as a tag.
|
|
816
|
+
*
|
|
817
|
+
* The verdict tag is the whole point of the row: a path alone cannot tell a reader
|
|
818
|
+
* whether the graph knows about it.
|
|
819
|
+
* @param {{file: any}} props
|
|
820
|
+
*/
|
|
821
|
+
function ActivityFileRow({ file }) {
|
|
822
|
+
const tone = file.verdict === 'indexed' ? 'solid' : file.verdict === 'missing' ? 'outline' : 'neutral';
|
|
823
|
+
const label = file.verdict === 'indexed' ? '已索引' : file.verdict === 'missing' ? '缺失' : '排除';
|
|
824
|
+
return h('div', { style: styles.fileRow }, 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 }, h(Tag, { tone }, label), h('span', { style: { ...MUTED, whiteSpace: 'nowrap' } }, `${file.changes}×`)));
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Git activity, crossed with the index.
|
|
828
|
+
*
|
|
829
|
+
* Body only — the card and its disclosure header come from `CollapsibleCard`. The
|
|
830
|
+
* header's collapsed line still carries the number that matters, so a reader who
|
|
831
|
+
* never expands it learns whether recent work is visible to the graph.
|
|
832
|
+
* @param {{activity: any}} props
|
|
833
|
+
*/
|
|
834
|
+
function GitActivitySection({ activity: rawActivity }) {
|
|
835
|
+
// Narrowed to a local const before use. `activityView` returns null for an absent
|
|
836
|
+
// activity, but TypeScript cannot carry that back to the parameter, so every later
|
|
837
|
+
// read would need its own guard — and a guard per read is how one gets forgotten.
|
|
838
|
+
const activity = rawActivity;
|
|
839
|
+
const view = activityView(activity);
|
|
840
|
+
if (view === null || activity === null || activity === undefined) {
|
|
841
|
+
return h('div', { style: { ...MUTED } }, 'Git 活动不可用');
|
|
842
|
+
}
|
|
843
|
+
const missing = view.verdicts.find((v) => v.key === 'missing')?.value ?? 0;
|
|
844
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '4px' } }, h('div', { style: MUTED }, view.totals), view.truncated
|
|
845
|
+
? h('div', { style: MUTED }, `活动窗口达到提交上限,更早的改动未统计`)
|
|
846
|
+
: null, h(MetricGrid, {
|
|
847
|
+
items: view.verdicts.map((v) => ({ label: v.label, value: compactNumber(v.value), state: v.tone })),
|
|
848
|
+
}),
|
|
849
|
+
// Commit activity over time. Placed above the file lists because "when" is
|
|
850
|
+
// what makes the rest legible: a hot file means something different in a
|
|
851
|
+
// burst week than in a slow one.
|
|
852
|
+
activity.histogram !== undefined && activity.histogram !== null
|
|
853
|
+
? h(Histogram, { histogram: activity.histogram })
|
|
854
|
+
: null,
|
|
855
|
+
// Missing files come from their own list, not from the display-capped
|
|
856
|
+
// by-changes slice — otherwise the summary can report a count with no rows.
|
|
857
|
+
missing > 0 && Array.isArray(activity.missingFiles) && activity.missingFiles.length > 0
|
|
858
|
+
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '应被索引却缺失'), activity.missingFiles
|
|
859
|
+
.slice(0, 8)
|
|
860
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
861
|
+
: null, Array.isArray(activity.byChanges) && activity.byChanges.length > 0
|
|
862
|
+
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '改动最频繁'), activity.byChanges
|
|
863
|
+
.filter((f) => f.verdict !== 'excluded')
|
|
864
|
+
.slice(0, 8)
|
|
865
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
866
|
+
: null, Array.isArray(activity.byRecency) && activity.byRecency.length > 0
|
|
867
|
+
? h('div', null, h('div', { style: { ...MUTED, marginBottom: '4px' } }, '最近改动'), activity.byRecency
|
|
868
|
+
.filter((f) => f.verdict !== 'excluded')
|
|
869
|
+
.slice(0, 5)
|
|
870
|
+
.map((file) => h(ActivityFileRow, { key: file.path, file })))
|
|
871
|
+
: null, Array.isArray(activity.authors) && activity.authors.length > 0
|
|
872
|
+
? 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} 文件`)))))
|
|
873
|
+
: null, Array.isArray(activity.branches) && activity.branches.length > 0
|
|
874
|
+
? 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) : '')))))
|
|
875
|
+
: null);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Resolve the project this panel is about.
|
|
879
|
+
*
|
|
880
|
+
* The panel shows the project the operator is *in*, not a global list of indexed
|
|
881
|
+
* repositories. A registry-driven picker was the first design and it was wrong for
|
|
882
|
+
* the job: it made `~/.gitnexus/registry.json` a hard dependency, so a fresh clone,
|
|
883
|
+
* a second working tree, or an index built with `GITNEXUS_HOME` redirected simply
|
|
884
|
+
* could not be shown — which is exactly when a coverage panel is most useful.
|
|
885
|
+
*
|
|
886
|
+
* The path comes from the session's own `cwd`, selected out of the session snapshot:
|
|
887
|
+
*
|
|
888
|
+
* useSessions((sessions) => sessions.byId[sessionId]?.cwd)
|
|
889
|
+
*
|
|
890
|
+
* That is the shipped pattern, read off `dsh-client-ui-sidebar-files`, whose body
|
|
891
|
+
* sits in this same slot and needs the same answer. An earlier revision guessed at
|
|
892
|
+
* `useWorkspaces` and the panel rendered "未解析到工作区" — a hook that exists is not
|
|
893
|
+
* the same as the hook that carries this value.
|
|
894
|
+
*
|
|
895
|
+
* @param {{ params?: {repo?: string}, sessionId?: string, useSessions?: Function, useWorkspaces?: Function }} props
|
|
896
|
+
* @returns {string | null}
|
|
897
|
+
*/
|
|
898
|
+
function useProjectPath(props) {
|
|
899
|
+
// A caller that opened the tab with an explicit repo wins, so `openTab` stays
|
|
900
|
+
// usable for previewing a project other than the session's.
|
|
901
|
+
const explicit = props?.params?.repo;
|
|
902
|
+
const sessionId = props?.sessionId;
|
|
903
|
+
// Hooks must be called unconditionally: a host that supplies neither still needs
|
|
904
|
+
// the calls to happen, so a no-op of the same shape stands in rather than a
|
|
905
|
+
// branch around the hook.
|
|
906
|
+
const selectSession = typeof props?.useSessions === 'function' ? props.useSessions : () => null;
|
|
907
|
+
const fromSession = selectSession((sessions) => typeof sessionId === 'string' ? sessions?.byId?.[sessionId]?.cwd ?? null : null);
|
|
908
|
+
const selectWorkspace = typeof props?.useWorkspaces === 'function' ? props.useWorkspaces : () => null;
|
|
909
|
+
const fromWorkspace = selectWorkspace((snapshot) => {
|
|
910
|
+
const list = snapshot?.workspaces;
|
|
911
|
+
if (!Array.isArray(list) || typeof sessionId !== 'string')
|
|
912
|
+
return null;
|
|
913
|
+
const mine = list.find((w) => Array.isArray(w?.sessionIds) && w.sessionIds.includes(sessionId));
|
|
914
|
+
return typeof mine?.cwd === 'string' && mine.cwd !== '' ? mine.cwd : null;
|
|
915
|
+
});
|
|
916
|
+
if (typeof explicit === 'string' && explicit !== '')
|
|
917
|
+
return explicit;
|
|
918
|
+
if (typeof fromSession === 'string' && fromSession !== '')
|
|
919
|
+
return fromSession;
|
|
920
|
+
return typeof fromWorkspace === 'string' && fromWorkspace !== '' ? fromWorkspace : null;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Which context props actually arrived.
|
|
924
|
+
*
|
|
925
|
+
* Rendered only when the project could not be resolved. Without it the panel can
|
|
926
|
+
* only say "no project", which is a dead end for whoever has to fix it — with it,
|
|
927
|
+
* one screenshot names the missing input.
|
|
928
|
+
*
|
|
929
|
+
* @param {any} props
|
|
930
|
+
* @returns {string[]}
|
|
931
|
+
*/
|
|
932
|
+
function contextDiagnostic(props) {
|
|
933
|
+
const present = [];
|
|
934
|
+
const absent = [];
|
|
935
|
+
const note = (name, ok) => {
|
|
936
|
+
(ok ? present : absent).push(name);
|
|
937
|
+
};
|
|
938
|
+
note('sessionId', typeof props?.sessionId === 'string' && props.sessionId !== '');
|
|
939
|
+
note('useSessions', typeof props?.useSessions === 'function');
|
|
940
|
+
note('useWorkspaces', typeof props?.useWorkspaces === 'function');
|
|
941
|
+
note('useTabInfo', typeof props?.useTabInfo === 'function');
|
|
942
|
+
note('params.repo', typeof props?.params?.repo === 'string');
|
|
943
|
+
return { present, absent };
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* The maintenance commands, shown verbatim rather than executed.
|
|
947
|
+
*
|
|
948
|
+
* The plugin deliberately does not run these. Spawning `gitnexus analyze` from a
|
|
949
|
+
* plugin bypasses the host's permission system: nothing would ask the operator
|
|
950
|
+
* whether a thirty-minute write against their working tree was intended. Handing
|
|
951
|
+
* over the exact command keeps the decision — and the approval — on the path the
|
|
952
|
+
* product already audits.
|
|
953
|
+
*
|
|
954
|
+
* Which one is *relevant* depends on whether an index exists, so the applicable
|
|
955
|
+
* command is marked. Presenting both with equal weight on an already-indexed
|
|
956
|
+
* project reads as "you may need to initialize this", which is not true.
|
|
957
|
+
*
|
|
958
|
+
* @param {{maintenance: any, projectPath: string | null, indexed: boolean}} props
|
|
959
|
+
*/
|
|
960
|
+
/**
|
|
961
|
+
* What the panel says when GitNexus is not on this machine.
|
|
962
|
+
*
|
|
963
|
+
* THE ORDER IS THE DESIGN
|
|
964
|
+
* -----------------------
|
|
965
|
+
* The install command comes first and is copyable, because that is the answer for most
|
|
966
|
+
* operators: they know their network, their registry and their toolchain, and a command
|
|
967
|
+
* they can run is more useful than a download this plugin performs on their behalf. The
|
|
968
|
+
* plugin-install option is second and behind a confirmation, because making a
|
|
969
|
+
* several-hundred-megabyte download the path of least resistance would be the wrong
|
|
970
|
+
* default for a panel whose job is to report, not to act.
|
|
971
|
+
*
|
|
972
|
+
* Nothing renders when the verdict is `ready` — a permanent "GitNexus is fine" banner is
|
|
973
|
+
* noise in a panel whose whole purpose is to surface problems.
|
|
974
|
+
*/
|
|
975
|
+
function ToolchainNotice({ toolchain, run, runError, now, onInstall, onCancelInstall, }) {
|
|
976
|
+
const [copied, setCopied] = React.useState(false);
|
|
977
|
+
const [confirming, setConfirming] = React.useState(false);
|
|
978
|
+
const [acknowledged, setAcknowledged] = React.useState(false);
|
|
979
|
+
if (toolchain === undefined || toolchain === null || toolchain.verdict === 'ready')
|
|
980
|
+
return null;
|
|
981
|
+
const activeRun = run ?? null;
|
|
982
|
+
const installing = activeRun?.state === 'running';
|
|
983
|
+
const at = typeof now === 'number' ? now : Date.now();
|
|
984
|
+
const elapsed = activeRun === null ? 0 : Math.max(0, Math.round(((activeRun.finishedAt ?? at) - activeRun.startedAt) / 1000));
|
|
985
|
+
const libraryOnly = toolchain.verdict === 'library-only';
|
|
986
|
+
const command = 'npm install -g gitnexus';
|
|
987
|
+
const copy = () => {
|
|
988
|
+
const write = typeof primitives.writeClipboard === 'function'
|
|
989
|
+
? primitives.writeClipboard
|
|
990
|
+
: (text) => navigator.clipboard?.writeText(text);
|
|
991
|
+
try {
|
|
992
|
+
const outcome = write(command);
|
|
993
|
+
if (outcome && typeof outcome.then === 'function')
|
|
994
|
+
outcome.then(() => setCopied(true)).catch(() => setCopied(false));
|
|
995
|
+
else
|
|
996
|
+
setCopied(true);
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
setCopied(false);
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
return h(Section, { wide: true, title: libraryOnly ? '缺少 gitnexus 命令' : '本机没有 GitNexus' }, h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '4px' } }, h('div', { style: { ...MUTED, display: 'flex', gap: '6px', alignItems: 'flex-start' } }, h(StateDot, { state: 'warning' }), h('span', null, libraryOnly
|
|
1003
|
+
? '找到了 GitNexus 的库,但没有可执行的 gitnexus 命令(可能只作为依赖安装、未全局链接)。读取不受影响;修复后即可执行增量刷新。'
|
|
1004
|
+
: '本插件读取 .gitnexus/gitnexus.json —— 那份文件由 GitNexus 分析仓库产生,包含每个函数定义、符号关系与执行流。没有它就没什么可展示的。')), h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } }, h('div', { style: { fontWeight: 600 } }, '① 自己安装(推荐)'), h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h('code', {
|
|
1005
|
+
style: {
|
|
1006
|
+
...styles.mono,
|
|
1007
|
+
flex: '1 1 auto',
|
|
1008
|
+
padding: '6px 8px',
|
|
1009
|
+
border: HAIRLINE,
|
|
1010
|
+
borderRadius: '6px',
|
|
1011
|
+
background: SOFT_BG,
|
|
1012
|
+
wordBreak: 'break-all',
|
|
1013
|
+
userSelect: 'all',
|
|
1014
|
+
},
|
|
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
|
|
1016
|
+
? h(Button, { variant: 'ghost', onClick: onCancelInstall }, `取消(已进行 ${elapsed}s)`)
|
|
1017
|
+
: h(Button, { variant: 'ghost', onClick: () => setConfirming(true) }, '授权并安装'), h('span', { style: MUTED }, '装到插件自己的目录,用你已配置的 npm 源,不改全局前缀、不需要管理员权限;' +
|
|
1018
|
+
'若当前项目还没有索引,装完会继续初始化一次。')), activeRun === null
|
|
1019
|
+
? null
|
|
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 === ''
|
|
1021
|
+
? null
|
|
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, {
|
|
1023
|
+
open: confirming,
|
|
1024
|
+
title: '授权本插件安装 GitNexus?',
|
|
1025
|
+
description: '本插件将执行:\n\nnpm install --prefix <插件数据目录>/gitnexus gitnexus@^1.6.12\n\n' +
|
|
1026
|
+
'· 从你已配置的 npm 源下载约 500 MB ~ 1 GB\n' +
|
|
1027
|
+
'· 需要几分钟;网络慢或需要本地编译时更久\n' +
|
|
1028
|
+
'· 会在本机运行这些包的安装期构建脚本(原生数据库与解析器需要本地编译)\n' +
|
|
1029
|
+
'· 安装到插件自己的目录,不改动你的全局 npm 前缀\n' +
|
|
1030
|
+
'· 安装成功后,若当前项目尚无索引,会继续运行一次 ' +
|
|
1031
|
+
'`gitnexus analyze --index-only`(只写 .gitnexus/,不改源文件)\n\n' +
|
|
1032
|
+
'命令走 DSH 的 shell 执行器,沙箱与其它命令一致 —— 本插件不申请扩权。',
|
|
1033
|
+
acknowledgeLabel: '我已了解这会下载数百 MB、在本机编译原生模块,并在需要时初始化当前项目',
|
|
1034
|
+
cancelLabel: '取消',
|
|
1035
|
+
confirmLabel: '授权并开始',
|
|
1036
|
+
closeLabel: '关闭',
|
|
1037
|
+
acknowledged,
|
|
1038
|
+
onAcknowledgedChange: setAcknowledged,
|
|
1039
|
+
onCancel: () => {
|
|
1040
|
+
setConfirming(false);
|
|
1041
|
+
setAcknowledged(false);
|
|
1042
|
+
},
|
|
1043
|
+
onConfirm: () => {
|
|
1044
|
+
setConfirming(false);
|
|
1045
|
+
setAcknowledged(false);
|
|
1046
|
+
onInstall();
|
|
1047
|
+
},
|
|
1048
|
+
})));
|
|
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
|
+
}
|
|
1151
|
+
function MaintenanceSection({ maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun, }) {
|
|
1152
|
+
// `projectPath` is part of the prop set for the header, which builds the commands
|
|
1153
|
+
// from it; the body does not read it.
|
|
1154
|
+
void projectPath;
|
|
1155
|
+
const [copied, setCopied] = React.useState(null);
|
|
1156
|
+
const [confirming, setConfirming] = React.useState(null);
|
|
1157
|
+
// `RiskConfirmation`'s checkbox is controlled, so the tick has to live here. Without
|
|
1158
|
+
// this the box renders but never moves, which is how it looked the first time.
|
|
1159
|
+
const [acknowledged, setAcknowledged] = React.useState(false);
|
|
1160
|
+
const commands = Array.isArray(maintenance?.commands) ? maintenance.commands : [];
|
|
1161
|
+
if (commands.length === 0)
|
|
1162
|
+
return null;
|
|
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;
|
|
1176
|
+
// Normalised rather than trusted. The declared type says `ClientAnalyzeRun | null`,
|
|
1177
|
+
// but a React element built without the prop arrives as `undefined` — which is
|
|
1178
|
+
// exactly what happened the first time this component was rendered by a caller that
|
|
1179
|
+
// predated the prop. `run === null` alone crashed on it.
|
|
1180
|
+
const activeRun = run ?? null;
|
|
1181
|
+
const running = activeRun?.state === 'running';
|
|
1182
|
+
const at = typeof now === 'number' ? now : Date.now();
|
|
1183
|
+
const elapsed = activeRun === null ? 0 : Math.max(0, Math.round(((activeRun.finishedAt ?? at) - activeRun.startedAt) / 1000));
|
|
1184
|
+
const copy = (command) => {
|
|
1185
|
+
const write = typeof primitives.writeClipboard === 'function'
|
|
1186
|
+
? primitives.writeClipboard
|
|
1187
|
+
: (text) => navigator.clipboard?.writeText(text);
|
|
1188
|
+
try {
|
|
1189
|
+
const outcome = write(command.shell);
|
|
1190
|
+
if (outcome && typeof outcome.then === 'function') {
|
|
1191
|
+
outcome.then(() => setCopied(command.mode)).catch(() => setCopied(null));
|
|
1192
|
+
}
|
|
1193
|
+
else {
|
|
1194
|
+
setCopied(command.mode);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
setCopied(null);
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '4px' } },
|
|
1202
|
+
// The old line said "复制后到会话执行". That stopped being the whole story once the
|
|
1203
|
+
// plugin could start the run itself, so the sentence says which parts it runs and
|
|
1204
|
+
// which it only hands over.
|
|
1205
|
+
h('div', { style: MUTED }, hostCurrent
|
|
1206
|
+
? '两条命令都可由本插件执行(走 DSH 的 shell 执行器,沙箱与其它命令一致,本插件不申请扩权)。' +
|
|
1207
|
+
'全量重建只应在增量刷新卡死或结果反常时使用。'
|
|
1208
|
+
: '当前宿主是旧版本(不包含 FTS 自适应与注册表重定向修复);重启 DSH 后再执行维护。命令仍可复制。'),
|
|
1209
|
+
// The run sits above the commands rather than under one of them: the host holds a
|
|
1210
|
+
// single run per repository, and nesting it would imply the two variants have
|
|
1211
|
+
// separate ones.
|
|
1212
|
+
activeRun === null
|
|
1213
|
+
? null
|
|
1214
|
+
: h('div', {
|
|
1215
|
+
style: {
|
|
1216
|
+
display: 'flex',
|
|
1217
|
+
flexDirection: 'column',
|
|
1218
|
+
gap: '4px',
|
|
1219
|
+
padding: '6px 8px',
|
|
1220
|
+
border: HAIRLINE,
|
|
1221
|
+
borderRadius: '6px',
|
|
1222
|
+
background: SOFT_BG,
|
|
1223
|
+
},
|
|
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
|
|
1225
|
+
? h(Button, { variant: 'ghost', onClick: onCancelRun }, '取消')
|
|
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),
|
|
1231
|
+
// The output is the evidence. A run that reports success with a warning in
|
|
1232
|
+
// its log is a different outcome from one that does not, and hiding the text
|
|
1233
|
+
// would leave the panel's own verdict as the only thing to check.
|
|
1234
|
+
activeRun.output === ''
|
|
1235
|
+
? null
|
|
1236
|
+
: h('pre', {
|
|
1237
|
+
style: {
|
|
1238
|
+
...styles.mono,
|
|
1239
|
+
margin: 0,
|
|
1240
|
+
whiteSpace: 'pre-wrap',
|
|
1241
|
+
maxHeight: '9em',
|
|
1242
|
+
overflow: 'auto',
|
|
1243
|
+
},
|
|
1244
|
+
}, activeRun.output)), runError === null
|
|
1245
|
+
? null
|
|
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
|
|
1296
|
+
? h('div', { style: { ...MUTED, display: 'flex', flexDirection: 'column', gap: '2px' } }, maintenance.rationale.map((note, index) => h('div', { key: index }, `· ${note}`)))
|
|
1297
|
+
: null,
|
|
1298
|
+
// The confirmation. Everything the operator needs before a process touches their
|
|
1299
|
+
// working tree goes in `description`, because that is the prop the modal renders:
|
|
1300
|
+
// an earlier revision passed this as children and the dialog opened with an empty
|
|
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.
|
|
1306
|
+
h(primitives.RiskConfirmation, {
|
|
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/',
|
|
1328
|
+
cancelLabel: '取消',
|
|
1329
|
+
confirmLabel: fullConfirm ? '开始重建' : '执行',
|
|
1330
|
+
closeLabel: '关闭',
|
|
1331
|
+
// The checkbox is controlled, so it needs state of its own, and it is reset on
|
|
1332
|
+
// close: the acknowledgement is per-action rather than remembered.
|
|
1333
|
+
acknowledged,
|
|
1334
|
+
onAcknowledgedChange: setAcknowledged,
|
|
1335
|
+
onCancel: () => {
|
|
1336
|
+
setConfirming(null);
|
|
1337
|
+
setAcknowledged(false);
|
|
1338
|
+
},
|
|
1339
|
+
onConfirm: () => {
|
|
1340
|
+
const force = confirming === 'full';
|
|
1341
|
+
setConfirming(null);
|
|
1342
|
+
setAcknowledged(false);
|
|
1343
|
+
onStartRun(force);
|
|
1344
|
+
},
|
|
1345
|
+
}));
|
|
1346
|
+
}
|
|
1347
|
+
function CoverageView({ report, maintenance, projectPath, context, busy, error, readAt, run, runError, now, onRefresh, onStartRun, onCancelRun, installRun, installError, onInstall, onCancelInstall, search, }) {
|
|
1348
|
+
// Whether an index exists decides which maintenance command is applicable. When
|
|
1349
|
+
// the project could not even be identified there is nothing to claim, so it is
|
|
1350
|
+
// false rather than assumed.
|
|
1351
|
+
const indexed = report?.repo?.indexed === true;
|
|
1352
|
+
const header = h(Row, null, report !== null && report.repo?.indexed ? h(StateDot, { state: healthState(report) }) : null, h('span', { style: { fontWeight: 600 } }, 'GitNexus 索引'),
|
|
1353
|
+
// The project, not a picker. Shown so the reader can confirm which tree the
|
|
1354
|
+
// numbers describe — a path is the only unambiguous answer to that.
|
|
1355
|
+
projectPath === null
|
|
1356
|
+
? h('span', { style: MUTED }, '未解析到工作区')
|
|
1357
|
+
: h('span', { style: { minWidth: 0, flex: '1 1 auto' } }, h(PathLabel, { path: projectPath })), h('span', { style: { flex: 1 } }),
|
|
1358
|
+
// The label states the scope. "刷新" alone next to a staleness warning reads as
|
|
1359
|
+
// "rebuild the index", which this control has never done and — by design, since
|
|
1360
|
+
// the plugin spawns nothing — cannot do.
|
|
1361
|
+
h(Button, {
|
|
1362
|
+
variant: 'ghost',
|
|
1363
|
+
onClick: onRefresh,
|
|
1364
|
+
disabled: busy,
|
|
1365
|
+
title: '重新读取报告(不会重建索引;重建见下方「维护命令」)',
|
|
1366
|
+
'aria-label': '重新读取报告',
|
|
1367
|
+
}, h(IconRefreshOutlineRegular, { size: 14 })));
|
|
1368
|
+
// "I cannot tell which project this is" and "this project has no index" are
|
|
1369
|
+
// different facts, and the first revision conflated them: an unresolved project
|
|
1370
|
+
// fell through to the no-index branch and told the operator to initialise an
|
|
1371
|
+
// index for a project it had not identified. The diagnostic exists so that state
|
|
1372
|
+
// is actionable rather than a dead end.
|
|
1373
|
+
if (projectPath === null) {
|
|
1374
|
+
return h('div', { style: styles.root }, header, h(Section, { wide: true, style: { display: 'flex', gap: '8px', alignItems: 'flex-start' } }, h(StateDot, { state: 'warning' }), h('span', null, '未能确定当前项目路径。')), context === null || context === undefined
|
|
1375
|
+
? null
|
|
1376
|
+
: h(Section, { wide: true, title: '面板收到的上下文' }, h('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px' } }, h('div', null, `存在:${context.present.length > 0 ? context.present.join('、') : '(无)'}`), h('div', null, `缺失:${context.absent.length > 0 ? context.absent.join('、') : '(无)'}`))));
|
|
1377
|
+
}
|
|
1378
|
+
if (error !== null) {
|
|
1379
|
+
return h('div', { style: styles.root }, header, h(Section, { wide: true, style: { display: 'flex', gap: '8px', alignItems: 'flex-start' } }, h(StateDot, { state: 'error' }), h('span', null, error)), maintenance === null || maintenance === undefined
|
|
1380
|
+
? null
|
|
1381
|
+
: h(Section, {
|
|
1382
|
+
wide: true,
|
|
1383
|
+
title: '维护命令',
|
|
1384
|
+
meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, '复制后到会话执行(沙箱与提权由 DSH 处理)'),
|
|
1385
|
+
}, h(MaintenanceSection, { maintenance, projectPath, indexed: false, run, runError, now, onStartRun, onCancelRun })));
|
|
1386
|
+
}
|
|
1387
|
+
if (report === null) {
|
|
1388
|
+
return h('div', { style: styles.root }, header, h('div', { style: MUTED }, '正在读取索引…'));
|
|
1389
|
+
}
|
|
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.
|
|
1405
|
+
if (report.repo?.indexed !== true) {
|
|
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
|
|
1421
|
+
? null
|
|
1422
|
+
: h(Section, {
|
|
1423
|
+
wide: true,
|
|
1424
|
+
title: '初始化索引',
|
|
1425
|
+
meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, maintenance.supportsForce === true
|
|
1426
|
+
? '均可直接执行,也可复制到会话(沙箱与提权由 DSH 处理)'
|
|
1427
|
+
: '重启 DSH 后可执行;当前仅可复制(沙箱与提权由 DSH 处理)'),
|
|
1428
|
+
}, h(MaintenanceSection, { maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun })));
|
|
1429
|
+
}
|
|
1430
|
+
return h('div', { style: styles.root },
|
|
1431
|
+
// The header and the two status lines span everything: they describe the whole
|
|
1432
|
+
// panel, so giving them a column would make them look like one card's heading.
|
|
1433
|
+
h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '6px' } }, header),
|
|
1434
|
+
// Immediately after the header, before any index content. If GitNexus is missing,
|
|
1435
|
+
// that single fact explains everything the panel is about to show or fail to show,
|
|
1436
|
+
// so it belongs above the numbers rather than beside them.
|
|
1437
|
+
h(ToolchainNotice, {
|
|
1438
|
+
toolchain: report.toolchain,
|
|
1439
|
+
run: installRun,
|
|
1440
|
+
runError: installError,
|
|
1441
|
+
now,
|
|
1442
|
+
onInstall,
|
|
1443
|
+
onCancelInstall,
|
|
1444
|
+
}), h('div', { style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '3px' } }, h('div', { style: MUTED }, `${report.repo.indexedAt ?? '未知时间'} · ${report.repo.branch ?? 'unknown'}@${String(report.repo.lastCommit ?? '').slice(0, 8)}` +
|
|
1445
|
+
` · manifest ${report.repo.metaFile ?? '?'} · GitNexus ${analyzer.gitnexusVersion}` +
|
|
1446
|
+
// The read time is the only thing on screen a refresh can change when the
|
|
1447
|
+
// repository has not moved, so without it the control reads as broken.
|
|
1448
|
+
(readAt === null ? '' : ` · 读取于 ${String(readAt).slice(11, 19)}Z`)),
|
|
1449
|
+
// Freshness against HEAD. The line above states the *indexed* commit, which
|
|
1450
|
+
// reads as current unless this one says otherwise.
|
|
1451
|
+
report.staleness === undefined
|
|
1452
|
+
? null
|
|
1453
|
+
: (() => {
|
|
1454
|
+
const s = stalenessView(report.staleness);
|
|
1455
|
+
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h(StateDot, { state: s.state }), h('span', null, s.text));
|
|
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 }),
|
|
1460
|
+
// One card for the whole "what is this index" question — file coverage and
|
|
1461
|
+
// graph contents together.
|
|
1462
|
+
//
|
|
1463
|
+
// They were two cards. Side by side, the ring made the coverage card tall while
|
|
1464
|
+
// its three metrics stayed short, so the card carried a band of dead space; and
|
|
1465
|
+
// splitting them forced a reader to assemble one answer from two boxes. They are
|
|
1466
|
+
// the same fact at two resolutions: what went in, and what came out.
|
|
1467
|
+
h(Section, {
|
|
1468
|
+
title: '索引概览',
|
|
1469
|
+
meta: report.graph === null || report.graph === undefined
|
|
1470
|
+
? null
|
|
1471
|
+
: h('span', { style: { ...MUTED, fontSize: '11px' } }, `${fixed(report.graph.edgesPerNode, 2)} 边/符号`),
|
|
1472
|
+
}, h('div', { style: { display: 'flex', gap: '16px', alignItems: 'flex-start' } }, ratio === null
|
|
1473
|
+
? null
|
|
1474
|
+
: h(Ring, {
|
|
1475
|
+
ratio,
|
|
1476
|
+
caption: '覆盖',
|
|
1477
|
+
tone: ratio >= 0.9
|
|
1478
|
+
? 'var(--dsw-alias-state-success-primary, #3fb950)'
|
|
1479
|
+
: 'var(--dsw-alias-state-warn-primary, #d9a441)',
|
|
1480
|
+
}), h('div', { style: { display: 'flex', flexDirection: 'column', gap: '9px', minWidth: 0, flex: '1 1 auto' } }, h('div', { style: MUTED }, summaryLine(report)),
|
|
1481
|
+
// Files in, graph out — the two halves sit side by side so the wide card
|
|
1482
|
+
// is used rather than merely filled.
|
|
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, {
|
|
1484
|
+
items: [
|
|
1485
|
+
{ label: '大小上限', value: cap.text, hint: capSourceLabel(cap.source) },
|
|
1486
|
+
{ label: '跳过合计', value: fileSize(totals.largeBytes) },
|
|
1487
|
+
{ label: '超限文件', value: compactNumber(totals.large) },
|
|
1488
|
+
],
|
|
1489
|
+
})), report.graph === null || report.graph === undefined
|
|
1490
|
+
? null
|
|
1491
|
+
: h('div', { style: { minWidth: '240px', flex: '1 1 auto' } }, h('div', { style: { ...MUTED, marginBottom: '5px' } }, '图谱'), h(MetricGrid, {
|
|
1492
|
+
items: [
|
|
1493
|
+
{ label: '符号', value: compactNumber(report.graph.nodes) },
|
|
1494
|
+
{ label: '边', value: compactNumber(report.graph.edges) },
|
|
1495
|
+
{ label: '社区', value: compactNumber(report.graph.communities) },
|
|
1496
|
+
{ label: '流程', value: compactNumber(report.graph.processes) },
|
|
1497
|
+
{ label: '向量', value: compactNumber(report.graph.embeddings) },
|
|
1498
|
+
],
|
|
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' } }, '顶部「关键词搜索」会先查图;图查询不可用时回退到源码关键词。'))))),
|
|
1502
|
+
// Shares a row with the overview card: a bar plus its legend is a proportional
|
|
1503
|
+
// reading, and pairing it with the coverage figure puts the two "how much of
|
|
1504
|
+
// this repository" answers side by side. Order matters — it must directly
|
|
1505
|
+
// follow the overview for the grid to place them in the same row.
|
|
1506
|
+
languages !== null && languages.rows.length > 0 ? h(LanguageSection, { languages }) : null,
|
|
1507
|
+
// Caveats, in one card, sharing a row with the provenance card.
|
|
1508
|
+
//
|
|
1509
|
+
// `notable` and `contextWarnings` were two cards — one amber, one an untitled
|
|
1510
|
+
// list of `·` lines. They are the same thing at two volumes: both say "this
|
|
1511
|
+
// index does not line up with the working tree". Splitting them made the reader
|
|
1512
|
+
// check two boxes to answer one question, and the untitled one had no heading
|
|
1513
|
+
// to say what it was.
|
|
1514
|
+
notable.length > 0 || contextWarnings.length > 0
|
|
1515
|
+
? h(Section, {
|
|
1516
|
+
title: `需要注意 · ${notable.length + contextWarnings.length}`,
|
|
1517
|
+
style: { display: 'flex', flexDirection: 'column', gap: '8px' },
|
|
1518
|
+
}, notable.map((warning, index) => h('div', {
|
|
1519
|
+
key: `${warning.code}-${index}`,
|
|
1520
|
+
style: {
|
|
1521
|
+
display: 'flex',
|
|
1522
|
+
gap: '7px',
|
|
1523
|
+
alignItems: 'flex-start',
|
|
1524
|
+
padding: '6px 8px',
|
|
1525
|
+
border: '1px solid color-mix(in srgb, #d9a441 45%, transparent)',
|
|
1526
|
+
background: 'color-mix(in srgb, #d9a441 9%, transparent)',
|
|
1527
|
+
borderRadius: '6px',
|
|
1528
|
+
},
|
|
1529
|
+
}, h(StateDot, { state: 'warning' }), h('span', { style: { minWidth: 0 } }, warning.message))),
|
|
1530
|
+
// The milder ones stay quiet rows: giving every item an amber box would
|
|
1531
|
+
// make a stale-index count look as urgent as a truncated activity window.
|
|
1532
|
+
contextWarnings.length === 0
|
|
1533
|
+
? null
|
|
1534
|
+
: h('div', {
|
|
1535
|
+
style: {
|
|
1536
|
+
display: 'flex',
|
|
1537
|
+
flexDirection: 'column',
|
|
1538
|
+
gap: '4px',
|
|
1539
|
+
...MUTED,
|
|
1540
|
+
...(notable.length > 0 ? { borderTop: HAIRLINE, paddingTop: '8px' } : {}),
|
|
1541
|
+
},
|
|
1542
|
+
}, contextWarnings.map((warning, index) => h('div', { key: `${warning.code}-${index}` }, `· ${warning.message}`))))
|
|
1543
|
+
: null,
|
|
1544
|
+
// Shares the row above. Resolver confidence is what makes the counts above it
|
|
1545
|
+
// lower bounds, so it belongs beside the caveats rather than after the charts.
|
|
1546
|
+
h(Section, {
|
|
1547
|
+
title: '解析质量与溯源',
|
|
1548
|
+
meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, provenanceCollapsed(report.graphQuality ?? null)),
|
|
1549
|
+
}, h(ProvenanceBlock, {
|
|
1550
|
+
quality: report.graphQuality ?? null,
|
|
1551
|
+
provenance: report.provenance ?? null,
|
|
1552
|
+
})),
|
|
1553
|
+
// Wide: file paths must keep their tails.
|
|
1554
|
+
largeFiles.length > 0
|
|
1555
|
+
? h(Section, {
|
|
1556
|
+
wide: true,
|
|
1557
|
+
title: `被跳过的超大文件 · ${largeFiles.length} 个 · ${fileSize(totals.largeBytes)}`,
|
|
1558
|
+
}, h(LargeFileList, { files: largeFiles.slice(0, 40) }), largeFiles.length > 40
|
|
1559
|
+
? h('div', { style: { ...MUTED, marginTop: '6px' } }, `…另有 ${largeFiles.length - 40} 个未显示`)
|
|
1560
|
+
: null)
|
|
1561
|
+
: null, report.activity === undefined || report.activity === null
|
|
1562
|
+
? null
|
|
1563
|
+
: h(Section, {
|
|
1564
|
+
wide: true,
|
|
1565
|
+
title: `Git 活动 · ${activityView(report.activity)?.window ?? ''}`,
|
|
1566
|
+
meta: h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '5px', ...MUTED, fontSize: '11px' } }, h(StateDot, {
|
|
1567
|
+
state: activityView(report.activity)?.verdicts.find((v) => v.key === 'missing')?.tone ?? 'done',
|
|
1568
|
+
}), activityCollapsed(report.activity)),
|
|
1569
|
+
}, h(GitActivitySection, { activity: report.activity })),
|
|
1570
|
+
// Wide, and always open: the commands are the action this panel offers.
|
|
1571
|
+
maintenance === null || maintenance === undefined
|
|
1572
|
+
? null
|
|
1573
|
+
: h(Section, {
|
|
1574
|
+
wide: true,
|
|
1575
|
+
title: '维护命令',
|
|
1576
|
+
meta: h('span', { style: { ...MUTED, fontSize: '11px' } }, `${maintenance.commands?.length ?? 0} 条 · ` +
|
|
1577
|
+
(maintenance.supportsForce === true
|
|
1578
|
+
? '均可直接执行,也可复制到会话'
|
|
1579
|
+
: '重启 DSH 后可执行;当前仅可复制') +
|
|
1580
|
+
'(沙箱与提权由 DSH 处理)'),
|
|
1581
|
+
}, h(MaintenanceSection, { maintenance, projectPath, indexed, run, runError, now, onStartRun, onCancelRun })));
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Stateful wrapper: resolves the current project, loads its coverage.
|
|
1585
|
+
*
|
|
1586
|
+
* No repository list and no picker. The panel answers for the project the operator
|
|
1587
|
+
* is in; a project with no index is a normal state that leads with the command to
|
|
1588
|
+
* create one, not an error.
|
|
1589
|
+
*
|
|
1590
|
+
* @param {{params?: {repo?: string}, sessionId?: string, useWorkspaces?: Function}} props
|
|
1591
|
+
*/
|
|
1592
|
+
function CoveragePanel(props) {
|
|
1593
|
+
const projectPath = useProjectPath(props);
|
|
1594
|
+
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
|
|
1595
|
+
const [report, setReport] = React.useState(null);
|
|
1596
|
+
const [maintenance, setMaintenance] = React.useState(null);
|
|
1597
|
+
const [error, setError] = React.useState(null);
|
|
1598
|
+
const [busy, setBusy] = React.useState(false);
|
|
1599
|
+
const [reloadToken, setReloadToken] = React.useState(0);
|
|
1600
|
+
// When the report on screen was read. Without it a refresh of an unchanged
|
|
1601
|
+
// repository produces a byte-identical panel, so the control looks inert even
|
|
1602
|
+
// when it did precisely what it was asked to do.
|
|
1603
|
+
const [readAt, setReadAt] = React.useState(null);
|
|
1604
|
+
// Only a manual refresh bypasses the host's cache; the initial load does not.
|
|
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);
|
|
1619
|
+
// The refresh run, and the tick that makes its elapsed time advance. `run` is null
|
|
1620
|
+
// until something is started in this host's lifetime — a state the card renders as
|
|
1621
|
+
// "not run yet" rather than as a failure.
|
|
1622
|
+
const [run, setRun] = React.useState(null);
|
|
1623
|
+
const [runError, setRunError] = React.useState(null);
|
|
1624
|
+
const [tick, setTick] = React.useState(0);
|
|
1625
|
+
const running = run?.state === 'running';
|
|
1626
|
+
/**
|
|
1627
|
+
* Poll while a run is in flight.
|
|
1628
|
+
*
|
|
1629
|
+
* Deliberately not an always-on interval: a panel left open on an idle project should
|
|
1630
|
+
* make no requests at all, and the host keeps one run per repository, so a missed
|
|
1631
|
+
* poll costs nothing but a second.
|
|
1632
|
+
*/
|
|
1633
|
+
React.useEffect(() => {
|
|
1634
|
+
if (projectPath === null || !running)
|
|
1635
|
+
return undefined;
|
|
1636
|
+
let cancelled = false;
|
|
1637
|
+
const poll = async () => {
|
|
1638
|
+
try {
|
|
1639
|
+
const res = await fetch(routeUrl(`/api/gitnexus/analyze?repo=${encodeURIComponent(projectPath)}`));
|
|
1640
|
+
const body = await res.json().catch(() => null);
|
|
1641
|
+
if (cancelled || !body?.ok)
|
|
1642
|
+
return;
|
|
1643
|
+
const next = body.run ?? null;
|
|
1644
|
+
setRun(next);
|
|
1645
|
+
// A finished run changed the index, so the report on screen is now a reading of
|
|
1646
|
+
// the previous one. This is the whole reason the run was started.
|
|
1647
|
+
if (next !== null && next.state !== 'running')
|
|
1648
|
+
setReloadToken((n) => n + 1);
|
|
1649
|
+
}
|
|
1650
|
+
catch {
|
|
1651
|
+
/* a dropped poll is not worth surfacing; the next one reports the state */
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
const id = setInterval(() => {
|
|
1655
|
+
setTick((n) => n + 1);
|
|
1656
|
+
void poll();
|
|
1657
|
+
}, 1000);
|
|
1658
|
+
return () => {
|
|
1659
|
+
cancelled = true;
|
|
1660
|
+
clearInterval(id);
|
|
1661
|
+
};
|
|
1662
|
+
}, [projectPath, running]);
|
|
1663
|
+
const startRun = React.useCallback(async (force = false) => {
|
|
1664
|
+
if (projectPath === null)
|
|
1665
|
+
return;
|
|
1666
|
+
setRunError(null);
|
|
1667
|
+
try {
|
|
1668
|
+
const suffix = force ? '&force=1' : '';
|
|
1669
|
+
const res = await fetch(routeUrl(`/api/gitnexus/analyze?repo=${encodeURIComponent(projectPath)}${suffix}`), { method: 'POST' });
|
|
1670
|
+
const body = await res.json().catch(() => null);
|
|
1671
|
+
if (!body?.ok)
|
|
1672
|
+
throw new Error(body?.message ?? `请求失败 (${res.status})`);
|
|
1673
|
+
setRun(body.run ?? null);
|
|
1674
|
+
setTick(0);
|
|
1675
|
+
}
|
|
1676
|
+
catch (cause) {
|
|
1677
|
+
setRunError(String(cause?.message ?? cause));
|
|
1678
|
+
}
|
|
1679
|
+
}, [projectPath]);
|
|
1680
|
+
const cancelRun = React.useCallback(async () => {
|
|
1681
|
+
if (projectPath === null)
|
|
1682
|
+
return;
|
|
1683
|
+
try {
|
|
1684
|
+
const res = await fetch(routeUrl(`/api/gitnexus/analyze?repo=${encodeURIComponent(projectPath)}`), {
|
|
1685
|
+
method: 'DELETE',
|
|
1686
|
+
});
|
|
1687
|
+
const body = await res.json().catch(() => null);
|
|
1688
|
+
if (body?.ok)
|
|
1689
|
+
setRun(body.run ?? null);
|
|
1690
|
+
}
|
|
1691
|
+
catch (cause) {
|
|
1692
|
+
setRunError(String(cause?.message ?? cause));
|
|
1693
|
+
}
|
|
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]);
|
|
1832
|
+
// The GitNexus install. Its own state because it is not tied to a repository: the
|
|
1833
|
+
// install target is one directory per machine, and the operator may have several
|
|
1834
|
+
// projects open across a session.
|
|
1835
|
+
const [installRun, setInstallRun] = React.useState(null);
|
|
1836
|
+
const [installError, setInstallError] = React.useState(null);
|
|
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);
|
|
1844
|
+
React.useEffect(() => {
|
|
1845
|
+
if (!installing)
|
|
1846
|
+
return undefined;
|
|
1847
|
+
let cancelled = false;
|
|
1848
|
+
const poll = async () => {
|
|
1849
|
+
try {
|
|
1850
|
+
const res = await fetch(routeUrl('/api/gitnexus/install'));
|
|
1851
|
+
const body = await res.json().catch(() => null);
|
|
1852
|
+
if (cancelled || !body?.ok)
|
|
1853
|
+
return;
|
|
1854
|
+
const next = body.run ?? null;
|
|
1855
|
+
setInstallRun(next);
|
|
1856
|
+
// A finished install changes what this machine can do, so re-read the report:
|
|
1857
|
+
// the toolchain verdict is computed per read, so this is what clears the notice.
|
|
1858
|
+
if (next !== null && next.state !== 'running')
|
|
1859
|
+
setReloadToken((n) => n + 1);
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
/* a dropped poll is not worth surfacing */
|
|
1863
|
+
}
|
|
1864
|
+
};
|
|
1865
|
+
const id = setInterval(() => {
|
|
1866
|
+
setTick((n) => n + 1);
|
|
1867
|
+
void poll();
|
|
1868
|
+
}, 1000);
|
|
1869
|
+
return () => {
|
|
1870
|
+
cancelled = true;
|
|
1871
|
+
clearInterval(id);
|
|
1872
|
+
};
|
|
1873
|
+
}, [installing]);
|
|
1874
|
+
const startInstall = React.useCallback(async () => {
|
|
1875
|
+
setInstallError(null);
|
|
1876
|
+
autoInitArmed.current = true;
|
|
1877
|
+
try {
|
|
1878
|
+
const res = await fetch(routeUrl('/api/gitnexus/install'), { method: 'POST' });
|
|
1879
|
+
const body = await res.json().catch(() => null);
|
|
1880
|
+
if (!body?.ok)
|
|
1881
|
+
throw new Error(body?.message ?? `请求失败 (${res.status})`);
|
|
1882
|
+
setInstallRun(body.run ?? null);
|
|
1883
|
+
setTick(0);
|
|
1884
|
+
}
|
|
1885
|
+
catch (cause) {
|
|
1886
|
+
autoInitArmed.current = false;
|
|
1887
|
+
setInstallError(String(cause?.message ?? cause));
|
|
1888
|
+
}
|
|
1889
|
+
}, []);
|
|
1890
|
+
const cancelInstall = React.useCallback(async () => {
|
|
1891
|
+
try {
|
|
1892
|
+
const res = await fetch(routeUrl('/api/gitnexus/install'), { method: 'DELETE' });
|
|
1893
|
+
const body = await res.json().catch(() => null);
|
|
1894
|
+
if (body?.ok)
|
|
1895
|
+
setInstallRun(body.run ?? null);
|
|
1896
|
+
}
|
|
1897
|
+
catch (cause) {
|
|
1898
|
+
setInstallError(String(cause?.message ?? cause));
|
|
1899
|
+
}
|
|
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]);
|
|
1930
|
+
React.useEffect(() => {
|
|
1931
|
+
if (projectPath === null)
|
|
1932
|
+
return undefined;
|
|
1933
|
+
const controller = new AbortController();
|
|
1934
|
+
setBusy(true);
|
|
1935
|
+
setError(null);
|
|
1936
|
+
const query = `/api/gitnexus/coverage?repo=${encodeURIComponent(projectPath)}` + (wantsFresh ? '&refresh=1' : '');
|
|
1937
|
+
fetch(routeUrl(query), {
|
|
1938
|
+
signal: controller.signal,
|
|
1939
|
+
})
|
|
1940
|
+
.then(async (res) => ({ status: res.status, body: await res.json().catch(() => null) }))
|
|
1941
|
+
.then(({ status, body }) => {
|
|
1942
|
+
// `not-indexed` is a state the panel renders, not a failure: the whole
|
|
1943
|
+
// point of leading with the initialize command is that reaching it is
|
|
1944
|
+
// expected on a project that has never been analysed.
|
|
1945
|
+
if (status === 404 && body?.code === 'not-indexed') {
|
|
1946
|
+
setReport({ repo: { indexed: false, path: projectPath } });
|
|
1947
|
+
setMaintenance(null);
|
|
1948
|
+
setReadAt(body?.readAt ?? new Date().toISOString());
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (!body?.ok)
|
|
1952
|
+
throw new Error(body?.message ?? `请求失败 (${status})`);
|
|
1953
|
+
setReport(body.report);
|
|
1954
|
+
setMaintenance(body.maintenance ?? null);
|
|
1955
|
+
setReadAt(typeof body.readAt === 'string' ? body.readAt : new Date().toISOString());
|
|
1956
|
+
})
|
|
1957
|
+
.catch((cause) => {
|
|
1958
|
+
if (cause?.name !== 'AbortError')
|
|
1959
|
+
setError(String(cause?.message ?? cause));
|
|
1960
|
+
})
|
|
1961
|
+
.finally(() => setBusy(false));
|
|
1962
|
+
return () => controller.abort();
|
|
1963
|
+
}, [projectPath, reloadToken]);
|
|
1964
|
+
const fallbackMaintenance = React.useMemo(() => (maintenance === null && projectPath !== null ? { commands: [], rationale: [] } : maintenance), [maintenance, projectPath]);
|
|
1965
|
+
return h(CoverageView, {
|
|
1966
|
+
report,
|
|
1967
|
+
maintenance: fallbackMaintenance,
|
|
1968
|
+
projectPath,
|
|
1969
|
+
context: projectPath === null ? contextDiagnostic(props) : null,
|
|
1970
|
+
busy,
|
|
1971
|
+
error,
|
|
1972
|
+
readAt,
|
|
1973
|
+
run,
|
|
1974
|
+
runError,
|
|
1975
|
+
// `tick` is read here rather than inside the card, so elapsed seconds re-render
|
|
1976
|
+
// even when the host reports nothing new — a run with no output for a minute
|
|
1977
|
+
// otherwise looks frozen.
|
|
1978
|
+
now: Date.now() + tick * 0,
|
|
1979
|
+
onRefresh: () => setReloadToken((n) => n + 1),
|
|
1980
|
+
onStartRun: (force) => void startRun(force),
|
|
1981
|
+
onCancelRun: () => void cancelRun(),
|
|
1982
|
+
installRun,
|
|
1983
|
+
installError,
|
|
1984
|
+
onInstall: () => void startInstall(),
|
|
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
|
+
},
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
/** The tab chip's live title. */
|
|
2016
|
+
function CoverageTitle() {
|
|
2017
|
+
return h('span', null, 'GitNexus');
|
|
2018
|
+
}
|
|
2019
|
+
// ---------------------------------------------------------------------------
|
|
2020
|
+
// Plugin
|
|
2021
|
+
// ---------------------------------------------------------------------------
|
|
2022
|
+
/**
|
|
2023
|
+
* Declared, not guessed: the loader hands `apply` a context carrying exactly
|
|
2024
|
+
* these, and reading anything else off `ctx` throws.
|
|
2025
|
+
*
|
|
2026
|
+
* `sidebarRightTabs` is deliberately absent and reached through `ctx.inject`
|
|
2027
|
+
* instead — it is an optional capability (a headless or TUI profile has no right
|
|
2028
|
+
* sidebar at all), and naming it here would leave this plugin permanently pending
|
|
2029
|
+
* on those profiles rather than simply contributing nothing.
|
|
2030
|
+
*/
|
|
2031
|
+
const inject = ['slots'];
|
|
2032
|
+
function apply(ctx) {
|
|
2033
|
+
const safe = (tag, fn) => {
|
|
2034
|
+
try {
|
|
2035
|
+
fn();
|
|
2036
|
+
}
|
|
2037
|
+
catch (cause) {
|
|
2038
|
+
// One unknown slot name must not take the rest of the surface down with it.
|
|
2039
|
+
console.warn(`[${PLUGIN_ID}] client surface '${tag}' failed:`, cause);
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
safe('sidebarRightTabs', () => {
|
|
2043
|
+
ctx.inject(['sidebarRightTabs'], (svcCtx) => {
|
|
2044
|
+
const tabs = svcCtx.sidebarRightTabs;
|
|
2045
|
+
if (tabs === undefined)
|
|
2046
|
+
return;
|
|
2047
|
+
ctx.effect(() => tabs.register({
|
|
2048
|
+
id: PLUGIN_ID,
|
|
2049
|
+
kind: TAB_KIND,
|
|
2050
|
+
title: () => 'GitNexus',
|
|
2051
|
+
// The guide page is how a user discovers a contributed page type: it
|
|
2052
|
+
// renders one entry box per `guide` entry, and picking one opens this
|
|
2053
|
+
// kind. Without it the panel would be reachable only by a caller that
|
|
2054
|
+
// already knew the kind name.
|
|
2055
|
+
guide: [
|
|
2056
|
+
{
|
|
2057
|
+
id: `${PLUGIN_ID}:coverage`,
|
|
2058
|
+
order: 40,
|
|
2059
|
+
title: () => 'GitNexus 索引',
|
|
2060
|
+
description: () => '查看索引覆盖:被跳过的超大文件与语言分布',
|
|
2061
|
+
},
|
|
2062
|
+
],
|
|
2063
|
+
}), `${PLUGIN_ID}: tab type`);
|
|
2064
|
+
});
|
|
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
|
+
});
|
|
2080
|
+
safe('sidebar.right.pane.tab', () => {
|
|
2081
|
+
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: PLUGIN_ID }, CoveragePanel));
|
|
2082
|
+
});
|
|
2083
|
+
safe('sidebar.right.pane.tab.title', () => {
|
|
2084
|
+
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ name: 'sidebar.right.pane.tab.title', key: PLUGIN_ID }, CoverageTitle));
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
module.exports = {
|
|
2088
|
+
inject,
|
|
2089
|
+
apply,
|
|
2090
|
+
__internals: {
|
|
2091
|
+
PLUGIN_ID,
|
|
2092
|
+
TAB_KIND,
|
|
2093
|
+
routeUrl,
|
|
2094
|
+
fileResourceAddress,
|
|
2095
|
+
formatBytes,
|
|
2096
|
+
fileSize,
|
|
2097
|
+
percent,
|
|
2098
|
+
stackSegments,
|
|
2099
|
+
splitWarnings,
|
|
2100
|
+
summaryLine,
|
|
2101
|
+
coverageRatio,
|
|
2102
|
+
healthState,
|
|
2103
|
+
stalenessView,
|
|
2104
|
+
capabilityRows,
|
|
2105
|
+
graphQualitySummary,
|
|
2106
|
+
activityView,
|
|
2107
|
+
compactNumber,
|
|
2108
|
+
useProjectPath,
|
|
2109
|
+
contextDiagnostic,
|
|
2110
|
+
CoveragePanel,
|
|
2111
|
+
CoverageView,
|
|
2112
|
+
CoverageTitle,
|
|
2113
|
+
LanguageBar,
|
|
2114
|
+
LanguageSection,
|
|
2115
|
+
LargeFileList,
|
|
2116
|
+
Ring,
|
|
2117
|
+
MetricGrid,
|
|
2118
|
+
Histogram,
|
|
2119
|
+
GitActivitySection,
|
|
2120
|
+
ProvenanceBlock,
|
|
2121
|
+
MaintenanceSection,
|
|
2122
|
+
CapabilityStrip,
|
|
2123
|
+
Section,
|
|
2124
|
+
classifyFileType,
|
|
2125
|
+
IconRefreshOutlineRegular,
|
|
2126
|
+
},
|
|
2127
|
+
};
|
|
2128
|
+
|
|
2129
|
+
return module.exports;
|
|
2130
|
+
}
|
|
2131
|
+
});
|