@lesliechan721/aw 0.1.0-beta.14 → 0.1.0-beta.16

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 (49) hide show
  1. package/dist/src/commands/aw-capabilities.d.ts +1 -1
  2. package/dist/src/commands/aw-capabilities.js +1 -0
  3. package/dist/src/commands/aw-capabilities.js.map +1 -1
  4. package/dist/src/commands/review-inspection.d.ts +169 -0
  5. package/dist/src/commands/review-inspection.js +916 -0
  6. package/dist/src/commands/review-inspection.js.map +1 -0
  7. package/dist/src/commands/skill-eval.d.ts +5 -0
  8. package/dist/src/commands/skill-eval.js +86 -63
  9. package/dist/src/commands/skill-eval.js.map +1 -1
  10. package/dist/src/commands/status-v6.d.ts +2 -1
  11. package/dist/src/commands/status-v6.js +7 -1
  12. package/dist/src/commands/status-v6.js.map +1 -1
  13. package/dist/src/eval/manifest-v1.js +0 -5
  14. package/dist/src/eval/manifest-v1.js.map +1 -1
  15. package/dist/src/index.js +39 -6
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/portable/commands.d.ts +1 -0
  18. package/dist/src/portable/commands.js +43 -1
  19. package/dist/src/portable/commands.js.map +1 -1
  20. package/dist/src/portable/materializer.d.ts +12 -0
  21. package/dist/src/portable/materializer.js +67 -41
  22. package/dist/src/portable/materializer.js.map +1 -1
  23. package/dist/src/review/eval-source-input.d.ts +43 -0
  24. package/dist/src/review/eval-source-input.js +207 -0
  25. package/dist/src/review/eval-source-input.js.map +1 -0
  26. package/dist/src/review/source-binding.d.ts +170 -0
  27. package/dist/src/review/source-binding.js +622 -0
  28. package/dist/src/review/source-binding.js.map +1 -0
  29. package/dist/src/tui/console-app-v4.js +523 -133
  30. package/dist/src/tui/console-app-v4.js.map +1 -1
  31. package/dist/src/tui/controller-runtime.js +59 -37
  32. package/dist/src/tui/controller-runtime.js.map +1 -1
  33. package/dist/src/tui/controller.d.ts +15 -1
  34. package/dist/src/tui/controller.js +86 -3
  35. package/dist/src/tui/controller.js.map +1 -1
  36. package/dist/src/tui/model.js +2 -3
  37. package/dist/src/tui/model.js.map +1 -1
  38. package/dist/src/tui/operation-review-planner.js +22 -1
  39. package/dist/src/tui/operation-review-planner.js.map +1 -1
  40. package/dist/src/tui/plugin-write-task.d.ts +2 -1
  41. package/dist/src/tui/plugin-write-task.js +17 -0
  42. package/dist/src/tui/plugin-write-task.js.map +1 -1
  43. package/dist/src/tui/router.d.ts +1 -0
  44. package/dist/src/tui/router.js +13 -5
  45. package/dist/src/tui/router.js.map +1 -1
  46. package/dist/src/tui/screen-registry.d.ts +21 -1
  47. package/dist/src/tui/screen-registry.js +5 -1
  48. package/dist/src/tui/screen-registry.js.map +1 -1
  49. package/package.json +3 -3
@@ -13,6 +13,7 @@ import { projectCatalogSourceLocation } from '../utils/catalog-source-presentati
13
13
  import { ActionRow, Callout, ChoiceRow, diagnosticDetailRowCount, DiagnosticSummary, InputCard, PluginListRow, ProgressRow, ResultCard, ResultRow, ReviewCard, ReviewChangeRow, ScreenFrame, SectionCard, SectionRow, StaticRow, StatusCard, TaskRow, TechnicalDetails, ToggleRow, } from './components.js';
14
14
  import { safeDiagnosticCode, sanitizeDiagnosticText, } from './diagnostics.js';
15
15
  import { extractPortableError, TuiConfigInvalidStoreError, userIssueKey, } from './controller.js';
16
+ import { pluginWriteTaskVersionEvidence } from './plugin-write-task.js';
16
17
  import { catalogSourceFreshnessLabel, catalogSourceListPresentation, } from './catalog-sources/screens.js';
17
18
  import { CatalogManagementIntro, CatalogPublicationSummary, CatalogStoreSummary, CatalogWorkspaceSummary, } from './catalog-management-screens.js';
18
19
  import { operationDiagnosticLineCount, operationHistoryMeta, operationHistoryState, operationReportLineCount, OperationDiagnosticScreen, OperationReportPreviewScreen, } from './operation-diagnostic-screens.js';
@@ -175,13 +176,16 @@ function pluginWriteTaskTargetKey(candidate) {
175
176
  const { pluginId, platform, scope } = candidate.target;
176
177
  return [pluginId, platform, scope].join('\0');
177
178
  }
