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.
- package/lib/adapters/plugin-data-declared.d.ts +12 -0
- package/lib/adapters/plugin-data-declared.js +57 -0
- package/lib/adapters/plugin-data.d.ts +4 -6
- package/lib/adapters/plugin-data.js +8 -36
- package/lib/adapters/plugins.d.ts +16 -0
- package/lib/adapters/plugins.js +223 -16
- package/lib/client.d.ts +104 -9
- package/lib/client.js +1800 -718
- package/lib/core/analyzer.js +3 -1
- package/lib/core/backup.d.ts +9 -0
- package/lib/core/backup.js +16 -1
- package/lib/core/messages.d.ts +11 -0
- package/lib/core/messages.js +22 -0
- package/lib/core/plugin-cli.d.ts +26 -0
- package/lib/core/plugin-cli.js +62 -2
- package/lib/core/rollback-attribution.d.ts +81 -0
- package/lib/core/rollback-attribution.js +236 -0
- package/lib/core/rollback-merge.d.ts +22 -0
- package/lib/core/rollback-merge.js +203 -0
- package/lib/core/rollback-point.d.ts +114 -0
- package/lib/core/rollback-point.js +534 -17
- package/lib/core/types.d.ts +28 -0
- package/lib/index.d.ts +26 -3
- package/lib/index.js +129 -16
- package/lib/routes/recovery.js +11 -0
- package/lib/schema/section-registry.js +5 -5
- package/lib/schema/types.d.ts +14 -0
- package/lib/ui/badge-tone.d.ts +16 -0
- package/lib/ui/badge-tone.js +20 -0
- package/lib/ui/i18n.d.ts +4 -4
- package/lib/ui/i18n.js +8 -8
- package/lib/ui/rollback-view.d.ts +95 -0
- package/lib/ui/rollback-view.js +55 -0
- package/lib/ui/selection-model.d.ts +140 -1
- package/lib/ui/selection-model.js +241 -9
- package/package.json +1 -1
- package/src/adapters/plugin-data-declared.ts +60 -0
- package/src/adapters/plugin-data.ts +7 -33
- package/src/adapters/plugins.test.ts +162 -0
- package/src/adapters/plugins.ts +236 -17
- package/src/adapters/units.test.ts +26 -0
- package/src/client/chat/ChatEntryView.tsx +147 -46
- package/src/client/chat/chat-entry-view.test.ts +277 -1
- package/src/client/chat/chat-entry-view.ts +98 -4
- package/src/client/chat/chat-node-registration.ts +71 -0
- package/src/client/chat/chat-slots.ts +30 -12
- package/src/client/common/ContentPicker.tsx +581 -175
- package/src/client/common/host-primitives.ts +64 -1
- package/src/client/common/ui.tsx +17 -3
- package/src/client/config-manager.module.css +630 -397
- package/src/client/css-scale-guard.test.ts +41 -3
- package/src/client/css-token-guard.test.ts +10 -4
- package/src/client/export/ExportView.tsx +2 -0
- package/src/client/index.ts +15 -3
- package/src/client/locales.ts +54 -10
- package/src/client/recovery/RollbackCard.tsx +56 -2
- package/src/client/recovery/RollbackDiffPane.tsx +5 -0
- package/src/client/recovery/RollbackFileChanges.tsx +46 -2
- package/src/client/recovery/recovery-locales.ts +22 -0
- package/src/client/recovery/rollback-file-changes.test.ts +1 -1
- package/src/core/analyzer.ts +3 -1
- package/src/core/backup.ts +16 -1
- package/src/core/messages.ts +22 -0
- package/src/core/plugin-cli.fs.test.ts +65 -3
- package/src/core/plugin-cli.ts +55 -2
- package/src/core/rollback-attribution.test.ts +120 -0
- package/src/core/rollback-attribution.ts +267 -0
- package/src/core/rollback-merge.test.ts +133 -0
- package/src/core/rollback-merge.ts +213 -0
- package/src/core/rollback-point.test.ts +412 -0
- package/src/core/rollback-point.ts +560 -20
- package/src/core/types.ts +29 -0
- package/src/index.facade.test.ts +60 -0
- package/src/index.ts +133 -19
- package/src/routes/recovery.ts +11 -0
- package/src/schema/section-registry.ts +5 -5
- package/src/schema/types.ts +14 -0
- package/src/ui/badge-tone.test.ts +23 -0
- package/src/ui/badge-tone.ts +27 -0
- package/src/ui/i18n.ts +8 -8
- package/src/ui/rollback-view.test.ts +27 -0
- package/src/ui/rollback-view.ts +139 -0
- package/src/ui/selection-model.test.ts +240 -2
- package/src/ui/selection-model.ts +349 -1
package/src/ui/rollback-view.ts
CHANGED
|
@@ -424,6 +424,23 @@ export interface RollbackPreviewFile {
|
|
|
424
424
|
removed: number | null
|
|
425
425
|
/** true = 本次没有可信行数(added/removed 均为 null)。**宁可说「不知道」,也绝不谎报行数。** */
|
|
426
426
|
truncated: boolean
|
|
427
|
+
/**
|
|
428
|
+
* true = 该文件在留点**之后**是**别的会话**写的(写入归属台账证明;见 core/rollback-attribution.ts)。
|
|
429
|
+
* 界面据此默认不勾选它(用户仍可勾上)。缺字段 / false = **没有归属证据**,
|
|
430
|
+
* 而不是「本会话写的」——「未判定」与「确知是本会话」必须分开。
|
|
431
|
+
*/
|
|
432
|
+
foreign?: boolean
|
|
433
|
+
/** 那些「别的会话」的会话 id(仅当 foreign 为 true) */
|
|
434
|
+
foreignSessionId?: string
|
|
435
|
+
/** 那次写入的时间(毫秒;仅当 foreign 为 true) */
|
|
436
|
+
foreignAt?: number
|
|
437
|
+
/**
|
|
438
|
+
* true = 这次回退**不会改动它**(与其它对话的改动冲突 / 无法安全合并)。行级统计为 null ——
|
|
439
|
+
* 「不改动」不是「0 行变化」,界面必须分开说。
|
|
440
|
+
*/
|
|
441
|
+
skipped?: boolean
|
|
442
|
+
/** 不改动的机器原因(界面映射成字典键,绝不裸渲染) */
|
|
443
|
+
mergeReason?: string
|
|
427
444
|
}
|
|
428
445
|
|
|
429
446
|
export interface RollbackWorkspacePreview {
|
|
@@ -551,6 +568,12 @@ export interface RollbackFileDiffResult {
|
|
|
551
568
|
identical: boolean
|
|
552
569
|
degraded: boolean
|
|
553
570
|
reason?: RollbackFileDiffReason
|
|
571
|
+
/** true = 「回退后」一侧显示的是三方合并的结果(不是记录里的原文) */
|
|
572
|
+
merged?: boolean
|
|
573
|
+
/** true = 这次回退不会改动它(冲突 / 无法安全合并)——此时两侧相同 */
|
|
574
|
+
mergeSkipped?: boolean
|
|
575
|
+
/** 不改动的机器原因(界面映射成字典键) */
|
|
576
|
+
mergeReason?: string
|
|
554
577
|
}
|
|
555
578
|
|
|
556
579
|
export interface RollbackSegmentResult {
|
|
@@ -577,6 +600,22 @@ export interface RollbackSegmentResult {
|
|
|
577
600
|
createdRefusedReason?: string | null
|
|
578
601
|
/** 工作区段:实际写回的字节数(宿主回传才有) */
|
|
579
602
|
writtenBytes?: number
|
|
603
|
+
/** 因归属隔离(B)跳过的记录条目数:该文件在留点后被**别的会话**写过(宿主回传才有) */
|
|
604
|
+
foreignSkipped?: number
|
|
605
|
+
/** 因路径选择(A)跳过的记录条目数(宿主回传才有) */
|
|
606
|
+
selectionSkipped?: number
|
|
607
|
+
/** 三方合并成功、写回「撤销本会话 + 保留对方改动」的条目数(宿主回传才有) */
|
|
608
|
+
mergedCount?: number
|
|
609
|
+
mergedPaths?: string[]
|
|
610
|
+
/** 冲突:两侧改到同一处 ⇒ 未写(宿主回传才有) */
|
|
611
|
+
conflictCount?: number
|
|
612
|
+
conflictPaths?: string[]
|
|
613
|
+
/** 无法安全撤销 ⇒ 未写(宿主回传才有) */
|
|
614
|
+
unavailableCount?: number
|
|
615
|
+
unavailablePaths?: string[]
|
|
616
|
+
/** 写入前发现又被改过(并发)⇒ 未写(宿主回传才有) */
|
|
617
|
+
concurrentCount?: number
|
|
618
|
+
concurrentPaths?: string[]
|
|
580
619
|
/** 兼容形态:宿主只回传计数时也算得出来 */
|
|
581
620
|
createdAfter?: RollbackCount
|
|
582
621
|
createdAfterTruncated?: boolean
|
|
@@ -613,6 +652,13 @@ export interface RollbackRewindOptions {
|
|
|
613
652
|
segments?: readonly RollbackState[]
|
|
614
653
|
allowPartial?: boolean
|
|
615
654
|
preRewindGuard?: RollbackGuardPolicy
|
|
655
|
+
/**
|
|
656
|
+
* 路径级选择性恢复(2026-10-17 定案 A):只恢复这些路径。
|
|
657
|
+
*
|
|
658
|
+
* 缺省 = 全量口径(一字不变)。**空数组是无效输入**(宿主 400)—— 「一个都没勾」由界面拦住,
|
|
659
|
+
* 绝不让它变成「整次回退静默 noop」。
|
|
660
|
+
*/
|
|
661
|
+
onlyPaths?: readonly string[]
|
|
616
662
|
}
|
|
617
663
|
|
|
618
664
|
/** 端口:由 src/client/recovery/recovery-api.ts 实现(Partial 消费,缺失时界面如实说能力缺失)。 */
|
|
@@ -986,6 +1032,76 @@ export function rollbackPreviewScope(
|
|
|
986
1032
|
return { files: files.length, added, removed, removals: removals.length }
|
|
987
1033
|
}
|
|
988
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* 预览里「能被单独勾选」的一行 —— `workspace.files` 的逐条映射(**只含会被改动的文件**)。
|
|
1037
|
+
*
|
|
1038
|
+
* 为什么要有这一层(2026-10-17 定案 A+B):A 要求用户能**逐文件**决定这次动哪些;
|
|
1039
|
+
* B(写入归属)给出「哪几个文件在留点之后是别的会话写的」,用来决定**默认**勾不勾。
|
|
1040
|
+
* 两件事都收敛在这里、只在这一处(框架无关、node 可测),组件只负责渲染与点击。
|
|
1041
|
+
*/
|
|
1042
|
+
export interface RollbackSelectionRow {
|
|
1043
|
+
path: string
|
|
1044
|
+
/** 目录前缀 + 基名(与文件行同口径;展示用) */
|
|
1045
|
+
parts: RollbackPathParts
|
|
1046
|
+
/** true = 该文件在留点**之后**是别的会话写的(引擎 foreign 标记;缺字段 = 没有归属证据) */
|
|
1047
|
+
foreign: boolean
|
|
1048
|
+
/** 那些会话的 id(仅 foreign 时给得出) */
|
|
1049
|
+
foreignSessionId?: string
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** 从预览推出可勾选行(只读;绝不改任何判定) */
|
|
1053
|
+
export function rollbackSelectionRows(preview: RollbackPreviewResult | null): RollbackSelectionRow[] {
|
|
1054
|
+
const files = preview?.workspace?.files
|
|
1055
|
+
if (!Array.isArray(files)) return []
|
|
1056
|
+
const out: RollbackSelectionRow[] = []
|
|
1057
|
+
for (const file of files) {
|
|
1058
|
+
if (file === null || typeof file !== 'object') continue
|
|
1059
|
+
const path = typeof file.path === 'string' ? file.path : ''
|
|
1060
|
+
if (path === '') continue
|
|
1061
|
+
const row: RollbackSelectionRow = { path, parts: splitRollbackPath(path), foreign: file.foreign === true }
|
|
1062
|
+
if (row.foreign && typeof file.foreignSessionId === 'string' && file.foreignSessionId !== '') {
|
|
1063
|
+
row.foreignSessionId = file.foreignSessionId
|
|
1064
|
+
}
|
|
1065
|
+
out.push(row)
|
|
1066
|
+
}
|
|
1067
|
+
return out
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* 默认勾选 = **非 foreign** 的行(B 的自动隔离口径)。用户可在弹窗里逐个改回去。
|
|
1072
|
+
*
|
|
1073
|
+
* 为什么要在这里定而不在组件里:同一条默认值要在面板与聊天两个入口一致,
|
|
1074
|
+
* 且「默认排除别的会话的改动」是产品决定,必须可测。
|
|
1075
|
+
*/
|
|
1076
|
+
export function rollbackDefaultSelection(rows: readonly RollbackSelectionRow[]): string[] {
|
|
1077
|
+
return rows.filter((row) => !row.foreign).map((row) => row.path)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* 勾选集 → `onlyPaths`:
|
|
1082
|
+
* · **全选**(含空清单)⇒ `null`(不传字段 = 走既有全量口径,老宿主也照样工作);
|
|
1083
|
+
* · 真的少勾 ⇒ 把选中的清单原样传给宿主(空数组由调用方拦:宿主侧它是 400)。
|
|
1084
|
+
*/
|
|
1085
|
+
export function rollbackOnlyPaths(
|
|
1086
|
+
rows: readonly RollbackSelectionRow[],
|
|
1087
|
+
selected: readonly string[],
|
|
1088
|
+
): string[] | null {
|
|
1089
|
+
if (rows.length === 0) return null
|
|
1090
|
+
const chosen = new Set(selected)
|
|
1091
|
+
const picked = rows.filter((row) => chosen.has(row.path)).map((row) => row.path)
|
|
1092
|
+
return picked.length === rows.length ? null : picked
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** 该不该拦住提交:清单非空、却一个都没勾(宿主会把空数组当 400) */
|
|
1096
|
+
export function rollbackSelectionBlocksSubmit(
|
|
1097
|
+
rows: readonly RollbackSelectionRow[],
|
|
1098
|
+
selected: readonly string[],
|
|
1099
|
+
): boolean {
|
|
1100
|
+
if (rows.length === 0) return false
|
|
1101
|
+
const chosen = new Set(selected)
|
|
1102
|
+
return !rows.some((row) => chosen.has(row.path))
|
|
1103
|
+
}
|
|
1104
|
+
|
|
989
1105
|
/**
|
|
990
1106
|
* 差异无法逐行展示时的字典键(本层**只回键**,文案在字典里)。
|
|
991
1107
|
* 未知 / 缺失原因回落 null —— 调用方按「未知原因」出话,绝不回落成「没有差异」(那是另一件事)。
|
|
@@ -1315,6 +1431,22 @@ export interface RollbackRewindRowView {
|
|
|
1315
1431
|
/** 逐条事实:**渲染前已逐条过 redact** */
|
|
1316
1432
|
detail: string[]
|
|
1317
1433
|
guidance: RollbackGuidanceRowView[]
|
|
1434
|
+
/**
|
|
1435
|
+
* 归属隔离(B)跳过的文件数:这些文件在留点之后是**别的会话**写的,回退没动它们。
|
|
1436
|
+
* 0 = 没有(或宿主没回传)—— 界面只在 > 0 时说这句话(绝不编造 0 的成就)。
|
|
1437
|
+
*/
|
|
1438
|
+
foreignSkipped: number
|
|
1439
|
+
/** 路径选择(A)跳过的文件数(用户自己没勾) */
|
|
1440
|
+
selectionSkipped: number
|
|
1441
|
+
/** 三方合并成功、写回「撤销本会话 + 保留对方改动」的文件数 */
|
|
1442
|
+
mergedCount: number
|
|
1443
|
+
/** 冲突(两侧改到同一处)未写的文件数 + 有界清单 */
|
|
1444
|
+
conflictCount: number
|
|
1445
|
+
conflictPaths: string[]
|
|
1446
|
+
/** 无法安全撤销(无中间版本 / 二进制 / 降级 / 对方删了它)未写的文件数 */
|
|
1447
|
+
unavailableCount: number
|
|
1448
|
+
/** 写入前发现又被改过(并发)未写的文件数 */
|
|
1449
|
+
concurrentCount: number
|
|
1318
1450
|
}
|
|
1319
1451
|
|
|
1320
1452
|
export function rollbackRewindRows(result: RollbackRewindResult | null): RollbackRewindRowView[] {
|
|
@@ -1341,6 +1473,13 @@ export function rollbackRewindRows(result: RollbackRewindResult | null): Rollbac
|
|
|
1341
1473
|
childSessionId,
|
|
1342
1474
|
detail: redactLines(segment.detail),
|
|
1343
1475
|
guidance: rollbackGuidanceRows(segment.guidance),
|
|
1476
|
+
foreignSkipped: numberOrNull(segment.foreignSkipped) ?? 0,
|
|
1477
|
+
selectionSkipped: numberOrNull(segment.selectionSkipped) ?? 0,
|
|
1478
|
+
mergedCount: numberOrNull(segment.mergedCount) ?? 0,
|
|
1479
|
+
conflictCount: numberOrNull(segment.conflictCount) ?? 0,
|
|
1480
|
+
conflictPaths: redactLines(segment.conflictPaths),
|
|
1481
|
+
unavailableCount: numberOrNull(segment.unavailableCount) ?? 0,
|
|
1482
|
+
concurrentCount: numberOrNull(segment.concurrentCount) ?? 0,
|
|
1344
1483
|
}
|
|
1345
1484
|
})
|
|
1346
1485
|
}
|
|
@@ -10,9 +10,14 @@ import test from 'node:test'
|
|
|
10
10
|
import assert from 'node:assert/strict'
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
-
buildExportRequest, buildSelectedPlan, defaultSelection,
|
|
13
|
+
applyPluginPendingChecks, buildExportRequest, buildSelectedPlan, defaultSelection,
|
|
14
14
|
defaultSelectionFromPlan, effectiveImportPlan, effectiveImportSelection, filterSections,
|
|
15
|
-
groupPickState, groupSelectedCount, groupUnits, HIGH_RISK_ADAPTERS, isHighRiskAdapter,
|
|
15
|
+
filterPluginTree, groupPickState, groupSelectedCount, groupUnits, HIGH_RISK_ADAPTERS, isHighRiskAdapter,
|
|
16
|
+
isPlanItemExcluded, isPluginTreeSection, isUnitSelected, PLUGIN_TREE_SECTIONS,
|
|
17
|
+
pluginEntryLeaves, pluginEntryPickState, pluginEntrySelectedCount, pluginEntryUnreadSections, pluginTree,
|
|
18
|
+
pluginEntryTotalCount, pluginTreeLeafCount, pluginTreePickState, pluginTreeSelectedCount, pluginTreeTotalCount,
|
|
19
|
+
pluginTreeUnreadSections,
|
|
20
|
+
togglePluginEntry, togglePluginTree,
|
|
16
21
|
planWriteSections,
|
|
17
22
|
tailWeightedEllipsis, pickerSummary,
|
|
18
23
|
sectionCountState, sectionExpandable, sectionPickState, sectionsFromPlan, sectionsFromPreview, selectedUnitCount, selectAll,
|
|
@@ -836,3 +841,236 @@ test('选择模型:sectionsFromPreview 透传 blank 事实(ContentPicker 据
|
|
|
836
841
|
assert.equal(nodes[0]?.units[0]?.blank, true);
|
|
837
842
|
assert.equal(nodes[1]?.units[0]?.blank, undefined, '非会话单元不得凭空带 blank');
|
|
838
843
|
});
|
|
844
|
+
|
|
845
|
+
/* ----------------------------------------------------------------------------------
|
|
846
|
+
「按插件看」:树模型 / 三态 / 一键勾选(用户需求:勾一个插件 = 带走它四处的东西)
|
|
847
|
+
---------------------------------------------------------------------------------- */
|
|
848
|
+
|
|
849
|
+
function treeNodes(): SelectionSection[] {
|
|
850
|
+
return [
|
|
851
|
+
{
|
|
852
|
+
section: 'plugins', count: 2, sizeBytes: 120, group: 'extensions',
|
|
853
|
+
units: [
|
|
854
|
+
{
|
|
855
|
+
id: 'plugin:alpha', label: 'alpha', detail: '1.0.0', sizeBytes: 10,
|
|
856
|
+
relatedAreas: [{ section: 'pluginData', relPath: '.alpha/runtime', role: 'config' }],
|
|
857
|
+
},
|
|
858
|
+
{ id: 'plugin:beta', label: 'beta', sizeBytes: 10 },
|
|
859
|
+
{ id: 'plugins:pnpm-workspace', label: 'pnpm-workspace.yaml', sizeBytes: 5 },
|
|
860
|
+
],
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
section: 'pluginData', count: 2, sizeBytes: 30,
|
|
864
|
+
|
|
865
|
+
units: [
|
|
866
|
+
{ id: 'pluginData:.alpha/runtime', label: '.alpha/runtime', sizeBytes: 20 },
|
|
867
|
+
{ id: 'pluginData:.gone/state', label: '.gone/state', sizeBytes: 10 },
|
|
868
|
+
],
|
|
869
|
+
},
|
|
870
|
+
// 大文件分区还没读:区域叶子只能是 unread(不可勾),绝不当成「没有内容」
|
|
871
|
+
{ section: 'pluginAssets', count: 0, sizeBytes: 0, units: [], unitsUnread: true },
|
|
872
|
+
{
|
|
873
|
+
section: 'pluginFiles', count: 1, sizeBytes: 4,
|
|
874
|
+
units: [{ id: 'pluginFiles:dsh-ssh.json', label: 'dsh-ssh.json', sizeBytes: 4 }],
|
|
875
|
+
},
|
|
876
|
+
]
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const TREE_SECTIONS: SectionId[] = ['plugins']
|
|
880
|
+
|
|
881
|
+
test('合并进插件树的分区只有那四项(配置管理器设置不是插件,仍按普通分区行渲染)', () => {
|
|
882
|
+
assert.deepEqual([...PLUGIN_TREE_SECTIONS].sort(), ['pluginAssets', 'pluginData', 'pluginFiles', 'plugins'])
|
|
883
|
+
for (const id of PLUGIN_TREE_SECTIONS) assert.equal(isPluginTreeSection(id), true)
|
|
884
|
+
assert.equal(isPluginTreeSection('self'), false, '配置管理器设置不得被吸收进树')
|
|
885
|
+
assert.equal(isPluginTreeSection('sessions'), false)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
test('按插件看:插件清单条目与它声明的数据区域收在同一个节点下(未读分区不可勾)', () => {
|
|
889
|
+
const tree = pluginTree(treeNodes())
|
|
890
|
+
assert.equal(tree.pluginsLoaded, true)
|
|
891
|
+
const alpha = tree.entries.find((e) => e.name === 'alpha')
|
|
892
|
+
assert.ok(alpha !== undefined)
|
|
893
|
+
assert.deepEqual(alpha!.leaves.map((l) => l.state), ['present', 'present'])
|
|
894
|
+
assert.deepEqual(alpha!.leaves.map((l) => l.unitId), ['plugin:alpha', 'pluginData:.alpha/runtime'])
|
|
895
|
+
// beta 没有任何声明 → 只有清单条目,绝不凭空造出数据区域
|
|
896
|
+
const beta = tree.entries.find((e) => e.name === 'beta')
|
|
897
|
+
assert.deepEqual(beta!.areas, [])
|
|
898
|
+
// 声明了 pluginAssets 的区域?alpha 只声明了 config 区域,故不存在 assets 叶子
|
|
899
|
+
assert.equal(alpha!.leaves.some((l) => l.section === 'pluginAssets'), false)
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
test('按插件看:注册表说有、磁盘上没有 = absent;分区没读 = unread(两者都不可勾)', () => {
|
|
903
|
+
const nodes = treeNodes()
|
|
904
|
+
// 让 alpha 声明一个大文件区域(该分区还没读)
|
|
905
|
+
nodes[0]!.units[0] = {
|
|
906
|
+
...nodes[0]!.units[0]!,
|
|
907
|
+
relatedAreas: [
|
|
908
|
+
{ section: 'pluginData', relPath: '.alpha/runtime', role: 'config' },
|
|
909
|
+
{ section: 'pluginAssets', relPath: '.alpha/uploads', role: 'assets' },
|
|
910
|
+
{ section: 'pluginData', relPath: '.alpha/nonexistent', role: 'config' },
|
|
911
|
+
],
|
|
912
|
+
}
|
|
913
|
+
const tree = pluginTree(nodes)
|
|
914
|
+
const alpha = tree.entries.find((e) => e.name === 'alpha')!
|
|
915
|
+
// 顺序 = 清单条目在前,随后按宿主声明顺序:[runtime(已读)、uploads(未读)、nonexistent(已读但没内容)]
|
|
916
|
+
assert.deepEqual(alpha.leaves.map((l) => l.state), ['present', 'present', 'unread', 'absent'])
|
|
917
|
+
assert.deepEqual(pluginEntryLeaves(alpha).map((l) => l.unitId), ['plugin:alpha', 'pluginData:.alpha/runtime'])
|
|
918
|
+
assert.deepEqual(pluginEntryUnreadSections(alpha), ['pluginAssets'])
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
test('按插件看不猜归属:只有 pluginFiles(无包名信息)与补丁行进桶;遗留数据不单列(用户要求不看)', () => {
|
|
922
|
+
const tree = pluginTree(treeNodes())
|
|
923
|
+
const bySection = new Map(tree.buckets.map((b) => [b.section, b]))
|
|
924
|
+
assert.deepEqual([...bySection.keys()].sort(), ['pluginFiles', 'plugins'],
|
|
925
|
+
'桶只有两个:pluginFiles 与「插件分区的其他条目」')
|
|
926
|
+
assert.deepEqual(bySection.get('pluginFiles')?.units.map((u) => u.id), ['pluginFiles:dsh-ssh.json'])
|
|
927
|
+
assert.deepEqual(bySection.get('plugins')?.units.map((u) => u.id), ['plugins:pnpm-workspace'])
|
|
928
|
+
assert.equal(bySection.get('pluginData'), undefined, '未被认领的数据单元不再单列「遗留数据」桶')
|
|
929
|
+
assert.equal(bySection.get('pluginAssets'), undefined)
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
test('按插件看:插件节点三态与一键勾选(逐个走 toggleUnit,不静默全选整个分区)', () => {
|
|
933
|
+
const nodes = treeNodes()
|
|
934
|
+
const alpha = pluginTree(nodes).entries.find((e) => e.name === 'alpha')!
|
|
935
|
+
let sel = defaultSelection(TREE_SECTIONS)
|
|
936
|
+
// 只有清单条目被勾上 → 部分选中(数据区域属于未勾选的分区)
|
|
937
|
+
assert.equal(pluginEntrySelectedCount(sel, alpha), 1)
|
|
938
|
+
assert.equal(pluginEntryPickState(sel, alpha), 'partial')
|
|
939
|
+
sel = togglePluginEntry(sel, nodes, alpha, true)
|
|
940
|
+
assert.deepEqual(sel.sections, ['plugins', 'pluginData'])
|
|
941
|
+
// 只勾该插件的区域:同分区里未被认领的遗留数据(.gone/state)不得被一起带上
|
|
942
|
+
assert.deepEqual(buildExportRequest(sel, nodes), {
|
|
943
|
+
only: ['plugins', 'pluginData'],
|
|
944
|
+
includeItems: { pluginData: ['pluginData:.alpha/runtime'] },
|
|
945
|
+
})
|
|
946
|
+
// 反向:取消该插件 → 它的清单条目与数据区域一起取消,其余插件不动
|
|
947
|
+
sel = togglePluginEntry(sel, nodes, alpha, false)
|
|
948
|
+
assert.deepEqual(sel.sections, ['plugins'], '数据区域被取消后,pluginData 随之移出勾选')
|
|
949
|
+
assert.equal(sel.excluded.includes('plugin:alpha'), true)
|
|
950
|
+
assert.equal(sel.excluded.includes('pluginData:.alpha/runtime'), true)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
test('「插件」一级区块:三态只算插件名下存在的叶子,未归属桶不参与', () => {
|
|
954
|
+
const nodes = treeNodes()
|
|
955
|
+
const tree = pluginTree(nodes)
|
|
956
|
+
let sel = defaultSelection(TREE_SECTIONS)
|
|
957
|
+
// 叶子 = alpha 的清单条目 + 它的数据区域 + beta 的清单条目(未归属的 .gone/state 与 pluginFiles 不计)
|
|
958
|
+
assert.equal(pluginTreeLeafCount(tree), 3)
|
|
959
|
+
assert.equal(pluginTreeSelectedCount(sel, tree), 2, '两个插件的清单条目默认已勾:alpha + beta')
|
|
960
|
+
assert.equal(pluginTreePickState(sel, tree), 'partial')
|
|
961
|
+
sel = togglePluginTree(sel, nodes, tree, true)
|
|
962
|
+
assert.equal(pluginTreePickState(sel, tree), 'all')
|
|
963
|
+
assert.deepEqual(buildExportRequest(sel, nodes), {
|
|
964
|
+
only: ['plugins', 'pluginData'],
|
|
965
|
+
includeItems: { pluginData: ['pluginData:.alpha/runtime'] },
|
|
966
|
+
})
|
|
967
|
+
// 未归属 / 遗留数据没有任何文档说过要一起带上:它们的单元不得因一级区块被勾选
|
|
968
|
+
assert.equal(sel.excluded.includes('pluginData:.gone/state'), true)
|
|
969
|
+
assert.equal(sel.sections.includes('pluginFiles'), false)
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
test('「插件」一级区块:取消勾选后所有插件单元都不再导出(桶里的补丁行不受影响)', () => {
|
|
973
|
+
const nodes = treeNodes()
|
|
974
|
+
const tree = pluginTree(nodes)
|
|
975
|
+
const sel = togglePluginTree(defaultSelection(TREE_SECTIONS), nodes, tree, false)
|
|
976
|
+
assert.equal(pluginTreePickState(sel, tree), 'none')
|
|
977
|
+
// 插件分区仍被勾着 —— 因为 **桶里** 的 pnpm-workspace.yaml / 补丁行不属于任何插件,
|
|
978
|
+
// 一级区块的勾选框不碰它们(「全选所有插件」不该顺手动没人认领的东西)
|
|
979
|
+
assert.deepEqual(sel.sections, ['plugins'])
|
|
980
|
+
assert.equal(sel.excluded.includes('plugin:alpha'), true)
|
|
981
|
+
assert.equal(sel.excluded.includes('plugin:beta'), true)
|
|
982
|
+
assert.equal(sel.excluded.includes('plugins:pnpm-workspace'), false)
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
test('计数不随清单到货跳动:「已选 n/m」的分母用「已知」叶子(含未读取的区域)', () => {
|
|
986
|
+
const nodes = treeNodes()
|
|
987
|
+
nodes[0]!.units[0] = {
|
|
988
|
+
...nodes[0]!.units[0]!,
|
|
989
|
+
relatedAreas: [
|
|
990
|
+
{ section: 'pluginData', relPath: '.alpha/runtime', role: 'config' },
|
|
991
|
+
{ section: 'pluginAssets', relPath: '.alpha/uploads', role: 'assets' },
|
|
992
|
+
],
|
|
993
|
+
}
|
|
994
|
+
const before = pluginTree(nodes)
|
|
995
|
+
const alphaBefore = before.entries.find((e) => e.name === 'alpha')!
|
|
996
|
+
assert.equal(pluginEntryTotalCount(alphaBefore), 3, '清单条目 + 已读到区域 + 未读取区域')
|
|
997
|
+
assert.equal(pluginTreeTotalCount(before), 4, 'alpha 3 项 + beta 1 项')
|
|
998
|
+
// 模拟清单到货:同一个声明区域从 unread 变成 present —— 分母必须**不变**
|
|
999
|
+
nodes[2] = {
|
|
1000
|
+
section: 'pluginAssets', count: 1, sizeBytes: 9,
|
|
1001
|
+
units: [{ id: 'pluginAssets:.alpha/uploads', label: '.alpha/uploads', sizeBytes: 9 }],
|
|
1002
|
+
}
|
|
1003
|
+
const after = pluginTree(nodes)
|
|
1004
|
+
const alphaAfter = after.entries.find((e) => e.name === 'alpha')!
|
|
1005
|
+
assert.equal(pluginEntryTotalCount(alphaAfter), 3)
|
|
1006
|
+
assert.equal(pluginTreeTotalCount(after), 4)
|
|
1007
|
+
assert.deepEqual(alphaAfter.leaves.map((l) => l.state), ['present', 'present', 'present'])
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
test('待补勾:清单没到齐时留在队列(绝不假装已勾),到货后补齐并出队', () => {
|
|
1011
|
+
const nodes = treeNodes()
|
|
1012
|
+
nodes[0]!.units[0] = {
|
|
1013
|
+
...nodes[0]!.units[0]!,
|
|
1014
|
+
relatedAreas: [
|
|
1015
|
+
{ section: 'pluginData', relPath: '.alpha/runtime', role: 'config' },
|
|
1016
|
+
{ section: 'pluginAssets', relPath: '.alpha/uploads', role: 'assets' },
|
|
1017
|
+
],
|
|
1018
|
+
}
|
|
1019
|
+
const pending = [{ key: 'alpha', kind: 'entry' as const }]
|
|
1020
|
+
const waiting = applyPluginPendingChecks(defaultSelection(TREE_SECTIONS), nodes, pluginTree(nodes), pending)
|
|
1021
|
+
assert.deepEqual(waiting.remaining, pending, '大文件分区还没读 → 继续等')
|
|
1022
|
+
// 到货
|
|
1023
|
+
nodes[2] = {
|
|
1024
|
+
section: 'pluginAssets', count: 1, sizeBytes: 9,
|
|
1025
|
+
units: [{ id: 'pluginAssets:.alpha/uploads', label: '.alpha/uploads', sizeBytes: 9 }],
|
|
1026
|
+
}
|
|
1027
|
+
const tree2 = pluginTree(nodes)
|
|
1028
|
+
const done = applyPluginPendingChecks(defaultSelection(TREE_SECTIONS), nodes, tree2, pending)
|
|
1029
|
+
assert.deepEqual(done.remaining, [])
|
|
1030
|
+
const alpha = tree2.entries.find((e) => e.name === 'alpha')!
|
|
1031
|
+
assert.equal(pluginEntrySelectedCount(done.selection, alpha), 3, '补齐后 = 该插件名下全部叶子')
|
|
1032
|
+
assert.equal(pluginEntryPickState(done.selection, alpha), 'all')
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
test('「插件」一级区块展开前要读的分区:插件清单自己没读到也要报(否则展开后没有读取入口)', () => {
|
|
1036
|
+
const nodes = treeNodes()
|
|
1037
|
+
// alpha 声明了一个大文件区域(该分区清单还没读)
|
|
1038
|
+
nodes[0]!.units[0] = {
|
|
1039
|
+
...nodes[0]!.units[0]!,
|
|
1040
|
+
relatedAreas: [
|
|
1041
|
+
{ section: 'pluginData', relPath: '.alpha/runtime', role: 'config' },
|
|
1042
|
+
{ section: 'pluginAssets', relPath: '.alpha/uploads', role: 'assets' },
|
|
1043
|
+
],
|
|
1044
|
+
}
|
|
1045
|
+
assert.deepEqual(pluginTreeUnreadSections(pluginTree(nodes)), ['pluginAssets'])
|
|
1046
|
+
const unreadPlugins: SelectionSection[] = [
|
|
1047
|
+
{ section: 'plugins', count: 0, sizeBytes: 0, units: [], unitsUnread: true },
|
|
1048
|
+
{ section: 'pluginData', count: 0, sizeBytes: 0, units: [], unitsUnread: true },
|
|
1049
|
+
]
|
|
1050
|
+
assert.deepEqual(pluginTreeUnreadSections(pluginTree(unreadPlugins)), ['plugins'])
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
test('按插件看:搜索命中插件名保留它全部叶子;只命中叶子时保留该叶子', () => {
|
|
1054
|
+
const tree = pluginTree(treeNodes())
|
|
1055
|
+
const byName = filterPluginTree(tree, 'ALPHA')
|
|
1056
|
+
assert.deepEqual(byName.entries.map((e) => e.name), ['alpha'])
|
|
1057
|
+
assert.equal(byName.entries[0]?.leaves.length, 2, '插件名命中时保留全部叶子')
|
|
1058
|
+
const byLeaf = filterPluginTree(tree, 'dsh-ssh')
|
|
1059
|
+
assert.deepEqual(byLeaf.entries.map((e) => e.name), [])
|
|
1060
|
+
assert.deepEqual(byLeaf.buckets.map((b) => b.section), ['pluginFiles'])
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
test('选择模型:sectionsFromPreview 透传 group 与 relatedAreas(按插件看的两个输入)', () => {
|
|
1064
|
+
const nodes = sectionsFromPreview([
|
|
1065
|
+
{
|
|
1066
|
+
section: 'plugins', count: 1, sizeBytes: 10,
|
|
1067
|
+
items: [{
|
|
1068
|
+
id: 'plugin:alpha', label: 'alpha', sizeBytes: 10,
|
|
1069
|
+
relatedAreas: [{ section: 'pluginData', relPath: '.alpha/runtime', role: 'config' }],
|
|
1070
|
+
}],
|
|
1071
|
+
},
|
|
1072
|
+
{ section: 'skills', count: 1, sizeBytes: 10, items: [{ id: 'skills:x', label: 'x', sizeBytes: 10 }] },
|
|
1073
|
+
])
|
|
1074
|
+
assert.deepEqual(nodes[0]?.units[0]?.relatedAreas, [{ section: 'pluginData', relPath: '.alpha/runtime', role: 'config' }])
|
|
1075
|
+
assert.equal(nodes[1]?.units[0]?.relatedAreas, undefined, '没有声明的单元不得凭空带 relatedAreas')
|
|
1076
|
+
});
|