dsh-oh-my-terminal 0.2.5 → 0.2.7

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/CLAUDE.md CHANGED
@@ -53,34 +53,43 @@ pnpm exec tsx --test tests/unit/*.test.ts
53
53
 
54
54
  ```
55
55
  src/
56
- ├── index.ts # 宿主半入口(cordis 插件壳 + settings 集成 + 会话生命周期 + loadPty 懒加载)
56
+ ├── index.ts # 宿主半入口(cordis 插件壳 + settings 集成 + 会话生命周期 + loadPty 懒加载 + prepareProfiles/settleProfiles 配置表落定)
57
57
  ├── routes.ts # HTTP 路由处理器工厂(createRouteHandler + 辅助函数)
58
58
  ├── ws-handler.ts # WebSocket 处理器工厂(createWsHandlers + WebServer 类型导出)
59
- ├── client.tsx # 浏览器半入口(TerminalPanel 组合壳 + 插件注册 + injectStyles 调用)
59
+ ├── client.tsx # 浏览器半入口(TerminalPanel 组合壳 + 插件注册 + shortcuts 命令注册 + injectStyles 调用)
60
60
  ├── client/
61
61
  │ ├── types.ts # 共享类型定义(TerminalInstance, TerminalGroup, TerminalState, TerminalAction, API 响应)
62
- │ ├── hooks.ts # 自定义 Hooks(terminalReducer + useTerminalState + useConfig + usePanelHeight + usePanelGeometry + useTerminalTabs)
62
+ │ ├── hooks.ts # 兼容壳(re-export terminal/ 子模块,保持对外接口不变)
63
63
  │ ├── term-pane.tsx # TermPane 组件(xterm + WebSocket + resize)+ RestartButton 组件
64
64
  │ ├── styles.ts # CSS 样式常量(PANEL_CSS)+ Campbell 暗色主题 + xterm 调优常量 + injectStyles()
65
65
  │ ├── dropdown.tsx # +号旁下拉菜单组件(新建/拆分/按种类新建)
66
66
  │ ├── side-list.tsx # 右侧终端列表面板(树形前缀 + 右键重命名 + useMemo 缓存)
67
+ │ ├── shortcut-bridge.ts # shortcuts 服务接入信号桥(命令注册状态 + 面板切换回调,软探测兼容旧宿主)
67
68
  │ ├── terminal/
68
- │ │ └── use-tabs.ts # 终端标签页状态管理(useTerminalTabs Hook:newTab/splitTerminal/closeTerminal/renameTerminal)
69
+ │ │ ├── reducer.ts # 终端状态 reducer(terminalReducer 纯函数 + createInitialState)
70
+ │ │ ├── use-terminal-state.ts # 终端状态管理 Hook(useTerminalState:useReducer 封装 + 启动恢复)
71
+ │ │ ├── use-panel-geometry.ts # 面板几何与高度管理(usePanelGeometry + usePanelHeight:拖拽调高 + localStorage + 对话列几何测量)
72
+ │ │ └── use-tabs.ts # 终端标签页状态管理(useTerminalTabs + useConfig:newTab/splitTerminal/closeTerminal/renameTerminal + 配置拉取)
69
73
  │ ├── settings/
70
74
  │ │ ├── types.ts # 设置页面类型定义(SettingsPageProps、ConfigWithProfiles)
71
75
  │ │ ├── store.ts # 设置页面状态管理(useSettingsState Hook)
72
- │ │ └── card.tsx # 设置卡片组件(SettingsCard)
76
+ │ │ ├── card.tsx # 设置卡片组件(SettingsCard)
77
+ │ │ ├── profile-table.tsx # 终端配置表格组件(TerminalProfile[] CRUD UI:inline 编辑/新增/删除/origin 约束)
78
+ │ │ ├── api.ts # Settings Bridge HTTP 客户端(封装与宿主半 Settings Bridge 的 HTTP 交互)
79
+ │ │ └── styles.ts # 配置表单样式模块(injectSettingsStyles + CSS 自定义属性主题自适应)
73
80
  │ ├── icons.tsx # SVG 图标组件集合(10 个纯函数)
74
81
  │ └── clipboard.ts # 剪贴板纯函数(Async Clipboard API + legacy 双层降级)
75
82
  ├── terminal/
76
- │ ├── index.ts # 终端模块统一导出(kinds/detect/resolve/store)
83
+ │ ├── index.ts # 终端模块统一导出(kinds/detect/resolve/store + settleProfileTable)
77
84
  │ ├── kinds.ts # 终端种类定义(TerminalKind 联合类型 + KIND_SPECS 平台映射表)
78
85
  │ ├── detect.ts # 终端探测(resolveCommand/detectGitBash/detectTerminalProfiles)
79
86
  │ ├── resolve.ts # 配置解析(resolveProfile:TerminalProfile → node-pty spawn 参数)
80
- │ └── store.ts # 配置表校验(validateAdd/validateUpdate/validateDelete/addProfile/updateProfile/deleteProfile)
87
+ │ └── store.ts # 配置表校验(validateAdd/validateUpdate/validateDelete/addProfile/updateProfile/deleteProfile + settleProfileTable 落盘决策)
81
88
  ├── settings/
82
- │ ├── namespace.ts # Settings 命名空间定义(SettingsNamespace)
83
- │ └── bridge.ts # Settings 桥接层(prepareProfiles:配置表与探测结果合并)
89
+ │ ├── index.ts # settings bridge 模块入口(registerSettingsIntegration + registerSettingsNamespace + SettingsChannel 读写通道 + volatile 解包)
90
+ │ ├── namespace.ts # Settings 命名空间定义(SettingsNamespace + SETTINGS_NS 常量)
91
+ │ ├── bridge.ts # Settings 桥接路由工厂(createSettingsBridgeRoutes:describe + mutate 路由,SettingsLike 最小接口)
92
+ │ └── patch.ts # JSON Patch 操作类型与构造函数(op:'set' 路径数组形式,零额外依赖)
84
93
  ├── persistence.ts # 会话持久化层(SessionStore:日志落盘/清理、元数据读写、启动恢复)
85
94
  ├── platform.ts # 平台适配层(PlatformAdapter 接口 + POSIX/Windows 适配器 + 默认 shell 探测)
86
95
  ├── constants.ts # 协议/尺寸/快捷键/环境变量/文件名常量
@@ -96,10 +105,11 @@ src/
96
105
  index.ts → routes.ts → terminal/, settings/, constants.ts, platform.ts, persistence.ts, server-command.ts, logger.ts
97
106
  index.ts → ws-handler.ts → constants.ts, persistence.ts
98
107
  index.ts → terminal/, settings/, constants.ts, platform.ts, persistence.ts, logger.ts
99
- settings/bridge.ts → terminal/detect.ts, terminal/kinds.ts, logger.ts
108
+ settings/index.ts → settings/bridge.ts, settings/namespace.ts, settings/patch.ts
109
+ settings/bridge.ts → settings/namespace.ts, settings/patch.ts
100
110
  terminal/detect.ts → terminal/kinds.ts
101
111
  terminal/resolve.ts → terminal/kinds.ts, platform.ts
102
- terminal/store.ts → terminal/kinds.ts
112
+ terminal/store.ts → terminal/kinds.ts, terminal/detect.ts
103
113
  platform.ts → terminal/detect.ts(复用 resolveCommand)
104
114
  persistence.ts → constants.ts, logger.ts
105
115
 
@@ -107,11 +117,17 @@ src/
107
117
  client.tsx → client/types.ts, client/icons.tsx, client/clipboard.ts,
108
118
  client/dropdown.tsx, client/side-list.tsx, client/hooks.ts,
109
119
  client/styles.ts, client/term-pane.tsx, client/settings/,
110
- client/terminal/, logger.ts
111
- client/hooks.ts → client/types.ts, shortcut.ts, logger.ts
112
- client/terminal/use-tabs.ts → client/types.ts, logger.ts
113
- client/settings/card.tsx → client/settings/types.ts, client/settings/store.ts, client/icons.tsx
120
+ client/shortcut-bridge.ts, logger.ts
121
+ client/hooks.ts → client/terminal/*(re-export 兼容壳:reducer/use-terminal-state/use-panel-geometry/use-tabs)
122
+ client/shortcut-bridge.ts → client/types.ts, constants.ts, logger.ts
123
+ client/terminal/reducer.ts → client/types.ts, logger.ts
124
+ client/terminal/use-terminal-state.ts → client/terminal/reducer.ts, client/types.ts, logger.ts
125
+ client/terminal/use-panel-geometry.ts → client/types.ts, constants.ts, logger.ts
126
+ client/terminal/use-tabs.ts → client/types.ts, shortcut.ts, client/shortcut-bridge.ts, constants.ts, logger.ts
127
+ client/settings/card.tsx → client/settings/types.ts, client/settings/api.ts, client/settings/store.ts, client/settings/profile-table.tsx, client/icons.tsx
114
128
  client/settings/store.ts → client/settings/types.ts
129
+ client/settings/profile-table.tsx → client/settings/types.ts, client/settings/api.ts
130
+ client/settings/api.ts → client/settings/types.ts
115
131
  client/dropdown.tsx → client/types.ts, client/icons.tsx
116
132
  client/side-list.tsx → client/types.ts, client/icons.tsx
117
133
  client/term-pane.tsx → client/types.ts, client/clipboard.ts, client/icons.tsx, client/styles.ts
@@ -135,7 +151,9 @@ src/
135
151
  | `path` | 可执行文件路径。留空则按 `type` 在 `$PATH` 中解析 |
136
152
  | `origin` | `auto`(启动探测)/ `user`(手动新增)。`auto` 项不可删除、path 不可改,但可改名 |
137
153
 
138
- **启动流程**:`prepareProfiles()` 在 apply 时跑一次 `$PATH` 探测(`detectTerminalProfiles()`),与已保存的配置表合并(`mergeProfiles()`:已保存项全保留,探测到的新 type/path 补进去)。首次运行(配置表为空)时把结果写回配置。
154
+ **启动流程**(两阶段,读写同通道):`detectTerminalProfiles()` 在 apply 时跑一次 `$PATH` 探测(`where`/`which` 子进程调用有成本,只跑一次并缓存);`prepareProfiles()` 读 volatile 配置初值与探测结果合并(`mergeProfiles()`:已保存项全保留,探测到的新 type/path 补进去)产出初值,routes 立即可用;settings 服务就绪后 `settleProfiles()` 经 `SettingsChannel`(`registerSettingsNamespace` 的 `onReady` 回调交出,read/update 同一命名空间)读回持久层已保存表,重算合并结果并 `cachedProfiles.splice(...)` 就地刷新(保持数组身份,routes/bridge 闭包引用同步生效)。首跑(持久层读回为空)时把探测结果落盘,读回非空时绝不写回(用户改过的 name/手动新增项原样保留)。
155
+
156
+ **读写同通道**:必须读写走同一 settings 通道(owner scope → 服务级 get → describe 扫描,逐能力探测)。旧实现读 cordis loader 的 volatile 引用、写 settings user 层,两通道互不可见导致读回恒空、每次启动判首跑并覆盖用户配置。volatile 字段的稳定引用(cosmokit `Symbol.for('cosmokit.volatile.write')`)需在通道内解包为标量,否则 JSON 序列化后只剩 `{}`、字符串字段永远"读不到"。
139
157
 
140
158
  **路径解析**:`resolveProfile()` 把 `TerminalProfile` 转成 node-pty 的 `{ file, args }`。**Windows 上 node-pty 不做 PATH/PATHEXT 查找**,裸命令名(`pwsh`、`cmd`)会直接以 `File not found: ` 失败,必须给完整路径——`resolveCommand()` 用 `where`/`which` 解析,并过滤 `WindowsApps` 下的 App Execution Alias 占位符(0 字节重解析点,spawn 必失败)。
141
159
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-oh-my-terminal
2
2
 
3
- [![version](https://img.shields.io/badge/version-0.2.4-blue)](package.json)
3
+ [![version](https://img.shields.io/badge/version-0.2.7-blue)](package.json)
4
4
  [![license](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE)
5
5
  [![node](https://img.shields.io/badge/node-%5E20.19.0%20%7C%7C%20%3E%3D22.0.0-brightgreen)](package.json)
6
6
 
package/README.zh.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-oh-my-terminal
2
2
 
3
- [![version](https://img.shields.io/badge/version-0.2.4-blue)](package.json)
3
+ [![version](https://img.shields.io/badge/version-0.2.7-blue)](package.json)
4
4
  [![license](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE)
5
5
  [![node](https://img.shields.io/badge/node-%5E20.19.0%20%7C%7C%20%3E%3D22.0.0-brightgreen)](package.json)
6
6
 
package/lib/index.js CHANGED
@@ -636,6 +636,11 @@ function mergeProfiles(saved, detected) {
636
636
  }
637
637
  return result;
638
638
  }
639
+ function settleProfileTable(saved, detected) {
640
+ const merged = mergeProfiles(saved, detected);
641
+ const persistPayload = saved.length === 0 && merged.length > 0 ? JSON.stringify(merged) : null;
642
+ return { merged, persistPayload };
643
+ }
639
644
 
640
645
  // src/routes.ts
641
646
  import { readFileSync as readFileSync2 } from "node:fs";
@@ -1014,7 +1019,31 @@ function createSettingsBridgeRoutes(settings, namespace, runtimeProfiles) {
1014
1019
  }
1015
1020
 
1016
1021
  // src/settings/index.ts
1017
- function registerSettingsNamespace(ctx, schema) {
1022
+ var VOLATILE_WRITE = /* @__PURE__ */ Symbol.for("cosmokit.volatile.write");
1023
+ function isVolatileRef(value) {
1024
+ return typeof value === "object" && value !== null && VOLATILE_WRITE in value && typeof value.get === "function";
1025
+ }
1026
+ function readNamespaceValue(facade, owner) {
1027
+ const candidates = [];
1028
+ if (typeof owner?.get === "function") {
1029
+ candidates.push(owner.get());
1030
+ } else {
1031
+ if (typeof facade.get === "function") candidates.push(facade.get(SETTINGS_NS));
1032
+ if (typeof facade.describe === "function") {
1033
+ const descriptor = facade.describe().find((d) => d.ns === SETTINGS_NS);
1034
+ if (descriptor !== void 0) candidates.push(descriptor.value);
1035
+ }
1036
+ }
1037
+ for (const value of candidates) {
1038
+ if (value !== null && typeof value === "object") {
1039
+ return Object.fromEntries(
1040
+ Object.entries(value).map(([key, item]) => [key, isVolatileRef(item) ? item.get() : item])
1041
+ );
1042
+ }
1043
+ }
1044
+ return void 0;
1045
+ }
1046
+ function registerSettingsNamespace(ctx, schema, onReady) {
1018
1047
  ctx.inject(["settings"], (sctx) => {
1019
1048
  const facade = sctx.settings;
1020
1049
  if (typeof facade.configure === "function") {
@@ -1024,14 +1053,25 @@ function registerSettingsNamespace(ctx, schema) {
1024
1053
  return typeof cleanup === "function" ? cleanup : () => void 0;
1025
1054
  });
1026
1055
  }
1056
+ let owner;
1027
1057
  if (typeof facade.register === "function") {
1028
1058
  try {
1029
- facade.register(SETTINGS_NS, schema);
1059
+ owner = facade.register(SETTINGS_NS, schema);
1030
1060
  } catch (error) {
1031
1061
  const message = error instanceof Error ? error.message : String(error);
1032
1062
  if (!message.includes("already registered")) throw error;
1033
1063
  }
1034
1064
  }
1065
+ if (onReady !== void 0) {
1066
+ onReady({
1067
+ read: () => readNamespaceValue(facade, owner),
1068
+ update: (patch) => {
1069
+ if (typeof owner?.update === "function") return owner.update(patch);
1070
+ if (typeof facade.update === "function") return facade.update(SETTINGS_NS, patch);
1071
+ return Promise.reject(new Error("settings \u670D\u52A1\u4E0D\u63D0\u4F9B update\uFF0C\u914D\u7F6E\u65E0\u6CD5\u843D\u76D8"));
1072
+ }
1073
+ });
1074
+ }
1035
1075
  });