178
- function pluginWriteTaskVersionEvidence(plugin, candidates) {
179
- return Object.fromEntries(candidates.flatMap((candidate) => {
180
- const update = plugin.update.targets.find((target) => target.platform === candidate.target.platform && target.scope === candidate.target.scope);
181
- const version = candidate.target.expectedUpdate?.activeVersion
182
- ?? update?.candidateVersion ?? update?.availableVersion;
183
- return version ? [[pluginWriteTaskTargetKey(candidate), version]] : [];
184
- }));
179
+ function dependencyGraphContextKey(pluginId, direction) {
180
+ return `${pluginId}\0${direction}`;
181
+ }
182
+ function dependencyGraphActionIds(projection) {
183
+ return [
184
+ ...projection.rows.map((row) => `row:${row.id}`),
185
+ ...projection.scopeSummary.unknownConsumerPluginIds.map((pluginId) => `unverified:${pluginId}`),
186
+ 'direction',
187
+ 'technical',
188
+ ];
185
189
  }
186
190
  function statusText(plugin) {
187
191
  if (plugin.configOnly)
@@ -927,6 +931,7 @@ export function ConsoleAppV4(props) {
927
931
  const [pendingLayerFieldKey, setPendingLayerFieldKey] = useState();
928
932
  const [confirmedSharedReviewToken, setConfirmedSharedReviewToken] = useState();
929
933
  const dynamicMenuFocus = useRef(undefined);
934
+ const dependencyGraphContexts = useRef(new Map());
930
935
  const externalDraftRevision = useRef({ config: 0, source: 0, batch: 0, catalog: 0 });
931
936
  const previousExternalDraftValues = useRef({
932
937
  draftUpdates, draftRemoves, objectDrafts, changedObjectDraftKeys, sourceDraft, batchDraft,
@@ -1039,9 +1044,9 @@ export function ConsoleAppV4(props) {
1039
1044
  || snapshot.result.operationKind !== 'pluginWriteTask'
1040
1045
  || !('returnRoute' in snapshot.result)
1041
1046
  || snapshot.result.returnRoute?.name !== 'pluginWriteTask'
1047
+ || snapshot.result.returnRoute.goal !== snapshot.result.summary.task?.goal
1042
1048
  || snapshot.result.returnRoute.batchOrigin?.owner !== 'batch-write'
1043
- || snapshot.result.returnRoute.batchOrigin.flowId
1044
- !== batchDraft?.flowId)
1049
+ || snapshot.result.returnRoute.batchOrigin.flowId !== batchDraft?.flowId)
1045
1050
  return;
1046
1051
  setBatchDraft(undefined);
1047
1052
  setDirty(false);
@@ -1439,6 +1444,7 @@ export function ConsoleAppV4(props) {
1439
1444
  const menuCount = itemCount > 0 && !query ? 1
1440
1445
  + Number(route.mode === 'installed' && props.controller.listPluginWriteTaskCandidates('update')
1441
1446
  .some((candidate) => candidate.eligible && listed.has(candidate.target.pluginId)))
1447
+ + Number(route.mode === 'installed' && props.controller.listCurrentProjectIntentRoots().length > 0)
1442
1448
  + Number(route.mode === 'installed' && props.controller.listPluginWriteTaskCandidates('restore')
1443
1449
  .some((candidate) => candidate.target.scope === 'project')) : 0;
1444
1450
  count = itemCount + menuCount || 1;
@@ -1465,13 +1471,48 @@ export function ConsoleAppV4(props) {
1465
1471
  useEffect(() => {
1466
1472
  if (route.name !== 'pluginDependencies')
1467
1473
  return;
1468
- setExpandedDependencyPluginIds(undefined);
1469
- setFocus(0);
1470
- setScrollOffset(0);
1474
+ const direction = route.direction ?? 'dependencies';
1475
+ const key = dependencyGraphContextKey(route.pluginId, direction);
1476
+ const stored = dependencyGraphContexts.current.get(key);
1477
+ const overviews = props.controller.listPluginDependencyOverviews();
1478
+ const knownPluginIds = new Set(overviews.flatMap((overview) => [
1479
+ overview.pluginId,
1480
+ ...overview.dependencies.map((item) => item.pluginId),
1481
+ ...overview.dependents.map((item) => item.pluginId),
1482
+ ]));
1483
+ const expandedPluginIds = stored
1484
+ ? new Set([...stored.expandedPluginIds].filter((pluginId) => knownPluginIds.has(pluginId)))
1485
+ : undefined;
1486
+ const projection = buildDependencyGraphProjection({
1487
+ overviews,
1488
+ rootPluginId: route.pluginId,
1489
+ direction,
1490
+ ...(expandedPluginIds ? { expandedPluginIds } : {}),
1491
+ });
1492
+ const actionIds = dependencyGraphActionIds(projection);
1493
+ const normalizedExpanded = expandedPluginIds ?? new Set(projection.defaultExpandedPluginIds);
1494
+ const focusedIndex = stored?.focusedActionId
1495
+ ? actionIds.indexOf(stored.focusedActionId)
1496
+ : 0;
1497
+ const nextFocus = focusedIndex >= 0 ? focusedIndex : 0;
1498
+ const anchorIndex = stored?.scrollAnchorActionId
1499
+ ? actionIds.indexOf(stored.scrollAnchorActionId)
1500
+ : 0;
1501
+ const nextScroll = Math.max(0, anchorIndex >= 0 ? Math.min(anchorIndex, nextFocus) : 0);
1502
+ setExpandedDependencyPluginIds(normalizedExpanded);
1503
+ setFocus(nextFocus);
1504
+ setScrollOffset(nextScroll);
1505
+ dependencyGraphContexts.current.set(key, {
1506
+ expandedPluginIds: normalizedExpanded,
1507
+ ...(actionIds[nextFocus] ? { focusedActionId: actionIds[nextFocus] } : {}),
1508
+ ...(actionIds[nextScroll] ? { scrollAnchorActionId: actionIds[nextScroll] } : {}),
1509
+ });
1471
1510
  }, [
1511
+ props.controller,
1472
1512
  route.name,
1473
1513
  route.name === 'pluginDependencies' ? route.pluginId : '',
1474
1514
  route.name === 'pluginDependencies' ? route.direction ?? 'dependencies' : '',
1515
+ snapshot.viewModel,
1475
1516
  ]);
1476
1517
  useEffect(() => {
1477
1518
  if (route.name !== 'pluginList' || snapshot.phase !== 'ready'
@@ -1489,6 +1530,7 @@ export function ConsoleAppV4(props) {
1489
1530
  const menuCount = query ? 0 : 1
1490
1531
  + Number(route.mode === 'installed' && props.controller.listPluginWriteTaskCandidates('update')
1491
1532
  .some((candidate) => candidate.eligible && listed.has(candidate.target.pluginId)))
1533
+ + Number(route.mode === 'installed' && props.controller.listCurrentProjectIntentRoots().length > 0)
1492
1534
  + Number(route.mode === 'installed' && props.controller.listPluginWriteTaskCandidates('restore')
1493
1535
  .some((candidate) => candidate.target.scope === 'project'));
1494
1536
  setFocus(menuCount);
@@ -1678,6 +1720,7 @@ export function ConsoleAppV4(props) {
1678
1720
  return 1
1679
1721
  + Number(mode === 'installed' && props.controller.listPluginWriteTaskCandidates('update')
1680
1722
  .some((candidate) => candidate.eligible && listed.has(candidate.target.pluginId)))
1723
+ + Number(mode === 'installed' && props.controller.listCurrentProjectIntentRoots().length > 0)
1681
1724
  + Number(mode === 'installed' && props.controller.listPluginWriteTaskCandidates('restore')
1682
1725
  .some((candidate) => candidate.target.scope === 'project'));
1683
1726
  };
@@ -1820,6 +1863,38 @@ export function ConsoleAppV4(props) {
1820
1863
  setFocus(0);
1821
1864
  setScrollOffset(0);
1822
1865
  };
1866
+ useEffect(() => {
1867
+ if (route.name !== 'pluginWriteTask' || route.goal !== 'restore')
1868
+ return;
1869
+ if (snapshot.router.current.context.autoPreparedRestore)
1870
+ return;
1871
+ const retainedTargetKeys = route.targetKeys ? new Set(route.targetKeys) : undefined;
1872
+ const allCandidates = props.controller.listPluginWriteTaskCandidates('restore');
1873
+ const currentTargetKeys = new Set(allCandidates.map(pluginWriteTaskTargetKey));
1874
+ if (route.targetKeys?.some((targetKey) => !currentTargetKeys.has(targetKey)))
1875
+ return;
1876
+ const candidates = allCandidates
1877
+ .filter((candidate) => retainedTargetKeys
1878
+ ? retainedTargetKeys.has(pluginWriteTaskTargetKey(candidate))
1879
+ : candidate.target.pluginId === route.pluginId);
1880
+ if (candidates.length === 0 || candidates.some((candidate) => !candidate.eligible))
1881
+ return;
1882
+ props.controller.updatePageContext({ autoPreparedRestore: true });
1883
+ beginReview({
1884
+ kind: 'pluginWriteTask',
1885
+ goal: 'restore',
1886
+ targets: candidates.map((candidate) => candidate.target),
1887
+ executionPolicy: 'all',
1888
+ }, props.controller.pluginWriteTaskReturnRoute(route));
1889
+ }, [
1890
+ props.controller,
1891
+ route.name,
1892
+ route.name === 'pluginWriteTask' ? route.goal : '',
1893
+ route.name === 'pluginWriteTask' ? route.pluginId : '',
1894
+ route.name === 'pluginWriteTask' ? route.targetKeys?.join('\0') ?? '' : '',
1895
+ snapshot.router.current.context.autoPreparedRestore,
1896
+ snapshot.viewModel,
1897
+ ]);
1823
1898
  const openBatchSelection = (action) => {
1824
1899
  props.controller.updatePageContext({
1825
1900
  focusIndex: focus,
@@ -1848,7 +1923,7 @@ export function ConsoleAppV4(props) {
1848
1923
  showSelected: false,
1849
1924
  });
1850
1925
  setDirty(true);
1851
- setFocus(0);
1926
+ setFocus(1);
1852
1927
  setScrollOffset(0);
1853
1928
  setSearchFocus(false);
1854
1929
  };
@@ -1856,21 +1931,43 @@ export function ConsoleAppV4(props) {
1856
1931
  const mode = draft.mode;
1857
1932
  const all = props.controller.listPlugins({ mode });
1858
1933
  if (draft.showSelected) {
1859
- const selected = new Set(draft.selectedPluginIds);
1860
- return all.filter((candidate) => selected.has(candidate.pluginId));
1934
+ const byId = new Map(all.map((candidate) => [candidate.pluginId, candidate]));
1935
+ return draft.selectedPluginIds.map((pluginId) => ({
1936
+ pluginId,
1937
+ ...(byId.get(pluginId) ? { plugin: byId.get(pluginId) } : {}),
1938
+ }));
1861
1939
  }
1862
1940
  return props.controller.listPlugins({
1863
1941
  mode,
1864
1942
  ...(draft.sourceId ? { sourceId: draft.sourceId } : {}),
1865
1943
  ...(draft.query ? { query: draft.query } : {}),
1866
1944
  installFilter: draft.installFilter,
1945
+ }).map((plugin) => ({ pluginId: plugin.pluginId, plugin }));
1946
+ };
1947
+ const batchCandidateBlockReason = (draft, candidate) => !candidate.plugin
1948
+ ? '插件已不在当前状态中,可取消选择'
1949
+ : candidate.plugin.configOnly
1950
+ ? '来源中没有可信插件定义'
1951
+ : draft.mode === 'browse' && hasInstalledPlacement(candidate.plugin)
1952
+ ? '这个插件已经添加'
1953
+ : undefined;
1954
+ const invalidBatchSelection = (draft) => {
1955
+ const byId = new Map(props.controller.listPlugins({ mode: draft.mode })
1956
+ .map((plugin) => [plugin.pluginId, plugin]));
1957
+ return draft.selectedPluginIds.filter((pluginId) => batchCandidateBlockReason(draft, {
1958
+ pluginId,
1959
+ ...(byId.get(pluginId) ? { plugin: byId.get(pluginId) } : {}),
1960
+ }) !== undefined);
1961
+ };
1962
+ const persistDependencyGraphContext = (graphRoute, projection, expandedPluginIds, focusIndex, offset) => {
1963
+ const direction = graphRoute.direction ?? 'dependencies';
1964
+ const actionIds = dependencyGraphActionIds(projection);
1965
+ dependencyGraphContexts.current.set(dependencyGraphContextKey(graphRoute.pluginId, direction), {
1966
+ expandedPluginIds: new Set(expandedPluginIds),
1967
+ ...(actionIds[focusIndex] ? { focusedActionId: actionIds[focusIndex] } : {}),
1968
+ ...(actionIds[offset] ? { scrollAnchorActionId: actionIds[offset] } : {}),
1867
1969
  });
1868
1970
  };
1869
- const batchCandidateBlockReason = (draft, candidate) => candidate.configOnly
1870
- ? '来源中没有可信插件定义'
1871
- : draft.mode === 'browse' && hasInstalledPlacement(candidate)
1872
- ? '这个插件已经添加'
1873
- : undefined;
1874
1971
  const toggleBatchPlugin = (pluginId) => {
1875
1972
  setBatchDraft((current) => current ? {
1876
1973
  ...current,
@@ -1879,15 +1976,24 @@ export function ConsoleAppV4(props) {
1879
1976
  : [...current.selectedPluginIds, pluginId],
1880
1977
  } : current);
1881
1978
  };
1882
- const existingBatchTargets = (pluginIds) => pluginIds.flatMap((pluginId) => props.controller.getPluginOverview(pluginId)
1883
- ?.installedPlacements.flatMap((placement) => placement.state === 'installed'
1884
- ? [{ pluginId, platform: placement.platform, scope: placement.scope }]
1885
- : placement.state === 'scopeConflict'
1886
- ? placement.placements.map(({ scope: targetScope }) => ({
1887
- pluginId, platform: placement.platform, scope: targetScope,
1888
- }))
1889
- : []) ?? []);
1979
+ const prepareCurrentProjectPlugins = () => {
1980
+ if (props.controller.listCurrentProjectIntentRoots().length === 0) {
1981
+ setActionError('当前项目没有已保存的插件清单。');
1982
+ return;
1983
+ }
1984
+ beginReview({
1985
+ kind: 'pluginWriteBatch',
1986
+ action: 'sync',
1987
+ selection: { kind: 'currentProjectIntentRoots' },
1988
+ platform: 'both',
1989
+ scope: 'project',
1990
+ });
1991
+ };
1890
1992
  const openSelectedPluginTask = (goal, pluginIds, scopeConstraint) => {
1993
+ if (batchDraft && invalidBatchSelection(batchDraft).length > 0) {
1994
+ setActionError('已选插件状态已变化。请返回选择页检查并取消失效项。');
1995
+ return;
1996
+ }
1891
1997
  const selected = new Set(pluginIds);
1892
1998
  const candidates = props.controller.listPluginWriteTaskCandidates(goal)
1893
1999
  .filter((candidate) => selected.has(candidate.target.pluginId)
@@ -1908,9 +2014,10 @@ export function ConsoleAppV4(props) {
1908
2014
  batchOrigin: { flowId: batchDraft.flowId, owner: 'batch-write' },
1909
2015
  } : {}),
1910
2016
  ...(goal === 'update'
1911
- ? { versionEvidence: Object.fromEntries(candidates.flatMap((candidate) => candidate.target.expectedUpdate
1912
- ? [[pluginWriteTaskTargetKey(candidate), candidate.target.expectedUpdate.activeVersion]]
1913
- : [])) }
2017
+ ? { versionEvidence: Object.assign({}, ...[...selected].map((pluginId) => {
2018
+ const candidatePlugin = props.controller.getPluginOverview(pluginId);
2019
+ return candidatePlugin ? pluginWriteTaskVersionEvidence(candidatePlugin, candidates.filter((candidate) => candidate.target.pluginId === pluginId)) : {};
2020
+ })) }
1914
2021
  : {}),
1915
2022
  });
1916
2023
  };
@@ -2401,18 +2508,19 @@ export function ConsoleAppV4(props) {
2401
2508
  const openConfigLayerTarget = (layer) => {
2402
2509
  if (route.name !== 'configLayer')
2403
2510
  return;
2404
- const field = route.fieldKey && configPage
2405
- ? configPage.groups.flatMap((group) => group.fields).find((candidate) => candidate.key === route.fieldKey)
2511
+ const definitionPage = configPage ?? configLayerPages.user ?? configLayerPages.project;
2512
+ const field = route.fieldKey && definitionPage
2513
+ ? definitionPage.groups.flatMap((group) => group.fields).find((candidate) => candidate.key === route.fieldKey)
2406
2514
  : undefined;
2407
- const fieldTarget = configPage ? configFieldTarget(configPage, route.fieldKey) : undefined;
2408
- const preset = route.presetId && configPage
2409
- ? configPage.presets.find((candidate) => candidate.id === route.presetId)
2515
+ const fieldTarget = definitionPage ? configFieldTarget(definitionPage, route.fieldKey) : undefined;
2516
+ const preset = route.presetId && definitionPage
2517
+ ? definitionPage.presets.find((candidate) => candidate.id === route.presetId)
2410
2518
  : undefined;
2411
2519
  const presetTarget = preset
2412
- ? configFieldTarget(configPage, Object.keys(preset.values)[0])
2520
+ ? configFieldTarget(definitionPage, Object.keys(preset.values)[0])
2413
2521
  : undefined;
2414
2522
  const target = fieldTarget ?? (route.groupId ? { groupId: route.groupId, focusIndex: 0 } : undefined) ?? presetTarget
2415
- ?? (configPage?.groups[0] ? { groupId: configPage.groups[0].id, focusIndex: 0 } : undefined);
2523
+ ?? (definitionPage?.groups[0] ? { groupId: definitionPage.groups[0].id, focusIndex: 0 } : undefined);
2416
2524
  const context = target
2417
2525
  ? { configGroupId: target.groupId, focusIndex: target.focusIndex, scrollOffset: 0 }
2418
2526
  : {};
@@ -2703,7 +2811,7 @@ export function ConsoleAppV4(props) {
2703
2811
  ? '选择一个插件,或从列表前部选择多个。'
2704
2812
  : '先处理推荐任务,或查看一个插件。';
2705
2813
  context = `${items.length} 个插件`;
2706
- help = `↑↓ 移动 · Enter 查看 · m 批量${route.mode === 'installed' ? ' · a 项目全同步 · f 可更新' : ''} · ←→ 来源 · / 搜索${route.mode === 'browse' ? ' · f 未安装' : ''} · Esc 返回`;
2814
+ help = `↑↓ 移动 · Enter 查看 · m 批量${route.mode === 'installed' ? ' · a 按项目清单准备 · f 可更新' : ''} · ←→ 来源 · / 搜索${route.mode === 'browse' ? ' · f 未安装' : ''} · Esc 返回`;
2707
2815
  const sourceError = sourceId !== 'all'
2708
2816
  && props.controller.getSourceOverview(sourceId).health?.state === 'unavailable';
2709
2817
  actions = sourceError
@@ -2716,6 +2824,7 @@ export function ConsoleAppV4(props) {
2716
2824
  .map((candidate) => candidate.target.pluginId))];
2717
2825
  const projectRestoreCandidates = props.controller.listPluginWriteTaskCandidates('restore')
2718
2826
  .filter((candidate) => candidate.target.scope === 'project');
2827
+ const projectIntentRoots = props.controller.listCurrentProjectIntentRoots();
2719
2828
  const menuRows = sourceError || items.length === 0 || query ? [] : [
2720
2829
  ...(route.mode === 'installed' && recommendedUpdatePluginIds.length > 0 ? [{
2721
2830
  pluginId: '@menu:update',
@@ -2727,11 +2836,21 @@ export function ConsoleAppV4(props) {
2727
2836
  status: 'warning',
2728
2837
  menuAction: () => openSelectedPluginTask('update', recommendedUpdatePluginIds),
2729
2838
  }] : []),
2730
- ...(route.mode === 'installed' && projectRestoreCandidates.length > 0 ? [{
2839
+ ...(route.mode === 'installed' && projectIntentRoots.length > 0 ? [{
2731
2840
  pluginId: '@menu:restore-project',
2732
- name: '恢复本项目…',
2841
+ name: '按项目清单准备…',
2842
+ sourceLabel: '',
2843
+ description: `${projectIntentRoots.length} 个已保存根插件;允许补齐当前项目缺少的安装`,
2844
+ placementSummary: '',
2845
+ statusLabel: '项目',
2846
+ status: 'info',
2847
+ menuAction: prepareCurrentProjectPlugins,
2848
+ }] : []),
2849
+ ...(route.mode === 'installed' && projectRestoreCandidates.length > 0 ? [{
2850
+ pluginId: '@menu:repair-project',
2851
+ name: '修复本项目异常…',
2733
2852
  sourceLabel: '',
2734
- description: '只处理当前项目已保存且需要恢复的插件位置',
2853
+ description: '严格按已确认的现有版本修复异常,不改变插件版本',
2735
2854
  placementSummary: '',
2736
2855
  statusLabel: '推荐',
2737
2856
  status: 'warning',
@@ -2839,18 +2958,19 @@ export function ConsoleAppV4(props) {
2839
2958
  const hiddenSelected = batchDraft.selectedPluginIds.filter((pluginId) => !currentIds.has(pluginId)).length;
2840
2959
  const candidateActions = candidates.map((candidate) => {
2841
2960
  const blockReason = batchCandidateBlockReason(batchDraft, candidate);
2961
+ const isSelected = selected.has(candidate.pluginId);
2842
2962
  return {
2843
- label: candidate.displayName,
2963
+ label: candidate.plugin?.displayName ?? `${candidate.pluginId}(已不可用,可取消选择)`,
2844
2964
  description: blockReason
2845
- ? `${candidate.sourceLabel} · 不可选择:${blockReason}`
2846
- : `${candidate.sourceLabel} · ${pluginPlacementSummary(candidate)}`,
2847
- selected: selected.has(candidate.pluginId),
2965
+ ? `${candidate.plugin?.sourceLabel ?? '状态已失效'} · ${isSelected ? '可取消选择' : '不可选择'}:${blockReason}`
2966
+ : `${candidate.plugin.sourceLabel} · ${pluginPlacementSummary(candidate.plugin)}`,
2967
+ selected: isSelected,
2848
2968
  pluginId: candidate.pluginId,
2849
- selectable: blockReason === undefined,
2969
+ canSelectNew: blockReason === undefined,
2850
2970
  status: blockReason ? 'muted' : undefined,
2851
- run: () => go(batchDraft.mode === 'browse'
2971
+ run: candidate.plugin ? () => go(batchDraft.mode === 'browse'
2852
2972
  ? { name: 'pluginPreview', pluginId: candidate.pluginId }
2853
- : { name: 'pluginDetail', pluginId: candidate.pluginId }),
2973
+ : { name: 'pluginDetail', pluginId: candidate.pluginId }) : undefined,
2854
2974
  };
2855
2975
  });
2856
2976
  const selectableIds = candidates
@@ -2861,6 +2981,11 @@ export function ConsoleAppV4(props) {
2861
2981
  setActionError('请至少选择一个插件。');
2862
2982
  return;
2863
2983
  }
2984
+ const invalid = invalidBatchSelection(batchDraft);
2985
+ if (invalid.length > 0) {
2986
+ setActionError(`${invalid.length} 个已选插件已不可用。请先在“查看全部已选”中取消选择。`);
2987
+ return;
2988
+ }
2864
2989
  if (batchDraft.mode === 'browse') {
2865
2990
  setBatchDraft((current) => current ? { ...current, action: 'install' } : current);
2866
2991
  props.controller.push({ name: 'batchPlacement' });
@@ -2871,16 +2996,20 @@ export function ConsoleAppV4(props) {
2871
2996
  setFocus(0);
2872
2997
  setScrollOffset(0);
2873
2998
  };
2999
+ const continueAction = {
3000
+ label: `查看确认(${batchDraft.selectedPluginIds.length})`,
3001
+ description: batchDraft.selectedPluginIds.length > 0 ? '继续选择任务和确认范围' : '先选择至少一个插件',
3002
+ run: continueSelection,
3003
+ };
2874
3004
  const utilityActions = [
2875
- {
2876
- label: `查看确认(${batchDraft.selectedPluginIds.length})`,
2877
- description: batchDraft.selectedPluginIds.length > 0 ? '继续选择任务和确认范围' : '先选择至少一个插件',
2878
- run: continueSelection,
2879
- },
2880
3005
  {
2881
3006
  label: batchDraft.showSelected ? '返回当前结果' : `查看全部已选(${batchDraft.selectedPluginIds.length})`,
2882
3007
  description: hiddenSelected > 0 ? `${hiddenSelected} 个选择在其他筛选中` : '完整 canonical 选择集合',
2883
- run: () => setBatchDraft((current) => current ? { ...current, showSelected: !current.showSelected } : current),
3008
+ run: () => {
3009
+ setBatchDraft((current) => current ? { ...current, showSelected: !current.showSelected } : current);
3010
+ setFocus(0);
3011
+ setScrollOffset(0);
3012
+ },
2884
3013
  },
2885
3014
  {
2886
3015
  label: '全选当前结果',
@@ -2903,13 +3032,19 @@ export function ConsoleAppV4(props) {
2903
3032
  run: () => setBatchDraft((current) => current ? { ...current, selectedPluginIds: [] } : current),
2904
3033
  },
2905
3034
  ];
2906
- actions = [...candidateActions, ...utilityActions];
3035
+ actions = [continueAction, ...candidateActions, ...utilityActions];
3036
+ const batchFocus = Math.min(focus, actions.length - 1);
2907
3037
  context = `已选 ${batchDraft.selectedPluginIds.length} 个 · 显示 ${candidates.length} 个${hiddenSelected > 0 ? ` · 隐藏 ${hiddenSelected} 个` : ''}`;
2908
3038
  help = '↑↓ 移动 · Space 选择 · Enter 查看/操作 · c 查看确认 · ←→ 来源 · / 搜索 · Esc 放弃';
2909
- const viewport = actionViewport(actions, policy.mode === 'compact', focus, scrollOffset, pageActionBudget(3));
3039
+ const scrollActions = [...candidateActions, ...utilityActions];
3040
+ const viewport = actionViewport(scrollActions, policy.mode === 'compact', Math.max(0, batchFocus - 1), Math.max(0, scrollOffset - 1), Math.max(1, pageActionBudget(5)));
3041
+ const focusedCandidate = batchFocus > 0 && batchFocus <= candidateActions.length
3042
+ ? candidates[batchFocus - 1]?.plugin : undefined;
2910
3043
  content = _jsxs(Box, { flexDirection: "column", children: [_jsx(SourceTabs, { controller: props.controller, current: batchDraft.showSelected ? 'all' : batchDraft.sourceId ?? 'all', compact: policy.mode === 'compact', columns: size.columns - 2, mode: batchDraft.mode, query: batchDraft.showSelected ? undefined : batchDraft.query || undefined, installFilter: batchDraft.showSelected ? 'all' : batchDraft.installFilter }), _jsxs(Text, { color: searchFocus ? tuiTheme.colors.accent : tuiTheme.colors.muted, children: ["\u641C\u7D22 / ", searchFocus
2911
3044
  ? _jsx(InputDisplay, { value: batchDraft.query, placeholder: "\u8F93\u5165\u641C\u7D22\u5185\u5BB9", columns: Math.max(8, size.columns - 14) })
2912
- : _jsxs(_Fragment, { children: [batchDraft.query || '搜索名称、用途或来源…', batchDraft.query ? ' / 编辑' : ''] })] }), _jsx(SemanticRows, { actions: actions, focus: focus, kind: "toggle", compact: policy.mode === 'compact', offset: viewport.offset, limit: viewport.limit })] });
3045
+ : _jsxs(_Fragment, { children: [batchDraft.query || '搜索名称、用途或来源…', batchDraft.query ? ' / 编辑' : ''] })] }), _jsx(SemanticRows, { actions: [continueAction], focus: batchFocus, kind: "action", compact: true }), focusedCandidate
3046
+ ? _jsx(StaticRow, { title: `用途 · ${focusedCandidate.description}`, status: "info", compact: true })
3047
+ : null, _jsx(SemanticRows, { actions: scrollActions, focus: batchFocus - 1, kind: "toggle", compact: policy.mode === 'compact', offset: viewport.offset, limit: viewport.limit })] });
2913
3048
  }
2914
3049
  else {
2915
3050
  const selectedIds = batchDraft.selectedPluginIds;
@@ -2918,8 +3053,12 @@ export function ConsoleAppV4(props) {
2918
3053
  return props.controller.listPluginWriteTaskCandidates(goal)
2919
3054
  .filter((candidate) => selected.has(candidate.target.pluginId));
2920
3055
  };
2921
- const exactTargets = existingBatchTargets(selectedIds);
2922
3056
  const startExistingBatch = (action) => {
3057
+ if (invalidBatchSelection(batchDraft).length > 0) {
3058
+ setActionError('已选插件状态已变化。请返回选择页检查并取消失效项。');
3059
+ return;
3060
+ }
3061
+ const exactTargets = props.controller.listPluginWriteBatchTargets(action, selectedIds);
2923
3062
  if (exactTargets.length === 0) {
2924
3063
  setActionError('所选插件没有可用的已有安装位置。');
2925
3064
  return;
@@ -2931,11 +3070,13 @@ export function ConsoleAppV4(props) {
2931
3070
  scope: exactTargets[0].scope,
2932
3071
  });
2933
3072
  };
3073
+ const syncTargets = props.controller.listPluginWriteBatchTargets('sync', selectedIds);
3074
+ const uninstallTargets = props.controller.listPluginWriteBatchTargets('uninstall', selectedIds);
2934
3075
  if (batchDraft.stage === 'maintenance') {
2935
3076
  actions = [
2936
- { label: '同步已保存使用设置…', description: '重新应用所选已有位置', run: () => startExistingBatch('sync') },
2937
- { label: '重新安装…', description: '重新生成所选已有位置的安装内容', run: () => startExistingBatch('reinstall') },
2938
- { label: '清理遗留内容…', description: '普通清理;保留 foreign 和 shared 内容', run: () => startExistingBatch('cleanup') },
3077
+ { label: '同步已保存使用设置…', description: `重新应用 ${syncTargets.length} 个已保存或现场目标`, run: () => startExistingBatch('sync') },
3078
+ { label: '重新安装…', description: `重新生成 ${props.controller.listPluginWriteBatchTargets('reinstall', selectedIds).length} 个已保存或现场目标`, run: () => startExistingBatch('reinstall') },
3079
+ { label: '清理遗留内容…', description: `${props.controller.listPluginWriteBatchTargets('cleanup', selectedIds).length} 个有受管证据的位置;保留 foreign 和 shared 内容`, run: () => startExistingBatch('cleanup') },
2939
3080
  {
2940
3081
  label: '返回任务选择',
2941
3082
  run: () => {
@@ -2964,7 +3105,7 @@ export function ConsoleAppV4(props) {
2964
3105
  status: 'warning',
2965
3106
  run: () => openSelectedPluginTask('restore', selectedIds),
2966
3107
  }] : []),
2967
- { label: '移除所选插件…', description: `${exactTargets.length} 个已有位置;保留设置`, status: 'error', run: () => startExistingBatch('uninstall') },
3108
+ { label: '移除所选插件…', description: `${uninstallTargets.length} 个现场目标;执行前检查所有权;保留设置`, status: 'error', run: () => startExistingBatch('uninstall') },
2968
3109
  {
2969
3110
  label: '其他维护方式…',
2970
3111
  description: '同步、重新安装和普通清理',
@@ -2977,7 +3118,7 @@ export function ConsoleAppV4(props) {
2977
3118
  { label: '返回调整选择', run: () => setBatchDraft((current) => current ? { ...current, stage: 'select' } : current) },
2978
3119
  ];
2979
3120
  }
2980
- context = `${selectedIds.length} 个插件 · ${exactTargets.length} 个已有位置`;
3121
+ context = `${selectedIds.length} 个插件 · ${syncTargets.length} 个已保存或现场目标`;
2981
3122
  help = '↑↓ 移动 · Enter 选择 · Esc 返回修改';
2982
3123
  const viewport = actionViewport(actions, policy.mode === 'compact', focus, scrollOffset, pageActionBudget(1));
2983
3124
  content = _jsx(SemanticRows, { actions: actions, focus: focus, kind: "task", compact: policy.mode === 'compact', offset: viewport.offset, limit: viewport.limit });
@@ -3015,6 +3156,10 @@ export function ConsoleAppV4(props) {
3015
3156
  disabled: batchDraft.selection === 'selected' && batchDraft.selectedPluginIds.length === 0
3016
3157
  ? '请至少选择一个插件' : undefined,
3017
3158
  run: () => {
3159
+ if (invalidBatchSelection(batchDraft).length > 0) {
3160
+ setActionError('已选插件状态已变化。请返回选择页检查并取消失效项。');
3161
+ return;
3162
+ }
3018
3163
  const intent = {
3019
3164
  kind: 'pluginWriteBatch',
3020
3165
  action: batchDraft.action,
@@ -3386,24 +3531,51 @@ export function ConsoleAppV4(props) {
3386
3531
  : row.depth === 0 ? '当前插件' : undefined,
3387
3532
  meta: state.value,
3388
3533
  status: state.status,
3389
- run: () => go({
3390
- name: 'pluginDependencyNode',
3391
- pluginId: plugin.pluginId,
3392
- nodePluginId: row.pluginId,
3393
- direction,
3394
- }),
3534
+ run: () => {
3535
+ persistDependencyGraphContext(route, projection, expandedDependencyPluginIds ?? new Set(projection.defaultExpandedPluginIds), focus, scrollOffset);
3536
+ go({
3537
+ name: 'pluginDependencyNode',
3538
+ pluginId: plugin.pluginId,
3539
+ nodePluginId: row.pluginId,
3540
+ direction,
3541
+ });
3542
+ },
3395
3543
  };
3396
3544
  });
3545
+ const unverifiedConsumerActions = direction === 'dependents'
3546
+ ? projection.scopeSummary.unknownConsumerPluginIds.map((pluginId) => {
3547
+ const declared = overview?.dependents.find((entry) => entry.pluginId === pluginId);
3548
+ return {
3549
+ label: `未验证声明者 · ${declared?.displayName ?? pluginId}`,
3550
+ description: '目录声明它依赖当前插件,但当前没有实际引用证据',
3551
+ meta: declared?.aggregateStatus === 'notInstalled' ? '未安装' : '未验证',
3552
+ status: 'muted',
3553
+ run: () => {
3554
+ persistDependencyGraphContext(route, projection, expandedDependencyPluginIds ?? new Set(projection.defaultExpandedPluginIds), focus, scrollOffset);
3555
+ go({
3556
+ name: 'pluginDependencyNode',
3557
+ pluginId: plugin.pluginId,
3558
+ nodePluginId: pluginId,
3559
+ direction,
3560
+ });
3561
+ },
3562
+ };
3563
+ })
3564
+ : [];
3397
3565
  actions = [
3398
3566
  ...rowActions,
3567
+ ...unverifiedConsumerActions,
3399
3568
  {
3400
3569
  label: direction === 'dependencies' ? '切换到“谁在使用它”' : '切换到“它需要的”',
3401
3570
  description: '切换关系方向',
3402
- run: () => props.controller.replace({
3403
- name: 'pluginDependencies',
3404
- pluginId: plugin.pluginId,
3405
- direction: direction === 'dependencies' ? 'dependents' : 'dependencies',
3406
- }),
3571
+ run: () => {
3572
+ persistDependencyGraphContext(route, projection, expandedDependencyPluginIds ?? new Set(projection.defaultExpandedPluginIds), focus, scrollOffset);
3573
+ props.controller.replace({
3574
+ name: 'pluginDependencies',
3575
+ pluginId: plugin.pluginId,
3576
+ direction: direction === 'dependencies' ? 'dependents' : 'dependencies',
3577
+ });
3578
+ },
3407
3579
  },
3408
3580
  { label: showTechnical ? '隐藏检查详情' : '查看检查详情', run: () => toggleTechnical() },
3409
3581
  ];
@@ -3413,7 +3585,9 @@ export function ConsoleAppV4(props) {
3413
3585
  const relationViewport = actionViewport(actions, policy.mode === 'compact', focus, scrollOffset, showTechnical ? responsiveBudget(3, 6) : responsiveBudget(7, 11));
3414
3586
  help = '↑↓ 选择 · → 展开 · ← 收起 · Enter 节点详情 · Esc 返回';
3415
3587
  content = _jsxs(Box, { flexDirection: "column", children: [_jsx(StaticRow, { title: `当前检查范围 · ${scopeText}`, status: "muted", compact: true }), projection.status.state === 'invalid'
3416
- ? _jsx(Callout, { status: "error", title: "\u4F9D\u8D56\u5173\u7CFB\u65E0\u6548", description: projection.status.message }) : null, _jsx(SemanticRows, { actions: actions, focus: focus, kind: "action", compact: true, offset: relationViewport.offset, limit: relationViewport.limit }), showTechnical ? _jsxs(TechnicalDetails, { children: [_jsx(StaticRow, { title: `${dependencyCount} 个依赖 · ${dependentCount} 个实际使用者`, meta: plugin.pluginId, compact: true }), _jsx(StaticRow, { title: `未知引用 · ${projection.scopeSummary.unknownConsumerPluginIds.length} 个`, compact: true })] }) : null] });
3588
+ ? _jsx(Callout, { status: "error", title: "\u4F9D\u8D56\u5173\u7CFB\u65E0\u6548", description: projection.status.message }) : null, unverifiedConsumerActions.length > 0
3589
+ ? _jsx(StaticRow, { title: `未验证声明者 · ${unverifiedConsumerActions.length} 个`, description: "\u4EE5\u4E0B\u58F0\u660E\u8005\u4E0D\u8BA1\u5165\u5B9E\u9645\u4F7F\u7528\u8005", status: "muted", compact: true })
3590
+ : null, _jsx(SemanticRows, { actions: actions, focus: focus, kind: "action", compact: true, offset: relationViewport.offset, limit: relationViewport.limit }), showTechnical ? _jsxs(TechnicalDetails, { children: [_jsx(StaticRow, { title: `${dependencyCount} 个依赖 · ${projection.scopeSummary.actualConsumerPluginIds.length} 个实际使用者`, meta: plugin.pluginId, compact: true }), _jsx(StaticRow, { title: `未验证声明者 · ${projection.scopeSummary.unknownConsumerPluginIds.length} 个`, compact: true })] }) : null] });
3417
3591
  }
3418
3592
  }
3419
3593
  else if (route.name === 'pluginDependencyNode' && plugin) {
@@ -3423,6 +3597,8 @@ export function ConsoleAppV4(props) {
3423
3597
  : route.direction === 'dependencies'
3424
3598
  ? overview?.dependencies.find((entry) => entry.pluginId === route.nodePluginId)
3425
3599
  : overview?.dependents.find((entry) => entry.pluginId === route.nodePluginId);
3600
+ const verifiedDependent = route.direction !== 'dependents'
3601
+ || (item?.placementReferences?.length ?? 0) > 0;
3426
3602
  const state = item ? dependencyStatus(item) : undefined;
3427
3603
  breadcrumb = `${plugin.displayName} / 依赖关系 / ${node?.displayName ?? route.nodePluginId}`;
3428
3604
  context = route.direction === 'dependencies' ? '它需要的' : '谁在使用它';
@@ -3431,7 +3607,9 @@ export function ConsoleAppV4(props) {
3431
3607
  ? '这是当前关系图的根插件。'
3432
3608
  : route.direction === 'dependencies'
3433
3609
  ? `${item?.requiredBy.map((entry) => entry.displayName).join('、') || plugin.displayName} 需要这个插件。`
3434
- : `${node?.displayName ?? route.nodePluginId} 的已安装位置实际引用当前插件。`;
3610
+ : verifiedDependent
3611
+ ? `${node?.displayName ?? route.nodePluginId} 的已安装位置实际引用当前插件。`
3612
+ : `${item?.displayName ?? route.nodePluginId} 声明依赖当前插件,但当前没有实际引用证据。`;
3435
3613
  actions = [
3436
3614
  ...(route.nodePluginId !== plugin.pluginId ? [{
3437
3615
  label: '以此节点查看关系',
@@ -3631,7 +3809,10 @@ export function ConsoleAppV4(props) {
3631
3809
  }
3632
3810
  else if (route.name === 'pluginWriteTask' && plugin) {
3633
3811
  const retainedTargetKeys = route.targetKeys ? new Set(route.targetKeys) : undefined;
3634
- const candidates = props.controller.listPluginWriteTaskCandidates(route.goal)
3812
+ const allTaskCandidates = props.controller.listPluginWriteTaskCandidates(route.goal);
3813
+ const currentTargetKeys = new Set(allTaskCandidates.map(pluginWriteTaskTargetKey));
3814
+ const missingTargetKeys = route.targetKeys?.filter((targetKey) => !currentTargetKeys.has(targetKey)) ?? [];
3815
+ const candidates = allTaskCandidates
3635
3816
  .filter((candidate) => retainedTargetKeys
3636
3817
  ? retainedTargetKeys.has(pluginWriteTaskTargetKey(candidate))
3637
3818
  : candidate.target.pluginId === route.pluginId);
@@ -3648,14 +3829,134 @@ export function ConsoleAppV4(props) {
3648
3829
  const activationSources = [...new Set(blockedCandidates
3649
3830
  .filter((candidate) => candidate.blockReason === 'catalogActivationRequired')
3650
3831
  .map((candidate) => candidate.sourceId))];
3832
+ const taskIssues = new Map(props.controller.listUserIssues().map((issue) => [issue.issueId, issue]));
3833
+ const recoveryActions = [...new Map(blockedCandidates.flatMap((candidate) => {
3834
+ if (candidate.blockReason === 'catalogActivationRequired')
3835
+ return [];
3836
+ const target = candidate.target;
3837
+ const targetLabel = `${candidate.displayName} · ${platformLabel(target.platform)} · ${scopeLabel(target.scope)}`;
3838
+ const linkedIssue = candidate.issueIds.map((issueId) => taskIssues.get(issueId)).find(Boolean);
3839
+ const sourceIssue = linkedIssue && (linkedIssue.group === 'sourceRemoved'
3840
+ || linkedIssue.group === 'sourceUnavailable')
3841
+ ? linkedIssue
3842
+ : [...taskIssues.values()].find((entry) => (entry.category === 'sourceUnavailable' || entry.category === 'sourceMissing'
3843
+ || entry.category === 'sourceIdentityChanged')
3844
+ && (entry.sourceId === candidate.sourceId || entry.pluginId === target.pluginId));
3845
+ const issue = linkedIssue ?? sourceIssue;
3846
+ const candidatePlugin = props.controller.getPluginOverview(target.pluginId);
3847
+ const configField = issue?.requiredFields?.[0];
3848
+ if (issue?.group === 'config' || configField) {
3849
+ const fieldKey = configField?.key;
3850
+ return [[`config:${target.pluginId}:${fieldKey ?? ''}`, {
3851
+ label: fieldKey ? `完成设置:${configField?.title ?? fieldKey}` : `检查 ${candidate.displayName} 的设置`,
3852
+ description: `${targetLabel};保存后返回任务重新读取当前证据`,
3853
+ status: 'warning',
3854
+ run: () => {
3855
+ beginConfigFlow(target.pluginId);
3856
+ props.controller.replace({
3857
+ name: 'configLayer',
3858
+ pluginId: target.pluginId,
3859
+ ...(fieldKey ? { fieldKey } : {}),
3860
+ });
3861
+ },
3862
+ }]];
3863
+ }
3864
+ if (candidate.blockReason === 'restoreVersionChanged') {
3865
+ const updateCandidates = props.controller.listPluginWriteTaskCandidates('update')
3866
+ .filter((entry) => pluginWriteTaskTargetKey(entry) === pluginWriteTaskTargetKey(candidate));
3867
+ const updatePlugin = props.controller.getPluginOverview(target.pluginId);
3868
+ if (updateCandidates.length > 0 && updatePlugin) {
3869
+ return [[`update:${pluginWriteTaskTargetKey(candidate)}`, {
3870
+ label: `更新并恢复 ${targetLabel}…`,
3871
+ description: '改用当前候选版本建立新任务;进入 Review 前仍会重新检查证据',
3872
+ status: 'warning',
3873
+ run: () => props.controller.beginNestedPluginWriteTask({
3874
+ name: 'pluginWriteTask',
3875
+ pluginId: target.pluginId,
3876
+ goal: 'update',
3877
+ targetKeys: updateCandidates.map(pluginWriteTaskTargetKey),
3878
+ ...(route.batchOrigin ? { batchOrigin: route.batchOrigin } : {}),
3879
+ versionEvidence: pluginWriteTaskVersionEvidence(updatePlugin, updateCandidates),
3880
+ }),
3881
+ }]];
3882
+ }
3883
+ }
3884
+ if (sourceIssue || candidatePlugin?.sourceUnavailable) {
3885
+ const sourceId = sourceIssue?.sourceId ?? candidate.sourceId;
3886
+ const sourceNeedsIssue = sourceIssue?.category === 'sourceMissing'
3887
+ || sourceIssue?.category === 'sourceIdentityChanged'
3888
+ || sourceIssue?.group === 'sourceRemoved';
3889
+ if (sourceNeedsIssue && sourceIssue) {
3890
+ return [[`source-issue:${sourceIssue.issueId}`, {
3891
+ label: sourceIssue.category === 'sourceMissing'
3892
+ ? `重新添加来源 ${sourceId}` : `核对来源身份 ${sourceId}`,
3893
+ description: `${targetLabel};先查看来源问题的安全恢复选择`,
3894
+ status: 'warning',
3895
+ run: () => go({ name: 'issueDetail', issueId: sourceIssue.issueId }),
3896
+ }]];
3897
+ }
3898
+ return [[`source:${sourceId}`, {
3899
+ label: `核对来源 ${sourceId}`,
3900
+ description: `${targetLabel};修复或重新检查后返回任务`,
3901
+ status: 'warning',
3902
+ run: () => go({ name: 'sourceDetail', sourceId }),
3903
+ }]];
3904
+ }
3905
+ if (candidate.blockReason === 'restoreVersionUnverified'
3906
+ || candidate.blockReason === 'updateEvidenceIncomplete') {
3907
+ return [
3908
+ [`reload:${pluginWriteTaskTargetKey(candidate)}`, {
3909
+ label: `重新检查 ${targetLabel}`,
3910
+ description: '重新读取安装、版本、来源和受管内容证据',
3911
+ run: async () => {
3912
+ await props.controller.load();
3913
+ setFocus(0);
3914
+ setScrollOffset(0);
3915
+ },
3916
+ }],
3917
+ [`diagnostic:${pluginWriteTaskTargetKey(candidate)}`, {
3918
+ label: `查看 ${targetLabel} 的诊断`,
3919
+ description: '版本仍无法确认时查看组件状态和安全恢复方向',
3920
+ run: () => go({ name: 'pluginTechnical', pluginId: target.pluginId }),
3921
+ }],
3922
+ ];
3923
+ }
3924
+ if (issue) {
3925
+ return [[`issue:${issue.issueId}`, {
3926
+ label: `处理问题:${issue.title}`,
3927
+ description: `${targetLabel};只提供该问题允许的恢复动作`,
3928
+ status: 'warning',
3929
+ run: () => go({ name: 'issueDetail', issueId: issue.issueId }),
3930
+ }]];
3931
+ }
3932
+ if (candidate.blockReason === 'targetUnavailable'
3933
+ || candidate.blockReason === 'manualRecoveryRequired'
3934
+ || candidate.blockReason === 'taskActionConflict') {
3935
+ return [[`maintenance:${pluginWriteTaskTargetKey(candidate)}`, {
3936
+ label: `维护 ${targetLabel}`,
3937
+ description: '检查这个精确平台位置可用的安全维护动作',
3938
+ status: 'warning',
3939
+ run: () => go({ name: 'platform', pluginId: target.pluginId, platform: target.platform }),
3940
+ }]];
3941
+ }
3942
+ return [[`diagnostic:${target.pluginId}`, {
3943
+ label: `查看 ${candidate.displayName} 的诊断`,
3944
+ description: `${pluginWriteTaskBlockLabel(candidate.blockReason)};当前没有可安全自动执行的动作`,
3945
+ run: () => go({ name: 'pluginTechnical', pluginId: target.pluginId }),
3946
+ }]];
3947
+ })).values()];
3651
3948
  const taskLabel = pluginWriteTaskLabel(route.goal);
3652
3949
  const startTaskReview = (executionPolicy) => {
3950
+ if (missingTargetKeys.length > 0) {
3951
+ setActionError(`${missingTargetKeys.length} 个冻结位置已失效或无需处理。请返回调整后重新开始。`);
3952
+ return;
3953
+ }
3653
3954
  props.controller.startPluginWriteTaskReview({
3654
3955
  kind: 'pluginWriteTask',
3655
3956
  goal: route.goal,
3656
3957
  targets: candidates.map((candidate) => candidate.target),
3657
3958
  executionPolicy,
3658
- }, route);
3959
+ }, props.controller.pluginWriteTaskReturnRoute(route));
3659
3960
  setFocus(0);
3660
3961
  setScrollOffset(0);
3661
3962
  };
@@ -3673,13 +3974,15 @@ export function ConsoleAppV4(props) {
3673
3974
  ? 'PgUp/PgDn 查看全部位置 · ↑↓ 移动 · Enter 选择 · Esc 返回'
3674
3975
  : '↑↓ 移动 · Enter 选择 · Esc 返回';
3675
3976
  actions = [
3676
- ...(!versionEvidenceChanged && blockedCandidates.length === 0 && executable.length > 0 ? [{
3977
+ ...(missingTargetKeys.length === 0 && !versionEvidenceChanged
3978
+ && blockedCandidates.length === 0 && executable.length > 0 ? [{
3677
3979
  label: `确认${taskLabel} ${executable.length} 个位置…`,
3678
3980
  description: '进入统一影响确认;执行前再次检查当前证据',
3679
3981
  status: 'success',
3680
3982
  run: () => startTaskReview('all'),
3681
3983
  }] : []),
3682
- ...(!versionEvidenceChanged && blockedCandidates.length > 0 && executable.length > 0 ? [{
3984
+ ...(missingTargetKeys.length === 0 && !versionEvidenceChanged
3985
+ && blockedCandidates.length > 0 && executable.length > 0 ? [{
3683
3986
  label: `先处理可执行的 ${executable.length} 个位置…`,
3684
3987
  description: `${blockedCandidates.length} 个受阻位置保留,不会无声跳过`,
3685
3988
  status: 'warning',
@@ -3691,6 +3994,7 @@ export function ConsoleAppV4(props) {
3691
3994
  status: 'warning',
3692
3995
  run: () => beginReview({ kind: 'catalogGeneration', action: 'update', sourceId }),
3693
3996
  })),
3997
+ ...recoveryActions,
3694
3998
  ...(versionEvidenceChanged ? [{
3695
3999
  label: '使用当前版本重新准备…',
3696
4000
  description: '来源启用后的插件版本与先前候选不同;先明确采用当前版本',
@@ -3719,7 +4023,9 @@ export function ConsoleAppV4(props) {
3719
4023
  const taskViewport = actionViewport(actions, policy.mode === 'compact', focus, scrollOffset, pageActionBudget(Math.min(candidates.length, policy.mode === 'compact' ? 3 : 6) + 2));
3720
4024
  content = _jsxs(Box, { flexDirection: "column", children: [_jsxs(SectionCard, { title: "\u5904\u7406\u8303\u56F4", meta: `${executable.length} 个可执行 · ${blockedCandidates.length} 个需决定`, compact: policy.mode === 'compact', children: [taskTargetOffset > 0 ? _jsx(StaticRow, { title: `↑ 前面还有 ${taskTargetOffset} 个位置`, status: "muted", compact: true }) : null, targetRows.length > 0 ? targetRows
3721
4025
  : _jsx(StaticRow, { title: "\u5F53\u524D\u6CA1\u6709\u53EF\u6267\u884C\u76EE\u6807", status: "muted", compact: true }), candidates.length > taskTargetOffset + targetRows.length
3722
- ? _jsx(StaticRow, { title: `↓ 后面还有 ${candidates.length - taskTargetOffset - targetRows.length} 个位置`, status: "muted", compact: true }) : null] }), blockedCandidates.length > 0 ? _jsx(Callout, { status: "warning", title: `${blockedCandidates.length} 个位置需要先处理或决定`, description: "\u53EA\u6709\u9009\u62E9\u201C\u5148\u5904\u7406\u53EF\u6267\u884C\u9879\u201D\u624D\u4F1A\u5EFA\u7ACB\u5B50\u96C6 Review\u3002", compact: policy.mode === 'compact' }) : null, versionEvidenceChanged ? _jsx(Callout, { status: "warning", title: "\u63D2\u4EF6\u76EE\u6807\u7248\u672C\u5DF2\u53D8\u5316", description: "\u4E0D\u4F1A\u6CBF\u7528\u6765\u6E90\u542F\u7528\u524D\u7684\u7248\u672C\u8BC1\u636E\uFF0C\u4E5F\u4E0D\u4F1A\u81EA\u52A8\u6267\u884C\u65B0\u7248\u672C\u3002", compact: policy.mode === 'compact' }) : null, _jsx(SemanticRows, { actions: actions, focus: focus, kind: "action", compact: true, offset: taskViewport.offset, limit: taskViewport.limit })] });
4026
+ ? _jsx(StaticRow, { title: `↓ 后面还有 ${candidates.length - taskTargetOffset - targetRows.length} 个位置`, status: "muted", compact: true }) : null] }), blockedCandidates.length > 0 ? _jsx(Callout, { status: "warning", title: `${blockedCandidates.length} 个位置需要先处理或决定`, description: executable.length > 0
4027
+ ? '可以先处理可执行项,或进入下方对应入口处理受阻位置。'
4028
+ : '请进入下方对应入口处理;返回后会重新读取当前证据。', compact: policy.mode === 'compact' }) : null, missingTargetKeys.length > 0 ? _jsx(Callout, { status: "warning", title: `${missingTargetKeys.length} 个冻结位置已失效或无需处理`, description: "\u4E0D\u4F1A\u628A\u8FD9\u4E9B\u4F4D\u7F6E\u5F53\u4F5C\u5DF2\u5B8C\u6210\uFF0C\u4E5F\u4E0D\u4F1A\u81EA\u52A8\u7F29\u5C0F\u672C\u6B21\u8303\u56F4\u3002\u8BF7\u8FD4\u56DE\u8C03\u6574\u9009\u62E9\u540E\u91CD\u65B0\u5F00\u59CB\u3002", compact: policy.mode === 'compact' }) : null, versionEvidenceChanged ? _jsx(Callout, { status: "warning", title: "\u63D2\u4EF6\u76EE\u6807\u7248\u672C\u5DF2\u53D8\u5316", description: "\u4E0D\u4F1A\u6CBF\u7528\u6765\u6E90\u542F\u7528\u524D\u7684\u7248\u672C\u8BC1\u636E\uFF0C\u4E5F\u4E0D\u4F1A\u81EA\u52A8\u6267\u884C\u65B0\u7248\u672C\u3002", compact: policy.mode === 'compact' }) : null, _jsx(SemanticRows, { actions: actions, focus: focus, kind: "action", compact: true, offset: taskViewport.offset, limit: taskViewport.limit })] });
3723
4029
  }
3724
4030
  else if (route.name === 'pluginDetail' && plugin) {
3725
4031
  breadcrumb = `我的插件 / ${plugin.displayName}`;
@@ -4158,11 +4464,12 @@ export function ConsoleAppV4(props) {
4158
4464
  }
4159
4465
  else if (route.name === 'configLayer' && plugin) {
4160
4466
  breadcrumb = `${plugin.displayName} / 设置`;
4161
- const selectedField = route.fieldKey && configPage
4162
- ? configPage.groups.flatMap((group) => group.fields).find((field) => field.key === route.fieldKey)
4467
+ const configLayerDefinitionPage = configPage ?? configLayerPages.user ?? configLayerPages.project;
4468
+ const selectedField = route.fieldKey && configLayerDefinitionPage
4469
+ ? configLayerDefinitionPage.groups.flatMap((group) => group.fields).find((field) => field.key === route.fieldKey)
4163
4470
  : undefined;
4164
- const selectedPreset = route.presetId && configPage
4165
- ? configPage.presets.find((preset) => preset.id === route.presetId)
4471
+ const selectedPreset = route.presetId && configLayerDefinitionPage
4472
+ ? configLayerDefinitionPage.presets.find((preset) => preset.id === route.presetId)
4166
4473
  : undefined;
4167
4474
  title = selectedField ? `修改“${selectedField.title}”`
4168
4475
  : selectedPreset ? `应用“${selectedPreset.title}”` : `修改 ${plugin.displayName} 设置`;
@@ -4177,8 +4484,8 @@ export function ConsoleAppV4(props) {
4177
4484
  return true;
4178
4485
  if (selectedField)
4179
4486
  return !selectedField.sensitive && !selectedField.sharedNativeRender;
4180
- if (selectedPreset && configPage) {
4181
- const effectiveFields = configPage.groups.flatMap((group) => group.fields);
4487
+ if (selectedPreset && configLayerDefinitionPage) {
4488
+ const effectiveFields = configLayerDefinitionPage.groups.flatMap((group) => group.fields);
4182
4489
  return Object.keys(selectedPreset.values).some((key) => {
4183
4490
  const field = effectiveFields.find((candidate) => candidate.key === key);
4184
4491
  return Boolean(field && !field.sensitive && !field.sharedNativeRender);
@@ -6083,21 +6390,36 @@ export function ConsoleAppV4(props) {
6083
6390
  const taskOperation = operationKind === 'pluginWriteTask';
6084
6391
  const taskSummary = resultSummary?.task;
6085
6392
  const taskBlockedCount = taskSummary?.blockedTargets.length ?? 0;
6086
- const taskBatchOrigin = 'returnRoute' in result
6393
+ const frozenTaskReturnRoute = 'returnRoute' in result
6087
6394
  && result.returnRoute?.name === 'pluginWriteTask'
6088
- ? result.returnRoute.batchOrigin : undefined;
6089
- const taskReturnRoute = taskSummary?.requestedTargets[0] ? {
6090
- name: 'pluginWriteTask',
6091
- pluginId: taskSummary.requestedTargets[0].pluginId,
6092
- goal: taskSummary.goal,
6093
- targetKeys: taskSummary.requestedTargets.map((target) => [target.pluginId, target.platform, target.scope].join('\0')),
6094
- ...(taskBatchOrigin ? { batchOrigin: taskBatchOrigin } : {}),
6095
- ...(taskSummary.goal === 'update' ? {
6096
- versionEvidence: Object.fromEntries(taskSummary.requestedTargets.flatMap((target) => target.expectedUpdate
6097
- ? [[[target.pluginId, target.platform, target.scope].join('\0'), target.expectedUpdate.activeVersion]]
6098
- : [])),
6099
- } : {}),
6100
- } : undefined;
6395
+ ? result.returnRoute : undefined;
6396
+ const nestedTaskReturn = frozenTaskReturnRoute !== undefined
6397
+ && taskSummary !== undefined
6398
+ && frozenTaskReturnRoute.goal !== taskSummary.goal;
6399
+ const taskBatchOrigin = frozenTaskReturnRoute?.batchOrigin;
6400
+ const remainingTaskTargets = taskSummary?.blockedTargets.map((entry) => entry.target) ?? [];
6401
+ const taskReturnRoute = nestedTaskReturn
6402
+ ? frozenTaskReturnRoute
6403
+ : taskSummary && remainingTaskTargets[0] ? {
6404
+ name: 'pluginWriteTask',
6405
+ pluginId: remainingTaskTargets[0].pluginId,
6406
+ goal: taskSummary.goal,
6407
+ targetKeys: remainingTaskTargets.map((target) => [target.pluginId, target.platform, target.scope].join('\0')),
6408
+ ...(taskBatchOrigin ? { batchOrigin: taskBatchOrigin } : {}),
6409
+ ...(taskSummary.goal === 'update' ? {
6410
+ versionEvidence: 'returnRoute' in result
6411
+ && result.returnRoute?.name === 'pluginWriteTask'
6412
+ && result.returnRoute.versionEvidence
6413
+ ? result.returnRoute.versionEvidence
6414
+ : Object.fromEntries(remainingTaskTargets.flatMap((target) => target.expectedUpdate
6415
+ ? [[[target.pluginId, target.platform, target.scope].join('\0'), JSON.stringify({
6416
+ version: target.expectedUpdate.activeVersion,
6417
+ sourceIdentity: target.expectedUpdate.sourceIdentity,
6418
+ binding: target.expectedUpdate.generationKey,
6419
+ })]]
6420
+ : [])),
6421
+ } : {}),
6422
+ } : undefined;
6101
6423
  const batchOperation = operationKind === 'pluginWriteBatch';
6102
6424
  const portableOperation = operationKind === 'portableInstall';
6103
6425
  const portableReceipt = result.state === 'succeeded' || result.state === 'partial'
@@ -6319,6 +6641,12 @@ export function ConsoleAppV4(props) {
6319
6641
  ]
6320
6642
  : taskOperation
6321
6643
  ? [
6644
+ ...(nestedTaskReturn && taskReturnRoute ? [{
6645
+ label: '返回剩余恢复任务',
6646
+ description: '保留父任务的冻结目标;返回后重新读取当前证据',
6647
+ status: 'success',
6648
+ run: () => leaveResult(taskReturnRoute),
6649
+ }] : []),
6322
6650
  ...((partial || blocked) && (taskSummary?.retryTargets?.length ?? 0) > 0 ? [{
6323
6651
  label: `继续处理剩余 ${taskSummary.retryTargets.length} 项…`,
6324
6652
  description: '重新检查失败的精确目标和必要依赖,并进入新的确认页',
@@ -6334,14 +6662,14 @@ export function ConsoleAppV4(props) {
6334
6662
  status: 'warning',
6335
6663
  run: () => leaveResult(taskReturnRoute),
6336
6664
  }] : []),
6337
- { label: '完成,返回我的插件', run: () => {
6338
- if (taskBatchOrigin?.owner === 'batch-write'
6339
- && taskBatchOrigin.flowId === batchDraft?.flowId) {
6340
- setBatchDraft(undefined);
6341
- setDirty(false);
6342
- }
6343
- leaveResult({ name: 'pluginList', mode: 'installed' });
6344
- } },
6665
+ ...(!nestedTaskReturn ? [{ label: '完成,返回我的插件', run: () => {
6666
+ if (taskBatchOrigin?.owner === 'batch-write'
6667
+ && taskBatchOrigin.flowId === batchDraft?.flowId) {
6668
+ setBatchDraft(undefined);
6669
+ setDirty(false);
6670
+ }
6671
+ leaveResult({ name: 'pluginList', mode: 'installed' });
6672
+ } }] : []),
6345
6673
  { label: '查看本次记录', run: () => result.operationRecordId
6346
6674
  && go({ name: 'operationDiagnostic', recordId: result.operationRecordId }) },
6347
6675
  ]
@@ -6450,7 +6778,8 @@ export function ConsoleAppV4(props) {
6450
6778
  { label: '返回首页', run: () => requestSessionNavigation('home') },
6451
6779
  ]
6452
6780
  : [{ label: partial || blockedDependencyFailure ? '查看失败项' : successfulConfigOperation && configOrigin
6453
- ? configOrigin?.name === 'pluginPreview' ? '返回插件预览' : '返回插件详情'
6781
+ ? configOrigin.name === 'pluginPreview' ? '返回插件预览'
6782
+ : configOrigin.name === 'pluginWriteTask' ? '返回恢复任务' : '返回插件详情'
6454
6783
  : configOperation ? '查看插件'
6455
6784
  : action === 'uninstall' || action === 'cleanup' ? '返回插件详情' : '查看插件', run: () => {
6456
6785
  if (successfulConfigOperation && configOrigin) {
@@ -6745,6 +7074,22 @@ export function ConsoleAppV4(props) {
6745
7074
  const targetActionSignature = route.name === 'installTargets' && route.view !== 'blocked'
6746
7075
  ? actions.map((action) => String(Boolean(action.disabled))).join(',')
6747
7076
  : '';
7077
+ const batchSelectionActionSignature = route.name === 'batchSelection'
7078
+ && batchDraft?.stage === 'select'
7079
+ ? actions.map((action) => action.pluginId ?? action.label).join('\0')
7080
+ : '';
7081
+ useEffect(() => {
7082
+ if (!batchSelectionActionSignature)
7083
+ return;
7084
+ const lastActionIndex = Math.max(0, actions.length - 1);
7085
+ if (focus > lastActionIndex) {
7086
+ setFocus(lastActionIndex);
7087
+ setScrollOffset(lastActionIndex);
7088
+ return;
7089
+ }
7090
+ if (scrollOffset > lastActionIndex)
7091
+ setScrollOffset(focus);
7092
+ }, [batchSelectionActionSignature, focus, scrollOffset]);
6748
7093
  useEffect(() => {
6749
7094
  if (!targetActionSignature || !actions[focus]?.disabled)
6750
7095
  return;
@@ -6851,6 +7196,9 @@ export function ConsoleAppV4(props) {
6851
7196
  setOverlayFocus(0);
6852
7197
  setOverlay('sessionNavigation');
6853
7198
  }, [props.pendingSessionNavigation, props.pendingSessionNavigationDrafts]);
7199
+ const normalizedEffectiveFocus = route.name === 'batchSelection' && batchDraft?.stage === 'select'
7200
+ ? Math.min(effectiveFocus, Math.max(0, actions.length - 1))
7201
+ : effectiveFocus;
6854
7202
  useInput((input, key) => {
6855
7203
  if (!overlay && route.name === 'reviewPreparing' && snapshot.reviewPreparation?.state === 'failed'
6856
7204
  && (key.escape || input.toLowerCase() === 'b')) {
@@ -7598,7 +7946,7 @@ export function ConsoleAppV4(props) {
7598
7946
  showSelected: false,
7599
7947
  } : draft);
7600
7948
  }
7601
- setFocus(0);
7949
+ setFocus(route.name === 'batchSelection' ? 1 : 0);
7602
7950
  setScrollOffset(0);
7603
7951
  return;
7604
7952
  }
@@ -7624,8 +7972,18 @@ export function ConsoleAppV4(props) {
7624
7972
  next.add(row.pluginId);
7625
7973
  if (key.leftArrow && row.canExpand)
7626
7974
  next.delete(row.pluginId);
7975
+ const nextProjection = buildDependencyGraphProjection({
7976
+ overviews: props.controller.listPluginDependencyOverviews(),
7977
+ rootPluginId: route.pluginId,
7978
+ direction: route.direction ?? 'dependencies',
7979
+ expandedPluginIds: next,
7980
+ });
7981
+ const nextFocus = Math.max(0, nextProjection.rows.findIndex((candidate) => candidate.id === row.id));
7982
+ const nextScroll = Math.min(scrollOffset, nextFocus);
7627
7983
  setExpandedDependencyPluginIds(next);
7628
- setScrollOffset(0);
7984
+ setFocus(nextFocus);
7985
+ setScrollOffset(nextScroll);
7986
+ persistDependencyGraphContext(route, nextProjection, next, nextFocus, nextScroll);
7629
7987
  return;
7630
7988
  }
7631
7989
  const sessionNavigation = resolveTuiSessionNavigationKey({ input, key }, { characterInput: isCharacterInputMode(route, searchFocus) });
@@ -7706,10 +8064,7 @@ export function ConsoleAppV4(props) {
7706
8064
  openBatchSelection(route.mode === 'browse' ? 'install' : undefined);
7707
8065
  }
7708
8066
  else if (route.name === 'pluginList' && route.mode === 'installed' && input.toLowerCase() === 'a') {
7709
- const projectRoots = [...new Set(props.controller.listPluginWriteTaskCandidates('restore')
7710
- .filter((candidate) => candidate.target.scope === 'project')
7711
- .map((candidate) => candidate.target.pluginId))];
7712
- openSelectedPluginTask('restore', projectRoots, 'project');
8067
+ prepareCurrentProjectPlugins();
7713
8068
  }
7714
8069
  else if (route.name === 'batchSelection' && batchDraft?.stage === 'select' && input.toLowerCase() === 'a') {
7715
8070
  const candidates = batchCandidates(batchDraft)
@@ -7724,6 +8079,11 @@ export function ConsoleAppV4(props) {
7724
8079
  }
7725
8080
  else if (route.name === 'batchSelection' && batchDraft?.stage === 'select' && input.toLowerCase() === 'c') {
7726
8081
  if (batchDraft.selectedPluginIds.length > 0) {
8082
+ const invalid = invalidBatchSelection(batchDraft);
8083
+ if (invalid.length > 0) {
8084
+ setActionError(`${invalid.length} 个已选插件已不可用。请先在“查看全部已选”中取消选择。`);
8085
+ return;
8086
+ }
7727
8087
  if (batchDraft.mode === 'browse') {
7728
8088
  setBatchDraft((current) => current ? { ...current, action: 'install' } : current);
7729
8089
  props.controller.push({ name: 'batchPlacement' });
@@ -7737,9 +8097,11 @@ export function ConsoleAppV4(props) {
7737
8097
  setActionError('请至少选择一个插件。');
7738
8098
  }
7739
8099
  else if (route.name === 'batchSelection' && batchDraft?.stage === 'select' && input === ' ') {
7740
- const action = actions[effectiveFocus];
7741
- if (action?.pluginId && action.selectable)
8100
+ const action = actions[normalizedEffectiveFocus];
8101
+ if (action?.pluginId
8102
+ && (batchDraft.selectedPluginIds.includes(action.pluginId) || action.canSelectNew)) {
7742
8103
  toggleBatchPlugin(action.pluginId);
8104
+ }
7743
8105
  }
7744
8106
  else if (route.name === 'batchSelection' && batchDraft?.stage === 'select' && input === '/') {
7745
8107
  setSearchFocus(true);
@@ -7755,7 +8117,7 @@ export function ConsoleAppV4(props) {
7755
8117
  ...(next === 'all' ? { sourceId: undefined } : { sourceId: next }),
7756
8118
  showSelected: false,
7757
8119
  } : draft);
7758
- setFocus(0);
8120
+ setFocus(1);
7759
8121
  setScrollOffset(0);
7760
8122
  }
7761
8123
  else if (route.name === 'configFields' && route.layer.scope !== 'effective' && input.toLowerCase() === 's') {
@@ -7812,7 +8174,8 @@ export function ConsoleAppV4(props) {
7812
8174
  }
7813
8175
  else if (key.upArrow || input.toLowerCase() === 'k') {
7814
8176
  setFocus((value) => {
7815
- const current = dynamicActionIds ? effectiveFocus : value;
8177
+ const current = route.name === 'batchSelection' && batchDraft?.stage === 'select'
8178
+ ? normalizedEffectiveFocus : dynamicActionIds ? effectiveFocus : value;
7816
8179
  const next = pluginRows.length > 0 ? moveIndex(pluginRows.length, current, -1) : moveFocus(actions, current, -1);
7817
8180
  if (dynamicActionIds && dynamicActionSignature !== undefined) {
7818
8181
  dynamicMenuFocus.current = {
@@ -7821,17 +8184,31 @@ export function ConsoleAppV4(props) {
7821
8184
  focusedActionId: dynamicActionIds[next],
7822
8185
  };
7823
8186
  }
7824
- const viewport = route.name === 'portableSkillSelect' && snapshot.portableDraft
7825
- ? portableSkillCandidateViewport(actions.slice(0, snapshot.portableDraft.candidates.length), snapshot.portableDraft.candidates, policy.mode === 'compact', next, scrollOffset, pageActionBudget(actions.length - snapshot.portableDraft.candidates.length
7826
- + (portableVariantCandidates.length > 0 ? 3 : 0)))
7827
- : actionViewport(actions, policy.mode === 'compact', next, scrollOffset, pageActionBudget(actionViewportStaticRows));
7828
- setScrollOffset(viewport.offset);
8187
+ const viewport = route.name === 'batchSelection' && batchDraft?.stage === 'select'
8188
+ ? actionViewport(actions.slice(1), policy.mode === 'compact', Math.max(0, next - 1), Math.max(0, scrollOffset - 1), Math.max(1, pageActionBudget(5)))
8189
+ : route.name === 'portableSkillSelect' && snapshot.portableDraft
8190
+ ? portableSkillCandidateViewport(actions.slice(0, snapshot.portableDraft.candidates.length), snapshot.portableDraft.candidates, policy.mode === 'compact', next, scrollOffset, pageActionBudget(actions.length - snapshot.portableDraft.candidates.length
8191
+ + (portableVariantCandidates.length > 0 ? 3 : 0)))
8192
+ : actionViewport(actions, policy.mode === 'compact', next, scrollOffset, pageActionBudget(actionViewportStaticRows));
8193
+ if (route.name === 'pluginDependencies' && route.view !== 'removeBlocked') {
8194
+ const projection = buildDependencyGraphProjection({
8195
+ overviews: props.controller.listPluginDependencyOverviews(),
8196
+ rootPluginId: route.pluginId,
8197
+ direction: route.direction ?? 'dependencies',
8198
+ ...(expandedDependencyPluginIds ? { expandedPluginIds: expandedDependencyPluginIds } : {}),
8199
+ });
8200
+ persistDependencyGraphContext(route, projection, expandedDependencyPluginIds ?? new Set(projection.defaultExpandedPluginIds), next, viewport.offset);
8201
+ }
8202
+ setScrollOffset(route.name === 'batchSelection' && batchDraft?.stage === 'select'
8203
+ ? next === 0 ? 0 : viewport.offset + 1
8204
+ : viewport.offset);
7829
8205
  return next;
7830
8206
  });
7831
8207
  }
7832
8208
  else if (key.downArrow || input.toLowerCase() === 'j') {
7833
8209
  setFocus((value) => {
7834
- const current = dynamicActionIds ? effectiveFocus : value;
8210
+ const current = route.name === 'batchSelection' && batchDraft?.stage === 'select'
8211
+ ? normalizedEffectiveFocus : dynamicActionIds ? effectiveFocus : value;
7835
8212
  const next = pluginRows.length > 0 ? moveIndex(pluginRows.length, current, 1) : moveFocus(actions, current, 1);
7836
8213
  if (dynamicActionIds && dynamicActionSignature !== undefined) {
7837
8214
  dynamicMenuFocus.current = {
@@ -7840,11 +8217,24 @@ export function ConsoleAppV4(props) {
7840
8217
  focusedActionId: dynamicActionIds[next],
7841
8218
  };
7842
8219
  }
7843
- const viewport = route.name === 'portableSkillSelect' && snapshot.portableDraft
7844
- ? portableSkillCandidateViewport(actions.slice(0, snapshot.portableDraft.candidates.length), snapshot.portableDraft.candidates, policy.mode === 'compact', next, scrollOffset, pageActionBudget(actions.length - snapshot.portableDraft.candidates.length
7845
- + (portableVariantCandidates.length > 0 ? 3 : 0)))
7846
- : actionViewport(actions, policy.mode === 'compact', next, scrollOffset, pageActionBudget(actionViewportStaticRows));
7847
- setScrollOffset(viewport.offset);
8220
+ const viewport = route.name === 'batchSelection' && batchDraft?.stage === 'select'
8221
+ ? actionViewport(actions.slice(1), policy.mode === 'compact', Math.max(0, next - 1), Math.max(0, scrollOffset - 1), Math.max(1, pageActionBudget(5)))
8222
+ : route.name === 'portableSkillSelect' && snapshot.portableDraft
8223
+ ? portableSkillCandidateViewport(actions.slice(0, snapshot.portableDraft.candidates.length), snapshot.portableDraft.candidates, policy.mode === 'compact', next, scrollOffset, pageActionBudget(actions.length - snapshot.portableDraft.candidates.length
8224
+ + (portableVariantCandidates.length > 0 ? 3 : 0)))
8225
+ : actionViewport(actions, policy.mode === 'compact', next, scrollOffset, pageActionBudget(actionViewportStaticRows));
8226
+ if (route.name === 'pluginDependencies' && route.view !== 'removeBlocked') {
8227
+ const projection = buildDependencyGraphProjection({
8228
+ overviews: props.controller.listPluginDependencyOverviews(),
8229
+ rootPluginId: route.pluginId,
8230
+ direction: route.direction ?? 'dependencies',
8231
+ ...(expandedDependencyPluginIds ? { expandedPluginIds: expandedDependencyPluginIds } : {}),
8232
+ });
8233
+ persistDependencyGraphContext(route, projection, expandedDependencyPluginIds ?? new Set(projection.defaultExpandedPluginIds), next, viewport.offset);
8234
+ }
8235
+ setScrollOffset(route.name === 'batchSelection' && batchDraft?.stage === 'select'
8236
+ ? next === 0 ? 0 : viewport.offset + 1
8237
+ : viewport.offset);
7848
8238
  return next;
7849
8239
  });
7850
8240
  }
@@ -7858,7 +8248,7 @@ export function ConsoleAppV4(props) {
7858
8248
  : { name: 'pluginDetail', pluginId: row.pluginId });
7859
8249
  }
7860
8250
  else if (key.return) {
7861
- runAction(actions[effectiveFocus]);
8251
+ runAction(actions[normalizedEffectiveFocus]);
7862
8252
  }
7863
8253
  });
7864
8254
  if (tooSmall) {