dsh-oh-my-terminal 0.2.4 → 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 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.1-blue)](package.json)
3
+ [![version](https://img.shields.io/badge/version-0.2.6-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
 
@@ -13,10 +13,11 @@ A bottom terminal panel plugin for DSH Web GUI. Powered by `@lydell/node-pty`, i
13
13
  - **Multiple terminal sessions**: manage several sessions simultaneously; a side list replaces the traditional horizontal tab bar, with right-click rename support
14
14
  - **Split terminals**: horizontal splits within the same group; a VSCode-style dropdown next to the `+` button (new / split / new by kind)
15
15
  - **Session persistence**: sessions survive panel close/reopen and are automatically restored on startup
16
- - **Configurable shortcut**: toggle the panel with a keyboard shortcut; on DSH 0.1.7-rc.2+ the plugin integrates with the host shortcut system and the panel label updates to reflect the current binding
16
+ - **Configurable shortcut**: toggle the panel with a keyboard shortcut; integrates with the host shortcut system and the panel label updates to reflect the current binding
17
17
  - **Resizable panel**: drag the top edge of the panel to adjust its height
18
18
  - **Cross-platform**: Windows ConPTY and POSIX openpty are selected automatically by `@lydell/node-pty`; pre-compiled binaries ship with the package, nothing to compile
19
- - **Configurable shell**: set a custom shell command and arguments
19
+ - **Profile-driven terminals**: a configurable terminal profile table (type / name / path) drives new-terminal creation; profiles are auto-detected at startup and can be added, renamed, and edited in the settings UI
20
+ - **Configurable fonts**: font family, size, and line height are adjustable and hot-reload on open terminals
20
21
 
21
22
  ## Installation
22
23
 
@@ -30,16 +31,22 @@ dsh plugin --profile web add github:btsd321/dsh-oh-my-terminal
30
31
 
31
32
  | Option | Description | Default |
32
33
  |---|---|---|
33
- | `toggleShortcut` | Shortcut to toggle the terminal panel (legacy host only; see below) | `` Ctrl+` `` |
34
- | `shellCommand` | Shell command for new terminals | System default (bash / PowerShell) |
34
+ | `toggleShortcut` | Shortcut to toggle the terminal panel (legacy host only; see below) | `` Ctrl+Shift+` `` |
35
+ | `fontFamily` | Terminal font family (CSS `font-family` string); empty = built-in default stack with CJK fallback | `''` |
36
+ | `fontSize` | Terminal font size in pixels | `12.5` |
37
+ | `lineHeight` | Terminal line height multiplier | `1.25` |
38
+ | `terminalProfiles` | Terminal profile table (JSON array); empty = use auto-detected $PATH terminals | `''` |
35
39
 
36
- ## DSH 0.1.7-rc.2 shortcut changes
40
+ ### Terminal profiles
37
41
 
38
- DSH 0.1.7-rc.2 introduced a host-level shortcut system. Its built-in terminal (`` Ctrl+` `` via `terminal.new`) conflicts with the previous default. To avoid double-triggering, this plugin changed its behavior starting with the rc.2-compatible release:
42
+ The terminal profile table is a JSON array of `{ id, type, name, path, origin }` entries:
39
43
 
40
- - **Integrated with shortcuts system**: the plugin registers the `terminal-panel.toggle` command with a default binding of `` Ctrl+Shift+` `` (no default on `web:linux`; bind it manually in DSH settings). Change the key in DSH Settings - Keyboard Shortcuts; the panel label updates automatically
41
- - **Legacy host (0.1.7-rc.1 and earlier)**: falls back to a bare `` Ctrl+` `` listener; the `toggleShortcut` setting still applies
42
- - **Migration note**: after upgrading the host, `` Ctrl+` `` opens the built-in terminal (side panel). Use `` Ctrl+Shift+` `` for this plugin's panel. To reclaim `` Ctrl+` `` for this plugin, first remove the built-in terminal's binding in DSH keyboard settings
44
+ - **type**: determines spawn semantics — `pwsh` / `powershell` / `cmd` / `bash` / `zsh` / `fish` / `gitbash` / `nushell` / `custom`
45
+ - **name**: display name shown in the dropdown menu (user-editable)
46
+ - **path**: executable path; empty = resolve by `type` in `$PATH`
47
+ - **origin**: `auto` (startup-detected) or `user` (manually added)
48
+
49
+ Auto-detected profiles (`origin: auto`) cannot be deleted and their path is read-only, but the name is always editable. The profile table is editable in the settings UI with inline editing, add-row, and delete support.
43
50
 
44
51
  ## Development
45
52
 
@@ -75,25 +82,30 @@ The host side lazy-loads the package (`await import()` on first session creation
75
82
 
76
83
  ```
77
84
  src/
78
- ├── index.ts # Host entry (Cordis plugin + settings + session lifecycle)
79
- ├── routes.ts # HTTP route handlers
80
- ├── ws-handler.ts # WebSocket handler (pty data forwarding)
81
- ├── client.tsx # Browser entry (React components + plugin registration)
85
+ ├── index.ts # Host entry (Cordis plugin + settings + session lifecycle)
86
+ ├── routes.ts # HTTP route handlers
87
+ ├── ws-handler.ts # WebSocket handler (pty data forwarding)
88
+ ├── client.tsx # Browser entry (React components + plugin registration)
82
89
  ├── client/
83
- │ ├── types.ts # Shared types
84
- │ ├── hooks.ts # useReducer state management + custom hooks
85
- │ ├── term-pane.tsx # xterm.js terminal pane component
86
- │ ├── dropdown.tsx # Dropdown menu next to the + button
87
- │ ├── side-list.tsx # Right-side terminal list panel
88
- │ ├── styles.ts # CSS constants + Campbell dark theme
89
- │ ├── icons.tsx # SVG icon components
90
- │ └── clipboard.ts # Clipboard utility functions
91
- ├── persistence.ts # Session persistence (log storage, metadata, startup restore)
92
- ├── platform.ts # Platform adapter (POSIX / Windows + shell detection)
93
- ├── constants.ts # Protocol, size, shortcut, and env var constants
94
- ├── server-command.ts # Command-line parsing utilities
95
- ├── shortcut.ts # Shortcut parsing utilities
96
- └── logger.ts # Structured logger
90
+ │ ├── types.ts # Shared types
91
+ │ ├── hooks.ts # useReducer state management + custom hooks
92
+ │ ├── term-pane.tsx # xterm.js terminal pane component
93
+ │ ├── dropdown.tsx # Dropdown menu next to the + button
94
+ │ ├── side-list.tsx # Right-side terminal list panel
95
+ │ ├── styles.ts # CSS constants + Campbell dark theme
96
+ │ ├── icons.tsx # SVG icon components
97
+ │ ├── clipboard.ts # Clipboard utility functions
98
+ │ ├── shortcut-bridge.ts # Shortcuts service integration (command registration + toggle bridge)
99
+ │ ├── settings/ # Settings UI (types / store / card / profile-table / api / styles)
100
+ │ └── terminal/ # Terminal tab state (use-tabs / reducer / use-terminal-state / geometry)
101
+ ├── persistence.ts # Session persistence (log storage, metadata, startup restore)
102
+ ├── platform.ts # Platform adapter (POSIX / Windows + shell detection)
103
+ ├── terminal/ # Terminal kinds, detection, resolution, profile store
104
+ ├── settings/ # Settings bridge (namespace, bridge routes, patch ops)
105
+ ├── constants.ts # Protocol, size, shortcut, and env var constants
106
+ ├── server-command.ts # Command-line parsing utilities
107
+ ├── shortcut.ts # Shortcut parsing utilities
108
+ └── logger.ts # Structured logger
97
109
  ```
98
110
 
99
111
  The host side and browser side communicate over WebSocket at `/api/dsh-oh-my-terminal` and do not import each other directly.
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.1-blue)](package.json)
3
+ [![version](https://img.shields.io/badge/version-0.2.6-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
 
@@ -11,10 +11,11 @@ DSH Web GUI 的底部终端面板插件,基于 `@lydell/node-pty` 提供多终
11
11
  - **多标签终端**:同时管理多个会话,右侧列表替代水平 tab 栏,支持右键重命名
12
12
  - **拆分终端**:同组内水平并排,+号旁下拉菜单(新建 / 拆分 / 按种类新建),VSCode 风格操作
13
13
  - **会话持久化**:面板收起后会话保持存活,重新打开后自动恢复
14
- - **快捷键开关**:可配置快捷键呼出/收起面板;DSH 0.1.7-rc.2+ 接入宿主统一快捷键系统,在设置界面改键后标签跟随更新
14
+ - **快捷键开关**:可配置快捷键呼出/收起面板;接入宿主统一快捷键系统,在设置界面改键后标签跟随更新
15
15
  - **拖拽调高**:拖拽面板上边缘调整高度
16
16
  - **跨平台**:Windows ConPTY / POSIX openpty,由 `@lydell/node-pty` 自动选择,二进制随 tarball 分发,安装即用
17
- - **可配置 shell**:自定义 shell 命令与启动参数
17
+ - **配置表驱动终端**:终端配置表(type / name / path)驱动新建终端;启动时自动探测 $PATH 中的终端,可在设置界面新增、改名、编辑路径
18
+ - **字体可配置**:字体族、字号、行高可配置,配置变更对已打开终端即时生效
18
19
 
19
20
  ## 安装
20
21
 
@@ -28,16 +29,22 @@ dsh plugin --profile web add github:btsd321/dsh-oh-my-terminal
28
29
 
29
30
  | 配置项 | 说明 | 默认值 |
30
31
  |---|---|---|
31
- | `toggleShortcut` | 呼出/收起终端面板的快捷键(仅旧宿主生效,见下方适配说明) | `` Ctrl+` `` |
32
- | `shellCommand` | 终端使用的 shell 命令 | 系统默认(bash / PowerShell) |
32
+ | `toggleShortcut` | 呼出/收起终端面板的快捷键(仅旧宿主生效,见下方适配说明) | `` Ctrl+Shift+` `` |
33
+ | `fontFamily` | 终端字体族(CSS `font-family` 串);留空使用内置默认字体栈(含 CJK 回退) | `''` |
34
+ | `fontSize` | 终端字号(像素) | `12.5` |
35
+ | `lineHeight` | 终端行高倍数 | `1.25` |
36
+ | `terminalProfiles` | 终端配置表(JSON 数组);留空 = 用启动时探测到的 $PATH 终端 | `''` |
33
37
 
34
- ## DSH 0.1.7-rc.2 快捷键适配说明
38
+ ### 终端配置表
35
39
 
36
- DSH 0.1.7-rc.2 起宿主自带全局快捷键系统,其内置终端(右侧栏 `terminal.new` 命令)占用了 `` Ctrl+` ``。为避免同一按键双重响应,本插件自 rc.2 适配版起做出以下调整:
40
+ 终端配置表是 `{ id, type, name, path, origin }` 条目的 JSON 数组:
37
41
 
38
- - **接入 shortcuts 系统**:插件注册 `terminal-panel.toggle` 命令,默认绑定 `` Ctrl+Shift+` ``(`web:linux` 无默认键,需在设置界面手动绑定)。改键在 DSH 设置 - 快捷键中进行,面板提示标签跟随当前有效绑定
39
- - **旧宿主(0.1.7-rc.1 及更早)**:自动降级为裸 `` Ctrl+` `` 监听,`toggleShortcut` 配置照常生效
40
- - **迁移指引**:升级宿主后,`` Ctrl+` `` 归内置终端使用,插件面板切换改用 `` Ctrl+Shift+` ``;如需改回 `` Ctrl+` ``,请先在 DSH 快捷键设置中解除内置终端的绑定
42
+ - **type**:决定 spawn 语义 — `pwsh` / `powershell` / `cmd` / `bash` / `zsh` / `fish` / `gitbash` / `nushell` / `custom`
43
+ - **name**:下拉菜单显示名(用户可改)
44
+ - **path**:可执行文件路径;留空 = 按 type 在 `$PATH` 中解析
45
+ - **origin**:`auto`(启动探测)/ `user`(手动新增)
46
+
47
+ 自动探测项(`origin: auto`)不可删除、路径不可改,但名称始终可编辑。配置表可在设置界面内 inline 编辑、新增行、删除。
41
48
 
42
49
  ## 开发
43
50
 
@@ -73,25 +80,30 @@ pnpm exec tsx --test tests/unit/*.test.ts
73
80
 
74
81
  ```
75
82
  src/
76
- ├── index.ts # 宿主半入口(Cordis 插件 + settings + 会话生命周期)
77
- ├── routes.ts # HTTP 路由处理器
78
- ├── ws-handler.ts # WebSocket 处理器(pty 数据转发)
79
- ├── client.tsx # 浏览器半入口(React 组件 + 插件注册)
83
+ ├── index.ts # 宿主半入口(Cordis 插件 + settings + 会话生命周期)
84
+ ├── routes.ts # HTTP 路由处理器
85
+ ├── ws-handler.ts # WebSocket 处理器(pty 数据转发)
86
+ ├── client.tsx # 浏览器半入口(React 组件 + 插件注册)
80
87
  ├── client/
81
- │ ├── types.ts # 共享类型
82
- │ ├── hooks.ts # useReducer 状态管理 + 自定义 Hooks
83
- │ ├── term-pane.tsx # xterm.js 终端面板组件
84
- │ ├── dropdown.tsx # +号旁下拉菜单
85
- │ ├── side-list.tsx # 右侧终端列表
86
- │ ├── styles.ts # CSS 常量 + Campbell 暗色主题
87
- │ ├── icons.tsx # SVG 图标组件
88
- │ └── clipboard.ts # 剪贴板工具函数
89
- ├── persistence.ts # 会话持久化层(日志落盘 / 元数据 / 启动恢复)
90
- ├── platform.ts # 平台适配层(POSIX / Windows + shell 探测)
91
- ├── constants.ts # 协议 / 尺寸 / 快捷键 / 环境变量常量
92
- ├── server-command.ts # 命令行解析工具
93
- ├── shortcut.ts # 快捷键解析工具
94
- └── logger.ts # 统一日志工具
88
+ │ ├── types.ts # 共享类型
89
+ │ ├── hooks.ts # useReducer 状态管理 + 自定义 Hooks
90
+ │ ├── term-pane.tsx # xterm.js 终端面板组件
91
+ │ ├── dropdown.tsx # +号旁下拉菜单
92
+ │ ├── side-list.tsx # 右侧终端列表
93
+ │ ├── styles.ts # CSS 常量 + Campbell 暗色主题
94
+ │ ├── icons.tsx # SVG 图标组件
95
+ │ ├── clipboard.ts # 剪贴板工具函数
96
+ │ ├── shortcut-bridge.ts # shortcuts 服务接入桥(命令注册 + 面板切换回调)
97
+ │ ├── settings/ # 设置界面(types / store / card / profile-table / api / styles)
98
+ │ └── terminal/ # 终端标签页状态(use-tabs / reducer / use-terminal-state / geometry)
99
+ ├── persistence.ts # 会话持久化层(日志落盘 / 元数据 / 启动恢复)
100
+ ├── platform.ts # 平台适配层(POSIX / Windows + shell 探测)
101
+ ├── terminal/ # 终端种类、探测、解析、配置表校验
102
+ ├── settings/ # Settings 桥接层(命名空间 / 路由 / patch 操作)
103
+ ├── constants.ts # 协议 / 尺寸 / 快捷键 / 环境变量常量
104
+ ├── server-command.ts # 命令行解析工具
105
+ ├── shortcut.ts # 快捷键解析工具
106
+ └── logger.ts # 统一日志工具
95
107
  ```
96
108
 
97
109
  宿主半与浏览器半经 WebSocket 通信,路由前缀 `/api/dsh-oh-my-terminal`,不直接 import。
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.4",
3
+ "version": "0.2.6",
4
4
  "description": "DSH Web GUI 底部终端面板插件——node-pty 驱动的多标签交互式终端(Windows ConPTY / POSIX openpty)",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -35,12 +35,11 @@
35
35
  "@deepseek-ai/schemastery": "^3.18.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@deepseek-ai/dsh-brand": "0.1.7-rc.2",
39
- "@deepseek-ai/dsh-client-shortcuts": "0.1.7-rc.2",
40
- "@deepseek-ai/dsh-client-store": "0.1.7-rc.2",
41
- "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
42
- "@deepseek-ai/dsh-host-webserver": "0.1.7-rc.2",
43
- "@deepseek-ai/dsh-settings": "0.1.7-rc.2",
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",
44
43
  "@types/node": "^22.0.0",
45
44
  "@types/react": "^18.2.0",
46
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
+ }
@@ -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
+ });