dsh-oh-my-terminal 0.2.5 → 0.2.6
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 +34 -16
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/index.js +64 -17
- package/package.json +1 -1
- package/src/index.ts +56 -36
- package/src/settings/index.ts +120 -13
- package/src/terminal/index.ts +1 -0
- package/src/terminal/store.ts +20 -0
- package/tests/unit/profile-settle.test.ts +74 -0
- package/tests/unit/settings-integration.test.ts +127 -1
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 #
|
|
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
|
-
│ │
|
|
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
|
-
│ │
|
|
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
|
-
│ ├──
|
|
83
|
-
│
|
|
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/
|
|
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/
|
|
111
|
-
client/hooks.ts → client/
|
|
112
|
-
client/
|
|
113
|
-
client/
|
|
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
|
-
|
|
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
|
-
[](package.json)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
6
|
|
package/README.zh.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-oh-my-terminal
|
|
2
2
|
|
|
3
|
-
[](package.json)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
1214
|
+
cachedProfiles.splice(0, cachedProfiles.length, ...merged);
|
|
1169
1215
|
}
|
|
1170
|
-
|
|
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
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
|
|
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
|
-
*
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
349
|
+
|
|
350
|
+
cachedProfiles.splice(0, cachedProfiles.length, ...merged);
|
|
332
351
|
}
|
|
333
352
|
|
|
334
|
-
/**
|
|
335
|
-
|
|
336
|
-
|
|
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>();
|
package/src/settings/index.ts
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/terminal/index.ts
CHANGED
package/src/terminal/store.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|