dsh-config-manager 0.1.72 → 0.1.76
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/README.md +40 -4
- package/README.zh-CN.md +40 -4
- package/lib/adapters/index.d.ts +1 -0
- package/lib/adapters/index.js +5 -0
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-data.d.ts +114 -0
- package/lib/adapters/plugin-data.js +309 -0
- package/lib/adapters/self.d.ts +1 -1
- package/lib/adapters/self.js +2 -1
- package/lib/adapters/test-helpers.d.ts +9 -0
- package/lib/adapters/test-helpers.js +14 -0
- package/lib/client.d.ts +103 -10
- package/lib/client.js +10748 -10119
- package/lib/core/analyzer.d.ts +11 -2
- package/lib/core/analyzer.js +31 -8
- package/lib/core/backup-plan.d.ts +10 -0
- package/lib/core/backup-plan.js +21 -3
- package/lib/core/backup.d.ts +20 -1
- package/lib/core/backup.js +48 -3
- package/lib/core/command-rollback.d.ts +6 -2
- package/lib/core/command-rollback.js +6 -2
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +2 -1
- package/lib/core/messages.js +4 -2
- package/lib/core/plugin-data-registry.d.ts +124 -0
- package/lib/core/plugin-data-registry.js +198 -0
- package/lib/core/rollback-gate.d.ts +33 -0
- package/lib/core/rollback-gate.js +57 -0
- package/lib/core/rollback-ledger.d.ts +274 -0
- package/lib/core/rollback-ledger.js +1400 -0
- package/lib/core/rollback-point.d.ts +406 -5
- package/lib/core/rollback-point.js +1453 -306
- package/lib/core/rollback.js +6 -4
- package/lib/core/types.d.ts +19 -0
- package/lib/foreign/registry.d.ts +1 -1
- package/lib/foreign/registry.js +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +83 -13
- package/lib/routes/messages.d.ts +1 -0
- package/lib/routes/messages.js +2 -0
- package/lib/routes/prefs.js +26 -3
- package/lib/routes/recovery.d.ts +21 -0
- package/lib/routes/recovery.js +127 -7
- package/lib/routes/sync.js +11 -1
- package/lib/schema/section-registry.d.ts +1 -1
- package/lib/schema/section-registry.js +32 -3
- package/lib/schema/types.d.ts +1 -1
- package/lib/sync/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +108 -18
- package/lib/sync/ui-prefs.d.ts +22 -0
- package/lib/sync/ui-prefs.js +28 -0
- package/lib/ui/diff-view.d.ts +10 -0
- package/lib/ui/diff-view.js +17 -0
- package/lib/ui/export-flow.js +2 -0
- package/lib/ui/i18n.d.ts +5 -3
- package/lib/ui/i18n.js +10 -6
- package/lib/ui/rollback-view.d.ts +59 -0
- package/lib/ui/rollback-view.js +14 -0
- package/lib/ui/test-helpers.js +2 -1
- package/lib/utils/bundle-scan.d.ts +5 -3
- package/lib/utils/bundle-scan.js +5 -3
- package/lib/utils/line-diff.d.ts +7 -1
- package/lib/utils/line-diff.js +8 -2
- package/package.json +1 -1
- package/src/adapters/index.ts +5 -0
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-data.test.ts +225 -0
- package/src/adapters/plugin-data.ts +351 -0
- package/src/adapters/self.ts +2 -1
- package/src/adapters/test-helpers.ts +15 -0
- package/src/client/chat/ChatEntryView.tsx +59 -10
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-entry-view.test.ts +164 -1
- package/src/client/chat/chat-entry-view.ts +127 -10
- package/src/client/chat/chat-node-registration.ts +40 -6
- package/src/client/chat/chat-point-window.ts +11 -1
- package/src/client/chat/rollback-diff-tab.ts +13 -3
- package/src/client/chat/rollback-entry-view.ts +5 -0
- package/src/client/chat/sidebar-slots.ts +24 -2
- package/src/client/common/DiffTable.tsx +54 -29
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/host-primitives.ts +78 -0
- package/src/client/common/routes.ts +10 -0
- package/src/client/common/section-labels.test.ts +1 -0
- package/src/client/common/section-labels.ts +2 -0
- package/src/client/config-manager.module.css +138 -14
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +54 -2
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +8 -6
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +26 -8
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +36 -6
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +77 -0
- package/src/client/sync/PushPreviewCard.tsx +57 -0
- package/src/client/sync/SyncPanel.tsx +2 -37
- package/src/client/sync/SyncSettingsView.test.ts +3 -0
- package/src/client/sync/sync-push-preview.test.ts +14 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/client/sync/sync-view.ts +9 -1
- package/src/core/analyzer.ts +31 -9
- package/src/core/backup-plan.test.ts +20 -5
- package/src/core/backup-plan.ts +22 -3
- package/src/core/backup.ts +50 -4
- package/src/core/command-rollback.ts +6 -2
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +4 -2
- package/src/core/plugin-data-registry.test.ts +113 -0
- package/src/core/plugin-data-registry.ts +261 -0
- package/src/core/rollback-gate.test.ts +66 -0
- package/src/core/rollback-gate.ts +61 -0
- package/src/core/rollback-ledger.test.ts +655 -0
- package/src/core/rollback-ledger.ts +1430 -0
- package/src/core/rollback-point.test.ts +866 -41
- package/src/core/rollback-point.ts +1582 -291
- package/src/core/rollback.ts +5 -3
- package/src/core/types.ts +19 -0
- package/src/foreign/registry.test.ts +1 -1
- package/src/foreign/registry.ts +1 -1
- package/src/index.ts +73 -10
- package/src/routes/messages.ts +2 -0
- package/src/routes/prefs.ts +31 -2
- package/src/routes/recovery.ts +122 -7
- package/src/routes/sync.ts +15 -1
- package/src/schema/manifest-schema.test.ts +4 -4
- package/src/schema/registry.test.ts +9 -2
- package/src/schema/section-registry.ts +32 -3
- package/src/schema/types.ts +2 -1
- package/src/sync/messages.ts +3 -0
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +106 -18
- package/src/sync/sync-preview-push.test.ts +158 -0
- package/src/sync/transport.test.ts +1 -0
- package/src/sync/ui-prefs.test.ts +44 -1
- package/src/sync/ui-prefs.ts +39 -0
- package/src/ui/diff-view.test.ts +9 -1
- package/src/ui/diff-view.ts +22 -0
- package/src/ui/export-flow.ts +2 -0
- package/src/ui/i18n.ts +10 -6
- package/src/ui/rollback-view.test.ts +17 -0
- package/src/ui/rollback-view.ts +66 -0
- package/src/ui/test-helpers.ts +2 -1
- package/src/utils/bundle-scan.ts +5 -3
- package/src/utils/line-diff.test.ts +12 -0
- package/src/utils/line-diff.ts +9 -3
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pluginData / pluginAssets 分区 adapter(第三方插件数据迁移)。
|
|
3
|
+
*
|
|
4
|
+
* 数据来源 = \`src/core/plugin-data-registry.ts\` 的来源注册表:内置已取证来源 + 已安装插件在
|
|
5
|
+
* 自己 package.json 里声明的 \`dsh.dataDirs\`。每个「区域(area)」在备份里是**一个可勾选单元**,
|
|
6
|
+
* 单元 id = 区域的 home 相对 POSIX 路径(如 \`.dsh-wallpaper-engine/uploads\`)——
|
|
7
|
+
* 用户能在导出选择器里逐区域看到「几个文件 / 多大」,也能只带走配置、不带几百 MB 的上传壁纸。
|
|
8
|
+
*
|
|
9
|
+
* 为什么两个分区而不是一个:
|
|
10
|
+
* - \`pluginData\` = 配置与轻量状态;\`pluginAssets\` = 大体积用户数据(上传的壁纸、本地数据库);
|
|
11
|
+
* - 「默认不勾选大资产」在既有 UI 选择模型里**只有分区级开关**这一条可靠通道(单元级只有
|
|
12
|
+
* 「默认全选 + 稀疏排除」,没有「默认排除」的概念)—— 拆成两个分区后,「可见但默认不勾选」
|
|
13
|
+
* 就是既有的 \`defaultIncluded=false\` 语义,不需要动共享选择模型,也不会与导入/市场两处选择语义打架。
|
|
14
|
+
*
|
|
15
|
+
* 实现:继承 FileCollectionAdapter(baseDir = '',即 home 根;与 agentInstructions 同一形态),
|
|
16
|
+
* 复用它的单元分组 / 单分区字节闸门 / 预览-导出一致性 / 幂等哈希比对;只覆写三处:
|
|
17
|
+
* ① \`listRelPaths()\` —— 清单来自来源注册表(并如实记录被环境变量覆盖而跳过的区域);
|
|
18
|
+
* ② \`unitIdOf()\` —— 单元 = 区域(最长前缀归属);
|
|
19
|
+
* ③ \`analyzeImport() / applyItem()\` —— **写入路径白名单**(见下)。
|
|
20
|
+
*
|
|
21
|
+
* 安全不变量(本分区是全仓唯一「按 home 相对路径任意写」的分区,必须自己守住):
|
|
22
|
+
* - **导入白名单**:只有「本机内置来源的区域」与「本机已安装插件声明的区域」之下的路径才允许写。
|
|
23
|
+
* 恶意/损坏的包想把 \`.credentials.yaml\` 或 \`AGENTS.md\` 塞进来,在这里被判 Error(计划里可见,
|
|
24
|
+
* 执行期不写);applyItem 再查一遍(纵深防御),内层还有基类的 control-plane namespace 拒绝。
|
|
25
|
+
* - **缓存永不收录**:role === 'cache' 的区域不属于任何分区,导出时根本不进清单。
|
|
26
|
+
* - **环境变量覆盖即跳过**:区域声明的 env 存在且解析结果与默认位置不同 → 报码跳过,绝不猜新位置。
|
|
27
|
+
* - **不产生用户可见散文**:告警与计划项文案一律走消息目录(i18n 铁律)。
|
|
28
|
+
*/
|
|
29
|
+
import { normalizePath } from '../utils/paths.ts';
|
|
30
|
+
import { engineMsg } from './messages.ts';
|
|
31
|
+
import { listFilesDetailed } from './link-report.ts';
|
|
32
|
+
import type { RecursiveListing } from '../utils/recursive-walk.ts';
|
|
33
|
+
import { sectionMeta } from '../schema/section-registry.ts';
|
|
34
|
+
import {
|
|
35
|
+
BUILTIN_PLUGIN_DATA_SOURCES, areaOfPath, areasForSection, isExcludedRel, normalizeHomeRel,
|
|
36
|
+
parseDeclaredDataDirs,
|
|
37
|
+
} from '../core/plugin-data-registry.ts';
|
|
38
|
+
import type { PluginDataArea, PluginDataSource, PluginDataSection } from '../core/plugin-data-registry.ts';
|
|
39
|
+
import { osHomeDirOf } from '../core/backup.ts';
|
|
40
|
+
import { FileCollectionAdapter } from './file-collection.ts';
|
|
41
|
+
import type { FilesSection } from '../schema/types.ts';
|
|
42
|
+
import type {
|
|
43
|
+
ApplyResult, ExportOptions, ExportSection, FileSystemFacade, HostContext, ImportContext, PlanItem,
|
|
44
|
+
SectionPreview,
|
|
45
|
+
} from '../core/types.ts';
|
|
46
|
+
|
|
47
|
+
/** 区域条目(来源 + 区域),编译期与运行期唯一的区域表示 */
|
|
48
|
+
export type PluginDataEntry = { source: PluginDataSource; area: PluginDataArea };
|
|
49
|
+
|
|
50
|
+
/** 声明来源缓存的 TTL(毫秒):一次导出/导入/执行里会多次解析,逐次读 15+ 个 package.json 是净开销 */
|
|
51
|
+
const DECLARED_TTL_MS = 5_000;
|
|
52
|
+
|
|
53
|
+
/** 单次解析里最多读取多少个已安装插件的 manifest(防御性上限:profile 依赖数量异常时不无限读) */
|
|
54
|
+
const MAX_DECLARED_PACKAGES = 300;
|
|
55
|
+
|
|
56
|
+
export interface PluginDataAdapterOptions {
|
|
57
|
+
/** 内置来源(缺省 = BUILTIN_PLUGIN_DATA_SOURCES;测试注入假来源) */
|
|
58
|
+
readonly sources?: readonly PluginDataSource[];
|
|
59
|
+
/** 自述声明缓存 TTL;测试可传 0 关闭缓存 */
|
|
60
|
+
readonly declaredTtlMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* 环境变量快照(缺省 process.env)。
|
|
63
|
+
*
|
|
64
|
+
* 为什么走构造参数而不是 HostContext:HostContext 上没有 env(它是宿主门面的集合),
|
|
65
|
+
* 而「区域被环境变量搬走」这件事必须在**同一次调用**里可判定、可注入测试值 ——
|
|
66
|
+
* 直接读 process.env 会让单测无法造「被覆盖」的场景(本机若恰好设了该变量,测试还会 flaky)。
|
|
67
|
+
*/
|
|
68
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class PluginDataAdapter extends FileCollectionAdapter {
|
|
72
|
+
readonly id: PluginDataSection;
|
|
73
|
+
readonly displayName: string;
|
|
74
|
+
readonly defaultIncluded = false;
|
|
75
|
+
readonly portability = 'deviceSpecific' as const;
|
|
76
|
+
/** home 根(相对路径 = home 相对路径,与 agentInstructions 同形) */
|
|
77
|
+
readonly baseDir = '';
|
|
78
|
+
|
|
79
|
+
private readonly builtin: readonly PluginDataSource[];
|
|
80
|
+
private readonly declaredTtlMs: number;
|
|
81
|
+
private readonly env: Readonly<Record<string, string | undefined>>;
|
|
82
|
+
/**
|
|
83
|
+
* 自述声明缓存。**必须带 ctx 键**(homeDir + profile):这台机器装了某插件,不代表另一台装了 ——
|
|
84
|
+
* 缓存若按实例共享,导入到「没装该插件」的目标机时会拿源机的声明去放行写入,
|
|
85
|
+
* 白名单就变成了「谁先导入谁说了算」。
|
|
86
|
+
*/
|
|
87
|
+
private declaredMemo: { key: string; at: number; sources: PluginDataSource[] } | undefined;
|
|
88
|
+
/** 本轮清单(listRelPaths 写入;unitIdOf 是同步纯函数,只能从这里拿区域表) */
|
|
89
|
+
private active: readonly PluginDataEntry[] | undefined;
|
|
90
|
+
/** 本轮告警(环境变量覆盖跳过等;export/preview 收尾合并进返回的 warnings) */
|
|
91
|
+
private pendingWarnings: string[] = [];
|
|
92
|
+
|
|
93
|
+
constructor(section: PluginDataSection, options: PluginDataAdapterOptions = {}) {
|
|
94
|
+
super();
|
|
95
|
+
this.id = section;
|
|
96
|
+
this.displayName = sectionMeta(section).displayName;
|
|
97
|
+
this.builtin = options.sources ?? BUILTIN_PLUGIN_DATA_SOURCES;
|
|
98
|
+
this.declaredTtlMs = options.declaredTtlMs ?? DECLARED_TTL_MS;
|
|
99
|
+
this.env = options.env ?? process.env;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 本分区可收集区域(内置 + 自述声明) */
|
|
103
|
+
private async allowedEntries(ctx: HostContext): Promise<readonly PluginDataEntry[]> {
|
|
104
|
+
const sources = await this.allSources(ctx);
|
|
105
|
+
return areasForSection(sources, this.id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async allSources(ctx: HostContext): Promise<readonly PluginDataSource[]> {
|
|
109
|
+
const declared = await this.declaredSources(ctx);
|
|
110
|
+
return [...this.builtin, ...declared];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 已安装插件的自述声明(dsh.dataDirs)。
|
|
115
|
+
*
|
|
116
|
+
* 为什么读 profile 的 node_modules 而不是全局目录:DSH 的插件装在 \`profiles/<profile>/node_modules\`
|
|
117
|
+
* 下(pnpm 隔离布局),且同一个包在不同 profile 可以是不同版本 —— 声明必须取**本 profile 实际装的**那份。
|
|
118
|
+
*
|
|
119
|
+
* 读不到(未安装 / 无声明 / 形状非法)一律跳过:本机制是**附加**信息,绝不因为一个包读不出来
|
|
120
|
+
* 就影响内置来源或整次导出。
|
|
121
|
+
*/
|
|
122
|
+
private async declaredSources(ctx: HostContext): Promise<PluginDataSource[]> {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const profile = ctx.profile !== undefined && ctx.profile !== '' ? ctx.profile : 'web';
|
|
125
|
+
const memoKey = ctx.homeDir + '|' + profile;
|
|
126
|
+
if (
|
|
127
|
+
this.declaredMemo !== undefined
|
|
128
|
+
&& this.declaredMemo.key === memoKey
|
|
129
|
+
&& now - this.declaredMemo.at < this.declaredTtlMs
|
|
130
|
+
) {
|
|
131
|
+
return this.declaredMemo.sources;
|
|
132
|
+
}
|
|
133
|
+
const base = 'profiles/' + profile;
|
|
134
|
+
const out: PluginDataSource[] = [];
|
|
135
|
+
let deps: string[] = [];
|
|
136
|
+
try {
|
|
137
|
+
const raw = await ctx.fs.readFile(base + '/package.json');
|
|
138
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(raw));
|
|
139
|
+
const dep = (parsed as { dependencies?: unknown } | null)?.dependencies;
|
|
140
|
+
if (dep !== null && typeof dep === 'object') deps = Object.keys(dep).slice(0, MAX_DECLARED_PACKAGES);
|
|
141
|
+
} catch {
|
|
142
|
+
deps = [];
|
|
143
|
+
}
|
|
144
|
+
for (const name of deps) {
|
|
145
|
+
try {
|
|
146
|
+
const raw = await ctx.fs.readFile(base + '/node_modules/' + name + '/package.json');
|
|
147
|
+
const manifest: unknown = JSON.parse(new TextDecoder().decode(raw));
|
|
148
|
+
const source = parseDeclaredDataDirs(name, manifest);
|
|
149
|
+
if (source !== null) out.push(source);
|
|
150
|
+
} catch {
|
|
151
|
+
// 未安装 / 无 manifest → 无声明,跳过
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this.declaredMemo = { key: memoKey, at: now, sources: out };
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 区域是否被环境变量覆盖(覆盖 → 返回变量名;未覆盖 / 未声明 → null)。
|
|
160
|
+
*
|
|
161
|
+
* 判定口径:变量**存在且解析结果与默认位置不同**才算覆盖 —— 仅仅 \`DSH_WE_DATA_DIR=~/.dsh-wallpaper-engine\`
|
|
162
|
+
* 这种「设成同一个值」的写法不该让区域凭空消失(那会让用户以为「没有数据」)。
|
|
163
|
+
* 解析只做 \`~\` 展开与分隔符归一(不做 realpath:不读盘、不猜)。
|
|
164
|
+
*/
|
|
165
|
+
private envOverrideOf(ctx: HostContext, area: PluginDataArea): string | null {
|
|
166
|
+
for (const name of area.envOverrides ?? []) {
|
|
167
|
+
const raw = this.env[name];
|
|
168
|
+
if (raw === undefined || raw.trim() === '') continue;
|
|
169
|
+
const normalized = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
170
|
+
// 默认位置以**系统 home** 为根(本分区的根;见 core/backup.ts 的 OS_HOME_FILE_SECTIONS)
|
|
171
|
+
const home = osHomeDirOf(ctx).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
172
|
+
const expanded = normalized.startsWith('~/') ? home + normalized.slice(1) : normalized;
|
|
173
|
+
const defaultAbs = home + '/' + area.relPath;
|
|
174
|
+
if (expanded !== defaultAbs) return name;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 区域是否是**普通文件**(而不是目录 / 不存在)。
|
|
181
|
+
*
|
|
182
|
+
* 判据顺序为什么是这样:`statSize` 对「非普通文件」与「不存在」都返回 null,无法单独区分
|
|
183
|
+
* 目录与缺失;所以先判「是文件」,其余一律按目录走遍历 —— 目录不存在时遍历返回空,
|
|
184
|
+
* 结果同样是「没有内容」,不会把缺失误报成文件,也不会把目录误读成空内容而静默丢数据。
|
|
185
|
+
* 门面未实现 statSize(旧宿主)时退回读一次内容:读得出来就是文件。
|
|
186
|
+
*/
|
|
187
|
+
private async isRegularFile(fsFacade: FileSystemFacade, rel: string): Promise<boolean> {
|
|
188
|
+
if (fsFacade.statSize !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
return (await fsFacade.statSize(rel)) !== null;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await fsFacade.readFile(rel);
|
|
197
|
+
return true;
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private static emptyListing(): RecursiveListing {
|
|
204
|
+
return { paths: [], skippedLinks: [], skippedDepDirs: [], followedLinks: 0, unreadableDirs: [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 清单枚举 = 来源注册表的区域(覆写基类的目录递归;baseDir='' 时基类实现会递归**整个 home**)。
|
|
209
|
+
*
|
|
210
|
+
* **必须覆写**:home 根上递归会遍历 profiles/ sessions/ attachments/ 等全部内容(本机实测
|
|
211
|
+
* 4000+ 文件 / 数百 MB),既慢又会把无关目录当成该分区的「可勾选单元」。
|
|
212
|
+
*/
|
|
213
|
+
protected override async listRelPaths(ctx: HostContext): Promise<RecursiveListing> {
|
|
214
|
+
// 本分区的根是**系统 home**(~/.dsh-wallpaper-engine、~/.mnemon …),不是 $DSH_HOME ——
|
|
215
|
+
// 内容 I/O 一律走 homeFs;宿主没提供就什么都不收集,并如实报码(绝不退回 $DSH_HOME 去读错位置)。
|
|
216
|
+
const homeFs = ctx.homeFs;
|
|
217
|
+
if (homeFs === undefined) {
|
|
218
|
+
this.active = [];
|
|
219
|
+
this.pendingWarnings = [engineMsg('adapter.pluginDataNoOsHome')];
|
|
220
|
+
return PluginDataAdapter.emptyListing();
|
|
221
|
+
}
|
|
222
|
+
const entries = await this.allowedEntries(ctx);
|
|
223
|
+
this.active = entries;
|
|
224
|
+
const warnings: string[] = [];
|
|
225
|
+
const paths: string[] = [];
|
|
226
|
+
const listing = PluginDataAdapter.emptyListing();
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
const rel = entry.area.relPath;
|
|
229
|
+
const env = this.envOverrideOf(ctx, entry.area);
|
|
230
|
+
if (env !== null) {
|
|
231
|
+
warnings.push(engineMsg('adapter.pluginDataEnvOverridden', { path: rel, env }));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (await this.isRegularFile(homeFs, rel)) {
|
|
235
|
+
paths.push(rel);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
let sub: RecursiveListing;
|
|
239
|
+
try {
|
|
240
|
+
sub = await listFilesDetailed(homeFs, rel);
|
|
241
|
+
} catch {
|
|
242
|
+
// 目录不存在 / 读不到 → 视为无内容(存在的空目录与缺失在这里同义:都没有文件可带)
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
listing.skippedLinks.push(...sub.skippedLinks);
|
|
246
|
+
listing.skippedDepDirs.push(...sub.skippedDepDirs);
|
|
247
|
+
listing.unreadableDirs.push(...sub.unreadableDirs);
|
|
248
|
+
listing.followedLinks += sub.followedLinks;
|
|
249
|
+
for (const p of sub.paths) {
|
|
250
|
+
if (isExcludedRel(entry.area, p)) continue;
|
|
251
|
+
paths.push(p);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
listing.paths = paths;
|
|
255
|
+
this.pendingWarnings = warnings;
|
|
256
|
+
return listing;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 单元 = 区域(最长前缀归属)。
|
|
261
|
+
*
|
|
262
|
+
* 用「本轮清单写入的区域表」而不是重新解析:listRelPaths 与本函数在同一次 collect 里成对调用,
|
|
263
|
+
* 重算会让两次解析结果不一致时出现「单元 id 不在清单里」(选择器勾不上)。测试/异常路径下
|
|
264
|
+
* \`active\` 可能是 undefined(例如直接调 listUnits),此时退回**内置来源**的最长前缀 —— 与清单口径同源。
|
|
265
|
+
*/
|
|
266
|
+
protected override unitIdOf(relativePath: string): string {
|
|
267
|
+
const entries = this.active ?? areasForSection(this.builtin, this.id);
|
|
268
|
+
const hit = areaOfPath(entries, normalizePath(relativePath));
|
|
269
|
+
return hit === null ? normalizePath(relativePath) : hit.area.relPath;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 读一个成员文件:**换根到系统 home 门面**(基类的其余逻辑 —— 体积、哈希、字节闸门 —— 一字不动)。
|
|
274
|
+
*
|
|
275
|
+
* 能走到这里说明 listRelPaths 产出过条目(缺门面时它不产出任何路径),因此缺门面即**抛错**,
|
|
276
|
+
* 不再造一个「空文件」出来(那会把缺通道伪装成空内容)。
|
|
277
|
+
*/
|
|
278
|
+
protected override async readMember(
|
|
279
|
+
ctx: HostContext, rel: string, mode: 'content' | 'size', statTimes?: Map<string, number>,
|
|
280
|
+
): Promise<{ data: Uint8Array; sizeBytes: number; contentHash: string }> {
|
|
281
|
+
const homeFs = ctx.homeFs;
|
|
282
|
+
if (homeFs === undefined) throw new Error(engineMsg('adapter.pluginDataNoOsHome'));
|
|
283
|
+
return super.readMember({ ...ctx, fs: homeFs }, rel, mode, statTimes);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
override async export(ctx: HostContext, options: ExportOptions): Promise<ExportSection<FilesSection>> {
|
|
287
|
+
this.pendingWarnings = [];
|
|
288
|
+
const out = await super.export(ctx, options);
|
|
289
|
+
return { ...out, warnings: [...out.warnings, ...this.pendingWarnings] };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
override async preview(ctx: HostContext, options: ExportOptions): Promise<SectionPreview<FilesSection>> {
|
|
293
|
+
this.pendingWarnings = [];
|
|
294
|
+
const out = await super.preview(ctx, options);
|
|
295
|
+
return {
|
|
296
|
+
...out,
|
|
297
|
+
section: { ...out.section, warnings: [...out.section.warnings, ...this.pendingWarnings] },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 导入分析 + **写入路径白名单**(本分区唯一的越界防线)。
|
|
303
|
+
*
|
|
304
|
+
* 判据:路径必须是本机可解析的某个区域(内置来源,或已安装插件的自述声明)之下,且未被该区域
|
|
305
|
+
* 的 excludeRel 排除。不满足 → Error(计划里可见、执行期不写),绝不「尽力而为」地写出去。
|
|
306
|
+
*/
|
|
307
|
+
override async analyzeImport(data: FilesSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
308
|
+
// 宿主没有系统 home 门面(旧宿主)→ 白名单为空 + 目标读也换不了根:**全部拒绝**并说明原因。
|
|
309
|
+
// 绝不退回 $DSH_HOME 的 ctx.fs 去比对/写入(那会落到错误位置)。
|
|
310
|
+
const homeFs = ctx.target.homeFs;
|
|
311
|
+
const entries = homeFs === undefined ? [] : await this.allowedEntries(ctx.target);
|
|
312
|
+
const items = await super.analyzeImport(
|
|
313
|
+
data,
|
|
314
|
+
homeFs === undefined ? ctx : { ...ctx, target: { ...ctx.target, fs: homeFs } },
|
|
315
|
+
);
|
|
316
|
+
return items.map((item) => {
|
|
317
|
+
const ref = item.target?.ref;
|
|
318
|
+
if (ref === undefined) return item;
|
|
319
|
+
const rel = normalizeHomeRel(ref);
|
|
320
|
+
if (rel !== null && areaOfPath(entries, rel) !== null) return item;
|
|
321
|
+
return {
|
|
322
|
+
id: item.id,
|
|
323
|
+
...(item.unitId !== undefined ? { unitId: item.unitId } : {}),
|
|
324
|
+
kind: 'Error',
|
|
325
|
+
adapter: this.id,
|
|
326
|
+
description: engineMsg(
|
|
327
|
+
homeFs === undefined ? 'adapter.pluginDataNoOsHome' : 'adapter.pluginDataPathNotAllowed',
|
|
328
|
+
{ path: ref },
|
|
329
|
+
),
|
|
330
|
+
severity: 'error',
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 应用前再查一遍白名单(纵深防御:plan 可能来自消费者手工构造/缓存反序列化) */
|
|
336
|
+
override async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
337
|
+
const homeFs = ctx.target.homeFs;
|
|
338
|
+
if (homeFs === undefined) {
|
|
339
|
+
return { ok: false, message: engineMsg('adapter.pluginDataNoOsHome') };
|
|
340
|
+
}
|
|
341
|
+
const ref = item.target?.ref;
|
|
342
|
+
if (ref !== undefined) {
|
|
343
|
+
const rel = normalizeHomeRel(ref);
|
|
344
|
+
const entries = await this.allowedEntries(ctx.target);
|
|
345
|
+
if (rel === null || areaOfPath(entries, rel) === null) {
|
|
346
|
+
return { ok: false, message: engineMsg('adapter.pluginDataPathNotAllowed', { path: ref }) };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return super.applyItem(item, { ...ctx, target: { ...ctx.target, fs: homeFs } });
|
|
350
|
+
}
|
|
351
|
+
}
|
package/src/adapters/self.ts
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import type { HostContext } from '../core/types.ts';
|
|
44
44
|
import type { RecursiveListing } from '../utils/recursive-walk.ts';
|
|
45
|
+
import { sectionMeta } from '../schema/section-registry.ts';
|
|
45
46
|
import { FileCollectionAdapter } from './file-collection.ts';
|
|
46
47
|
|
|
47
48
|
/** self 分区白名单文件(相对 baseDir,即 $DSH_HOME/dsh-config-manager/)。 */
|
|
@@ -109,7 +110,7 @@ function canonicalizeWithoutRunState(raw: Uint8Array): Uint8Array | null {
|
|
|
109
110
|
|
|
110
111
|
export class SelfAdapter extends FileCollectionAdapter {
|
|
111
112
|
readonly id = 'self' as const;
|
|
112
|
-
readonly displayName = '
|
|
113
|
+
readonly displayName = sectionMeta('self').displayName;
|
|
113
114
|
readonly defaultIncluded = true;
|
|
114
115
|
readonly portability = 'portable' as const;
|
|
115
116
|
/** 插件自身配置目录(相对 homeDir;宿主按 dataDir 解析注入,缺省 dsh-config-manager) */
|
|
@@ -265,6 +265,20 @@ export class MockHostContext implements HostContext {
|
|
|
265
265
|
sessions?: HostContext['sessions'];
|
|
266
266
|
/** 可选(issue #71):技能服务端口;测试按需注入(缺省 = 退回纯目录扫描)。 */
|
|
267
267
|
skills?: HostContext['skills'];
|
|
268
|
+
/**
|
|
269
|
+
* 可选(第三方插件数据):系统 home 与它的文件门面。缺省 undefined = **旧宿主形态**
|
|
270
|
+
* (pluginData / pluginAssets 不收集也不写入,如实报码);需要它们的测试自行注入,
|
|
271
|
+
* 或用 withOsHome() 把系统 home 指向本夹具自己的根。
|
|
272
|
+
*/
|
|
273
|
+
osHomeDir?: string;
|
|
274
|
+
homeFs?: HostContext['homeFs'];
|
|
275
|
+
|
|
276
|
+
/** 测试助手:把「系统 home」指向本上下文的根(两分区在本夹具里即可直接用) */
|
|
277
|
+
withOsHome(): this {
|
|
278
|
+
this.osHomeDir = this.homeDir;
|
|
279
|
+
this.homeFs = this.fs;
|
|
280
|
+
return this;
|
|
281
|
+
}
|
|
268
282
|
constructor(platform: string, homeDir: string, profile?: string) {
|
|
269
283
|
this.platform = platform;
|
|
270
284
|
this.homeDir = homeDir;
|
|
@@ -289,6 +303,7 @@ export function makeManifest(platform = 'win32'): Manifest {
|
|
|
289
303
|
settings: false, ui: false, providers: false, plugins: false, mcp: false, prompts: false,
|
|
290
304
|
skills: false, agentPresets: false, agentInstructions: false, workspaces: false, pluginFiles: false,
|
|
291
305
|
credentialsStatus: false, secrets: false, sessions: false, self: false,
|
|
306
|
+
pluginData: false, pluginAssets: false,
|
|
292
307
|
},
|
|
293
308
|
security: { containsSecrets: false, encrypted: false, encryption: null },
|
|
294
309
|
};
|
|
@@ -22,6 +22,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
|
|
|
22
22
|
import type { TranslateNS } from '../client-types.ts'
|
|
23
23
|
import type { RollbackPointPort } from '../../ui/rollback-view.ts'
|
|
24
24
|
import { CopyStateIcon, RefreshIcon } from '../common/Icon.tsx'
|
|
25
|
+
import { isRollbackEntryEnabled, subscribeRollbackEntryEnabled } from '../recovery/rollback-enabled.ts'
|
|
25
26
|
import { copyTextToClipboard } from '../common/clipboard.ts'
|
|
26
27
|
import { RollbackReviewDialog } from './RollbackReviewDialog.tsx'
|
|
27
28
|
import type { RollbackDiffOpenResult } from './rollback-diff-tab.ts'
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
buildChatEntrySafeRow,
|
|
30
31
|
buildChatEntryView,
|
|
31
32
|
chatEntryButtonState,
|
|
33
|
+
projectChatEntryTextWithHost,
|
|
32
34
|
chatEntryTurnPhase,
|
|
33
35
|
planChatEntryAttachments,
|
|
34
36
|
lookupPointForTurn,
|
|
@@ -40,6 +42,7 @@ import {
|
|
|
40
42
|
type ChatPointLookup,
|
|
41
43
|
} from './chat-entry-view.ts'
|
|
42
44
|
import { ChatPointWindowController, type ChatPointWindowState } from './chat-point-window.ts'
|
|
45
|
+
import { hostFileTypeIcon, hostUserTextProjector } from '../common/host-primitives.ts'
|
|
43
46
|
import type { ArchiveOutcome } from './rollback-entry-view.ts'
|
|
44
47
|
import type { ChildOpenState } from './rollback-entry-view.ts'
|
|
45
48
|
import type { RollbackFileIconRenderer } from './RollbackFileIcon.tsx'
|
|
@@ -92,11 +95,21 @@ export interface ChatEntryViewInjected {
|
|
|
92
95
|
*/
|
|
93
96
|
export type ChatEntryViewProps = PropsRuntime<'conversation.chat.node', 'user'> & PropsLocale<'config-manager-chat'> & ChatEntryViewInjected
|
|
94
97
|
|
|
95
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* 文件卡:类型图标 + 文件名 + 体积。
|
|
100
|
+
*
|
|
101
|
+
* 类型图标**优先用宿主自己的** `FileTypeIcon`(ui-primitives,帧内惰取 —— 见 common/host-primitives.ts);
|
|
102
|
+
* 拿不到(老宿主)才回落我们自己的**扩展名徽标**(口径与宿主 fileExtension 一致,见 fileExtensionOf)。
|
|
103
|
+
* 2026-10-15:此前一律走徽标,是因为「ui-primitives 包私有不可导入」只对**构建期 import** 成立 ——
|
|
104
|
+
* 运行期 require 是生态通行做法(dsh-reedit / dsh-better-sidebar / 本仓 RollbackFileIcon 都这么做)。
|
|
105
|
+
*/
|
|
96
106
|
function FileCard({ file, size }: { file: ChatEntryFile; size: string | null }) {
|
|
107
|
+
const DshIcon = hostFileTypeIcon()
|
|
97
108
|
return (
|
|
98
109
|
<span className={css.chatFileCard} title={file.name !== '' ? file.name : undefined}>
|
|
99
|
-
{
|
|
110
|
+
{DshIcon !== null && file.name !== ''
|
|
111
|
+
? <DshIcon path={file.name} size={14} />
|
|
112
|
+
: file.ext !== null && <span className={css.chatFileExt}>{file.ext}</span>}
|
|
100
113
|
<span className={css.chatFileName}>{file.name !== '' ? file.name : '?'}</span>
|
|
101
114
|
{size !== null && <span className={css.chatFileMeta}>{size}</span>}
|
|
102
115
|
</span>
|
|
@@ -144,6 +157,9 @@ function ChatEntryCopyAction({ text, t }: { text: string; t: TranslateNS<'config
|
|
|
144
157
|
interface ChatEntryBodyProps extends ChatEntryViewInjected {
|
|
145
158
|
node: unknown
|
|
146
159
|
sessionId: string
|
|
160
|
+
/** 宿主的文件 / 技能打开器(给宿主正文投影的 chip 用;形态不符时投影自己会当没有)。 */
|
|
161
|
+
openFile?: unknown
|
|
162
|
+
openSkill?: unknown
|
|
147
163
|
/** 宿主图片渲染位(SlotMap 的 keyProps 把返回值声明成 unknown ⇒ 用它当 `MessageImagesRenderer` 必须过一层窄化)。 */
|
|
148
164
|
renderMessageImages?: unknown
|
|
149
165
|
t: TranslateNS<'config-manager-chat'>
|
|
@@ -158,13 +174,26 @@ interface ChatEntryBodyProps extends ChatEntryViewInjected {
|
|
|
158
174
|
* (`projectChatEntryText`),且 `data.referenceLabels` 非空时渲染**宿主的引用汇总行**
|
|
159
175
|
* (见下方 `css.chatEntryReference` + `t('chat.entry.referenceSummary')` 那段,文案与宿主
|
|
160
176
|
* `引用会话 · {labels}` 逐字同义)。**仍有的差距**只有 chip 的**外观与可点开**(已追认)。
|
|
161
|
-
* · **技能(skillNames
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* ·
|
|
165
|
-
*
|
|
177
|
+
* · **技能(skillNames)与引用 chip**:2026-10-15 起正文**优先交给宿主自己的** `projectUserText`
|
|
178
|
+
* (ui-primitives 帧内惰取,见 common/host-primitives.ts)——技能与引用渲染成 chip,`openFile` /
|
|
179
|
+
* `openSkill` 可用时还能点开;拿不到原语(老宿主)就回落我们的纯文本投影,**不编造内容、不留空白**。
|
|
180
|
+
* · 文件类型图标:同样优先宿主的 `FileTypeIcon`,拿不到才回落到**扩展名徽标**兜底;
|
|
181
|
+
* · 气泡底色:`--cm-chat-bubble`(= 宿主 `--dsw-specific-bubble`,2026-10-15 之前误用 `--cm-surface-2`
|
|
182
|
+
* ⇒ 浅色主题下与页面同色、气泡看不见;详见 DESIGN.md §15b 与 css 里那两条定义行的注释)。
|
|
183
|
+
*/
|
|
184
|
+
/**
|
|
185
|
+
* 订阅入口可见性信号(issue #88)。
|
|
186
|
+
*
|
|
187
|
+
* 为什么要在组件里订阅而不是把开关当 prop 往下传:入口是**遮蔽宿主渲染器**注册的(无常),
|
|
188
|
+
* 开关变化时没有任何父级会重渲染它 —— DNS 状态只能靠这条信号推。订阅开销 = 一个 Set 里的回调。
|
|
166
189
|
*/
|
|
167
|
-
function
|
|
190
|
+
function useRollbackEntryEnabled(): boolean {
|
|
191
|
+
const [value, setValue] = useState(() => isRollbackEntryEnabled())
|
|
192
|
+
useEffect(() => subscribeRollbackEntryEnabled(() => { setValue(isRollbackEntryEnabled()) }), [])
|
|
193
|
+
return value
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImages, t, rt, port, openChildSession, archiveParentSession, startFreshSession, openDiff, fileIcon }: ChatEntryBodyProps) {
|
|
168
197
|
const view = useMemo(() => buildChatEntryView(node), [node])
|
|
169
198
|
const [lookup, setLookup] = useState<ChatPointLookup | null>(null)
|
|
170
199
|
/** 有界重试窗口是否还开着(**状态诚实**的唯一开关,R1)—— 由状态机的 `onState` 驱动。 */
|
|
@@ -201,7 +230,9 @@ function ChatEntryBody({ node, sessionId, renderMessageImages, t, rt, port, open
|
|
|
201
230
|
* 只在「回合已收尾」时才读 —— 收尾未知/未结束的回合注定没有点,不浪费这次请求(按钮此时已如实禁用)。
|
|
202
231
|
* 「用户再次触发」= 重新挂载(切档 / 重进会话)或会话/回合变化 ⇒ 新实例;不需要刷新页面。
|
|
203
232
|
*/
|
|
204
|
-
|
|
233
|
+
// issue #88:功能关闭时连**查询都不发**(不只是不画按钮)—— 入口收起 = 这一整个入口链路停摆。
|
|
234
|
+
const entryEnabled = useRollbackEntryEnabled()
|
|
235
|
+
const canLookup = entryEnabled && turn !== null && phase === 'closed' && sessionId !== ''
|
|
205
236
|
useEffect(() => {
|
|
206
237
|
if (!canLookup || turn === null) return undefined
|
|
207
238
|
const controller = new ChatPointWindowController({
|
|
@@ -239,6 +270,17 @@ function ChatEntryBody({ node, sessionId, renderMessageImages, t, rt, port, open
|
|
|
239
270
|
: null)
|
|
240
271
|
: <FileCard key={'file:' + String(item.index)} file={item.file} size={item.size} />)
|
|
241
272
|
const hasAttachments = attachmentNodes.some((node) => node !== null)
|
|
273
|
+
/**
|
|
274
|
+
* 正文:**优先宿主自己的** `projectUserText`(会话引用 / 技能渲染成 chip,给了打开器就能点开),
|
|
275
|
+
* 拿不到就回落我们自己的纯文本投影(`bubble.text`,线格式也已换成 label)。
|
|
276
|
+
* 四条回落判据在纯层(`projectChatEntryTextWithHost`);投影器**帧内惰取**(老宿主没有原语包 → null)。
|
|
277
|
+
*/
|
|
278
|
+
const hostText = projectChatEntryTextWithHost(hostUserTextProjector(), bubble.rawText, {
|
|
279
|
+
sessionLabels: view.referenceLabels,
|
|
280
|
+
skillNames: view.skillNames,
|
|
281
|
+
openFile,
|
|
282
|
+
openSkill,
|
|
283
|
+
})
|
|
242
284
|
const reasonKey = button.kind === 'disabled' ? button.key : null
|
|
243
285
|
|
|
244
286
|
return (
|
|
@@ -252,7 +294,9 @@ function ChatEntryBody({ node, sessionId, renderMessageImages, t, rt, port, open
|
|
|
252
294
|
)}
|
|
253
295
|
{bubble.showBubble && (
|
|
254
296
|
<div className={css.chatBubble}>
|
|
255
|
-
{bubble.text !== '' &&
|
|
297
|
+
{bubble.text !== '' && (
|
|
298
|
+
<span className={css.chatBubbleText}>{hostText === null ? bubble.text : hostText}</span>
|
|
299
|
+
)}
|
|
256
300
|
{bubble.raw.map((block) => (
|
|
257
301
|
<RawBlock
|
|
258
302
|
key={'raw-' + block.id}
|
|
@@ -284,6 +328,10 @@ function ChatEntryBody({ node, sessionId, renderMessageImages, t, rt, port, open
|
|
|
284
328
|
2026-10-10 用户第二条要求「回退按钮只有一个 icon 而不是一个按钮」。可见文案退到
|
|
285
329
|
aria-label/title;禁用原因是原生 title 里的一句话(InfoHint 的 Portal 钉在插件设置页
|
|
286
330
|
根节点,聊天区不存在)。按钮始终留在原地(禁用而不是消失):入口可见性不随判据漂移。 */}
|
|
331
|
+
{/* 开关**关闭时整颗收起**(issue #88 明确要求「入口收起」,不是置灰)。
|
|
332
|
+
与上面那条「入口可见性不随判据漂移」不冲突:那说的是**暂时读不到点**这类判据,
|
|
333
|
+
而这里是「用户把这个功能关了」——此时入口本身就不该存在。 */}
|
|
334
|
+
{entryEnabled && (
|
|
287
335
|
<button
|
|
288
336
|
type="button"
|
|
289
337
|
className={css.chatActionBtn}
|
|
@@ -297,6 +345,7 @@ function ChatEntryBody({ node, sessionId, renderMessageImages, t, rt, port, open
|
|
|
297
345
|
>
|
|
298
346
|
<RefreshIcon size={13} />
|
|
299
347
|
</button>
|
|
348
|
+
)}
|
|
300
349
|
</div>
|
|
301
350
|
</div>
|
|
302
351
|
{dialogTurn !== null && (
|
|
@@ -11,8 +11,10 @@ import { useEffect, useState } from 'react'
|
|
|
11
11
|
import type { TranslateNS } from '../client-types.ts'
|
|
12
12
|
import type { RollbackFileDiffResult, RollbackPointPort } from '../../ui/rollback-view.ts'
|
|
13
13
|
import { Empty } from '../common/ui.tsx'
|
|
14
|
-
import { parseRollbackDiffAddress } from './rollback-diff-tab.ts'
|
|
14
|
+
import { parseRollbackDiffAddress, rollbackDiffBaseName } from './rollback-diff-tab.ts'
|
|
15
|
+
import { RollbackFileIcon, type RollbackFileIconRenderer } from './RollbackFileIcon.tsx'
|
|
15
16
|
import { RollbackDiffPane, type RollbackDiffState } from '../recovery/RollbackDiffPane.tsx'
|
|
17
|
+
import css from '../config-manager.module.css'
|
|
16
18
|
|
|
17
19
|
/** 装配点注入面(端口的读差异方法 + recovery 字典)。 */
|
|
18
20
|
export interface RollbackDiffTabInjected {
|
|
@@ -57,5 +59,50 @@ export function RollbackDiffTab({ port, rt, useTabInfo }: RollbackDiffTabProps)
|
|
|
57
59
|
return () => { alive = false }
|
|
58
60
|
}, [port, contentId])
|
|
59
61
|
if (target === null) return <Empty>{rt('rollback.diff.failed')}</Empty>
|
|
60
|
-
|
|
62
|
+
// 侧栏标签页的空间里没有别处写路径(标签标题会被截断),文件头由差异表自己画;
|
|
63
|
+
// fill:正文是这一屏的唯一内容 ⇒ 撑满宿主高度(用户 2026-10-14:不要自己再留一道滚动条)
|
|
64
|
+
return <RollbackDiffPane state={state} rt={rt} title={target.path} fill />
|
|
65
|
+
}
|
|
66
|
+
/** 芯片位的注入面(只有文件类型图标的外部来源)。 */
|
|
67
|
+
export interface RollbackDiffTabTitleInjected {
|
|
68
|
+
/** 文件类型图标的外部来源(装了 dsh-better-sidebar 时由装配点注入);缺省回落 DSH 自带的画法。 */
|
|
69
|
+
fileIcon?: RollbackFileIconRenderer
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 芯片位的完整 props:注入面 + 侧栏 seat 给的 tab 信息钩子(可能不存在)。 */
|
|
73
|
+
export type RollbackDiffTabTitleProps = RollbackDiffTabTitleInjected & {
|
|
74
|
+
useTabInfo?: () => { tab?: { title?: unknown; contentId?: unknown } }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 从 seat 的 tab 信息钩子里读「宿主给的标题 + 资源地址」(钩子缺失 / 抛错一律当读不到,不猜)。 */
|
|
78
|
+
function readTab(useTabInfo: RollbackDiffTabTitleProps['useTabInfo']): { title: string; contentId: unknown } | null {
|
|
79
|
+
if (typeof useTabInfo !== 'function') return null
|
|
80
|
+
try {
|
|
81
|
+
const tab = useTabInfo()?.tab
|
|
82
|
+
if (tab === null || tab === undefined) return null
|
|
83
|
+
return { title: typeof tab.title === 'string' ? tab.title : '', contentId: tab.contentId }
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 右侧栏标签页的**芯片标题**:[文件类型图标] + 文件名(与 DSH 自己的文件标签同一个观感)。
|
|
91
|
+
*
|
|
92
|
+
* 为什么要有这个组件:宿主的 tab 类型定义**没有 icon 字段** —— 芯片只由 title 文字画出来,
|
|
93
|
+
* 芯片内容属于 `sidebar.right.pane.tab.title` 这个 keyed 位(dsh-better-sidebar 的自述注释写明)。
|
|
94
|
+
* 地址读不出来 / 不是本类型的地址:退回宿主给的那串标题文字,绝不空着、也不自己编一个名字。
|
|
95
|
+
*/
|
|
96
|
+
export function RollbackDiffTabTitle({ fileIcon, useTabInfo }: RollbackDiffTabTitleProps) {
|
|
97
|
+
const info = readTab(useTabInfo)
|
|
98
|
+
const target = parseRollbackDiffAddress(info?.contentId)
|
|
99
|
+
if (target === null) return <>{info?.title ?? ''}</>
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
<span className={css.rollbackTabIcon} aria-hidden="true">
|
|
103
|
+
<RollbackFileIcon path={target.path} size={14} render={fileIcon} />
|
|
104
|
+
</span>
|
|
105
|
+
{rollbackDiffBaseName(target.path)}
|
|
106
|
+
</>
|
|
107
|
+
)
|
|
61
108
|
}
|