dsh-config-manager 0.1.73 → 0.1.77

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 (96) 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.js +92 -12
  6. package/lib/client.d.ts +112 -13
  7. package/lib/client.js +2102 -888
  8. package/lib/core/messages.d.ts +1 -0
  9. package/lib/core/messages.js +2 -0
  10. package/lib/core/rollback-attribution.d.ts +81 -0
  11. package/lib/core/rollback-attribution.js +236 -0
  12. package/lib/core/rollback-ledger.d.ts +29 -3
  13. package/lib/core/rollback-ledger.js +125 -7
  14. package/lib/core/rollback-merge.d.ts +22 -0
  15. package/lib/core/rollback-merge.js +203 -0
  16. package/lib/core/rollback-point.d.ts +335 -3
  17. package/lib/core/rollback-point.js +1323 -169
  18. package/lib/core/types.d.ts +18 -0
  19. package/lib/index.d.ts +26 -3
  20. package/lib/index.js +129 -16
  21. package/lib/routes/recovery.js +11 -0
  22. package/lib/schema/section-registry.js +5 -5
  23. package/lib/sync/messages.d.ts +1 -0
  24. package/lib/sync/messages.js +3 -0
  25. package/lib/sync/sync-engine.js +56 -7
  26. package/lib/ui/badge-tone.d.ts +16 -0
  27. package/lib/ui/badge-tone.js +20 -0
  28. package/lib/ui/i18n.d.ts +5 -5
  29. package/lib/ui/i18n.js +10 -10
  30. package/lib/ui/rollback-view.d.ts +95 -0
  31. package/lib/ui/rollback-view.js +55 -0
  32. package/lib/ui/selection-model.d.ts +140 -1
  33. package/lib/ui/selection-model.js +241 -9
  34. package/lib/utils/bundle-scan.d.ts +5 -3
  35. package/lib/utils/bundle-scan.js +5 -3
  36. package/lib/utils/line-diff.d.ts +7 -1
  37. package/lib/utils/line-diff.js +8 -2
  38. package/package.json +1 -1
  39. package/src/adapters/plugin-data-declared.ts +60 -0
  40. package/src/adapters/plugin-data.ts +7 -33
  41. package/src/adapters/plugins.ts +98 -13
  42. package/src/adapters/units.test.ts +26 -0
  43. package/src/client/chat/ChatEntryView.tsx +179 -49
  44. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  45. package/src/client/chat/chat-entry-view.test.ts +440 -1
  46. package/src/client/chat/chat-entry-view.ts +222 -11
  47. package/src/client/chat/chat-node-registration.ts +111 -6
  48. package/src/client/chat/chat-slots.ts +30 -12
  49. package/src/client/common/ContentPicker.tsx +581 -175
  50. package/src/client/common/DiffTable.tsx +12 -23
  51. package/src/client/common/host-primitives.ts +141 -0
  52. package/src/client/common/ui.tsx +17 -3
  53. package/src/client/config-manager.module.css +658 -404
  54. package/src/client/css-scale-guard.test.ts +41 -3
  55. package/src/client/css-token-guard.test.ts +10 -4
  56. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  57. package/src/client/export/ExportView.tsx +2 -0
  58. package/src/client/index.ts +22 -4
  59. package/src/client/library/RestorePlanView.tsx +0 -1
  60. package/src/client/locales.ts +54 -12
  61. package/src/client/recovery/RollbackCard.tsx +56 -2
  62. package/src/client/recovery/RollbackDiffPane.tsx +18 -6
  63. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  64. package/src/client/recovery/recovery-locales.ts +24 -4
  65. package/src/client/recovery/rollback-file-changes.test.ts +37 -1
  66. package/src/client/sync/PushPreviewCard.tsx +57 -0
  67. package/src/client/sync/SyncPanel.tsx +2 -37
  68. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  69. package/src/client/sync/sync-push-preview.test.ts +14 -0
  70. package/src/client/sync/sync-view.ts +9 -1
  71. package/src/core/messages.ts +2 -0
  72. package/src/core/rollback-attribution.test.ts +120 -0
  73. package/src/core/rollback-attribution.ts +267 -0
  74. package/src/core/rollback-ledger.ts +123 -7
  75. package/src/core/rollback-merge.test.ts +133 -0
  76. package/src/core/rollback-merge.ts +213 -0
  77. package/src/core/rollback-point.test.ts +1004 -17
  78. package/src/core/rollback-point.ts +1421 -175
  79. package/src/core/types.ts +19 -0
  80. package/src/index.facade.test.ts +60 -0
  81. package/src/index.ts +133 -19
  82. package/src/routes/recovery.ts +11 -0
  83. package/src/schema/section-registry.ts +5 -5
  84. package/src/sync/messages.ts +3 -0
  85. package/src/sync/sync-engine.ts +55 -7
  86. package/src/sync/sync-preview-push.test.ts +158 -0
  87. package/src/ui/badge-tone.test.ts +23 -0
  88. package/src/ui/badge-tone.ts +27 -0
  89. package/src/ui/i18n.ts +10 -10
  90. package/src/ui/rollback-view.test.ts +27 -0
  91. package/src/ui/rollback-view.ts +139 -0
  92. package/src/ui/selection-model.test.ts +240 -2
  93. package/src/ui/selection-model.ts +349 -1
  94. package/src/utils/bundle-scan.ts +5 -3
  95. package/src/utils/line-diff.test.ts +12 -0
  96. package/src/utils/line-diff.ts +9 -3
