dsh-config-manager 0.1.76 → 0.1.78

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 (84) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.d.ts +16 -0
  6. package/lib/adapters/plugins.js +223 -16
  7. package/lib/client.d.ts +104 -9
  8. package/lib/client.js +1800 -718
  9. package/lib/core/analyzer.js +3 -1
  10. package/lib/core/backup.d.ts +9 -0
  11. package/lib/core/backup.js +16 -1
  12. package/lib/core/messages.d.ts +11 -0
  13. package/lib/core/messages.js +22 -0
  14. package/lib/core/plugin-cli.d.ts +26 -0
  15. package/lib/core/plugin-cli.js +62 -2
  16. package/lib/core/rollback-attribution.d.ts +81 -0
  17. package/lib/core/rollback-attribution.js +236 -0
  18. package/lib/core/rollback-merge.d.ts +22 -0
  19. package/lib/core/rollback-merge.js +203 -0
  20. package/lib/core/rollback-point.d.ts +114 -0
  21. package/lib/core/rollback-point.js +534 -17
  22. package/lib/core/types.d.ts +28 -0
  23. package/lib/index.d.ts +26 -3
  24. package/lib/index.js +129 -16
  25. package/lib/routes/recovery.js +11 -0
  26. package/lib/schema/section-registry.js +5 -5
  27. package/lib/schema/types.d.ts +14 -0
  28. package/lib/ui/badge-tone.d.ts +16 -0
  29. package/lib/ui/badge-tone.js +20 -0
  30. package/lib/ui/i18n.d.ts +4 -4
  31. package/lib/ui/i18n.js +8 -8
  32. package/lib/ui/rollback-view.d.ts +95 -0
  33. package/lib/ui/rollback-view.js +55 -0
  34. package/lib/ui/selection-model.d.ts +140 -1
  35. package/lib/ui/selection-model.js +241 -9
  36. package/package.json +1 -1
  37. package/src/adapters/plugin-data-declared.ts +60 -0
  38. package/src/adapters/plugin-data.ts +7 -33
  39. package/src/adapters/plugins.test.ts +162 -0
  40. package/src/adapters/plugins.ts +236 -17
  41. package/src/adapters/units.test.ts +26 -0
  42. package/src/client/chat/ChatEntryView.tsx +147 -46
  43. package/src/client/chat/chat-entry-view.test.ts +277 -1
  44. package/src/client/chat/chat-entry-view.ts +98 -4
  45. package/src/client/chat/chat-node-registration.ts +71 -0
  46. package/src/client/chat/chat-slots.ts +30 -12
  47. package/src/client/common/ContentPicker.tsx +581 -175
  48. package/src/client/common/host-primitives.ts +64 -1
  49. package/src/client/common/ui.tsx +17 -3
  50. package/src/client/config-manager.module.css +630 -397
  51. package/src/client/css-scale-guard.test.ts +41 -3
  52. package/src/client/css-token-guard.test.ts +10 -4
  53. package/src/client/export/ExportView.tsx +2 -0
  54. package/src/client/index.ts +15 -3
  55. package/src/client/locales.ts +54 -10
  56. package/src/client/recovery/RollbackCard.tsx +56 -2
  57. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  58. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  59. package/src/client/recovery/recovery-locales.ts +22 -0
  60. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  61. package/src/core/analyzer.ts +3 -1
  62. package/src/core/backup.ts +16 -1
  63. package/src/core/messages.ts +22 -0
  64. package/src/core/plugin-cli.fs.test.ts +65 -3
  65. package/src/core/plugin-cli.ts +55 -2
  66. package/src/core/rollback-attribution.test.ts +120 -0
  67. package/src/core/rollback-attribution.ts +267 -0
  68. package/src/core/rollback-merge.test.ts +133 -0
  69. package/src/core/rollback-merge.ts +213 -0
  70. package/src/core/rollback-point.test.ts +412 -0
  71. package/src/core/rollback-point.ts +560 -20
  72. package/src/core/types.ts +29 -0
  73. package/src/index.facade.test.ts +60 -0
  74. package/src/index.ts +133 -19
  75. package/src/routes/recovery.ts +11 -0
  76. package/src/schema/section-registry.ts +5 -5
  77. package/src/schema/types.ts +14 -0
  78. package/src/ui/badge-tone.test.ts +23 -0
  79. package/src/ui/badge-tone.ts +27 -0
  80. package/src/ui/i18n.ts +8 -8
  81. package/src/ui/rollback-view.test.ts +27 -0
  82. package/src/ui/rollback-view.ts +139 -0
  83. package/src/ui/selection-model.test.ts +240 -2
  84. package/src/ui/selection-model.ts +349 -1
