dsh-gitnexus-plus 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README-CN.md +14 -5
  2. package/README.md +40 -8
  3. package/lib/client.js +386 -66
  4. package/lib/gitnexus/analyze.d.ts +6 -0
  5. package/lib/gitnexus/analyze.d.ts.map +1 -1
  6. package/lib/gitnexus/analyze.js +10 -0
  7. package/lib/gitnexus/analyze.js.map +1 -1
  8. package/lib/gitnexus/commands.d.ts +11 -1
  9. package/lib/gitnexus/commands.d.ts.map +1 -1
  10. package/lib/gitnexus/commands.js +27 -8
  11. package/lib/gitnexus/commands.js.map +1 -1
  12. package/lib/gitnexus/coverage.d.ts +8 -0
  13. package/lib/gitnexus/coverage.d.ts.map +1 -1
  14. package/lib/gitnexus/coverage.js +14 -2
  15. package/lib/gitnexus/coverage.js.map +1 -1
  16. package/lib/gitnexus/discover.d.ts +91 -0
  17. package/lib/gitnexus/discover.d.ts.map +1 -0
  18. package/lib/gitnexus/discover.js +214 -0
  19. package/lib/gitnexus/discover.js.map +1 -0
  20. package/lib/gitnexus/graph.d.ts +9 -0
  21. package/lib/gitnexus/graph.d.ts.map +1 -1
  22. package/lib/gitnexus/graph.js +2 -0
  23. package/lib/gitnexus/graph.js.map +1 -1
  24. package/lib/gitnexus/index-meta.d.ts +34 -6
  25. package/lib/gitnexus/index-meta.d.ts.map +1 -1
  26. package/lib/gitnexus/index-meta.js +47 -7
  27. package/lib/gitnexus/index-meta.js.map +1 -1
  28. package/lib/gitnexus/install.d.ts +11 -0
  29. package/lib/gitnexus/install.d.ts.map +1 -1
  30. package/lib/gitnexus/install.js +65 -9
  31. package/lib/gitnexus/install.js.map +1 -1
  32. package/lib/gitnexus/search.d.ts +13 -0
  33. package/lib/gitnexus/search.d.ts.map +1 -1
  34. package/lib/gitnexus/search.js +2 -0
  35. package/lib/gitnexus/search.js.map +1 -1
  36. package/lib/index.d.ts +7 -0
  37. package/lib/index.d.ts.map +1 -1
  38. package/lib/index.js +27 -7
  39. package/lib/index.js.map +1 -1
  40. package/lib/routes/analyze.d.ts +5 -4
  41. package/lib/routes/analyze.d.ts.map +1 -1
  42. package/lib/routes/analyze.js +17 -4
  43. package/lib/routes/analyze.js.map +1 -1
  44. package/lib/routes/coverage.d.ts +16 -10
  45. package/lib/routes/coverage.d.ts.map +1 -1
  46. package/lib/routes/coverage.js +31 -22
  47. package/lib/routes/coverage.js.map +1 -1
  48. package/lib/routes/delta.js +8 -8
  49. package/lib/routes/delta.js.map +1 -1
  50. package/lib/routes/guard.d.ts +3 -6
  51. package/lib/routes/guard.d.ts.map +1 -1
  52. package/lib/routes/guard.js +3 -6
  53. package/lib/routes/guard.js.map +1 -1
  54. package/lib/routes/projects.d.ts +21 -0
  55. package/lib/routes/projects.d.ts.map +1 -0
  56. package/lib/routes/projects.js +61 -0
  57. package/lib/routes/projects.js.map +1 -0
  58. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -2444,6 +2444,10 @@ define("sections/maintenance", ["require", "exports", "react", "@deepseek-ai/dsh
2444
2444
  // install or the registry write. Refuse to start it and say why, rather than
2445
2445
  // leaving a button that is known to be wrong on this host.
2446
2446
  const hostCurrent = maintenance.supportsForce === true;
2447
+ // A folder with no `.git` is indexable, but only with `--skip-git`, and GitNexus
2448
+ // disables commit tracking and incremental updates for it. Calling that action an
2449
+ // "incremental refresh" would describe a mode the index cannot have.
2450
+ const skipGit = maintenance.skipGit === true;
2447
2451
  // Normalised rather than trusted. The declared type says `ClientAnalyzeRun | null`,
2448
2452
  // but a React element built without the prop arrives as `undefined` — which is
2449
2453
  // exactly what happened the first time this component was rendered by a caller that
@@ -2599,11 +2603,17 @@ define("sections/maintenance", ["require", "exports", "react", "@deepseek-ai/dsh
2599
2603
  // an index that already existed. Only the label and the "when" line are stateful;
2600
2604
  // the command itself is identical.
2601
2605
  const shown = command.mode === 'init' && indexed
2602
- ? {
2603
- ...command,
2604
- label: '增量刷新',
2605
- when: '索引落后或有文件改动时使用;未变更文件的解析产物会复用。',
2606
- }
2606
+ ? skipGit
2607
+ ? {
2608
+ ...command,
2609
+ label: '重新分析',
2610
+ when: '该目录不是 git 仓库:没有增量刷新,每次都会完整重分析;未变更文件的解析缓存仍会复用。',
2611
+ }
2612
+ : {
2613
+ ...command,
2614
+ label: '增量刷新',
2615
+ when: '索引落后或有文件改动时使用;未变更文件的解析产物会复用。',
2616
+ }
2607
2617
  : command;
2608
2618
  return (0, primitives_7.h)('div', {
2609
2619
  key: command.mode,
@@ -2632,9 +2642,17 @@ define("sections/maintenance", ["require", "exports", "react", "@deepseek-ai/dsh
2632
2642
  ? '运行中'
2633
2643
  : gateClean
2634
2644
  ? '无需刷新'
2635
- : hostCurrent
2636
- ? '执行增量刷新'
2637
- : '执行增量刷新(需重启)')
2645
+ : !indexed
2646
+ ? hostCurrent
2647
+ ? '初始化索引'
2648
+ : '初始化索引(需重启)'
2649
+ : skipGit
2650
+ ? hostCurrent
2651
+ ? '执行重新分析'
2652
+ : '执行重新分析(需重启)'
2653
+ : hostCurrent
2654
+ ? '执行增量刷新'
2655
+ : '执行增量刷新(需重启)')
2638
2656
  : command.mode === 'repair-fts'
2639
2657
  ? (0, primitives_7.h)(primitives_7.Button, { variant: 'ghost', disabled: running || !hostCurrent, onClick: () => setConfirming('repair-fts') }, running ? '运行中' : '修复搜索索引')
2640
2658
  : hostCurrent
@@ -2670,12 +2688,21 @@ define("sections/maintenance", ["require", "exports", "react", "@deepseek-ai/dsh
2670
2688
  // and can take minutes, while the incremental path reuses unchanged files.
2671
2689
  (0, primitives_7.h)(primitives.RiskConfirmation, {
2672
2690
  open: confirming !== null,
2673
- title: repairConfirm ? '修复搜索索引(FTS)?' : fullConfirm ? '执行全量重建?' : '执行增量刷新?',
2691
+ title: repairConfirm
2692
+ ? '修复搜索索引(FTS)?'
2693
+ : fullConfirm
2694
+ ? '执行全量重建?'
2695
+ : !indexed
2696
+ ? '初始化索引?'
2697
+ : skipGit
2698
+ ? '重新分析这个目录?'
2699
+ : '执行增量刷新?',
2674
2700
  description: repairConfirm
2675
2701
  ? '将执行:\n\n' +
2676
2702
  (confirmCommand?.shell ?? 'gitnexus analyze --index-only --repair-fts') +
2677
2703
  '\n\n' +
2678
2704
  '只重建搜索索引(FTS),不重跑分析、不改图,耗时通常在秒级。' +
2705
+ (skipGit ? '该目录没有 .git,命令带 --skip-git。' : '') +
2679
2706
  '只写入 .gitnexus/,不改动源文件。' +
2680
2707
  '如果 FTS 扩展文件本身缺失(Windows 上常见),修复也会失败 —— 那是上游安装问题,不是索引坏了。' +
2681
2708
  '命令由 DSH 的 shell 执行器运行,沙箱与其它命令一致 —— 本插件不申请扩权。'
@@ -2686,20 +2713,32 @@ define("sections/maintenance", ["require", "exports", "react", "@deepseek-ai/dsh
2686
2713
  '这会重建图与 FTS;未变更文件的解析产物仍会复用。' +
2687
2714
  '但耗时以分钟计,索引很大时更久(上限 10 分钟,可随时取消)。' +
2688
2715
  '重建期间索引可能短暂不可用。' +
2716
+ (skipGit ? '该目录没有 .git,命令带 --skip-git;重建后仍没有提交基线与增量刷新。' : '') +
2689
2717
  '只写入 .gitnexus/,不改动源文件,也不会重写 AGENTS.md / CLAUDE.md。' +
2690
2718
  '命令由 DSH 的 shell 执行器运行,沙箱与其它命令一致 —— 本插件不申请扩权。'
2691
2719
  : '将在项目目录下运行:\n\n' +
2692
2720
  (confirmCommand?.shell ?? 'gitnexus analyze --index-only') +
2693
2721
  '\n\n' +
2694
2722
  '只写入 .gitnexus/,不改动任何源文件,也不会重写 AGENTS.md / CLAUDE.md。' +
2695
- '耗时取决于改动量:无改动时约 7 秒,改动几个文件时实测可到 3 分钟,' +
2696
- '大仓库的初始化或刷新可能更久(上限 10 分钟,可随时取消)。' +
2723
+ (skipGit
2724
+ ? '该目录没有 .git,命令带 --skip-git:GitNexus 会直接索引这个目录,' +
2725
+ '但没有提交基线,也没有增量刷新 —— 每次都会完整重分析,也不会有 Git 活动。'
2726
+ : '耗时取决于改动量:无改动时约 7 秒,改动几个文件时实测可到 3 分钟,' +
2727
+ '大仓库的初始化或刷新可能更久(上限 10 分钟,可随时取消)。') +
2697
2728
  '命令由 DSH 的 shell 执行器运行,沙箱与其它命令一致 —— 本插件不申请扩权。',
2698
2729
  acknowledgeLabel: fullConfirm
2699
2730
  ? '我了解这会重建图与 FTS、耗时数分钟,并可能让索引短暂不可用'
2700
2731
  : '我了解这会运行一个进程并写入 .gitnexus/',
2701
2732
  cancelLabel: '取消',
2702
- confirmLabel: repairConfirm ? '开始修复' : fullConfirm ? '开始重建' : '执行',
2733
+ confirmLabel: repairConfirm
2734
+ ? '开始修复'
2735
+ : fullConfirm
2736
+ ? '开始重建'
2737
+ : !indexed
2738
+ ? '开始初始化'
2739
+ : skipGit
2740
+ ? '开始重新分析'
2741
+ : '执行',
2703
2742
  closeLabel: '关闭',
2704
2743
  // The checkbox is controlled, so it needs state of its own, and it is reset on
2705
2744
  // close: the acknowledgement is per-action rather than remembered.
@@ -2993,10 +3032,57 @@ define("sections/search", ["require", "exports", "primitives"], function (requir
2993
3032
  define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-ui-primitives", "format", "navigation", "primitives", "sections/overview", "sections/activity", "sections/capabilities", "sections/install", "sections/languages", "sections/large-files", "sections/maintenance", "sections/search", "sections/graph"], function (require, exports, React, primitives, format_10, navigation_3, primitives_11, overview_1, activity_1, capabilities_2, install_1, languages_1, large_files_1, maintenance_1, search_1, graph_1) {
2994
3033
  "use strict";
2995
3034
  Object.defineProperty(exports, "__esModule", { value: true });
3035
+ exports.chooseProject = chooseProject;
2996
3036
  exports.LoadingBar = LoadingBar;
2997
3037
  exports.CoverageView = CoverageView;
2998
3038
  exports.CoveragePanel = CoveragePanel;
2999
3039
  exports.CoverageTitle = CoverageTitle;
3040
+ /**
3041
+ * Pick the project a workspace opens on.
3042
+ *
3043
+ * An explicit remembered choice wins while it still exists. Otherwise an indexed
3044
+ * project is preferred over an unindexed git repository, and the workspace's own index
3045
+ * wins over a descendant. A workspace with no indexes opens on the workspace itself —
3046
+ * the scan-only state, with the unindexed git projects available in the switcher.
3047
+ */
3048
+ function chooseProject(projects, workspace, remembered) {
3049
+ if (remembered !== null) {
3050
+ const hit = projects.find((project) => project.path === remembered);
3051
+ if (hit !== undefined)
3052
+ return hit.path;
3053
+ }
3054
+ const selfIndex = projects.find((project) => project.direction === 'self' && project.kind === 'index');
3055
+ if (selfIndex !== undefined)
3056
+ return selfIndex.path;
3057
+ const indexed = projects.find((project) => project.kind === 'index');
3058
+ if (indexed !== undefined)
3059
+ return indexed.path;
3060
+ const self = projects.find((project) => project.direction === 'self');
3061
+ if (self !== undefined)
3062
+ return self.path;
3063
+ return projects[0]?.path ?? workspace;
3064
+ }
3065
+ const ACTIVE_PROJECT_KEY = 'dsh-gitnexus-plus:active-project:';
3066
+ function readRememberedProject(workspace) {
3067
+ try {
3068
+ if (typeof localStorage === 'undefined')
3069
+ return null;
3070
+ return localStorage.getItem(`${ACTIVE_PROJECT_KEY}${workspace}`);
3071
+ }
3072
+ catch {
3073
+ return null;
3074
+ }
3075
+ }
3076
+ function rememberProject(workspace, project) {
3077
+ try {
3078
+ if (typeof localStorage === 'undefined')
3079
+ return;
3080
+ localStorage.setItem(`${ACTIVE_PROJECT_KEY}${workspace}`, project);
3081
+ }
3082
+ catch {
3083
+ /* storage may be unavailable in a hardened webview; selection stays in memory */
3084
+ }
3085
+ }
3000
3086
  /**
3001
3087
  * Whether two polled run snapshots say the same thing.
3002
3088
  *
@@ -3055,17 +3141,62 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3055
3141
  },
3056
3142
  }), (0, primitives_11.h)('div', { style: { ...primitives_11.MUTED, fontSize: '10px' } }, text));
3057
3143
  }
3058
- function CoverageView({ report, maintenance, projectPath, context, busy, progress, error, readAt, run, runError, delta, deltaError, deltaRoute, onRefresh, onStartRun, onCancelRun, installRun, installError, onInstall, onCancelInstall, search, graph, }) {
3144
+ function CoverageView({ report, maintenance, projectPath, workspacePath, projects, projectsVisited, projectsTruncated, projectsError, onSelectProject, context, busy, progress, error, readAt, run, runError, delta, deltaError, deltaRoute, onRefresh, onStartRun, onCancelRun, installRun, installError, onInstall, onCancelInstall, search, graph, }) {
3059
3145
  // Whether an index exists decides which maintenance command is applicable. When
3060
3146
  // the project could not even be identified there is nothing to claim, so it is
3061
3147
  // false rather than assumed.
3062
3148
  const indexed = report?.repo?.indexed === true;
3063
- const header = (0, primitives_11.h)(primitives_11.Row, null, report !== null && report.repo?.indexed ? (0, primitives_11.h)(primitives_11.StateDot, { state: (0, format_10.healthState)(report) }) : null, (0, primitives_11.h)('span', { style: { fontWeight: 600 } }, 'GitNexus 索引'),
3064
- // The project, not a picker. Shown so the reader can confirm which tree the
3065
- // numbers describe — a path is the only unambiguous answer to that.
3149
+ // A maintenance payload with no commands is not a section: rendering its title
3150
+ // produced an empty card on failed reads and on hosts that predate the route.
3151
+ const maintenanceSection = maintenance !== null &&
3152
+ maintenance !== undefined &&
3153
+ Array.isArray(maintenance.commands) &&
3154
+ maintenance.commands.length > 0
3155
+ ? maintenance
3156
+ : null;
3157
+ // The workspace and the active project are different facts when the index lives in a
3158
+ // subdirectory. The path position becomes a switcher when there is more than one.
3159
+ const projectChoices = projects ?? [];
3160
+ const indexedProjectCount = projectChoices.filter((project) => project.kind === 'index').length;
3161
+ const gitProjectCount = projectChoices.filter((project) => project.kind === 'git').length;
3162
+ const projectLabel = (project) => {
3163
+ const base = project.relPath === ''
3164
+ ? `${project.name}(本工作区)`
3165
+ : project.relPath === project.name
3166
+ ? project.name
3167
+ : `${project.name} — ${project.relPath}`;
3168
+ return project.kind === 'git' ? `${base}(未索引)` : base;
3169
+ };
3170
+ const activeInChoices = projectChoices.some((project) => project.path === projectPath);
3171
+ const showSwitcher = projectChoices.length > 1 &&
3172
+ activeInChoices &&
3173
+ onSelectProject !== undefined &&
3174
+ projectPath !== null;
3175
+ const activeDiffers = projectPath !== null && workspacePath !== null && projectPath !== workspacePath;
3176
+ const header = (0, primitives_11.h)('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px', minWidth: 0 } }, (0, primitives_11.h)(primitives_11.Row, null, report !== null && report.repo?.indexed ? (0, primitives_11.h)(primitives_11.StateDot, { state: (0, format_10.healthState)(report) }) : null, (0, primitives_11.h)('span', { style: { fontWeight: 600 } }, 'GitNexus 索引'),
3177
+ // The project, not a global picker. A workspace-scoped switcher is the one
3178
+ // selection this panel offers; it changes what the cards describe, nothing else.
3066
3179
  projectPath === null
3067
3180
  ? (0, primitives_11.h)('span', { style: primitives_11.MUTED }, '未解析到工作区')
3068
- : (0, primitives_11.h)('span', { style: { minWidth: 0, flex: '1 1 auto' } }, (0, primitives_11.h)(primitives_11.PathLabel, { path: projectPath })), (0, primitives_11.h)('span', { style: { flex: 1 } }),
3181
+ : showSwitcher
3182
+ ? (0, primitives_11.h)('span', { style: { display: 'flex', alignItems: 'center', gap: '2px', minWidth: 0, flex: '1 1 auto' } }, (0, primitives_11.h)('select', {
3183
+ value: projectPath,
3184
+ onChange: (event) => onSelectProject?.(event.target.value),
3185
+ 'aria-label': '切换工作区内的 GitNexus 索引',
3186
+ title: `工作区:${workspacePath ?? ''} · ${projectChoices.length} 个索引`,
3187
+ style: {
3188
+ appearance: 'none',
3189
+ border: 0,
3190
+ background: 'transparent',
3191
+ color: 'inherit',
3192
+ font: 'inherit',
3193
+ minWidth: 0,
3194
+ maxWidth: '100%',
3195
+ padding: 0,
3196
+ cursor: 'pointer',
3197
+ },
3198
+ }, projectChoices.map((project) => (0, primitives_11.h)('option', { key: project.path, value: project.path }, projectLabel(project)))), (0, primitives_11.h)('span', { style: { ...primitives_11.MUTED, fontSize: '10px' } }, '▾'))
3199
+ : (0, primitives_11.h)('span', { style: { minWidth: 0, flex: '1 1 auto' } }, (0, primitives_11.h)(primitives_11.PathLabel, { path: projectPath })), (0, primitives_11.h)('span', { style: { flex: 1 } }),
3069
3200
  // The label states the scope. "刷新" alone next to a staleness warning reads as
3070
3201
  // "rebuild the index", which this control has never done and — by design, since
3071
3202
  // the plugin spawns nothing — cannot do.
@@ -3075,7 +3206,30 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3075
3206
  disabled: busy,
3076
3207
  title: '重新读取报告(不会重建索引;重建见下方「维护命令」)',
3077
3208
  'aria-label': '重新读取报告',
3078
- }, (0, primitives_11.h)(primitives_11.IconRefreshOutlineRegular, { size: 14 })));
3209
+ }, (0, primitives_11.h)(primitives_11.IconRefreshOutlineRegular, { size: 14 }))), showSwitcher ||
3210
+ activeDiffers ||
3211
+ projectsTruncated === true ||
3212
+ (projectsError !== null && projectsError !== undefined)
3213
+ ? (0, primitives_11.h)('div', { style: { ...primitives_11.MUTED, fontSize: '11px', minWidth: 0, overflowWrap: 'anywhere' } }, [
3214
+ showSwitcher
3215
+ ? `工作区:${workspacePath ?? ''} · ${projectChoices.length} 个项目` +
3216
+ (indexedProjectCount > 0
3217
+ ? `(${indexedProjectCount} 个已索引)`
3218
+ : gitProjectCount > 0
3219
+ ? `(${gitProjectCount} 个 git 项目未索引)`
3220
+ : '') +
3221
+ ` · 当前 ${projectPath ?? ''}`
3222
+ : activeDiffers
3223
+ ? `工作区:${workspacePath ?? ''} · 当前 ${projectPath ?? ''}`
3224
+ : null,
3225
+ projectsTruncated === true ? '项目搜索达到上限,可能还有未列出的项目。' : null,
3226
+ projectsError !== null && projectsError !== undefined
3227
+ ? `项目列表读取失败:${projectsError}`
3228
+ : null,
3229
+ ]
3230
+ .filter((line) => line !== null)
3231
+ .join(' '))
3232
+ : null);
3079
3233
  // A report already on screen stays readable while a manual refresh runs; the bar
3080
3234
  // rides above it. Before the first report exists, the bar is the whole body.
3081
3235
  // `percent` is null when the host does not stream (old host, or a cached read):
@@ -3097,14 +3251,14 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3097
3251
  : (0, primitives_11.h)(primitives_11.Section, { wide: true, title: '面板收到的上下文' }, (0, primitives_11.h)('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px' } }, (0, primitives_11.h)('div', null, `存在:${context.present.length > 0 ? context.present.join('、') : '(无)'}`), (0, primitives_11.h)('div', null, `缺失:${context.absent.length > 0 ? context.absent.join('、') : '(无)'}`))));
3098
3252
  }
3099
3253
  if (error !== null) {
3100
- return (0, primitives_11.h)('div', { style: primitives_11.styles.root }, header, (0, primitives_11.h)(primitives_11.Section, { wide: true, style: { display: 'flex', gap: '8px', alignItems: 'flex-start' } }, (0, primitives_11.h)(primitives_11.StateDot, { state: 'error' }), (0, primitives_11.h)('span', null, error)), maintenance === null || maintenance === undefined
3101
- ? null
3102
- : (0, primitives_11.h)(primitives_11.Section, {
3254
+ return (0, primitives_11.h)('div', { style: primitives_11.styles.root }, header, (0, primitives_11.h)(primitives_11.Section, { wide: true, style: { display: 'flex', gap: '8px', alignItems: 'flex-start' } }, (0, primitives_11.h)(primitives_11.StateDot, { state: 'error' }), (0, primitives_11.h)('span', null, error)), maintenanceSection !== null
3255
+ ? (0, primitives_11.h)(primitives_11.Section, {
3103
3256
  wide: true,
3104
3257
  title: '维护命令',
3105
3258
  meta: (0, primitives_11.h)('span', { style: { ...primitives_11.MUTED, fontSize: '12px' } }, '复制后到会话执行(沙箱与提权由 DSH 处理)'),
3106
3259
  scroll: 320,
3107
- }, (0, primitives_11.h)(maintenance_1.MaintenanceSection, { maintenance, projectPath, indexed: false, run, runError, delta, deltaError, deltaRoute, onStartRun, onCancelRun })));
3260
+ }, (0, primitives_11.h)(maintenance_1.MaintenanceSection, { maintenance: maintenanceSection, projectPath, indexed: false, run, runError, delta, deltaError, deltaRoute, onStartRun, onCancelRun }))
3261
+ : null);
3108
3262
  }
3109
3263
  if (report === null) {
3110
3264
  return (0, primitives_11.h)('div', { style: primitives_11.styles.root }, header, progressBar);
@@ -3134,22 +3288,32 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3134
3288
  runError: installError,
3135
3289
  onInstall,
3136
3290
  onCancelInstall,
3137
- }), (0, primitives_11.h)('div', { style: { gridColumn: '1 / -1', display: 'flex', gap: '8px', alignItems: 'flex-start' } }, (0, primitives_11.h)(primitives_11.StateDot, { state: 'idle' }), (0, primitives_11.h)('span', null, '该项目还没有 GitNexus 索引。下面是工作区扫描结果;初始化后可看到图谱、查询能力与 Git 活动对账。')), (0, primitives_11.h)(primitives_11.Section, { wide: true, title: '扫描概览 · 尚未索引' }, (0, primitives_11.h)(primitives_11.MetricGrid, {
3291
+ }), (0, primitives_11.h)('div', { style: { gridColumn: '1 / -1', display: 'flex', gap: '8px', alignItems: 'flex-start' } }, (0, primitives_11.h)(primitives_11.StateDot, { state: 'idle' }), (0, primitives_11.h)('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 } }, (0, primitives_11.h)('span', null, report.repo?.hasGit === false
3292
+ ? '该目录不是 git 仓库,也没有 GitNexus 索引。下面是工作区扫描结果;仍可建立索引(--skip-git),但没有提交基线与增量刷新,也不会有 Git 活动。'
3293
+ : '该项目还没有 GitNexus 索引。下面是工作区扫描结果;初始化后可看到图谱、查询能力与 Git 活动对账。'), Array.isArray(projects)
3294
+ ? (0, primitives_11.h)('span', { style: { ...primitives_11.MUTED, fontSize: '11px' } }, projectsError !== null && projectsError !== undefined
3295
+ ? `项目列表读取失败:${projectsError};已按工作区描述。`
3296
+ : indexedProjectCount === 0 && gitProjectCount > 0
3297
+ ? `已在 ${projectsVisited ?? 0} 个目录中查找;工作区内有 ${gitProjectCount} 个 git 项目尚未建索引,可在标题栏切换后初始化。`
3298
+ : indexedProjectCount === 0
3299
+ ? `已在 ${projectsVisited ?? 0} 个目录中查找,没有找到 GitNexus 索引或 git 仓库。`
3300
+ : null)
3301
+ : null)), (0, primitives_11.h)(primitives_11.Section, { wide: true, title: '扫描概览 · 尚未索引' }, (0, primitives_11.h)(primitives_11.MetricGrid, {
3138
3302
  items: [
3139
3303
  { label: '扫描文件', value: (0, format_10.compactNumber)(totals.scanned) },
3140
3304
  { label: '选中文件', value: (0, format_10.compactNumber)(totals.selected) },
3141
3305
  { label: '扫描字节', value: (0, format_10.fileSize)(totals.scannedBytes ?? 0) },
3142
3306
  { label: '大小上限', value: cap.text, hint: (0, format_10.capSourceLabel)(cap.source) },
3143
3307
  ],
3144
- }), (0, primitives_11.h)('div', { style: { ...primitives_11.MUTED, marginTop: '6px' } }, '这里的数字来自工作区,不是索引;索引里有什么,只有建立后才能回答。')), languages !== null && languages.rows.length > 0 ? (0, primitives_11.h)(languages_1.MemoizedLanguageSection, { languages }) : null, maintenance === null || maintenance === undefined
3145
- ? null
3146
- : (0, primitives_11.h)(primitives_11.Section, {
3308
+ }), (0, primitives_11.h)('div', { style: { ...primitives_11.MUTED, marginTop: '6px' } }, '这里的数字来自工作区,不是索引;索引里有什么,只有建立后才能回答。')), languages !== null && languages.rows.length > 0 ? (0, primitives_11.h)(languages_1.MemoizedLanguageSection, { languages }) : null, maintenanceSection !== null
3309
+ ? (0, primitives_11.h)(primitives_11.Section, {
3147
3310
  wide: true,
3148
3311
  title: '初始化索引',
3149
- meta: (0, primitives_11.h)('span', { style: { ...primitives_11.MUTED, fontSize: '11px' } }, maintenance.supportsForce === true
3312
+ meta: (0, primitives_11.h)('span', { style: { ...primitives_11.MUTED, fontSize: '11px' } }, maintenanceSection.supportsForce === true
3150
3313
  ? '均可直接执行,也可复制到会话(沙箱与提权由 DSH 处理)'
3151
3314
  : '重启 DSH 后可执行;当前仅可复制(沙箱与提权由 DSH 处理)'),
3152
- }, (0, primitives_11.h)(maintenance_1.MaintenanceSection, { maintenance, projectPath, indexed, run, runError, delta, deltaError, deltaRoute, onStartRun, onCancelRun })));
3315
+ }, (0, primitives_11.h)(maintenance_1.MaintenanceSection, { maintenance: maintenanceSection, projectPath, indexed, run, runError, delta, deltaError, deltaRoute, onStartRun, onCancelRun }))
3316
+ : null);
3153
3317
  }
3154
3318
  return (0, primitives_11.h)('div', { style: primitives_11.styles.root },
3155
3319
  // The header and the two status lines span everything: they describe the whole
@@ -3333,18 +3497,18 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3333
3497
  onDaysChange: setActivityDays,
3334
3498
  })),
3335
3499
  // Wide, and always open: the commands are the action this panel offers.
3336
- maintenance === null || maintenance === undefined
3337
- ? null
3338
- : (0, primitives_11.h)(primitives_11.Section, {
3500
+ maintenanceSection !== null
3501
+ ? (0, primitives_11.h)(primitives_11.Section, {
3339
3502
  wide: true,
3340
3503
  title: '维护命令',
3341
- meta: (0, primitives_11.h)('span', { style: { ...primitives_11.MUTED, fontSize: '12px' } }, `${maintenance.commands?.length ?? 0} 条 · ` +
3342
- (maintenance.supportsForce === true
3504
+ meta: (0, primitives_11.h)('span', { style: { ...primitives_11.MUTED, fontSize: '12px' } }, `${maintenanceSection.commands?.length ?? 0} 条 · ` +
3505
+ (maintenanceSection.supportsForce === true
3343
3506
  ? '均可直接执行,也可复制到会话'
3344
3507
  : '重启 DSH 后可执行;当前仅可复制') +
3345
3508
  '(沙箱与提权由 DSH 处理)'),
3346
3509
  scroll: 360,
3347
- }, (0, primitives_11.h)(maintenance_1.MaintenanceSection, { maintenance, projectPath, indexed, run, runError, delta, deltaError, deltaRoute, onStartRun, onCancelRun })));
3510
+ }, (0, primitives_11.h)(maintenance_1.MaintenanceSection, { maintenance: maintenanceSection, projectPath, indexed, run, runError, delta, deltaError, deltaRoute, onStartRun, onCancelRun }))
3511
+ : null);
3348
3512
  }
3349
3513
  /**
3350
3514
  * Stateful wrapper: resolves the current project, loads its coverage.
@@ -3356,11 +3520,36 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3356
3520
  * @param {{params?: {repo?: string}, sessionId?: string, useWorkspaces?: Function}} props
3357
3521
  */
3358
3522
  function CoveragePanel(props) {
3359
- const projectPath = (0, navigation_3.useProjectPath)(props);
3523
+ // The path the operator opened. It is the search root for the project list; the
3524
+ // active project may be the workspace itself or one of its descendants.
3525
+ const workspacePath = (0, navigation_3.useProjectPath)(props);
3360
3526
  const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
3361
- const [report, setReport] = React.useState(null);
3362
- const [maintenance, setMaintenance] = React.useState(null);
3363
- const [error, setError] = React.useState(null);
3527
+ const [rawReport, setRawReport] = React.useState(null);
3528
+ const [rawMaintenance, setRawMaintenance] = React.useState(null);
3529
+ const [rawError, setRawError] = React.useState(null);
3530
+ /** The project the current report belongs to. */
3531
+ const [reportProject, setReportProject] = React.useState(null);
3532
+ const [projectList, setProjectList] = React.useState(null);
3533
+ /** The workspace `projectList` was resolved for. Guards against a workspace switch. */
3534
+ const [projectListFor, setProjectListFor] = React.useState(null);
3535
+ const [activeProject, setActiveProject] = React.useState(null);
3536
+ const [projectsError, setProjectsError] = React.useState(null);
3537
+ const [resolving, setResolving] = React.useState(false);
3538
+ /**
3539
+ * Bumped after the project list resolves, so the coverage effect waits for it.
3540
+ * Without this, a manual refresh would read coverage for the old active project
3541
+ * while the resolve was still deciding whether that project still exists.
3542
+ */
3543
+ const [loadToken, setLoadToken] = React.useState(0);
3544
+ const projectsReady = projectListFor === workspacePath;
3545
+ const visibleProjects = projectsReady ? projectList?.projects ?? [] : undefined;
3546
+ const projectPath = activeProject ?? workspacePath;
3547
+ const reportVisible = reportProject === projectPath;
3548
+ const report = reportVisible ? rawReport : null;
3549
+ const maintenance = reportVisible ? rawMaintenance : null;
3550
+ const error = reportVisible ? rawError : null;
3551
+ /** The graph is only meaningful for an index; a scan-only project has no projection. */
3552
+ const graphAvailable = report?.repo?.indexed === true;
3364
3553
  const [busy, setBusy] = React.useState(false);
3365
3554
  const [progress, setProgress] = React.useState(null);
3366
3555
  const [reloadToken, setReloadToken] = React.useState(0);
@@ -3388,6 +3577,8 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3388
3577
  // "not run yet" rather than as a failure.
3389
3578
  const [run, setRun] = React.useState(null);
3390
3579
  const [runError, setRunError] = React.useState(null);
3580
+ /** Which project the run status belongs to; a switch must not show it elsewhere. */
3581
+ const [runProject, setRunProject] = React.useState(null);
3391
3582
  /** The Tier 0 verdict, or null while unknown (no route on this host, or not read yet). */
3392
3583
  const [delta, setDelta] = React.useState(null);
3393
3584
  const [deltaError, setDeltaError] = React.useState(null);
@@ -3398,6 +3589,8 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3398
3589
  * gate and no explanation. Same lesson as the graph card: absent is a state to name.
3399
3590
  */
3400
3591
  const [deltaRoute, setDeltaRoute] = React.useState('unknown');
3592
+ /** Which project the current gate reading belongs to, so a switch can clear it. */
3593
+ const deltaRepoRef = React.useRef(null);
3401
3594
  /** The bounded graph projection, its scope, and the load state around it. */
3402
3595
  const [graphProjection, setGraphProjection] = React.useState(null);
3403
3596
  const [graphLoading, setGraphLoading] = React.useState(false);
@@ -3412,7 +3605,9 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3412
3605
  const [graphRoute, setGraphRoute] = React.useState('unknown');
3413
3606
  const [graphScope, setGraphScope] = React.useState(null);
3414
3607
  const [graphReload, setGraphReload] = React.useState(0);
3415
- const running = run?.state === 'running';
3608
+ /** Which project the current projection belongs to, so a switch can clear it. */
3609
+ const graphRepoRef = React.useRef(null);
3610
+ const running = run?.state === 'running' && runProject === projectPath;
3416
3611
  /**
3417
3612
  * Poll while a run is in flight.
3418
3613
  *
@@ -3433,6 +3628,7 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3433
3628
  const next = body.run ?? null;
3434
3629
  // Polls fire every second; only a changed verdict should re-render the report.
3435
3630
  setRun((previous) => (sameRun(previous, next) ? previous : next));
3631
+ setRunProject(projectPath);
3436
3632
  // A finished run changed the index, so the report on screen is now a reading of
3437
3633
  // the previous one. This is the whole reason the run was started.
3438
3634
  if (next !== null && next.state !== 'running')
@@ -3454,6 +3650,7 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3454
3650
  if (projectPath === null)
3455
3651
  return;
3456
3652
  setRunError(null);
3653
+ setRunProject(projectPath);
3457
3654
  try {
3458
3655
  // One request, three modes. Sending them as flags rather than a body keeps the
3459
3656
  // operation visible in the URL the host logs alongside the command it ran.
@@ -3463,6 +3660,7 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3463
3660
  if (!body?.ok)
3464
3661
  throw new Error(body?.message ?? `请求失败 (${res.status})`);
3465
3662
  setRun(body.run ?? null);
3663
+ setRunProject(projectPath);
3466
3664
  }
3467
3665
  catch (cause) {
3468
3666
  setRunError(String(cause?.message ?? cause));
@@ -3471,6 +3669,7 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3471
3669
  const cancelRun = React.useCallback(async () => {
3472
3670
  if (projectPath === null)
3473
3671
  return;
3672
+ setRunProject(projectPath);
3474
3673
  try {
3475
3674
  const res = await fetch((0, navigation_3.routeUrl)(`/api/gitnexus/analyze?repo=${encodeURIComponent(projectPath)}`), {
3476
3675
  method: 'DELETE',
@@ -3717,13 +3916,93 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3717
3916
  void startRun('refresh');
3718
3917
  }, [installRun, projectPath, report, startRun]);
3719
3918
  React.useEffect(() => {
3720
- if (projectPath === null)
3919
+ if (workspacePath === null)
3721
3920
  return undefined;
3722
3921
  const controller = new AbortController();
3922
+ setResolving(true);
3923
+ setProjectsError(null);
3924
+ // A workspace switch must not leave the previous project's report on screen. A
3925
+ // refresh of the same workspace keeps it readable while the list is re-read.
3926
+ if (projectListFor !== workspacePath) {
3927
+ setRawReport(null);
3928
+ setRawMaintenance(null);
3929
+ setRawError(null);
3930
+ setReadAt(null);
3931
+ setReportProject(null);
3932
+ setActiveProject(null);
3933
+ }
3934
+ void (async () => {
3935
+ try {
3936
+ const res = await fetch((0, navigation_3.routeUrl)(`/api/gitnexus/projects?root=${encodeURIComponent(workspacePath)}`), { signal: controller.signal });
3937
+ const body = (await res.json().catch(() => null));
3938
+ if (!res.ok || body?.ok !== true || !Array.isArray(body.projects)) {
3939
+ throw new Error(body?.message ?? `读取项目列表失败 (${res.status})`);
3940
+ }
3941
+ if (controller.signal.aborted)
3942
+ return;
3943
+ const list = {
3944
+ root: workspacePath,
3945
+ projects: body.projects,
3946
+ visited: typeof body.visited === 'number' ? body.visited : 0,
3947
+ truncated: body.truncated === true,
3948
+ };
3949
+ setProjectList(list);
3950
+ setProjectListFor(workspacePath);
3951
+ const remembered = readRememberedProject(workspacePath);
3952
+ const next = chooseProject(list.projects, workspacePath, remembered);
3953
+ setActiveProject(next);
3954
+ if (remembered !== null && next !== remembered && list.truncated !== true) {
3955
+ // The remembered project disappeared; persist the fallback so the next open
3956
+ // does not look for it again. A truncated walk never proves it disappeared:
3957
+ // it may simply not have reached it before the bound, so keep the preference
3958
+ // and let a later complete resolve restore it.
3959
+ rememberProject(workspacePath, next);
3960
+ }
3961
+ setLoadToken((n) => n + 1);
3962
+ }
3963
+ catch (cause) {
3964
+ if (controller.signal.aborted)
3965
+ return;
3966
+ setProjectsError(String(cause?.message ?? cause));
3967
+ // First load: fall back to scanning the workspace. A refresh keeps the list
3968
+ // already on screen; a transient resolve failure should not remove it.
3969
+ if (projectListFor !== workspacePath) {
3970
+ setProjectList({ root: workspacePath, projects: [], visited: 0, truncated: false });
3971
+ setProjectListFor(workspacePath);
3972
+ setActiveProject(workspacePath);
3973
+ }
3974
+ setLoadToken((n) => n + 1);
3975
+ }
3976
+ finally {
3977
+ if (!controller.signal.aborted)
3978
+ setResolving(false);
3979
+ }
3980
+ })();
3981
+ return () => controller.abort();
3982
+ }, [workspacePath, reloadToken]);
3983
+ React.useEffect(() => {
3984
+ if (activeProject === null || workspacePath === null)
3985
+ return undefined;
3986
+ // Wait until the resolve has produced the list for this workspace. A manual
3987
+ // refresh bumps loadToken after the resolve, so coverage never reads the old
3988
+ // active project while the list is still being decided.
3989
+ if (projectListFor !== workspacePath || resolving)
3990
+ return undefined;
3991
+ const controller = new AbortController();
3992
+ // A project switch must not leave the previous report, maintenance payload or
3993
+ // read time under the new path. A refresh of the same project keeps them readable
3994
+ // while the new reading is fetched.
3995
+ if (reportProject !== activeProject) {
3996
+ setRawReport(null);
3997
+ setRawMaintenance(null);
3998
+ setRawError(null);
3999
+ setReadAt(null);
4000
+ }
3723
4001
  setBusy(true);
3724
- setError(null);
4002
+ setRawError(null);
4003
+ setReportProject(activeProject);
3725
4004
  setProgress({ percent: 0, label: '连接索引服务…' });
3726
- const query = `/api/gitnexus/coverage?repo=${encodeURIComponent(projectPath)}` +
4005
+ const query = `/api/gitnexus/coverage?repo=${encodeURIComponent(activeProject)}` +
3727
4006
  (wantsFresh ? '&refresh=1' : '') +
3728
4007
  '&stream=1';
3729
4008
  const load = async () => {
@@ -3781,8 +4060,8 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3781
4060
  throw new Error('读取中断:没有收到完整报告。');
3782
4061
  if (controller.signal.aborted)
3783
4062
  return;
3784
- setReport(payload.report);
3785
- setMaintenance(payload.maintenance ?? null);
4063
+ setRawReport(payload.report);
4064
+ setRawMaintenance(payload.maintenance ?? null);
3786
4065
  setReadAt(typeof payload.readAt === 'string' ? payload.readAt : new Date().toISOString());
3787
4066
  return;
3788
4067
  }
@@ -3795,16 +4074,16 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3795
4074
  // point of leading with the initialize command is that reaching it is
3796
4075
  // expected on a project that has never been analysed.
3797
4076
  if (res.status === 404 && body?.code === 'not-indexed') {
3798
- setReport({ repo: { indexed: false, path: projectPath } });
3799
- setMaintenance(null);
4077
+ setRawReport({ repo: { indexed: false, path: activeProject } });
4078
+ setRawMaintenance(null);
3800
4079
  setReadAt(body?.readAt ?? new Date().toISOString());
3801
4080
  return;
3802
4081
  }
3803
4082
  if (!body?.ok || body.report === undefined) {
3804
4083
  throw new Error(body?.message ?? `请求失败 (${res.status})`);
3805
4084
  }
3806
- setReport(body.report);
3807
- setMaintenance(body.maintenance ?? null);
4085
+ setRawReport(body.report);
4086
+ setRawMaintenance(body.maintenance ?? null);
3808
4087
  setReadAt(typeof body.readAt === 'string' ? body.readAt : new Date().toISOString());
3809
4088
  }
3810
4089
  catch (cause) {
@@ -3813,7 +4092,7 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3813
4092
  const error = cause;
3814
4093
  if (error?.name === 'AbortError')
3815
4094
  return;
3816
- setError(String(error?.message ?? cause));
4095
+ setRawError(String(error?.message ?? cause));
3817
4096
  }
3818
4097
  finally {
3819
4098
  if (!controller.signal.aborted) {
@@ -3824,7 +4103,7 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3824
4103
  };
3825
4104
  void load();
3826
4105
  return () => controller.abort();
3827
- }, [projectPath, reloadToken]);
4106
+ }, [activeProject, projectListFor, workspacePath, loadToken, resolving]);
3828
4107
  /**
3829
4108
  * Read the Tier 0 verdict whenever the project or the index changes.
3830
4109
  *
@@ -3837,10 +4116,15 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3837
4116
  * changed; there is no polling here for the same reason the run poll is conditional.
3838
4117
  */
3839
4118
  React.useEffect(() => {
3840
- if (projectPath === null) {
4119
+ // A gate read for the previous project must not be shown under the new one. A
4120
+ // refresh of the same project keeps the readable verdict while it is re-read.
4121
+ if (deltaRepoRef.current !== projectPath) {
4122
+ deltaRepoRef.current = projectPath;
3841
4123
  setDelta(null);
3842
4124
  setDeltaError(null);
3843
4125
  setDeltaRoute('unknown');
4126
+ }
4127
+ if (projectPath === null) {
3844
4128
  return undefined;
3845
4129
  }
3846
4130
  const controller = new AbortController();
@@ -3878,11 +4162,6 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3878
4162
  })();
3879
4163
  return () => controller.abort();
3880
4164
  }, [projectPath, reloadToken]);
3881
- // A different project means the previous drill-down is another repository's
3882
- // community: keeping the scope would ask the new project for an id that cannot exist.
3883
- React.useEffect(() => {
3884
- setGraphScope(null);
3885
- }, [projectPath]);
3886
4165
  /**
3887
4166
  * Read the graph projection for the current scope.
3888
4167
  *
@@ -3895,14 +4174,40 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3895
4174
  */
3896
4175
  React.useEffect(() => {
3897
4176
  if (projectPath === null) {
4177
+ graphRepoRef.current = null;
4178
+ setGraphProjection(null);
4179
+ setGraphError(null);
4180
+ setGraphLoading(false);
4181
+ setGraphScope(null);
4182
+ return undefined;
4183
+ }
4184
+ const projectChanged = graphRepoRef.current !== projectPath;
4185
+ if (projectChanged) {
4186
+ graphRepoRef.current = projectPath;
4187
+ setGraphProjection(null);
4188
+ setGraphError(null);
4189
+ // The previous scope is another repository's community. Clearing it makes the
4190
+ // next render fetch the overview instead of asking the new project for an id it
4191
+ // cannot have.
4192
+ setGraphScope(null);
4193
+ }
4194
+ if (!graphAvailable) {
3898
4195
  setGraphProjection(null);
3899
4196
  setGraphError(null);
4197
+ setGraphLoading(false);
4198
+ return undefined;
4199
+ }
4200
+ if (projectChanged && graphScope !== null) {
4201
+ // The setGraphScope(null) above re-runs this effect; do not also fire a request
4202
+ // for the old scope against the new project.
3900
4203
  return undefined;
3901
4204
  }
3902
4205
  const controller = new AbortController();
3903
4206
  setGraphLoading(true);
3904
- const view = graphScope === null ? 'communities' : 'community';
3905
- const suffix = graphScope === null ? '' : `&id=${encodeURIComponent(graphScope)}`;
4207
+ setGraphError(null);
4208
+ const effectiveScope = projectChanged ? null : graphScope;
4209
+ const view = effectiveScope === null ? 'communities' : 'community';
4210
+ const suffix = effectiveScope === null ? '' : `&id=${encodeURIComponent(effectiveScope)}`;
3906
4211
  void (async () => {
3907
4212
  try {
3908
4213
  const res = await fetch((0, navigation_3.routeUrl)(`/api/gitnexus/graph?repo=${encodeURIComponent(projectPath)}&view=${view}${suffix}`), { signal: controller.signal });
@@ -3940,7 +4245,7 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3940
4245
  }
3941
4246
  })();
3942
4247
  return () => controller.abort();
3943
- }, [projectPath, reloadToken, graphScope, graphReload]);
4248
+ }, [projectPath, reloadToken, graphScope, graphReload, graphAvailable]);
3944
4249
  /**
3945
4250
  * Open a symbol node, with the same fallbacks the search hits use.
3946
4251
  *
@@ -3979,18 +4284,32 @@ define("views/panel", ["require", "exports", "react", "@deepseek-ai/dsh-client-u
3979
4284
  setGraphError(`当前宿主没有可用的文件打开能力;位置:${text}`);
3980
4285
  }
3981
4286
  }, [sessionId]);
3982
- const fallbackMaintenance = React.useMemo(() => (maintenance === null && projectPath !== null ? { commands: [], rationale: [] } : maintenance), [maintenance, projectPath]);
4287
+ const selectProject = React.useCallback((path) => {
4288
+ if (workspacePath === null)
4289
+ return;
4290
+ setActiveProject(path);
4291
+ setProjectsError(null);
4292
+ rememberProject(workspacePath, path);
4293
+ }, [workspacePath]);
4294
+ const visibleRun = runProject === projectPath ? run : null;
4295
+ const visibleRunError = runProject === projectPath ? runError : null;
3983
4296
  return (0, primitives_11.h)(CoverageView, {
3984
4297
  report,
3985
- maintenance: fallbackMaintenance,
4298
+ maintenance,
3986
4299
  projectPath,
3987
- context: projectPath === null ? (0, navigation_3.contextDiagnostic)(props) : null,
3988
- busy,
3989
- progress,
4300
+ workspacePath,
4301
+ projects: visibleProjects,
4302
+ projectsVisited: projectsReady ? projectList?.visited ?? 0 : undefined,
4303
+ projectsTruncated: projectsReady ? projectList?.truncated === true : undefined,
4304
+ projectsError: projectsReady ? projectsError : null,
4305
+ onSelectProject: selectProject,
4306
+ context: workspacePath === null ? (0, navigation_3.contextDiagnostic)(props) : null,
4307
+ busy: busy || resolving,
4308
+ progress: resolving ? { percent: 0, label: '正在查找项目索引…' } : progress,
3990
4309
  error,
3991
4310
  readAt,
3992
- run,
3993
- runError,
4311
+ run: visibleRun,
4312
+ runError: visibleRunError,
3994
4313
  delta,
3995
4314
  deltaError,
3996
4315
  deltaRoute,
@@ -4184,6 +4503,7 @@ define("index", ["require", "exports", "format", "navigation", "primitives", "se
4184
4503
  compactNumber: format_11.compactNumber,
4185
4504
  useProjectPath: navigation_4.useProjectPath,
4186
4505
  contextDiagnostic: navigation_4.contextDiagnostic,
4506
+ chooseProject: panel_1.chooseProject,
4187
4507
  CoveragePanel: panel_1.CoveragePanel,
4188
4508
  CoverageView: panel_1.CoverageView,
4189
4509
  CoverageTitle: panel_1.CoverageTitle,