@@ -0,0 +1,12 @@
1
+ import type { PluginDataSource } from '../core/plugin-data-registry.ts';
2
+ import type { HostContext } from '../core/types.ts';
3
+ /** 单次扫描最多读取多少个已安装插件的 manifest(防御性上限:profile 依赖数量异常时不无限读) */
4
+ export declare const MAX_DECLARED_PACKAGES = 300;
5
+ /**
6
+ * 解析声明时用的 profile 目录名。
7
+ *
8
+ * 与 DSH 的 profile 解析同口径:空 / undefined = \`web\`(与改造前的私有实现一致)。
9
+ */
10
+ export declare function declaredProfileName(profile: string | undefined): string;
11
+ /** 扫描本机已安装插件自述的数据目录声明(零副作用;读不到的一律跳过)。 */
12
+ export declare function scanDeclaredDataSources(ctx: HostContext, maxPackages?: number): Promise<PluginDataSource[]>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 「已安装插件自述数据目录(dsh.dataDirs)」的**单一扫描实现**。
3
+ *
4
+ * 为什么单独一个模块:这份扫描有两个消费者 ——
5
+ * - \`pluginData\` / \`pluginAssets\` adapter(决定要收集哪些区域);
6
+ * - \`plugins\` adapter(导出选择器「按插件看」需要知道**每个插件自己声明的数据区域**,
7
+ * 才能在不读任何数据目录的前提下画出树骨架)。
8
+ * 两处各写一份必然漂移(同一条铁律:同一事实只有一份实现)。
9
+ *
10
+ * 语义(与改造前 plugin-data.ts 的私有实现逐字一致):
11
+ * - 读的是 **profile 实际安装**的那份 manifest(\`profiles/<profile>/node_modules/<pkg>/package.json\`),
12
+ * 不是全局目录 —— 同一个包在不同 profile 可以是不同版本,声明必须取本 profile 装的;
13
+ * - 读不到(未安装 / 无声明 / 形状非法)一律跳过:本机制是**附加**信息,绝不因为一个包读不出来
14
+ * 就影响内置来源或整次导出;
15
+ * - 本函数**不缓存**(缓存策略由调用方决定:pluginData 有实例级 TTL 缓存,plugins 有模块级缓存)。
16
+ */
17
+ import { parseDeclaredDataDirs } from '../core/plugin-data-registry.js';
18
+ /** 单次扫描最多读取多少个已安装插件的 manifest(防御性上限:profile 依赖数量异常时不无限读) */
19
+ export const MAX_DECLARED_PACKAGES = 300;
20
+ /**
21
+ * 解析声明时用的 profile 目录名。
22
+ *
23
+ * 与 DSH 的 profile 解析同口径:空 / undefined = \`web\`(与改造前的私有实现一致)。
24
+ */
25
+ export function declaredProfileName(profile) {
26
+ return profile !== undefined && profile !== '' ? profile : 'web';
27
+ }
28
+ /** 扫描本机已安装插件自述的数据目录声明(零副作用;读不到的一律跳过)。 */
29
+ export async function scanDeclaredDataSources(ctx, maxPackages = MAX_DECLARED_PACKAGES) {
30
+ const base = 'profiles/' + declaredProfileName(ctx.profile);
31
+ const out = [];
32
+ let deps = [];
33
+ try {
34
+ const raw = await ctx.fs.readFile(base + '/package.json');
35
+ const parsed = JSON.parse(new TextDecoder().decode(raw));
36
+ const dep = parsed?.dependencies;
37
+ if (dep !== null && typeof dep === 'object')
38
+ deps = Object.keys(dep).slice(0, maxPackages);
39
+ }
40
+ catch {
41
+ deps = [];
42
+ }
43
+ for (const name of deps) {
44
+ try {
45
+ const raw = await ctx.fs.readFile(base + '/node_modules/' + name + '/package.json');
46
+ const manifest = JSON.parse(new TextDecoder().decode(raw));
47
+ const source = parseDeclaredDataDirs(name, manifest);
48
+ if (source !== null)
49
+ out.push(source);
50
+ }
51
+ catch {
52
+ // 未安装 / 无 manifest → 无声明,跳过
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ //# sourceMappingURL=plugin-data-declared.js.map
@@ -47,13 +47,11 @@ export declare class PluginDataAdapter extends FileCollectionAdapter {
47
47
  private allowedEntries;
48
48
  private allSources;
49
49
  /**
50
- * 已安装插件的自述声明(dsh.dataDirs)。
50
+ * 已安装插件的自述声明(dsh.dataDirs)—— 本方法只做**缓存**,扫描实现见 plugin-data-declared.ts。
51
51
  *
52
- * 为什么读 profile 的 node_modules 而不是全局目录:DSH 的插件装在 \`profiles/<profile>/node_modules\`
53
- * 下(pnpm 隔离布局),且同一个包在不同 profile 可以是不同版本 —— 声明必须取**本 profile 实际装的**那份。
54
- *
55
- * 读不到(未安装 / 无声明 / 形状非法)一律跳过:本机制是**附加**信息,绝不因为一个包读不出来
56
- * 就影响内置来源或整次导出。
52
+ * 为什么缓存必须带 ctx 键(homeDir + profile):DSH 的插件装在 \`profiles/<profile>/node_modules\`
53
+ * 下(pnpm 隔离布局),而「这台机器装了某插件」不代表另一台装了 —— 缓存若按实例共享,
54
+ * 导入到「没装该插件」的目标机时会拿源机的声明去放行写入,白名单就变成「谁先导入谁说了算」。
57
55
  */
58
56
  private declaredSources;
59
57
  /**
@@ -30,13 +30,12 @@ import { normalizePath } from '../utils/paths.js';
30
30
  import { engineMsg } from './messages.js';
31
31
  import { listFilesDetailed } from './link-report.js';
32
32
  import { sectionMeta } from '../schema/section-registry.js';
33
- import { BUILTIN_PLUGIN_DATA_SOURCES, areaOfPath, areasForSection, isExcludedRel, normalizeHomeRel, parseDeclaredDataDirs, } from '../core/plugin-data-registry.js';
33
+ import { BUILTIN_PLUGIN_DATA_SOURCES, areaOfPath, areasForSection, isExcludedRel, normalizeHomeRel, } from '../core/plugin-data-registry.js';
34
+ import { declaredProfileName, scanDeclaredDataSources } from './plugin-data-declared.js';
34
35
  import { osHomeDirOf } from '../core/backup.js';
35
36
  import { FileCollectionAdapter } from './file-collection.js';
36
37
  /** 声明来源缓存的 TTL(毫秒):一次导出/导入/执行里会多次解析,逐次读 15+ 个 package.json 是净开销 */
37
38
  const DECLARED_TTL_MS = 5_000;
38
- /** 单次解析里最多读取多少个已安装插件的 manifest(防御性上限:profile 依赖数量异常时不无限读) */
39
- const MAX_DECLARED_PACKAGES = 300;
40
39
  export class PluginDataAdapter extends FileCollectionAdapter {
41
40
  id;
42
41
  displayName;
@@ -75,48 +74,21 @@ export class PluginDataAdapter extends FileCollectionAdapter {
75
74
  return [...this.builtin, ...declared];
76
75
  }
77
76
  /**
78
- * 已安装插件的自述声明(dsh.dataDirs)。
77
+ * 已安装插件的自述声明(dsh.dataDirs)—— 本方法只做**缓存**,扫描实现见 plugin-data-declared.ts。
79
78
  *
80
- * 为什么读 profile 的 node_modules 而不是全局目录:DSH 的插件装在 \`profiles/<profile>/node_modules\`
81
- * 下(pnpm 隔离布局),且同一个包在不同 profile 可以是不同版本 —— 声明必须取**本 profile 实际装的**那份。
82
- *
83
- * 读不到(未安装 / 无声明 / 形状非法)一律跳过:本机制是**附加**信息,绝不因为一个包读不出来
84
- * 就影响内置来源或整次导出。
79
+ * 为什么缓存必须带 ctx 键(homeDir + profile):DSH 的插件装在 \`profiles/<profile>/node_modules\`
80
+ * 下(pnpm 隔离布局),而「这台机器装了某插件」不代表另一台装了 —— 缓存若按实例共享,
81
+ * 导入到「没装该插件」的目标机时会拿源机的声明去放行写入,白名单就变成「谁先导入谁说了算」。
85
82
  */
86
83
  async declaredSources(ctx) {
87
84
  const now = Date.now();
88
- const profile = ctx.profile !== undefined && ctx.profile !== '' ? ctx.profile : 'web';
89
- const memoKey = ctx.homeDir + '|' + profile;
85
+ const memoKey = ctx.homeDir + '|' + declaredProfileName(ctx.profile);
90
86
  if (this.declaredMemo !== undefined
91
87
  && this.declaredMemo.key === memoKey
92
88
  && now - this.declaredMemo.at < this.declaredTtlMs) {
93
89
  return this.declaredMemo.sources;
94
90
  }
95
- const base = 'profiles/' + profile;
96
- const out = [];
97
- let deps = [];
98
- try {
99
- const raw = await ctx.fs.readFile(base + '/package.json');
100
- const parsed = JSON.parse(new TextDecoder().decode(raw));
101
- const dep = parsed?.dependencies;
102
- if (dep !== null && typeof dep === 'object')
103
- deps = Object.keys(dep).slice(0, MAX_DECLARED_PACKAGES);
104
- }
105
- catch {
106
- deps = [];
107
- }
108
- for (const name of deps) {
109
- try {
110
- const raw = await ctx.fs.readFile(base + '/node_modules/' + name + '/package.json');
111
- const manifest = JSON.parse(new TextDecoder().decode(raw));
112
- const source = parseDeclaredDataDirs(name, manifest);
113
- if (source !== null)
114
- out.push(source);
115
- }
116
- catch {
117
- // 未安装 / 无 manifest → 无声明,跳过
118
- }
119
- }
91
+ const out = await scanDeclaredDataSources(ctx);
120
92
  this.declaredMemo = { key: memoKey, at: now, sources: out };
121
93
  return out;
122
94
  }
@@ -23,6 +23,8 @@ import { isENOENT } from '../utils/guards.js';
23
23
  import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.js';
24
24
  import { readEffectivePatchLines, resolveWriteLayer, USER_PATCH_FILE } from '../core/patch-layers.js';
25
25
  import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.js';
26
+ import { BUILTIN_PLUGIN_DATA_SOURCES, areasForSection } from '../core/plugin-data-registry.js';
27
+ import { declaredProfileName, scanDeclaredDataSources } from './plugin-data-declared.js';
26
28
  import { sectionMeta } from '../schema/section-registry.js';
27
29
  import { validateJsonSection } from './json-section.js';
28
30
  /**
@@ -126,6 +128,66 @@ function entriesOfRaw(raw) {
126
128
  * 导出产物(真实 base64)走另一条分支,行为逐字不变。
127
129
  */
128
130
  const previewTarballBytes = new WeakMap();
131
+ /**
132
+ * 「插件包名 → 它声明的数据区域」映射(只有 \`plugin:<包名>\` 单元用;导出选择器「按插件看」的树骨架)。
133
+ *
134
+ * 为什么挂 WeakMap 而不是给 PluginsSection 加字段:这是**展示用旁路信息** —— 既不是备份格式的
135
+ * 一部分(schema 校验与市场防线都不该看见它),也与「导出真的带什么」无关;挂在载荷对象上随对象
136
+ * 一起回收,载荷本身逐字节不变(与 previewTarballBytes 同一套理由)。
137
+ */
138
+ const relatedAreasByData = new WeakMap();
139
+ /** 内置来源 + 自述声明的扫描缓存(TTL 内的同一 home/profile 只扫一次;/export-preview 一趟会问两次) */
140
+ const declaredSourcesMemo = { key: '', at: 0, sources: [] };
141
+ const DECLARED_MEMO_TTL_MS = 5_000;
142
+ /**
143
+ * 本机可归属的插件数据来源 = 内置来源表 + 已安装插件自述声明。
144
+ *
145
+ * 缓存键必须带 homeDir + profile:同一台机器换 profile 就是另一批已安装插件,拿旧声明画树
146
+ * 会让用户看到不属于当前 profile 的数据区域。
147
+ */
148
+ async function pluginDataSources(ctx) {
149
+ const key = ctx.homeDir + '|' + declaredProfileName(ctx.profile);
150
+ const now = Date.now();
151
+ if (declaredSourcesMemo.key === key && now - declaredSourcesMemo.at < DECLARED_MEMO_TTL_MS) {
152
+ return declaredSourcesMemo.sources;
153
+ }
154
+ const sources = [...BUILTIN_PLUGIN_DATA_SOURCES, ...await scanDeclaredDataSources(ctx)];
155
+ declaredSourcesMemo.key = key;
156
+ declaredSourcesMemo.at = now;
157
+ declaredSourcesMemo.sources = sources;
158
+ return sources;
159
+ }
160
+ /**
161
+ * 包名 → 该插件的数据区域引用。
162
+ *
163
+ * 口径与 pluginData / pluginAssets 的收集面**同源**(同一个 areasForSection):role=config 进
164
+ * pluginData、role=assets 进 pluginAssets;role=cache 的区域不属于任何分区,因此**绝不进树**
165
+ * (缓存是可再生产物,列出来只会诱导用户把它带走)。
166
+ *
167
+ * 只产出「注册表说它应该在哪儿」,不代表磁盘上真有 —— 界面必须把两者分开渲染。
168
+ */
169
+ async function relatedAreasOf(ctx) {
170
+ const sources = await pluginDataSources(ctx);
171
+ const map = new Map();
172
+ for (const source of sources) {
173
+ const refs = [];
174
+ for (const section of ['pluginData', 'pluginAssets']) {
175
+ for (const entry of areasForSection([source], section)) {
176
+ refs.push({ section, relPath: entry.area.relPath, role: entry.area.role === 'assets' ? 'assets' : 'config' });
177
+ }
178
+ }
179
+ if (refs.length === 0)
180
+ continue;
181
+ for (const pkg of source.packages) {
182
+ const cur = map.get(pkg);
183
+ if (cur === undefined)
184
+ map.set(pkg, [...refs]);
185
+ else
186
+ cur.push(...refs);
187
+ }
188
+ }
189
+ return map;
190
+ }
129
191
  /**
130
192
  * 零 spawn 推导本地源插件的体积(预览专用)。
131
193
  *
@@ -179,7 +241,7 @@ async function measureLocalSources(plugins, ctx) {
179
241
  * `tarballBytes` = 预览路径登记到的本地源体积(见 measureLocalSources,0 = 体积未知但仍随包携带);
180
242
  * 缺省时体积与文件数完全按导出产物里的 base64 计算 —— 与改造前逐字一致。
181
243
  */
182
- function unitsOf(data, tarballBytes) {
244
+ function unitsOf(data, tarballBytes, related) {
183
245
  const wsId = 'plugins:pnpm-workspace';
184
246
  const patchFileIds = (data.patchFiles ?? []).map((pf) => `plugins:patch:${normalizePath(pf.relativePath)}`);
185
247
  const group = [wsId, ...patchFileIds];
@@ -187,6 +249,7 @@ function unitsOf(data, tarballBytes) {
187
249
  for (const pl of data.plugins) {
188
250
  const tarball = tarballOf(data, pl.name);
189
251
  const measured = tarballBytes?.get(pl.name);
252
+ const areas = related?.get(pl.name);
190
253
  units.push({
191
254
  id: `plugin:${pl.name}`,
192
255
  label: pl.name,
@@ -194,6 +257,8 @@ function unitsOf(data, tarballBytes) {
194
257
  // base64 长度 → 原始字节数(仅用于展示体积,不求精确);预览路径没有字节,用 stat 量到的源体积
195
258
  sizeBytes: tarball !== undefined ? Math.floor((tarball.base64.length * 3) / 4) : measured ?? 0,
196
259
  ...(tarball !== undefined || measured !== undefined ? { fileCount: 1 } : {}),
260
+ // 「按插件看」的树骨架:该插件声明的数据区域(零数据目录 I/O)
261
+ ...(areas !== undefined && areas.length > 0 ? { relatedAreas: areas } : {}),
197
262
  });
198
263
  }
199
264
  for (const line of data.patch) {
@@ -260,7 +325,7 @@ export class PluginsAdapter {
260
325
  section: collected,
261
326
  // 与 src/index.ts 对 JSON 分区现算体积的口径一致(utf8 字节数)
262
327
  sizeBytes: new TextEncoder().encode(stringifyJsonSafe(data)).length,
263
- items: unitsOf(data, tarballBytes),
328
+ items: unitsOf(data, tarballBytes, relatedAreasByData.get(data)),
264
329
  };
265
330
  }
266
331
  /**
@@ -401,16 +466,30 @@ export class PluginsAdapter {
401
466
  const alive = new Set(keptPlugins.map((pl) => pl.name));
402
467
  keptTarballs = localTarballs?.filter((t) => alive.has(t.packageName));
403
468
  }
469
+ const data = {
470
+ version: 1,
471
+ plugins: keptPlugins,
472
+ patch: keptPatch,
473
+ pnpmWorkspace: keptWorkspace,
474
+ ...(keptTarballs !== undefined ? { localTarballs: keptTarballs } : {}),
475
+ ...(keptPatchFiles !== undefined ? { patchFiles: keptPatchFiles } : {}),
476
+ };
477
+ /**
478
+ * 「按插件看」的树骨架(导出选择器):登记每个插件声明的数据区域。
479
+ * 只登记「至少有一个区域」的插件 —— 其余插件单元保持无该字段,界面据此不显示空壳节点。
480
+ * 归属信息是**附加**信息:扫描失败不得影响导出/预览本身(与 pluginData 同一纪律)。
481
+ */
482
+ try {
483
+ const related = await relatedAreasOf(ctx);
484
+ if (related.size > 0)
485
+ relatedAreasByData.set(data, related);
486
+ }
487
+ catch {
488
+ // 读不到声明就不登记(界面按「没有已知关联」渲染,绝不猜)
489
+ }
404
490
  return {
405
491
  sectionId: 'plugins',
406
- data: {
407
- version: 1,
408
- plugins: keptPlugins,
409
- patch: keptPatch,
410
- pnpmWorkspace: keptWorkspace,
411
- ...(keptTarballs !== undefined ? { localTarballs: keptTarballs } : {}),
412
- ...(keptPatchFiles !== undefined ? { patchFiles: keptPatchFiles } : {}),
413
- },
492
+ data,
414
493
  counts: {
415
494
  plugins: keptPlugins.length,
416
495
  patchLines: keptPatch.length,
@@ -429,8 +508,9 @@ export class PluginsAdapter {
429
508
  * 由选择模型保证勾选/取消同步。
430
509
  */
431
510
  listUnits(section) {
432
- // 预览产物没有 tarball 字节,体积来自 measureLocalSources 挂上的统计(导出产物走 base64 分支)
433
- return unitsOf(section.data, previewTarballBytes.get(section.data));
511
+ // 预览产物没有 tarball 字节,体积来自 measureLocalSources 挂上的统计(导出产物走 base64 分支);
512
+ // 「按插件看」的数据区域骨架同样由 collect() 登记在载荷对象上(两条路径逐字一致,units.test 钉住)
513
+ return unitsOf(section.data, previewTarballBytes.get(section.data), relatedAreasByData.get(section.data));
434
514
  }
435
515
  /**
436
516
  * 导入后目标机**可能**存在的 patch 文件集合(相对 profile 目录)——issue #35。
package/lib/client.d.ts CHANGED
@@ -185,6 +185,24 @@ interface ExportUnit {
185
185
  * 只下发**事实**,不下发文案 —— 界面按当前语言显示(与 DSH 同口径),绝不用 uuid 冒充标题。
186
186
  */
187
187
  blank?: boolean;
188
+ /**
189
+ * 该插件**声明的第三方数据区域**(只有 \`plugin:<包名>\` 单元可能有值;导出选择器「按插件看」用)。
190
+ *
191
+ * 用途:选择器要在**不读任何数据目录**的前提下画出「插件 → 数据目录 / 大文件」的树骨架 ——
192
+ * 区域清单来自来源注册表(内置来源表 + 插件自述 dsh.dataDirs),是静态知识,零额外 I/O;
193
+ * 体积与「实际有没有内容」等该分区的清单到货后再填。
194
+ *
195
+ * 语义:**纯展示**,不参与勾选/导出契约(契约只认 id)。这里是「注册表说它应该在哪儿」,
196
+ * **不是**「磁盘上确实有」—— 界面必须把两者分开渲染(unread / 无内容 / 已读到)。
197
+ */
198
+ relatedAreas?: readonly PluginAreaRef[];
199
+ }
200
+ /** 插件数据区域引用(pluginData / pluginAssets;只读展示用,不参与勾选契约)。 */
201
+ interface PluginAreaRef {
202
+ section: 'pluginData' | 'pluginAssets';
203
+ /** home 相对 POSIX 路径 —— 就是该分区单元的 id 后缀(\`pluginData:.mnemon/runtime\` 的末段) */
204
+ relPath: string;
205
+ role: 'config' | 'assets';
188
206
  }
189
207
  type PlanItemKind = 'Create' | 'Update' | 'Skip' | 'Conflict' | 'Install' | 'MissingSecret' | 'MissingDependency' | 'PathMapping' | 'Warning' | 'Error';
190
208
  type ItemResolution = 'keepCurrent' | 'useImported' | 'review';
@@ -647,6 +665,12 @@ interface SnapshotMeta {
647
665
  * (maxLines / maxD)时**降级为整块替换**(全删 + 全增),保证任何输入都有确定
648
666
  * 耗时的输出 —— 大文件不会卡死宿主。
649
667
  *
668
+ * 预算怎么定(2026-10-15 真机实测,本仓 rollback-point.ts 的一对版本):
669
+ * · 时间是 (N+M)·D 量级:D=4000、两侧合计 ~7500 行时 ≈ 70 ms(D=8000 ≈ 115 ms);
670
+ * · 内存是 O(D²)·4B(回溯要逐轮留 V 快照):D=2000 时 RSS +~30 MB、D=4000 +~100 MB、D=8000 +~175 MB。
671
+ * ⇒ 缺省取 2000(覆盖「大文件只改了几处」这一大类);单文件按需查看的调用方可以往上调,
672
+ * 到 4000 仍是一次点击可接受的开销;再大就该换线性空间算法,而不是继续加预算。
673
+ *
650
674
  * 行为约定:
651
675
  * - 行尾 \r\n 归一为 \n;文本末尾的换行不产生"空行"(行数 = 换行符语义)。
652
676
  * - 仅空白差异同样是差异(不做 ignoreWhitespace)。
@@ -1523,7 +1547,7 @@ declare const uiZh: {
1523
1547
  readonly 'error.downloadFailed': '下载失败:HTTP {status}';
1524
1548
  readonly 'error.exportTimeout': '导出超时({minutes} 分钟):导出过程未完成,请重试;若反复超时请检查 DSH 状态';
1525
1549
  readonly 'error.syncTimeout': '同步请求超时({minutes} 分钟):请检查网络与仓库可达性后重试';
1526
- readonly 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):恢复过程未完成,请重试;若反复超时请检查 DSH 状态';
1550
+ readonly 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):过程可能仍在后台进行,请先刷新预览确认;确实未完成再重试';
1527
1551
  readonly 'error.settingsConflict.title': '配置已被并发修改';
1528
1552
  readonly 'error.settingsConflict.action': '目标 DSH 的该配置在导入期间被其他进程修改,为避免覆盖请重新导出或手动核对后再试。';
1529
1553
  readonly 'error.notConfirmed.title': '导入未确认';
@@ -1948,10 +1972,10 @@ declare const uiZh: {
1948
1972
  readonly 'export.category.settings': 'DSH 全局设置(namespace 分区,redacted)';
1949
1973
  readonly 'export.category.providers': 'LLM Provider / Model / 默认模型 / BaseURL';
1950
1974
  readonly 'export.category.plugins': '已安装插件清单与启用状态(不含二进制)';
1951
- readonly 'export.category.pluginFiles': '其他插件放在 ~/.dsh 根下的零散配置文件(白名单 + plugin-config/ 目录,整文件复制)';
1952
- readonly 'export.category.self': '本插件自身配置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)';
1953
- readonly 'export.category.pluginData': '其他插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)';
1954
- readonly 'export.category.pluginAssets': '其他插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)';
1975
+ readonly 'export.category.pluginFiles': '插件放在 ~/.dsh 根下的配置文件(白名单 + plugin-config/ 目录,整文件复制)';
1976
+ readonly 'export.category.self': '配置管理器自身的设置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)';
1977
+ readonly 'export.category.pluginData': '插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)';
1978
+ readonly 'export.category.pluginAssets': '插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)';
1955
1979
  readonly 'export.category.mcp': 'MCP 服务器组合配置(需重启生效)';
1956
1980
  readonly 'export.category.prompts': 'System Prompt / Plan Mode 提示';
1957
1981
  readonly 'export.category.skills': '用户技能文件(~/.dsh/skills)';
@@ -4158,6 +4182,23 @@ interface RollbackPreviewFile {
4158
4182
  removed: number | null;
4159
4183
  /** true = 本次没有可信行数(added/removed 均为 null)。**宁可说「不知道」,也绝不谎报行数。** */
4160
4184
  truncated: boolean;
4185
+ /**
4186
+ * true = 该文件在留点**之后**是**别的会话**写的(写入归属台账证明;见 core/rollback-attribution.ts)。
4187
+ * 界面据此默认不勾选它(用户仍可勾上)。缺字段 / false = **没有归属证据**,
4188
+ * 而不是「本会话写的」——「未判定」与「确知是本会话」必须分开。
4189
+ */
4190
+ foreign?: boolean;
4191
+ /** 那些「别的会话」的会话 id(仅当 foreign 为 true) */
4192
+ foreignSessionId?: string;
4193
+ /** 那次写入的时间(毫秒;仅当 foreign 为 true) */
4194
+ foreignAt?: number;
4195
+ /**
4196
+ * true = 这次回退**不会改动它**(与其它对话的改动冲突 / 无法安全合并)。行级统计为 null ——
4197
+ * 「不改动」不是「0 行变化」,界面必须分开说。
4198
+ */
4199
+ skipped?: boolean;
4200
+ /** 不改动的机器原因(界面映射成字典键,绝不裸渲染) */
4201
+ mergeReason?: string;
4161
4202
  }
4162
4203
  interface RollbackWorkspacePreview {
4163
4204
  /** 将被写回(覆盖)的条目数 */
@@ -4279,6 +4320,12 @@ interface RollbackFileDiffResult {
4279
4320
  identical: boolean;
4280
4321
  degraded: boolean;
4281
4322
  reason?: RollbackFileDiffReason;
4323
+ /** true = 「回退后」一侧显示的是三方合并的结果(不是记录里的原文) */
4324
+ merged?: boolean;
4325
+ /** true = 这次回退不会改动它(冲突 / 无法安全合并)——此时两侧相同 */
4326
+ mergeSkipped?: boolean;
4327
+ /** 不改动的机器原因(界面映射成字典键) */
4328
+ mergeReason?: string;
4282
4329
  }
4283
4330
  interface RollbackSegmentResult {
4284
4331
  state: string;
@@ -4304,6 +4351,22 @@ interface RollbackSegmentResult {
4304
4351
  createdRefusedReason?: string | null;
4305
4352
  /** 工作区段:实际写回的字节数(宿主回传才有) */
4306
4353
  writtenBytes?: number;
4354
+ /** 因归属隔离(B)跳过的记录条目数:该文件在留点后被**别的会话**写过(宿主回传才有) */
4355
+ foreignSkipped?: number;
4356
+ /** 因路径选择(A)跳过的记录条目数(宿主回传才有) */
4357
+ selectionSkipped?: number;
4358
+ /** 三方合并成功、写回「撤销本会话 + 保留对方改动」的条目数(宿主回传才有) */
4359
+ mergedCount?: number;
4360
+ mergedPaths?: string[];
4361
+ /** 冲突:两侧改到同一处 ⇒ 未写(宿主回传才有) */
4362
+ conflictCount?: number;
4363
+ conflictPaths?: string[];
4364
+ /** 无法安全撤销 ⇒ 未写(宿主回传才有) */
4365
+ unavailableCount?: number;
4366
+ unavailablePaths?: string[];
4367
+ /** 写入前发现又被改过(并发)⇒ 未写(宿主回传才有) */
4368
+ concurrentCount?: number;
4369
+ concurrentPaths?: string[];
4307
4370
  /** 兼容形态:宿主只回传计数时也算得出来 */
4308
4371
  createdAfter?: RollbackCount;
4309
4372
  createdAfterTruncated?: boolean;
@@ -4342,6 +4405,13 @@ interface RollbackRewindOptions {
4342
4405
  segments?: readonly RollbackState[];
4343
4406
  allowPartial?: boolean;
4344
4407
  preRewindGuard?: RollbackGuardPolicy;
4408
+ /**
4409
+ * 路径级选择性恢复(2026-10-17 定案 A):只恢复这些路径。
4410
+ *
4411
+ * 缺省 = 全量口径(一字不变)。**空数组是无效输入**(宿主 400)—— 「一个都没勾」由界面拦住,
4412
+ * 绝不让它变成「整次回退静默 noop」。
4413
+ */
4414
+ onlyPaths?: readonly string[];
4345
4415
  }
4346
4416
  /** 端口:由 src/client/recovery/recovery-api.ts 实现(Partial 消费,缺失时界面如实说能力缺失)。 */
4347
4417
  interface RollbackPointPort {
@@ -4683,20 +4753,20 @@ declare const zh$4: {
4683
4753
  readonly 'section.settings': '设置';
4684
4754
  readonly 'section.ui': 'UI 偏好';
4685
4755
  readonly 'section.providers': 'Provider 与模型';
4686
- readonly 'section.plugins': '插件';
4756
+ readonly 'section.plugins': '插件清单';
4687
4757
  readonly 'section.mcp': 'MCP 服务器';
4688
4758
  readonly 'section.prompts': '提示词';
4689
4759
  readonly 'section.skills': '技能';
4690
4760
  readonly 'section.agentPresets': 'Agent 预设';
4691
4761
  readonly 'section.agentInstructions': '全局指令';
4692
4762
  readonly 'section.workspaces': '工作区';
4693
- readonly 'section.pluginFiles': '其他插件零散文件';
4763
+ readonly 'section.pluginFiles': '插件散落配置';
4694
4764
  readonly 'section.credentialsStatus': '凭据状态';
4695
4765
  readonly 'section.secrets': '密钥';
4696
4766
  readonly 'section.sessions': '历史会话';
4697
- readonly 'section.self': '本插件自身配置';
4698
- readonly 'section.pluginData': '其他插件数据';
4699
- readonly 'section.pluginAssets': '其他插件大文件';
4767
+ readonly 'section.self': '配置管理器设置';
4768
+ readonly 'section.pluginData': '插件数据目录';
4769
+ readonly 'section.pluginAssets': '插件大文件';
4700
4770
  readonly 'view.about': '关于';
4701
4771
  readonly 'about.snippet.environment': '### 环境 / Environment';
4702
4772
  readonly 'about.releaseNotes.rateLimited': 'GitHub API 速率限制(Rate limit exceeded),请稍后再试或直接在 GitHub 上查看。';
@@ -4836,6 +4906,26 @@ declare const zh$4: {
4836
4906
  readonly 'picker.sensitive': '敏感';
4837
4907
  readonly 'picker.showAll': '显示全部(共 {count} 条)';
4838
4908
  readonly 'picker.summaryImport': '将导入 {sections} 个分区 · {units} 个条目';
4909
+ readonly 'picker.pluginRoot': '插件';
4910
+ readonly 'picker.pluginLeafPlugin': '插件清单条目';
4911
+ readonly 'picker.pluginLeafConfig': '数据目录(配置)';
4912
+ readonly 'picker.pluginLeafAssets': '大文件(壁纸 / 数据库)';
4913
+ readonly 'picker.pluginAreaUnread': '未读取';
4914
+ readonly 'picker.pluginAreaAbsent': '无内容';
4915
+ readonly 'picker.pluginViewEmpty': '插件清单还没读到,先读取「插件」分区。';
4916
+ readonly 'picker.bucketPlugins': '插件分区的其他条目(补丁行 / pnpm-workspace.yaml)';
4917
+ readonly 'picker.bucketLooseFiles': '未归属到插件的配置(~/.dsh 根)';
4918
+ readonly 'picker.bucketHint': '这些条目没有可证明的插件归属,不猜;展开可逐条勾选。';
4919
+ readonly 'picker.groupHint.extensions': '这一组里的插件分区合并成一棵按插件分组的树:默认只显示一个「插件」区块,展开它才列出各个插件,插件名下面是它的清单条目、数据目录与大文件。只显示能证明的归属(内置来源表 + 插件自述 dsh.dataDirs)——「插件散落配置」没有包名信息,因此留在「未归属」里;带「设备相关」的分区默认不勾选,跨设备导入时可能不适用。';
4920
+ readonly 'group.general': '通用';
4921
+ readonly 'group.ai': 'AI';
4922
+ readonly 'group.extensions': '插件与扩展';
4923
+ readonly 'group.mcp': 'MCP / 工具';
4924
+ readonly 'group.customization': '定制';
4925
+ readonly 'group.automation': '自动化';
4926
+ readonly 'group.workspace': '工作区';
4927
+ readonly 'group.ui': '界面';
4928
+ readonly 'group.optional': '可选数据';
4839
4929
  readonly 'import.nothingSelected': '当前没有勾选任何要导入的内容,请把需要导入的分区或条目勾上。';
4840
4930
  readonly 'import.excludedByUser': '本次有 {count} 项被你取消勾选:未导入,也未进快照。';
4841
4931
  readonly 'import.consult.hint': '只读分析:先看结论与依据,确认后再选择要导入的内容。';
@@ -4997,7 +5087,6 @@ declare const zh$4: {
4997
5087
  readonly 'snapshots.plan.diffEscape': '路径越界,已拒绝读取';
4998
5088
  readonly 'snapshots.plan.diffNotApplicable': '该动作不是文件内容变更,无逐行对照';
4999
5089
  readonly 'snapshots.plan.diffDegraded': '差异过大,已按整块对比显示';
5000
- readonly 'snapshots.plan.diffTruncated': '仅显示前 {shown} 行(共 {total} 行)';
5001
5090
  readonly 'snapshots.plan.statSkipped.tooLarge': '体积超上限,未统计';
5002
5091
  readonly 'snapshots.plan.statSkipped.binary': '二进制';
5003
5092
  readonly 'snapshots.plan.statSkipped.budget': '未统计(文件数上限)';
@@ -6485,7 +6574,7 @@ declare const zh$1: {
6485
6574
  readonly 'rollback.code.fork-failed': 'fork 子会话失败,会话段未回退';
6486
6575
  readonly 'rollback.code.capture-failed': '该态留点失败';
6487
6576
  readonly 'rollback.code.capture-truncated': '该态留点到达上限被截断(已如实标注)';
6488
- readonly 'rollback.code.capture-degraded': '该态留点不完整(有链接或读不到的内容被跳过,已逐条留痕)';
6577
+ readonly 'rollback.code.capture-degraded': '该态留点不完整(有读不到或期间被改写的内容,已逐条留痕)';
6489
6578
  readonly 'rollback.code.no-chunks': '没有给出工作区路径分块';
6490
6579
  readonly 'rollback.code.chunk-not-absolute': '工作区分块必须是绝对路径';
6491
6580
  readonly 'rollback.code.chunk-not-found': '工作区分块不存在或不可用';
@@ -6524,6 +6613,14 @@ declare const zh$1: {
6524
6613
  readonly 'rollback.preview.notice.skipped': '{n} 项在留点时被跳过(未记录;已计入证明名单 ⇒ 不参与删除判据)';
6525
6614
  readonly 'rollback.preview.notice.truncated': '该点捕获被上限截断(超单文件上限 / 字节预算 / 文件数预算);删除判据另看证明名单(路径索引)是否完整';
6526
6615
  readonly 'rollback.preview.files.title': '将被回退的文件:';
6616
+ readonly 'rollback.preview.select.foreign': '其它对话改过';
6617
+ readonly 'rollback.preview.select.toggle': '「{path}」:勾上则这次回退恢复它';
6618
+ readonly 'rollback.result.foreignSkipped': '{n} 个文件在留点后被其它对话改过,这次没有回退它们';
6619
+ readonly 'rollback.result.merged': '{n} 个文件已合并:撤销本次对话的改动,保留其它对话的改动';
6620
+ readonly 'rollback.result.conflict': '{n} 个文件两侧改到同一处,已保持原样 —— 需要你手工处理';
6621
+ readonly 'rollback.result.unavailable': '{n} 个文件无法安全撤销(缺少中间版本 / 二进制 / 差异过大),已保持原样';
6622
+ readonly 'rollback.result.concurrent': '{n} 个文件在回退过程中又被改过,已保持原样';
6623
+ readonly 'rollback.result.selectionSkipped': '{n} 个文件按你的勾选跳过了';
6527
6624
  readonly 'rollback.preview.files.empty': '工作区与该回退点一致,无需恢复';
6528
6625
  readonly 'rollback.preview.files.unavailable': '该回退点的工作区态没有可用的记录(原因:{reason}),无需恢复';
6529
6626
  readonly 'rollback.preview.files.unavailableSession': '该回退点的工作区态没有可用的记录(原因:{reason});会话仍会回退到该点之前';
@@ -6533,6 +6630,9 @@ declare const zh$1: {
6533
6630
  readonly 'rollback.preview.file.removed': '−{n}';
6534
6631
  /** 整文件删除(用户 2026-10-12 第 6 条):写「删除」而不是 `+0 −N` —— 那是两件事。 */
6535
6632
  readonly 'rollback.preview.file.deleted': '删除';
6633
+ readonly 'rollback.preview.file.skipped': '不会改动(与其它对话的改动冲突,或无法安全合并)';
6634
+ readonly 'rollback.diff.merged': '下面是合并后的结果:撤销本对话的改动、保留其它对话的改动';
6635
+ readonly 'rollback.diff.skipped': '这次回退不会改动它(与其它对话的改动冲突,或无法安全合并)';
6536
6636
  readonly 'rollback.preview.file.unknown': '行数未统计';
6537
6637
  readonly 'rollback.preview.file.unknownHint': '这些文件的行数没有统计出来(文件过大、不是文本或内容副本读不出来)—— 回退仍会照常覆盖它们。';
6538
6638
  readonly 'rollback.diff.viewHint': '查看差异:{path}';
@@ -6541,7 +6641,6 @@ declare const zh$1: {
6541
6641
  readonly 'rollback.diff.after': '回退后';
6542
6642
  readonly 'rollback.diff.identical': '该文件与回退点内容一致,回退不会改动它';
6543
6643
  readonly 'rollback.diff.degraded': '差异规模较大,已按整块替换展示(仍是完整结果,只是不是最小的编辑脚本)';
6544
- readonly 'rollback.diff.truncated': '只画了前 {shown} 行(共 {total} 行),数据本身是完整的';
6545
6644
  readonly 'rollback.diff.reason.missingBlob': '这份内容副本读不出来,无法逐行对照(回退仍会尝试写回它)';
6546
6645
  readonly 'rollback.diff.reason.binary': '不是文本文件,没有逐行差异可看';
6547
6646
  readonly 'rollback.diff.reason.tooLarge': '文件超过展示上限,没有逐行差异可看(回退仍会照常覆盖它)';