@@ -15,7 +15,8 @@
15
15
  * 单元 id 由宿主侧 ConfigAdapter.listUnits() 产出,与导入侧 PlanItem.id 同一命名空间。
16
16
  */
17
17
  import type { SectionId } from '../schema/types.ts'
18
- import type { ImportPlan, PlanItem, PlanItemKind, Portability } from '../core/types.ts'
18
+ import type { ImportPlan, PlanItem, PlanItemKind, PluginAreaRef, Portability } from '../core/types.ts'
19
+ import type { ExportGroup } from './types.ts'
19
20
  // 运行时依赖:会话 id 归一化(零依赖纯函数,客户端 bundle 可安全内联)
20
21
  import { projectKeyOf, sessionIdKey } from '../core/session-select.ts'
21
22
 
@@ -66,6 +67,14 @@ export interface SelectionUnit {
66
67
  * 而不是把 `session-<uuid>` 当标题(用户要求「始终用对话标题」)。文案由界面按当前语言解析。
67
68
  */
68
69
  blank?: boolean
70
+ /**
71
+ * 该插件**声明的**第三方数据区域(宿主下发;只有 `plugin:<包名>` 单元可能有值)。
72
+ *
73
+ * 用途:「按插件看」视图的树骨架 —— 静态知识(内置来源表 + 插件自述 dsh.dataDirs),
74
+ * **零数据目录 I/O**;体积与「磁盘上到底有没有」等该分区清单到货后再填。
75
+ * 纯展示字段,不参与勾选/导出契约(契约只认 id)。缺省 = 该插件没有已知关联(不猜)。
76
+ */
77
+ relatedAreas?: readonly PluginAreaRef[]
69
78
  }
70
79
 
71
80
  /** 一个分区及其可勾选单元;units 为空 = 本分区不可细分(UI 只给整体开关)。 */
@@ -95,6 +104,14 @@ export interface SelectionSection {
95
104
  portability?: Portability
96
105
  /** 涉及秘密状态(如 credentialsStatus):安全提示用,绝不显示值。 */
97
106
  sensitive?: boolean
107
+ /**
108
+ * 分区所属的**导出分组**(`ui/types.ts` 的 EXPORT_GROUPS)。
109
+ *
110
+ * 用途:选择器在分组切换处插入标题行(General / 插件与扩展 / …)。渲染顺序**由调用方给的顺序
111
+ * 决定**(宿主目录本来已按分组排好,UI 不重排)。缺省 = 不渲染分组标题 ——
112
+ * 导入向导与市场通道的节点没有这个概念,行为逐像素不变。
113
+ */
114
+ group?: ExportGroup
98
115
  }
99
116
 
100
117
  /** 唯一的导出选择状态。 */
@@ -187,6 +204,8 @@ export function sectionsFromPreview(
187
204
  ...(u.projectKey !== undefined ? { projectKey: u.projectKey } : {}),
188
205
  ...(u.parentSessionId !== undefined ? { parentSessionId: u.parentSessionId } : {}),
189
206
  ...(u.blank === true ? { blank: true } : {}),
207
+ // 「按插件看」的树骨架(宿主下发的静态区域清单;缺省 = 该插件没有已知关联)
208
+ ...(u.relatedAreas !== undefined && u.relatedAreas.length > 0 ? { relatedAreas: u.relatedAreas } : {}),
190
209
  })),
191
210
  }))
192
211
  }