1036
1076
  }
1037
1077
  function registerSettingsIntegration(ctx, schema, runtimeProfiles) {
@@ -1122,8 +1162,8 @@ function apply(ctx, config) {
1122
1162
  * 终端配置表(已合并探测结果的完整列表)。
1123
1163
  *
1124
1164
  * 启动时由 prepareProfiles() 求值一次并缓存——探测涉及多次 where/which
1125
- * 子进程调用,不适合每次 /config 请求都重跑。配置热更新时由
1126
- * rebuildProfiles() 重建。
1165
+ * 子进程调用,不适合每次 /config 请求都重跑。settings 就绪后由
1166
+ * settleProfiles() 按持久层读回值重算并就地刷新。
1127
1167
  */
1128
1168
  get profiles() {
1129
1169
  return cachedProfiles;
@@ -1150,25 +1190,32 @@ function apply(ctx, config) {
1150
1190
  }
1151
1191
  function prepareProfiles() {
1152
1192
  const saved = parseProfiles(config.terminalProfiles.get());
1153
- const detected = detectTerminalProfiles();
1154
- const merged = mergeProfiles(saved, detected);
1155
- if (saved.length === 0 && merged.length > 0) {
1156
- const payload = JSON.stringify(merged);
1157
- ctx.inject(["settings"], (sctx) => {
1158
- const settings = sctx.settings;
1159
- if (typeof settings.update !== "function") return;
1160
- void settings.update(SETTINGS_NS, { terminalProfiles: payload }).then(() => {
1193
+ return mergeProfiles(saved, detectedProfiles);
1194
+ }
1195
+ function settleProfiles(channel) {
1196
+ const persisted = channel.read()?.terminalProfiles;
1197
+ const raw = typeof persisted === "string" ? persisted : config.terminalProfiles.get();
1198
+ const saved = parseProfiles(raw);
1199
+ const { merged, persistPayload } = settleProfileTable(saved, detectedProfiles);
1200
+ if (persistPayload !== null) {
1201
+ const persist = async () => {
1202
+ try {
1203
+ await channel.update({ terminalProfiles: persistPayload });
1161
1204
  log3.info(`\u7EC8\u7AEF\u914D\u7F6E\u8868\u5DF2\u521D\u59CB\u5316\uFF1A\u63A2\u6D4B\u5230 ${merged.length} \u4E2A\u7EC8\u7AEF`);
1162
- }).catch((error) => {
1205
+ } catch (error) {
1163
1206
  const msg = error instanceof Error ? error.message : String(error);
1164
1207
  log3.warn(`\u7EC8\u7AEF\u914D\u7F6E\u8868\u5199\u5165\u5931\u8D25\uFF0C\u4EC5\u672C\u6B21\u8FD0\u884C\u6709\u6548\uFF1A${msg}`);
1165
- });
1166
- });
1208
+ }
1209
+ };
1210
+ void persist();
1211
+ } else if (saved.length > 0) {
1212
+ log3.info(`\u7EC8\u7AEF\u914D\u7F6E\u8868\u5DF2\u8BFB\u56DE\uFF1A\u6301\u4E45\u5C42 ${saved.length} \u9879\uFF0C\u5408\u5E76\u63A2\u6D4B\u540E ${merged.length} \u9879`);
1167
1213
  }
1168
- return merged;
1214
+ cachedProfiles.splice(0, cachedProfiles.length, ...merged);
1169
1215
  }
1170
- registerSettingsNamespace(ctx, Config);
1216
+ const detectedProfiles = detectTerminalProfiles();
1171
1217
  const cachedProfiles = prepareProfiles();
1218
+ registerSettingsNamespace(ctx, Config, settleProfiles);
1172
1219
  const sessions = /* @__PURE__ */ new Map();
1173
1220
  const upgradeDisposers = /* @__PURE__ */ new Map();
1174
1221
  const DATA_DIR = process.env[ENV_DATA_DIR] ?? pathJoin3(process.env.DSH_HOME ?? pathJoin3(homedir2(), ".dsh"), "plugin-data", "terminal");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-oh-my-terminal",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "DSH Web GUI 底部终端面板插件——node-pty 驱动的多标签交互式终端(Windows ConPTY / POSIX openpty)",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -35,11 +35,11 @@
35
35
  "@deepseek-ai/schemastery": "^3.18.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@deepseek-ai/dsh-brand": "0.2.0-rc.1",
39
- "@deepseek-ai/dsh-client-shortcuts": "0.2.0-rc.1",
40
- "@deepseek-ai/dsh-client-store": "0.2.0-rc.1",
41
- "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
42
- "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
38
+ "@deepseek-ai/dsh-brand": "0.2.0-rc.2",
39
+ "@deepseek-ai/dsh-client-shortcuts": "0.2.0-rc.2",
40
+ "@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
41
+ "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2",
42
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.2",
43
43
  "@types/node": "^22.0.0",
44
44
  "@types/react": "^18.2.0",
45
45
  "@types/ws": "^8.5.0",
package/src/index.ts CHANGED
@@ -61,9 +61,10 @@ import { createWsHandlers } from './ws-handler.js';
61
61
  import { createRouteHandler } from './routes.js';
62
62
  import type { CreateSessionOptions } from './routes.js';
63
63
  import { makeId, getSessionCounter, resolveSpawn, resolveSessionCwd } from './routes.js';
64
- import { registerSettingsIntegration, registerSettingsNamespace, SETTINGS_NS } from './settings/index.js';
64
+ import { registerSettingsIntegration, registerSettingsNamespace } from './settings/index.js';
65
+ import type { SettingsChannel } from './settings/index.js';
65
66
  import type { TerminalProfile } from './terminal/index.js';
66
- import { detectTerminalProfiles, mergeProfiles } from './terminal/index.js';
67
+ import { detectTerminalProfiles, mergeProfiles, settleProfileTable } from './terminal/index.js';
67
68
 
68
69
  const log = createLogger('terminal-host');
69
70
 
@@ -252,8 +253,8 @@ export function apply(ctx: Context, config: Config): void {
252
253
  * 终端配置表(已合并探测结果的完整列表)。
253
254
  *
254
255
  * 启动时由 prepareProfiles() 求值一次并缓存——探测涉及多次 where/which
255
- * 子进程调用,不适合每次 /config 请求都重跑。配置热更新时由
256
- * rebuildProfiles() 重建。
256
+ * 子进程调用,不适合每次 /config 请求都重跑。settings 就绪后由
257
+ * settleProfiles() 按持久层读回值重算并就地刷新。
257
258
  */
258
259
  get profiles(): readonly TerminalProfile[] {
259
260
  return cachedProfiles;
@@ -297,46 +298,65 @@ export function apply(ctx: Context, config: Config): void {
297
298
  }
298
299
 
299
300
  /**
300
- * 启动时求值一次配置表并缓存。
301
+ * 启动时求值一次配置表并缓存(快路径)。
301
302
  *
302
- * 若配置项为空(首次安装),把探测结果写回配置——这样用户对自动探测项改的
303
- * 名字能跨重启保留,且下次启动不必再全量探测。
304
- *
305
- * 写入走 settings 服务的 update(ns, patch):Volatile 引用是只读视图,没有
306
- * setter。settings 服务在 apply 时未必就绪,所以用 ctx.inject 子级延迟注册;
307
- * 写失败(只读 profile / 无 settings 服务)只记警告,不阻断插件启动。
303
+ * volatile 配置通道这里读不到已保存表也没关系——结果只是初值,settings
304
+ * 就绪后 settleProfiles() 会按持久层读回值重算并就地刷新。
308
305
  */
309
306
  function prepareProfiles(): TerminalProfile[] {
310
307
  const saved = parseProfiles(config.terminalProfiles.get());
311
- const detected = detectTerminalProfiles();
312
- const merged = mergeProfiles(saved, detected);
313
-
314
- // 首次运行:把探测结果落盘,后续启动只补增量
315
- if (saved.length === 0 && merged.length > 0) {
316
- const payload = JSON.stringify(merged);
317
- ctx.inject(['settings'], (sctx) => {
318
- const settings = sctx.settings as { update?: (ns: string, patch: object) => Promise<void> };
319
- if (typeof settings.update !== 'function') return;
320
- // 命名空间必须与 settings 集成注册的 SETTINGS_NS 逐字一致——
321
- // 此前误用包名派生串(PKG_NAME 去前缀 = 'oh-my-terminal'),与注册的
322
- // 'terminal-panel' 对不上,首次落盘恒报「namespace not registered」
323
- void settings.update(SETTINGS_NS, { terminalProfiles: payload })
324
- .then(() => { log.info(`终端配置表已初始化:探测到 ${merged.length} 个终端`); })
325
- .catch((error: unknown) => {
326
- const msg = error instanceof Error ? error.message : String(error);
327
- log.warn(`终端配置表写入失败,仅本次运行有效:${msg}`);
328
- });
329
- });
308
+ return mergeProfiles(saved, detectedProfiles);
309
+ }
310
+
311
+ /**
312
+ * settings 就绪后按持久层落定配置表:读回已保存表 → 合并探测结果 → 仅首跑
313
+ * 落盘 → 就地刷新缓存。
314
+ *
315
+ * 读写必须走同一通道(settings user 层):旧实现在 apply 期读 volatile 配置、
316
+ * 却把首跑结果写进 settings user 层,两个通道互不可见,saved 恒为空——每次
317
+ * 启动都判首跑,重新生成配置 id 并覆盖用户改过的 name。就地刷新(splice 保持
318
+ * 数组身份)让 routes、settings bridge 与 runtimeSettings.profiles 持有的引用
319
+ * 同步生效。
320
+ *
321
+ * @param channel - settings 命名空间读写通道(settings/index.ts 逐能力探测)
322
+ */
323
+ function settleProfiles(channel: SettingsChannel): void {
324
+ const persisted = channel.read()?.terminalProfiles;
325
+ // 通道值非字符串(宿主异常形态,如未解包的 volatile 引用)时回落 volatile
326
+ // 配置读;空串是合法的"用户清空配置表",不再回落
327
+ const raw = typeof persisted === 'string' ? persisted : config.terminalProfiles.get();
328
+ const saved = parseProfiles(raw);
329
+ const { merged, persistPayload } = settleProfileTable(saved, detectedProfiles);
330
+
331
+ // 首跑落盘:异步写回,失败只降级警告(只读 profile /老宿主不阻断启动)
332
+ if (persistPayload !== null) {
333
+ const persist = async (): Promise<void> => {
334
+ try {
335
+ // 命名空间必须与 settings 集成注册的 SETTINGS_NS 逐字一致——
336
+ // 此前误用包名派生串(PKG_NAME 去前缀 = 'oh-my-terminal'),与注册的
337
+ // 'terminal-panel' 对不上,首次落盘恒报「namespace not registered」
338
+ await channel.update({ terminalProfiles: persistPayload });
339
+ log.info(`终端配置表已初始化:探测到 ${merged.length} 个终端`);
340
+ } catch (error) {
341
+ const msg = error instanceof Error ? error.message : String(error);
342
+ log.warn(`终端配置表写入失败,仅本次运行有效:${msg}`);
343
+ }
344
+ };
345
+ void persist();
346
+ } else if (saved.length > 0) {
347
+ log.info(`终端配置表已读回:持久层 ${saved.length} 项,合并探测后 ${merged.length} 项`);
330
348
  }
331
- return merged;
349
+
350
+ cachedProfiles.splice(0, cachedProfiles.length, ...merged);
332
351
  }
333
352
 
334
- /** 启动时求值的终端配置表(探测 + 合并结果) */
335
- // 命名空间注册必须先于 prepareProfiles 的首跑写回(时序竞态:写回早于注册会
336
- // 报「namespace is not registered」)——故在此提前注册,settings/index.ts
337
- // 内部保留幂等兜底
338
- registerSettingsNamespace(ctx, Config);
353
+ /** 启动时探测结果(只探测一次:where/which 子进程调用有成本) */
354
+ const detectedProfiles = detectTerminalProfiles();
355
+ /** 启动时求值的终端配置表(初值;settings 就绪后由 settleProfiles 就地刷新) */
339
356
  const cachedProfiles: TerminalProfile[] = prepareProfiles();
357
+ // 命名空间注册与首跑落盘/读回落定同回调内先注册后读写,天然有序——
358
+ // 写回早于注册会报「namespace is not registered」(时序竞态)
359
+ registerSettingsNamespace(ctx, Config, settleProfiles);
340
360
 
341
361
  /** id -> 会话记录 */
342
362
  const sessions = new Map<string, SessionRecord>();
@@ -23,6 +23,104 @@ export { createSettingsBridgeRoutes } from './bridge.js';
23
23
 
24
24
  // —— 插件集成辅助函数 ——
25
25
 
26
+ /**
27
+ * settings 命名空间读写通道({@link registerSettingsNamespace} 就绪回调的入参)。
28
+ *
29
+ * 读走命名空间的解析值(schema 默认 → base → user 层合并),与 update 写入的
30
+ * user 层同源可见——首跑落盘写进去的值下次启动能从这里读回来。volatile 字段
31
+ * 的稳定引用已在通道内解包为标量,调用方按普通配置值使用。不同宿主的
32
+ * settings 服务暴露面不同,read/update 内部逐能力探测:read 探测不到返回
33
+ * undefined(调用方按"读不到"降级),update 探测不到时返回 rejected promise
34
+ * (调用方按写失败降级)。
35
+ */
36
+ export interface SettingsChannel {
37
+ /** 读取本命名空间的解析值(volatile 字段已解包);宿主能力不足时返回 undefined */
38
+ read(): Record<string, unknown> | undefined;
39
+ /** 合并写入本命名空间的 user 层 */
40
+ update(patch: Record<string, unknown>): Promise<void>;
41
+ }
42
+
43
+ /** settings 服务注册命名空间返回的 owner 能力面(按需探测,不同宿主缺项) */
44
+ interface SettingsOwnerScope {
45
+ /** 读取本命名空间解析值 */
46
+ get?: () => unknown;
47
+ /** 合并写入本命名空间 user 层 */
48
+ update?: (patch: Record<string, unknown>) => Promise<void>;
49
+ }
50
+
51
+ /** settings 服务能力面(不同宿主版本暴露面不同,逐能力探测) */
52
+ interface SettingsFacade {
53
+ /** 新宿主可选能力:关闭自动写回;旧宿主(0.1.5 系)无此方法 */
54
+ configure?: (options: { auto: boolean }, fiber?: unknown) => void;
55
+ /**
56
+ * 注册命名空间 schema;重复注册抛 "already registered"。
57
+ * 可选——宿主 SettingsForms 靠 cordis loader entries 发现命名空间,
58
+ * 不一定提供此方法(如 0.1.7-rc.1 的 SettingsForms 无 register)。
59
+ */
60
+ register?: (ns: string, schema: unknown) => unknown;
61
+ /** 服务级读取命名空间解析值(register 缺失时的读回通道) */
62
+ get?: (ns: string) => unknown;
63
+ /** 服务级命名空间快照列表(register 与 get 都缺失时的兜底读回通道) */
64
+ describe?: () => Array<{ ns: string; value: unknown }>;
65
+ /** 服务级合并写入(owner scope 缺失时的落盘通道) */
66
+ update?: (ns: string, patch: Record<string, unknown>) => Promise<void>;
67
+ }
68
+
69
+ /** cosmokit volatile 引用的跨包识别符(与 cosmokit isVolatile 同一 Symbol.for 键,跨 ESM/CJS 副本一致) */
70
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write');
71
+
72
+ /**
73
+ * 判断值是否为 schemastery volatile 字段的解析产物(cosmokit 稳定引用)。
74
+ *
75
+ * `.volatile()` 字段的解析结果是 `createVolatile(...)` 引用对象而非标量本身
76
+ * (JSON 序列化后只剩 `{}`);把引用当标量读会让字符串字段永远"读不到"。
77
+ *
78
+ * @param value - 待判断的值
79
+ * @returns 值是稳定引用(可 `.get()` 取当前值)时 true
80
+ */
81
+ function isVolatileRef(value: unknown): value is { get(): unknown } {
82
+ return typeof value === 'object' && value !== null
83
+ && VOLATILE_WRITE in value
84
+ && typeof (value as { get?: unknown }).get === 'function';
85
+ }
86
+
87
+ /**
88
+ * 逐能力探测读取命名空间解析值。
89
+ *
90
+ * 优先 owner scope(注册即所得),其次服务级 get,最后 describe 扫描——
91
+ * 三条通道读到的都是同一份解析值,差别只在宿主暴露面。返回前把 volatile
92
+ * 字段的稳定引用解包成标量,调用方拿到的就是普通配置值。全部探测不到时
93
+ * 返回 undefined,调用方按"读不到"降级,不伪造空配置。
94
+ *
95
+ * @param facade - settings 服务实例
96
+ * @param owner - register 返回的 owner scope(未注册成功时为 undefined)
97
+ * @returns 命名空间解析值对象(volatile 字段已解包);读不到时 undefined
98
+ */
99
+ function readNamespaceValue(
100
+ facade: SettingsFacade,
101
+ owner: SettingsOwnerScope | undefined,
102
+ ): Record<string, unknown> | undefined {
103
+ const candidates: unknown[] = [];
104
+ if (typeof owner?.get === 'function') {
105
+ candidates.push(owner.get());
106
+ } else {
107
+ if (typeof facade.get === 'function') candidates.push(facade.get(SETTINGS_NS));
108
+ if (typeof facade.describe === 'function') {
109
+ const descriptor = facade.describe().find((d) => d.ns === SETTINGS_NS);
110
+ if (descriptor !== undefined) candidates.push(descriptor.value);
111
+ }
112
+ }
113
+ for (const value of candidates) {
114
+ if (value !== null && typeof value === 'object') {
115
+ return Object.fromEntries(
116
+ Object.entries(value as Record<string, unknown>)
117
+ .map(([key, item]) => [key, isVolatileRef(item) ? item.get() : item]),
118
+ );
119
+ }
120
+ }
121
+ return undefined;
122
+ }
123
+
26
124
  /**
27
125
  * 注册 settings 集成(在插件主入口调用)
28
126
  *
@@ -54,21 +152,17 @@ export { createSettingsBridgeRoutes } from './bridge.js';
54
152
  *
55
153
  * @param ctx - cordis 上下文
56
154
  * @param schema - 插件 Config 的 schemastery schema
155
+ * @param onReady - settings 就绪回调,在命名空间注册(或确认宿主无 register)
156
+ * 之后同步触发,入参是同命名空间的读写通道——首跑落盘/读回落定用它保证
157
+ * 读写同一通道,避免 apply 期读 volatile、写 settings 的通道错位
57
158
  */
58
- export function registerSettingsNamespace(ctx: Context, schema: unknown): void {
159
+ export function registerSettingsNamespace(
160
+ ctx: Context,
161
+ schema: unknown,
162
+ onReady?: (channel: SettingsChannel) => void,
163
+ ): void {
59
164
  // 注册命名空间 +(可选)关闭自动模式——软探测,服务不存在时不激活
60
165
  ctx.inject(['settings'], (sctx) => {
61
- /** settings 服务能力面(不同宿主版本暴露面不同,逐能力探测) */
62
- interface SettingsFacade {
63
- /** 新宿主可选能力:关闭自动写回;旧宿主(0.1.5 系)无此方法 */
64
- configure?: (options: { auto: boolean }, fiber?: unknown) => void;
65
- /**
66
- * 注册命名空间 schema;重复注册抛 "already registered"。
67
- * 可选——宿主 SettingsForms 靠 cordis loader entries 发现命名空间,
68
- * 不一定提供此方法(如 0.1.7-rc.1 的 SettingsForms 无 register)。
69
- */
70
- register?: (ns: string, schema: unknown) => unknown;
71
- }
72
166
  const facade = sctx.settings as unknown as SettingsFacade;
73
167
 
74
168
  // configure 仅在宿主提供时调用——0.1.5 系宿主的 settings 服务没有该方法,
@@ -92,14 +186,27 @@ export function registerSettingsNamespace(ctx: Context, schema: unknown): void {
92
186
  // (0.1.7-rc.1 的 SettingsForms 无此方法)。方法不存在时跳过,不抛 TypeError;
93
187
  // 方法存在时重复注册(热重载、重复 apply)抛 "already registered",作为幂等
94
188
  // 情形吞掉;其余注册失败(如存储段损坏)如实上抛,不把失败包装成成功。
189
+ let owner: SettingsOwnerScope | undefined;
95
190
  if (typeof facade.register === 'function') {
96
191
  try {
97
- facade.register(SETTINGS_NS, schema);
192
+ owner = facade.register(SETTINGS_NS, schema) as SettingsOwnerScope | undefined;
98
193
  } catch (error) {
99
194
  const message = error instanceof Error ? error.message : String(error);
100
195
  if (!message.includes('already registered')) throw error;
101
196
  }
102
197
  }
198
+
199
+ // 就绪回调在注册之后触发:回调内的任何读写都能看到已注册的命名空间
200
+ if (onReady !== undefined) {
201
+ onReady({
202
+ read: () => readNamespaceValue(facade, owner),
203
+ update: (patch) => {
204
+ if (typeof owner?.update === 'function') return owner.update(patch);
205
+ if (typeof facade.update === 'function') return facade.update(SETTINGS_NS, patch);
206
+ return Promise.reject(new Error('settings 服务不提供 update,配置无法落盘'));
207
+ },
208
+ });
209
+ }
103
210
  });
104
211
  }
105
212
 
@@ -41,4 +41,5 @@ export {
41
41
  validateUpdatePath,
42
42
  updatePath,
43
43
  mergeProfiles,
44
+ settleProfileTable,
44
45
  } from './store.js';
@@ -266,3 +266,23 @@ export function mergeProfiles(
266
266
 
267
267
  return result;
268
268
  }
269
+
270
+ /**
271
+ * 配置表持久层落定:合并探测结果,并决定是否需要首跑落盘。
272
+ *
273
+ * 落盘只在持久层读回为空且探测有结果时发生(首跑安装)。读回非空说明持久层
274
+ * 已有配置表,用户改过的 name、手动新增的项都存在里面,任何写回都会覆盖它们,
275
+ * 因此一律不写——这也是"值跨重启保留"的唯一保障。
276
+ *
277
+ * @param saved - 持久层读回的配置表(读不到按空表传入)
278
+ * @param detected - 启动时探测到的配置表
279
+ * @returns merged 合并后的配置表;persistPayload 首跑落盘的 JSON 串,无需落盘时为 null
280
+ */
281
+ export function settleProfileTable(
282
+ saved: TerminalProfile[],
283
+ detected: TerminalProfile[],
284
+ ): { merged: TerminalProfile[]; persistPayload: string | null } {
285
+ const merged = mergeProfiles(saved, detected);
286
+ const persistPayload = saved.length === 0 && merged.length > 0 ? JSON.stringify(merged) : null;
287
+ return { merged, persistPayload };
288
+ }
package/src/ws-handler.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * registerSessionWs 三个闭包函数。
7
7
  *
8
8
  * webServer 服务的路由与升级路由类型直接采用官方
9
- * @deepseek-ai/dsh-host-webserver(0.1.7-rc.2,devDependencies 提供
9
+ * @deepseek-ai/dsh-host-webserver(0.2.0-rc.2,devDependencies 提供
10
10
  * 类型源)并 re-export 供 index.ts 等模块共享——import type 构建
11
11
  * 期擦除,插件产物不携带对该包的运行时引用,运行期由宿主提供
12
12
  * webServer 服务实例。官方 d.ts 同时声明 cordis Context 的
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file 配置表持久层落定单元测试
3
+ * @description 回归锁定 settleProfileTable 的合并与落盘决策语义:
4
+ * 读回非空时绝不写回(用户改过的 name/手动新增项必须原样保留,
5
+ * 任何写回都会覆盖它们);仅首跑(读回为空且探测有结果)产出落盘
6
+ * 载荷;合并语义与 mergeProfiles 一致(已保存项全保留,探测到的
7
+ * 新 type/path 补进去,type+path 双匹配视为同一项)。
8
+ *
9
+ * 曾因读写通道错位(apply 期读 volatile 配置、写 settings user 层)
10
+ * 导致 saved 恒为空,每次启动都判首跑并覆盖用户配置——本组用例
11
+ * 锁住"读回非空不落盘"这条保命语义。
12
+ */
13
+
14
+ import { describe, it } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import type { TerminalProfile } from '../../src/terminal/detect.js';
17
+ import { settleProfileTable } from '../../src/terminal/store.js';
18
+
19
+ /** 构造一个自动探测项(origin 固定 auto,id 按入参钉死便于断言) */
20
+ function detectedProfile(id: string, type: TerminalProfile['type'], path: string): TerminalProfile {
21
+ return { id, type, name: type, path, origin: 'auto' };
22
+ }
23
+
24
+ describe('settleProfileTable', () => {
25
+ it('首跑(读回为空)产出落盘载荷,内容与合并结果一致', () => {
26
+ const detected = [detectedProfile('t-pwsh-1111', 'pwsh', 'C:\\pwsh.exe')];
27
+ const { merged, persistPayload } = settleProfileTable([], detected);
28
+ assert.equal(merged.length, 1);
29
+ assert.notEqual(persistPayload, null);
30
+ assert.deepEqual(JSON.parse(persistPayload ?? '[]'), merged);
31
+ });
32
+
33
+ it('读回非空时不落盘,用户改过的 name 原样保留', () => {
34
+ const saved: TerminalProfile[] = [
35
+ { id: 't-pwsh-8bd5', type: 'pwsh', name: '我的主力终端', path: 'C:\\pwsh.exe', origin: 'auto' },
36
+ ];
37
+ const detected = [detectedProfile('t-pwsh-2222', 'pwsh', 'C:\\pwsh.exe')];
38
+ const { merged, persistPayload } = settleProfileTable(saved, detected);
39
+ assert.equal(persistPayload, null);
40
+ assert.equal(merged.length, 1);
41
+ assert.equal(merged[0]?.id, 't-pwsh-8bd5');
42
+ assert.equal(merged[0]?.name, '我的主力终端');
43
+ });
44
+
45
+ it('读回非空时探测到的新终端补进末尾,不覆盖既有项', () => {
46
+ const saved: TerminalProfile[] = [
47
+ { id: 't-pwsh-8bd5', type: 'pwsh', name: '我的主力终端', path: 'C:\\pwsh.exe', origin: 'auto' },
48
+ ];
49
+ const detected = [
50
+ detectedProfile('t-pwsh-2222', 'pwsh', 'C:\\pwsh.exe'),
51
+ detectedProfile('t-cmd-3333', 'cmd', 'C:\\Windows\\System32\\cmd.exe'),
52
+ ];
53
+ const { merged, persistPayload } = settleProfileTable(saved, detected);
54
+ assert.equal(persistPayload, null);
55
+ assert.equal(merged.length, 2);
56
+ assert.equal(merged[0]?.name, '我的主力终端');
57
+ assert.equal(merged[1]?.type, 'cmd');
58
+ });
59
+
60
+ it('type 相同但 path 不同视为不同项(用户可能装了两个同类型终端)', () => {
61
+ const saved: TerminalProfile[] = [
62
+ { id: 't-bash-aaaa', type: 'bash', name: '旧 bash', path: 'C:\\old\\bash.exe', origin: 'user' },
63
+ ];
64
+ const detected = [detectedProfile('t-bash-bbbb', 'bash', 'C:\\new\\bash.exe')];
65
+ const { merged } = settleProfileTable(saved, detected);
66
+ assert.equal(merged.length, 2);
67
+ });
68
+
69
+ it('读回与探测都为空时不落盘(没有内容可写)', () => {
70
+ const { merged, persistPayload } = settleProfileTable([], []);
71
+ assert.equal(merged.length, 0);
72
+ assert.equal(persistPayload, null);
73
+ });
74
+ });
@@ -14,7 +14,8 @@
14
14
  import { describe, it } from 'node:test';
15
15
  import assert from 'node:assert/strict';
16
16
  import type { Context } from '@deepseek-ai/cordis';
17
- import { registerSettingsIntegration, SETTINGS_NS } from '../../src/settings/index.js';
17
+ import { registerSettingsIntegration, registerSettingsNamespace, SETTINGS_NS } from '../../src/settings/index.js';
18
+ import type { SettingsChannel } from '../../src/settings/index.js';
18
19
 
19
20
  /** register 调用记录 */
20
21
  interface RegisterCall {
@@ -130,3 +131,128 @@ describe('settings 集成注册', () => {
130
131
  assert.equal(registerCalls.length, 0);
131
132
  });
132
133
  });
134
+
135
+ describe('settings 命名空间读写通道', () => {
136
+ /** 捕获 onReady 交出的通道(inject 在假上下文里同步回调,调用后即可断言) */
137
+ function captureChannel(settings: unknown): SettingsChannel | undefined {
138
+ let channel: SettingsChannel | undefined;
139
+ registerSettingsNamespace(makeFakeContext(settings), { marker: 'x' }, (c) => {
140
+ channel = c;
141
+ });
142
+ return channel;
143
+ }
144
+
145
+ it('注册成功后通道走 owner scope 读写', async () => {
146
+ const updates: Array<Record<string, unknown>> = [];
147
+ const settings = {
148
+ register: (): unknown => ({
149
+ get: (): unknown => ({ terminalProfiles: '[{"id":"t-a"}]' }),
150
+ update: async (patch: Record<string, unknown>): Promise<void> => {
151
+ updates.push(patch);
152
+ },
153
+ }),
154
+ };
155
+ const channel = captureChannel(settings);
156
+ assert.notEqual(channel, undefined);
157
+ assert.deepEqual(channel?.read(), { terminalProfiles: '[{"id":"t-a"}]' });
158
+ await channel?.update({ terminalProfiles: '[]' });
159
+ assert.deepEqual(updates, [{ terminalProfiles: '[]' }]);
160
+ });
161
+
162
+ it('register 缺失时回落服务级 get 与 update', async () => {
163
+ const updates: Array<{ ns: string; patch: Record<string, unknown> }> = [];
164
+ const settings = {
165
+ get: (ns: string): unknown =>
166
+ ns === SETTINGS_NS ? { terminalProfiles: '[{"id":"t-b"}]' } : undefined,
167
+ update: async (ns: string, patch: Record<string, unknown>): Promise<void> => {
168
+ updates.push({ ns, patch });
169
+ },
170
+ };
171
+ const channel = captureChannel(settings);
172
+ assert.deepEqual(channel?.read(), { terminalProfiles: '[{"id":"t-b"}]' });
173
+ await channel?.update({ terminalProfiles: '[]' });
174
+ assert.deepEqual(updates, [{ ns: SETTINGS_NS, patch: { terminalProfiles: '[]' } }]);
175
+ });
176
+
177
+ it('get 缺失时回落 describe 扫描', () => {
178
+ const settings = {
179
+ describe: (): unknown => [{ ns: SETTINGS_NS, value: { terminalProfiles: '[{"id":"t-c"}]' } }],
180
+ };
181
+ const channel = captureChannel(settings);
182
+ assert.deepEqual(channel?.read(), { terminalProfiles: '[{"id":"t-c"}]' });
183
+ });
184
+
185
+ it('already registered 时通道仍就绪并走服务级读', () => {
186
+ const settings = {
187
+ register: (): unknown => {
188
+ throw new Error('settings namespace "terminal-panel" is already registered');
189
+ },
190
+ get: (): unknown => ({ terminalProfiles: '[{"id":"t-d"}]' }),
191
+ };
192
+ const channel = captureChannel(settings);
193
+ assert.deepEqual(channel?.read(), { terminalProfiles: '[{"id":"t-d"}]' });
194
+ });
195
+
196
+ it('三条读通道都拿不到对象时 read 返回 undefined,不伪造空配置', () => {
197
+ const settings = {
198
+ register: (): unknown => ({ get: (): unknown => undefined }),
199
+ };
200
+ const channel = captureChannel(settings);
201
+ assert.equal(channel?.read(), undefined);
202
+ });
203
+
204
+ it('update 三条通道都缺失时返回 rejected promise(调用方按写失败降级)', async () => {
205
+ const channel = captureChannel({ register: (): unknown => ({}) });
206
+ await assert.rejects(
207
+ async () => channel?.update({ terminalProfiles: '[]' }),
208
+ /无法落盘/u,
209
+ );
210
+ });
211
+
212
+ /** 构造 schemastery volatile 字段的解析产物形态(cosmokit 稳定引用) */
213
+ function volatileRef(value: unknown): unknown {
214
+ return Object.freeze({
215
+ get: (): unknown => value,
216
+ [Symbol.for('cosmokit.volatile.write')]: (): void => undefined,
217
+ });
218
+ }
219
+
220
+ it('volatile 字段的稳定引用在通道内解包为标量(回归:解包缺失致读回恒空、每轮判首跑)', () => {
221
+ const settings = {
222
+ register: (): unknown => ({
223
+ get: (): unknown => ({
224
+ terminalProfiles: volatileRef('[{"id":"t-a"}]'),
225
+ fontFamily: volatileRef(''),
226
+ fontSize: volatileRef(12.5),
227
+ }),
228
+ }),
229
+ };
230
+ const channel = captureChannel(settings);
231
+ assert.deepEqual(channel?.read(), {
232
+ terminalProfiles: '[{"id":"t-a"}]',
233
+ fontFamily: '',
234
+ fontSize: 12.5,
235
+ });
236
+ });
237
+
238
+ it('describe 兜底路径同样解包 volatile 引用', () => {
239
+ const settings = {
240
+ describe: (): unknown => [{
241
+ ns: SETTINGS_NS,
242
+ value: { terminalProfiles: volatileRef('[{"id":"t-b"}]') },
243
+ }],
244
+ };
245
+ const channel = captureChannel(settings);
246
+ assert.deepEqual(channel?.read(), { terminalProfiles: '[{"id":"t-b"}]' });
247
+ });
248
+
249
+ it('普通值原样透传,不被误判为引用', () => {
250
+ const settings = {
251
+ register: (): unknown => ({
252
+ get: (): unknown => ({ terminalProfiles: '[{"id":"t-c"}]' }),
253
+ }),
254
+ };
255
+ const channel = captureChannel(settings);
256
+ assert.deepEqual(channel?.read(), { terminalProfiles: '[{"id":"t-c"}]' });
257
+ });
258
+ });