@@ -973,3 +992,332 @@ export function effectiveImportPlan(
973
992
  它们住在 src/ui/market-import.ts,不在这里再长第二套选择逻辑。
974
993
  ---------------------------------------------------------------------------------- */
975
994
 
995
+ /* ----------------------------------------------------------------------------------
996
+ 「按插件看」第二视图(纯展示模型,node 可测)
997
+ ----------------------------------------------------------------------------------
998
+ 为什么要它:插件相关的东西在导出目录里是**四处分身** —— plugins 里是「装了哪些」,
999
+ pluginFiles / pluginData / pluginAssets 里是它散落在别处的文件与数据。按分区平铺时,
1000
+ 「勾这个插件到底会带走什么」只能靠用户自己在两三个分区之间对照,容易漏带、也容易多带。
1001
+
1002
+ 四条不变量:
1003
+ ① **只做视图**:叶子仍然是原来的单元 id,勾选仍走 toggleUnit / unitChecked —— 分区勾选语义一字不改;
1004
+ ② **不猜归属**:只有宿主下发了 `relatedAreas`(内置来源表 + 插件自述 dsh.dataDirs)且包名对得上
1005
+ 才进插件节点;pluginFiles 的单元没有任何包名信息 → 一律进「未归属」桶(绝不按目录名/文件名猜);
1006
+ ③ **「注册表说有」与「磁盘上真有」分开渲染**:区域清单是静态知识,该分区清单没读到时叶子是
1007
+ `unread`(不可勾,展开时才读);读到但该 id 不在清单里是 `absent`(不可勾,明确标「无内容」)——
1008
+ 否则会出现「勾了却没导出」的幽灵勾选(引擎只带走清单里真实存在的单元);
1009
+ ④ **模型不出文案**:`label` 一律是宿主直出文本(包名 / home 相对路径),界面按 kind 取 i18n 文案。
1010
+ ---------------------------------------------------------------------------------- */
1011
+
1012
+ /** 树上一个叶子的种类:插件清单条目 / 配置类数据区域 / 大文件区域 */
1013
+ export type PluginLeafKind = 'plugin' | 'config' | 'assets'
1014
+
1015
+ export interface PluginTreeLeaf {
1016
+ kind: PluginLeafKind
1017
+ section: SectionId
1018
+ /** 单元 id(present 时可勾;unread / absent 时只是占位,不可勾) */
1019
+ unitId: string
1020
+ /** 宿主直出文本:插件包名 / 区域 home 相对路径 */
1021
+ label: string
1022
+ /** 单元副标题(版本等;只有已读到清单时才有) */
1023
+ detail?: string
1024
+ sizeBytes: number
1025
+ /** present=清单里确有该单元(可勾);unread=该分区清单还没读;absent=已读但没有这个单元 */
1026
+ state: 'present' | 'unread' | 'absent'
1027
+ }
1028
+
1029
+ export interface PluginTreeEntry {
1030
+ /** 插件包名(= plugins 单元 id 的 `plugin:` 后缀) */
1031
+ name: string
1032
+ /** 插件清单条目(该分区还没读到清单时为 undefined) */
1033
+ main?: PluginTreeLeaf
1034
+ /** 该插件声明的数据区域(宿主静态表;可能为空数组) */
1035
+ areas: PluginTreeLeaf[]
1036
+ /** main + areas(渲染顺序:清单条目在前,区域按宿主声明顺序) */
1037
+ leaves: PluginTreeLeaf[]
1038
+ }
1039
+
1040
+ /** 「无法归属到具体插件」的桶:整块来自某个分区(不猜归属)。 */
1041
+ export interface PluginTreeBucket {
1042
+ section: SectionId
1043
+ /** 该分区的清单是否已读到(false = 还没读 / 读取失败,界面给「读取该分区」入口) */
1044
+ loaded: boolean
1045
+ units: SelectionUnit[]
1046
+ }
1047
+
1048
+ export interface PluginTree {
1049
+ /** 插件清单是否已读到;未读到时 entries 为空(界面提示先读取插件分区) */
1050
+ pluginsLoaded: boolean
1051
+ entries: PluginTreeEntry[]
1052
+ /** 桶:无归属的分区单元(插件的补丁行 / pnpm-workspace.yaml;pluginFiles 整块) */
1053
+ buckets: PluginTreeBucket[]
1054
+ }
1055
+
1056
+ /**
1057
+ * 合并进「按插件看」那棵树的分区(导出选择器里「插件与扩展」组的成员)。
1058
+ *
1059
+ * 这四项的归属键只有 npm 包名(plugin:<包名> ↔ 来源注册表的 packages);self(配置管理器设置)
1060
+ * **不在**这里 —— 它不是插件,仍按普通分区行渲染。清单顺序仍由调用方给的节点顺序决定
1061
+ * (宿主目录已按 EXPORT_GROUPS 排好,树只在第一个成员处渲染一次)。
1062
+ */
1063
+ export const PLUGIN_TREE_SECTIONS: readonly SectionId[] = ['plugins', 'pluginFiles', 'pluginData', 'pluginAssets']
1064
+
1065
+ /** 该分区是否合并进「按插件看」的树(见 PLUGIN_TREE_SECTIONS)。 */
1066
+ export function isPluginTreeSection(section: SectionId): boolean {
1067
+ return PLUGIN_TREE_SECTIONS.includes(section)
1068
+ }
1069
+
1070
+ /** 插件单元 id 前缀(与 adapters/units.ts 的 `plugin:<包名>` 同一命名空间) */
1071
+ const PLUGIN_UNIT_PREFIX = 'plugin:'
1072
+
1073
+ /** 把一条区域引用投影成叶子(按该分区清单是否到货决定 present / unread / absent)。 */
1074
+ function areaLeaf(node: SelectionSection | undefined, ref: PluginAreaRef): PluginTreeLeaf {
1075
+ const unitId = ref.section + ':' + ref.relPath
1076
+ const kind: PluginLeafKind = ref.role === 'assets' ? 'assets' : 'config'
1077
+ const unit = node?.units.find((u) => u.id === unitId)
1078
+ if (unit === undefined) {
1079
+ // 分区清单没读到 → unread(展开时才去读);读到了却没有这个单元 → absent(注册表说有、盘上没有)
1080
+ return {
1081
+ kind, section: ref.section, unitId, label: ref.relPath, sizeBytes: 0,
1082
+ state: node === undefined || node.unitsUnread === true ? 'unread' : 'absent',
1083
+ }
1084
+ }
1085
+ return {
1086
+ kind, section: ref.section, unitId, label: ref.relPath,
1087
+ ...(unit.detail !== undefined ? { detail: unit.detail } : {}),
1088
+ sizeBytes: unit.sizeBytes,
1089
+ state: 'present',
1090
+ }
1091
+ }
1092
+
1093
+ /** 把选择器分区模型投影成「按插件看」的树(纯函数)。 */
1094
+ export function pluginTree(nodes: readonly SelectionSection[]): PluginTree {
1095
+ const bySection = new Map<SectionId, SelectionSection>()
1096
+ for (const n of nodes) bySection.set(n.section, n)
1097
+ const pluginsNode = bySection.get('plugins')
1098
+ const entries: PluginTreeEntry[] = []
1099
+ const extras: SelectionUnit[] = []
1100
+ for (const unit of pluginsNode?.units ?? []) {
1101
+ if (!unit.id.startsWith(PLUGIN_UNIT_PREFIX)) {
1102
+ // 补丁行 / pnpm-workspace.yaml:与插件无归属关系,进桶(在插件视图里也必须可见可选)
1103
+ extras.push(unit)
1104
+ continue
1105
+ }
1106
+ const name = unit.id.slice(PLUGIN_UNIT_PREFIX.length)
1107
+ const main: PluginTreeLeaf = {
1108
+ kind: 'plugin', section: 'plugins', unitId: unit.id, label: unit.label,
1109
+ ...(unit.detail !== undefined ? { detail: unit.detail } : {}),
1110
+ sizeBytes: unit.sizeBytes,
1111
+ state: 'present',
1112
+ }
1113
+ const areas = (unit.relatedAreas ?? []).map((ref) => areaLeaf(bySection.get(ref.section), ref))
1114
+ entries.push({ name, main, areas, leaves: [main, ...areas] })
1115
+ }
1116
+ const buckets: PluginTreeBucket[] = []
1117
+ const pluginExtras = bySection.get('plugins')
1118
+ if (pluginExtras !== undefined) {
1119
+ buckets.push({ section: 'plugins', loaded: pluginExtras.unitsUnread !== true, units: extras })
1120
+ }
1121
+ /**
1122
+ * 只留一个桶:pluginFiles。
1123
+ *
1124
+ * 它没有任何包名信息(白名单文件 + `plugin-config/<任意目录名>/`),按目录名猜包名 = 猜错就在树里
1125
+ * 勾错东西 ⇒ 整块归「未归属」。**pluginData / pluginAssets 里没被插件声明认领的单元不再单列
1126
+ * 「遗留数据」桶**(用户 2026-10-17 明确要求:不需要看到这些)—— 它们仍会随该分区整体勾选导出,
1127
+ * 只是不在插件树里单开一行。
1128
+ */
1129
+ for (const id of ['pluginFiles'] as const) {
1130
+ const node = bySection.get(id)
1131
+ if (node === undefined) continue
1132
+ buckets.push({ section: id, loaded: node.unitsUnread !== true, units: node.units })
1133
+ }
1134
+ return { pluginsLoaded: pluginsNode !== undefined && pluginsNode.unitsUnread !== true, entries, buckets }
1135
+ }
1136
+
1137
+ /** 树上的搜索过滤:插件名命中 → 保留它全部叶子;否则只保留标签命中的叶子;桶按单元标签过滤。 */
1138
+ export function filterPluginTree(tree: PluginTree, query: string): PluginTree {
1139
+ const q = query.trim().toLowerCase()
1140
+ if (q === '') return tree
1141
+ const hit = (text: string): boolean => text.toLowerCase().includes(q)
1142
+ const entries = tree.entries
1143
+ .map((e) => (hit(e.name) ? e : { ...e, leaves: e.leaves.filter((l) => hit(l.label) || hit(l.detail ?? '')) }))
1144
+ .filter((e) => hit(e.name) || e.leaves.length > 0)
1145
+ const buckets = tree.buckets
1146
+ .map((b) => ({ ...b, units: b.units.filter((u) => hit(u.label) || hit(u.detail ?? '')) }))
1147
+ .filter((b) => b.units.length > 0)
1148
+ return { ...tree, entries, buckets }
1149
+ }
1150
+
1151
+ /** 该插件节点下**真实可勾选**的叶子(unread / absent 不参与勾选)。 */
1152
+ export function pluginEntryLeaves(entry: PluginTreeEntry): PluginTreeLeaf[] {
1153
+ return entry.leaves.filter((l) => l.state === 'present')
1154
+ }
1155
+
1156
+ /**
1157
+ * 该插件名下**已知**的叶子(存在 + 还没读到清单的区域声明)。
1158
+ *
1159
+ * 计数与三态用它,**不用 pluginEntryLeaves(只含 present)**:否则「已选 n/m」会随清单到货跳动 ——
1160
+ * 折叠时显示 1/1、展开(触发了读取)后变成 1/3,用户看到的是「数字自己变了」(2026-10-17 实测反馈)。
1161
+ * absent(注册表说有、盘上确实没有)不计入:它永远不可勾,算进去只会让分母虚高。
1162
+ */
1163
+ export function pluginEntryKnownLeaves(entry: PluginTreeEntry): PluginTreeLeaf[] {
1164
+ return entry.leaves.filter((l) => l.state !== 'absent')
1165
+ }
1166
+
1167
+ export function pluginEntryTotalCount(entry: PluginTreeEntry): number {
1168
+ return pluginEntryKnownLeaves(entry).length
1169
+ }
1170
+
1171
+ export function pluginEntrySelectedCount(sel: Selection, entry: PluginTreeEntry): number {
1172
+ return pluginEntryLeaves(entry).reduce((n, l) => n + (unitChecked(sel, l.section, l.unitId) ? 1 : 0), 0)
1173
+ }
1174
+
1175
+ /**
1176
+ * 插件节点三态(与分区/分组同一套口径,但分母用**已知**叶子)。
1177
+ *
1178
+ * 还有区域没读到(unread)时最多只能是 `partial`:那些单元此刻勾不上,
1179
+ * 报「全选」会让用户以为已经全带上(真正补齐见 ContentPicker 的待补勾队列)。
1180
+ */
1181
+ export function pluginEntryPickState(sel: Selection, entry: PluginTreeEntry): SectionPickState {
1182
+ const known = pluginEntryKnownLeaves(entry)
1183
+ if (known.length === 0) return 'none'
1184
+ const picked = pluginEntrySelectedCount(sel, entry)
1185
+ if (picked === 0) return 'none'
1186
+ return picked === known.length ? 'all' : 'partial'
1187
+ }
1188
+
1189
+ /**
1190
+ * 插件节点级勾选:把它名下**存在的**单元一起勾 / 取消。
1191
+ *
1192
+ * 逐个走 toggleUnit:分区未勾时「勾一个单元 = 只勾这一个」的既有语义因此自动生效,
1193
+ * 不会因为一键操作就把整个 pluginData / pluginAssets 分区静默全选。
1194
+ */
1195
+ export function togglePluginEntry(
1196
+ sel: Selection,
1197
+ nodes: SelectionSection[],
1198
+ entry: PluginTreeEntry,
1199
+ checked: boolean,
1200
+ ): Selection {
1201
+ let out = sel
1202
+ for (const leaf of pluginEntryLeaves(entry)) out = toggleUnit(out, nodes, leaf.unitId, checked)
1203
+ return out
1204
+ }
1205
+
1206
+ /** 树上所有**真实可勾选**的叶子(各插件名下存在单元的合计;不含未归属 / 遗留数据桶)。 */
1207
+ export function pluginTreeLeaves(tree: PluginTree): PluginTreeLeaf[] {
1208
+ return tree.entries.flatMap((e) => pluginEntryLeaves(e))
1209
+ }
1210
+
1211
+ export function pluginTreeLeafCount(tree: PluginTree): number {
1212
+ return pluginTreeLeaves(tree).length
1213
+ }
1214
+
1215
+ /** 树上**已知**的叶子(存在 + 未读取;不含 absent)—— 一级区块的计数与三态用它,口径同插件节点。 */
1216
+ export function pluginTreeKnownLeaves(tree: PluginTree): PluginTreeLeaf[] {
1217
+ return tree.entries.flatMap((e) => pluginEntryKnownLeaves(e))
1218
+ }
1219
+
1220
+ export function pluginTreeTotalCount(tree: PluginTree): number {
1221
+ return pluginTreeKnownLeaves(tree).length
1222
+ }
1223
+
1224
+ export function pluginTreeSelectedCount(sel: Selection, tree: PluginTree): number {
1225
+ return pluginTreeLeaves(tree).reduce((n, l) => n + (unitChecked(sel, l.section, l.unitId) ? 1 : 0), 0)
1226
+ }
1227
+
1228
+ /**
1229
+ * 「插件」一级区块(把全部插件收在一个可折叠区块里)的三态。
1230
+ *
1231
+ * 只算**插件名下真实存在的叶子**:未归属 / 遗留数据是「无法归属的东西」,不参与这个勾选框 ——
1232
+ * 否则「全选所有插件」会顺手把没人认领的散落配置一起带上。
1233
+ */
1234
+ export function pluginTreePickState(sel: Selection, tree: PluginTree): SectionPickState {
1235
+ const known = pluginTreeKnownLeaves(tree)
1236
+ if (known.length === 0) return 'none'
1237
+ const picked = pluginTreeSelectedCount(sel, tree)
1238
+ if (picked === 0) return 'none'
1239
+ return picked === known.length ? 'all' : 'partial'
1240
+ }
1241
+
1242
+ /** 一级区块勾选:把所有插件名下存在的单元一起勾 / 取消(逐个走 toggleUnit;含未归属桶吗?不含)。 */
1243
+ export function togglePluginTree(
1244
+ sel: Selection,
1245
+ nodes: SelectionSection[],
1246
+ tree: PluginTree,
1247
+ checked: boolean,
1248
+ ): Selection {
1249
+ let out = sel
1250
+ for (const leaf of pluginTreeLeaves(tree)) out = toggleUnit(out, nodes, leaf.unitId, checked)
1251
+ return out
1252
+ }
1253
+
1254
+ /**
1255
+ * 展开「插件」一级区块前还需要读取哪些分区的清单(惰性)。
1256
+ *
1257
+ * **插件清单自己没读到也要报**(否则一级区块展开后只有一句「先读取插件分区」却没有入口)。
1258
+ */
1259
+ export function pluginTreeUnreadSections(tree: PluginTree): SectionId[] {
1260
+ const out: SectionId[] = tree.pluginsLoaded ? [] : ['plugins']
1261
+ for (const entry of tree.entries) {
1262
+ for (const s of pluginEntryUnreadSections(entry)) if (!out.includes(s)) out.push(s)
1263
+ }
1264
+ // pluginFiles(未归属桶)没读到也要报:它的读取入口挂在「展开这一行」上
1265
+ for (const bucket of tree.buckets) {
1266
+ if (!bucket.loaded && !out.includes(bucket.section)) out.push(bucket.section)
1267
+ }
1268
+ return out
1269
+ }
1270
+
1271
+ /** 一条待补勾请求(键 = 「插件」一级区块,或某个插件名)。 */
1272
+ export interface PluginPendingCheck {
1273
+ key: string
1274
+ kind: 'root' | 'entry'
1275
+ }
1276
+
1277
+ /**
1278
+ * 待补勾的出队判定(纯函数,node 可测)。
1279
+ *
1280
+ * 背景:清单是**惰性**读取的 —— 用户勾「插件」一级区块或某个插件时,它名下可能还有区域没读到
1281
+ * (unread),那些单元此刻勾不上。若就此结束,用户会以为「全带上了」,实际包里少了数据目录/大文件。
1282
+ * 调用方在点击时先勾已知的、把没到齐的键入队;清单到货(nodes 变化)后再调本函数补齐。
1283
+ *
1284
+ * 判据 = 该节点名下**已无 unread 叶子**(`pluginEntryUnreadSections` / `pluginsLoaded`):
1285
+ * 到齐 → 出队并补齐;没到齐 → 留在队列里继续等(绝不猜成「已勾」)。
1286
+ */
1287
+ export function applyPluginPendingChecks(
1288
+ sel: Selection,
1289
+ nodes: SelectionSection[],
1290
+ tree: PluginTree,
1291
+ pending: readonly PluginPendingCheck[],
1292
+ ): { selection: Selection; remaining: PluginPendingCheck[] } {
1293
+ const remaining: PluginPendingCheck[] = []
1294
+ let out = sel
1295
+ for (const p of pending) {
1296
+ if (p.kind === 'root') {
1297
+ if (!tree.pluginsLoaded || tree.entries.some((e) => pluginEntryUnreadSections(e).length > 0)) {
1298
+ remaining.push(p)
1299
+ continue
1300
+ }
1301
+ out = togglePluginTree(out, nodes, tree, true)
1302
+ continue
1303
+ }
1304
+ const entry = tree.entries.find((e) => e.name === p.key)
1305
+ if (entry === undefined) continue
1306
+ if (pluginEntryUnreadSections(entry).length > 0) {
1307
+ remaining.push(p)
1308
+ continue
1309
+ }
1310
+ out = togglePluginEntry(out, nodes, entry, true)
1311
+ }
1312
+ return { selection: out, remaining }
1313
+ }
1314
+
1315
+ /** 勾上该插件前还需要读取哪些分区的清单(展开/点击时触发惰性读取)。 */
1316
+ export function pluginEntryUnreadSections(entry: PluginTreeEntry): SectionId[] {
1317
+ const out: SectionId[] = []
1318
+ for (const leaf of entry.leaves) {
1319
+ if (leaf.state === 'unread' && !out.includes(leaf.section)) out.push(leaf.section)
1320
+ }
1321
+ return out
1322
+ }
1323
+