Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e275aa321 | ||
|
|
ef8a707da8 | ||
|
|
83a658efd4 | ||
|
|
cba7e77896 | ||
|
|
e484556c1e | ||
|
|
7d524724ae | ||
|
|
c0aeefc936 | ||
|
|
ea250c9e87 | ||
|
|
9b87c1da16 | ||
|
|
001e86b1e1 | ||
|
|
277e7d3b40 | ||
|
|
58999c2f3b | ||
|
|
2bcfdfce87 | ||
|
|
cbc28473a0 | ||
|
|
1b0b382edc | ||
|
|
0995a58454 | ||
|
|
319d34ed09 | ||
|
|
ced3a1eade | ||
|
|
82a69546d2 | ||
|
|
288a499c7f | ||
|
|
c70d868c58 | ||
|
|
61e31321d6 | ||
|
|
9aeb0464ce | ||
|
|
dff1595e77 | ||
|
|
28d20a46c0 | ||
|
|
10fdeae409 | ||
|
|
562bc96f7e | ||
|
|
9c8bdc4aa5 | ||
|
|
bd49fcc3cd | ||
|
|
c8fc6c7cbf | ||
|
|
6b4fed1010 | ||
|
|
20bb60dd03 | ||
|
|
df1ab1c65f | ||
|
|
131398efe1 | ||
|
|
09de13c7bc | ||
|
|
77ed1ba437 | ||
|
|
d94465c28d | ||
|
|
387992a13f | ||
|
|
52341a9d60 | ||
|
|
cde107ce9e | ||
|
|
9642e95e89 | ||
|
|
0c254a2197 | ||
|
|
c5fda3716d | ||
|
|
37d91befdb | ||
|
|
d83490c93e | ||
|
|
96064c31a9 | ||
|
|
047f48337d | ||
|
|
efb2e2bd58 | ||
|
|
97e52b650e | ||
|
|
3cbf43854b | ||
|
|
e128c303bc | ||
|
|
8bf6d1d7ba | ||
|
|
0f9af851ae | ||
|
|
3d88752eb1 | ||
|
|
1b7c8ba497 | ||
|
|
5a93f610d8 | ||
|
|
9fdb44d9f9 | ||
|
|
e515d74f77 | ||
|
|
0a07cff6a3 | ||
|
|
a85d1ab004 | ||
|
|
b9f8eea56d | ||
|
|
72ba010ab7 | ||
|
|
1b7014b2d4 | ||
|
|
64c6aa477e | ||
|
|
5f6d4c97d5 | ||
|
|
58d6167b69 | ||
|
|
23b1b564d7 | ||
|
|
b31a9a601f | ||
|
|
2e9db44a71 | ||
|
|
1bfabbbfc2 | ||
|
|
ce052faee8 | ||
|
|
a2bb04bce0 |
@@ -0,0 +1,43 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Tests run git log against this repository and need the full history.
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
# The plugin's git revert/cherry-pick spawn git without setting an
|
||||
# identity (deliberate, see tests/smoke.spec.ts) and rely on ambient
|
||||
# config; the runner has none by default.
|
||||
- name: Configure git identity for tests
|
||||
run: |
|
||||
git config --global user.name "dsh-better-sidebar-ci"
|
||||
git config --global user.email "ci@dsh.invalid"
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
# The consumer-facing declaration surface guard: a browser-only plugin
|
||||
# (no @types/node, skipLibCheck: false) must type-check against the
|
||||
# shipped lib/types. Runs AFTER build (it reads the built output).
|
||||
- name: Check consumer type surface
|
||||
run: pnpm check:consumer-types
|
||||
@@ -146,3 +146,6 @@ lib/
|
||||
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
|
||||
registry/
|
||||
.npmrc
|
||||
|
||||
# stray npm cache scratch dir
|
||||
.npm-cache-tmp/
|
||||
|
||||
@@ -10,7 +10,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
|
||||
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
|
||||
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
|
||||
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +86,16 @@ export function apply(ctx: Context): void {
|
||||
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
|
||||
```
|
||||
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。v0.12.0 起服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
|
||||
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
|
||||
} from 'dsh-better-sidebar/client/service'
|
||||
```
|
||||
|
||||
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中,service 声明会拉入它)——纯浏览器侧插件建议走 `client/service` 路径,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码,宿主消费者本就处于 Node 环境)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),无 `@types/node`、`skipLibCheck: false` 也能编译。
|
||||
|
||||
---
|
||||
|
||||
@@ -118,7 +127,7 @@ interface TabDescriptor {
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播
|
||||
/**
|
||||
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
|
||||
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
||||
@@ -127,17 +136,76 @@ interface TabDescriptor {
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
|
||||
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
|
||||
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
|
||||
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
|
||||
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
|
||||
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
|
||||
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
|
||||
* 与 `render`(自定义设置面板,替代行列表)。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
/** 行控件类型;缺省 'switch'(向后兼容)。 */
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
/** number 行的提交钳制下限。 */
|
||||
min?: number
|
||||
/** number 行的提交钳制上限。 */
|
||||
max?: number
|
||||
/** text 行的输入占位符。 */
|
||||
placeholder?: string
|
||||
/** 输入框后的单位后缀(如 'px')。 */
|
||||
unit?: string
|
||||
}[]
|
||||
/** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的,
|
||||
* 持久化在 `pluginSettings[<descriptor id>]`——不需要宿主 PrefsSchema 字段。 */
|
||||
pluginToggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
unit?: string
|
||||
}[]
|
||||
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。
|
||||
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
|
||||
* updatePluginSetting(key, value) 与 close()。抛错会被吞掉并显示内联错误。 */
|
||||
render?: (props: {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
prefs: SidebarPrefs
|
||||
pluginSettings: Record<string, unknown>
|
||||
updatePluginSetting: (key: string, value: unknown) => void
|
||||
close: () => void
|
||||
}) => ReactNode
|
||||
}
|
||||
/**
|
||||
* tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
|
||||
* string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
|
||||
* 抛错会被吞掉(不显示角标,不影响渲染)。
|
||||
*/
|
||||
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
|
||||
/**
|
||||
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
|
||||
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦**不算**打开);
|
||||
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
|
||||
* - onClose:closeTab 关闭 tab 后。
|
||||
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发回调——
|
||||
* 但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走 service 路径。
|
||||
* 回调抛错只 console.error,绝不打断打开/关闭流程。openTab 的回调 scope
|
||||
* 携带调用者传入的 { sessionId, cwd? }(+ 菜单路径带 cwd;无 scope 的自动
|
||||
* 打开路径只有 sessionId);closeTab/activateTab 的回调 scope 只在显式传入
|
||||
* scope 参数时带 cwd。
|
||||
*/
|
||||
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onClose?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
@@ -202,7 +270,8 @@ ctx.effect(() =>
|
||||
title: 'Commits',
|
||||
icon: <CommitIcon />,
|
||||
order: 70,
|
||||
available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled
|
||||
available: (ctx, scope, state) => hasGitRepo(state), // 三参 (ctx, scope, state);返回 false 时 + 菜单显示为 disabled
|
||||
// 注意:available 只影响 + 菜单的 disabled 状态,不会拒绝 openTab(只有设置页的禁用开关会)。
|
||||
dedupeKey: () => 'my-plugin:commits',
|
||||
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
|
||||
})
|
||||
@@ -245,8 +314,9 @@ interface FileViewerDescriptor {
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' 时的加载函数 */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
|
||||
* 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
|
||||
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
|
||||
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
|
||||
/** 渲染函数 */
|
||||
@@ -277,7 +347,7 @@ interface FileViewerProps {
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
@@ -292,7 +362,7 @@ interface FileViewerProps {
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
@@ -369,22 +439,61 @@ interface BetterSidebarService {
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
||||
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
|
||||
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
||||
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。聚焦既有
|
||||
* tab 时 url 不会覆写其 path。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
|
||||
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
|
||||
* 定向打开**不自动展开**目标 session 的面板(用户看不见,展开无意义);
|
||||
* 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。
|
||||
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
|
||||
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
|
||||
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
|
||||
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
|
||||
closeTab(tabId: string, scope?: SessionScope): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
||||
/** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */
|
||||
readonly version: string
|
||||
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
|
||||
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
|
||||
* 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
|
||||
readonly features: readonly string[]
|
||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||
* session 未激活时 state/sessionId 为 undefined。 */
|
||||
getSnapshot(): SidebarSnapshot
|
||||
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
|
||||
subscribeState(listener: () => void): () => void
|
||||
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
|
||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
|
||||
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
||||
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
}
|
||||
|
||||
/** openTab 的 seed(v0.12.0 起导出命名类型) */
|
||||
interface OpenTabSeed {
|
||||
type: string
|
||||
title?: string
|
||||
path?: string
|
||||
diff?: SidebarTab['diff']
|
||||
id?: string
|
||||
url?: string
|
||||
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复);
|
||||
* updateTab/seed 传 undefined 表示「不改」,传 null 可显式清除 */
|
||||
meta?: unknown
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
|
||||
|
||||
---
|
||||
|
||||
@@ -407,8 +516,8 @@ interface BetterSidebarService {
|
||||
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
|
||||
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
|
||||
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好) |
|
||||
| **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
|
||||
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 |
|
||||
| **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
|
||||
|
||||
---
|
||||
|
||||
@@ -484,7 +593,7 @@ function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /
|
||||
function parseCsv(text: string): string[][] { /* ... */ }
|
||||
```
|
||||
|
||||
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。
|
||||
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,浏览器硬刷新即可(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
|
||||
|
||||
---
|
||||
|
||||
@@ -492,11 +601,12 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
|
||||
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
|
||||
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -1,31 +1,199 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
> 一个插件,一套完整工作台:
|
||||
>
|
||||
> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、任务管理页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
|
||||
>
|
||||
> 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览
|
||||
<!-- 头部介绍区(HTML 排版) -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">一个插件,一套完整工作台</b><br /><br />
|
||||
<code>文件管理</code> <code>编辑预览</code> <code>内嵌浏览器</code> <code>真实终端</code> <code>Git 面板</code> <code>后台任务页</code><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,一个插件全部搞定。<br />
|
||||
<small>支持 Tab 窗口随意拖拽,支持三方拓展注册新 Tab 页面和文件预览</small>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
|
||||
</div>
|
||||
|
||||
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
<small>v0.12.1</small>
|
||||
|
||||
| 功能 | 说明 | 截图 |
|
||||
|---|---|---|
|
||||
| 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`) | |
|
||||
| ➕ 添加插件 | 设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件 | |
|
||||
| 🖱️ 标签页滚轮 | 标签页栏支持鼠标滚轮横向滚动 | |
|
||||
| 🐛 修复 | 远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径 | |
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
|
||||
- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板)
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
||||
- **🧩 任务管理页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台命令**(当前树全部任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `task_output`;两击确认可强制终止)
|
||||
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
|
||||
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
|
||||
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
|
||||
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
|
||||
- **🌏 多语言**:界面文案跟随 DSH 的语言设置(zh/en)实时切换——Host 偏好优先于浏览器语言,词典注册进 DSH 的 i18n 命名空间;切换语言无需刷新
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe)
|
||||
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效)
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等
|
||||
- **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止)
|
||||
- **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关)
|
||||
- **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
|
||||
- **🔌 服务化(基座)**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务);v0.12.1 起支持角标/生命周期回调/状态订阅/定向打开/插件自有设置(见 [AGENTS.md](./AGENTS.md) 与 [外部插件接入指南](./docs/external-plugin-guide.md))
|
||||
- **➕ 添加插件**:设置页「侧边卡片」两个网格(侧边栏内容 / 文件预览)末尾的虚线卡片分别打开 Tab / 预览插件弹窗:声明扩展点、「在 GitHub 上浏览更多插件」按钮、推荐插件目录(「跳转」直达仓库、「复制」安装命令到剪贴板);弹窗不打开终端、无失败路径,不阻塞启动与侧边栏
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**macOS / Linux**(Windows 装了 Git Bash 或 WSL 也可):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows(PowerShell 5.1+ / pwsh)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
|
||||
|
||||
<details>
|
||||
<summary><b>指定版本 / 装完自动重启(可选)</b></summary>
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.1 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -Restart
|
||||
```
|
||||
|
||||
不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>手动安装(逐步命令,想看清每一步)</b></summary>
|
||||
|
||||
与一键脚本等价。**第 ③ 步可重复执行;①② 只需做一次。**
|
||||
|
||||
**macOS / Linux(bash)**:
|
||||
|
||||
```sh
|
||||
cd ~/.dsh/profiles/web
|
||||
|
||||
# ① 放行 node-pty / protobufjs 的构建脚本(pnpm 11 默认拦截;pnpm 10 可跳过)
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② 放行「发布不足 24h」的新版本(装老版本可跳过;若已有该键,把下面那行并入其下即可)
|
||||
cat >> pnpm-workspace.yaml <<'EOF'
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.1)
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
**Windows(PowerShell)**:
|
||||
|
||||
```powershell
|
||||
cd ~\.dsh\profiles\web
|
||||
|
||||
# ① 放行构建脚本
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② 放行新版本(一次性;若已有该键,把 - dsh-better-sidebar 并入其下即可)
|
||||
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
|
||||
|
||||
# ③ 安装并自动挂载
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>脚本内部做了什么(技术细节)</b></summary>
|
||||
|
||||
一键脚本自动完成 4 件事(全部幂等,可安全重复执行):
|
||||
|
||||
1. 预写 `allowBuilds`(node-pty / protobufjs),规避 pnpm 11 的构建脚本拦截;
|
||||
2. 预写 `minimumReleaseAgeExclude`,放行「发布不足 24 小时」的新版本;
|
||||
3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载;
|
||||
4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。
|
||||
|
||||
`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.1` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>更新</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>常见问题</b></summary>
|
||||
|
||||
| 现象 | 原因与解决 |
|
||||
|---|---|
|
||||
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。跑 `pnpm approve-builds --all`(一键脚本已自动处理)。 |
|
||||
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`);一键脚本已自动处理。 |
|
||||
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
|
||||
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`(一键脚本会自动清)。 |
|
||||
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
|
||||
| Windows 没有 bash / curl | 直接用 PowerShell 一键命令;或安装 Git Bash / WSL 再跑 bash 命令。 |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>从源码安装 / 开发(可选,替代 npm 方式)</b></summary>
|
||||
|
||||
调试本地改动或跟随开发分支时,把依赖指向本地克隆并自行构建:
|
||||
|
||||
```text
|
||||
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>"
|
||||
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
4. 在 ~/.dsh/profiles/web 执行 pnpm install
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.1"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>通过 plugin-registry 安装(可选,与上述二选一)</b></summary>
|
||||
|
||||
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
@@ -38,51 +206,6 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
|
||||
|
||||
```text
|
||||
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
|
||||
|
||||
1. 克隆并构建:
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
|
||||
2. 注册到 web profile:
|
||||
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
|
||||
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
3. 在 ~/.dsh/profiles/web 执行 pnpm install
|
||||
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
|
||||
```
|
||||
|
||||
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
|
||||
|
||||
### 更新
|
||||
|
||||
```text
|
||||
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build(401/404 处理同上)
|
||||
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
|
||||
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
|
||||
```
|
||||
|
||||
### 通过 plugin-registry 安装(可选,与上述二选一)
|
||||
|
||||
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
|
||||
|
||||
## 🔌 服务化:注册 tab 与文件预览器
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
|
||||
@@ -97,14 +220,22 @@ export function apply(ctx: Context) {
|
||||
}
|
||||
```
|
||||
|
||||
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。
|
||||
v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等),详见下方接入文档。
|
||||
|
||||
完整接入文档:
|
||||
- **[`AGENTS.md`](./AGENTS.md)**——仓库内维护的接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测);
|
||||
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**——面向外部插件开发者的接入指南(含完整最小示例)。
|
||||
|
||||
### ➕ 添加插件(推荐插件目录)
|
||||
|
||||
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
|
||||
|
||||
**收录新插件**:向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册)追加一条 `PluginEntry`,并把仓库打上 `dsh-better-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
|
||||
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
|
||||
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
|
||||
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
|
||||
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.6),直接解析、无需令牌
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
|
||||
@@ -123,7 +254,7 @@ pnpm watch # tsdown --watch
|
||||
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
|
||||
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
|
||||
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
|
||||
- Office 三件套预览(.docx/.xlsx/.pptx)已移至「推荐插件」(Office 预览插件,见设置页「添加插件」弹窗);未安装时此类文件走代码/下载查看兜底
|
||||
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
|
||||
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
|
||||
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
|
||||
@@ -131,3 +262,9 @@ pnpm watch # tsdown --watch
|
||||
## 🖥️ 平台支持
|
||||
|
||||
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
|
||||
|
||||
## 🔗 友情链接
|
||||
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
|
||||
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装
|
||||
- [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
<!-- Hero (HTML layout) -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">One plugin, one complete workbench</b><br /><br />
|
||||
<code>File management</code> <code>Edit & preview</code> <code>Embedded browser</code> <code>Real terminal</code> <code>Git panel</code> <code>Background tasks</code><br /><br />
|
||||
<b>Dual workbench: right sidebar + bottom panel</b>, all in one plugin.<br />
|
||||
<small>Tabs are freely draggable; third-party plugins can register new tab pages and file viewers</small>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
|
||||
</div>
|
||||
|
||||
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
<small>v0.12.1</small>
|
||||
|
||||
| Feature | Description | Screenshot |
|
||||
|---|---|---|
|
||||
| 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`) | |
|
||||
| ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | |
|
||||
| 🖱️ Tab-bar scroll | Mouse-wheel horizontal scrolling on the tab bar | |
|
||||
| 🐛 Fixes | Remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path | |
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path
|
||||
- **📝 Edit & Preview**: CodeMirror 6 editing (Ctrl/Cmd+S atomic save, drafts survive tab switches); inline preview for images / Markdown / HTML / PDF / Word / Excel / PPT (HTML in a sandboxed iframe)
|
||||
- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 Browser**: multiple embedded web tabs, back/forward/refresh; pages run in a sandboxed iframe (no access to UI data or local files, rejects local addresses), temporarily unlockable (red warning); sites refusing embedding show a reason panel; external links open in the sidebar by default
|
||||
- **💻 Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model; custom font (family + 9–32px size, applied live)
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc.
|
||||
- **🧩 Background Tasks**: main-session agent topology with click-through to execution records; background tasks on the same page (type badges + exit codes, live-output peek, force-kill)
|
||||
- **🪟 Bottom Panel**: an independent second workbench that only squeezes the center output area; auto-opens a terminal on first expand (toggleable)
|
||||
- **📱 Mobile**: narrow viewports (<768px) merge into a full-width drawer with the bottom panel's tabs folded into the sidebar; files/links auto-expand the panel
|
||||
- **🔧 Split-pane Workbench**: drag tabs to split/merge panes (cross-panel supported), divider to adjust ratios; one-click collapse/expand both panels from the top-right buttons
|
||||
- **🔁 Session Isolation**: layout / tabs / panel states persisted per session, stale state auto-purged; "produced files" open in the sidebar
|
||||
- **⚙️ Declarative Settings**: the "Side Cards" settings section renders a registry-driven toggle grid, each item independently switchable; secondary settings (auto-expand, terminal tools, sandbox, etc.) edited in a native dialog via the gear button
|
||||
- **🔌 Service API**: exposes `ctx.betterSidebar` — other plugins can register tabs and file viewers (the 7 built-in tabs + 9 viewers share the same service, see [AGENTS.md](./AGENTS.md) and the [external plugin guide](./docs/external-plugin-guide.md))
|
||||
- **➕ Add Plugins**: dashed cards at the end of both settings grids ("Sidebar content" / "File viewers") open tab / previewer plugin modals: the extension point declaration, a "Browse more plugins on GitHub" BUTTON, and the recommended plugin catalog ("Open" jumps to the repo, "Copy" puts the install command on the clipboard); no terminal is opened, nothing can fail — never blocking startup or the sidebar
|
||||
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
|
||||
|
||||
**macOS / Linux** (also works in Git Bash / WSL on Windows):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows (PowerShell 5.1+ / pwsh)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
|
||||
|
||||
<details>
|
||||
<summary><b>Pin a version / auto-restart (optional)</b></summary>
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.1 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -Restart
|
||||
```
|
||||
|
||||
Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Manual install (step by step)</b></summary>
|
||||
|
||||
Equivalent to the one-click script. **Step ③ is repeatable; ①② only need to run once.**
|
||||
|
||||
**macOS / Linux (bash)**:
|
||||
|
||||
```sh
|
||||
cd ~/.dsh/profiles/web
|
||||
|
||||
# ① Allow node-pty / protobufjs build scripts (pnpm 11 blocks them by default; skip on pnpm 10)
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② Allow versions published less than 24h ago (skip for older releases; if the key already exists, merge the line under it instead)
|
||||
cat >> pnpm-workspace.yaml <<'EOF'
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.12.1)
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
|
||||
```powershell
|
||||
cd ~\.dsh\profiles\web
|
||||
|
||||
# ① Allow build scripts
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② Allow fresh releases (once; if the key already exists, merge - dsh-better-sidebar under it instead)
|
||||
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
|
||||
|
||||
# ③ Install and auto-mount
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What the script does (technical details)</b></summary>
|
||||
|
||||
The one-click script does four things, all idempotent (safe to re-run):
|
||||
|
||||
1. Pre-writes `allowBuilds` (node-pty / protobufjs) to dodge pnpm 11's build-script block;
|
||||
2. Pre-writes `minimumReleaseAgeExclude` to allow versions younger than 24 hours;
|
||||
3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`;
|
||||
4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page).
|
||||
|
||||
`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.1` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Updating</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.1"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Troubleshooting</b></summary>
|
||||
|
||||
| Symptom | Cause & fix |
|
||||
|---|---|
|
||||
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` (the one-click script handles it). |
|
||||
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`); the one-click script handles it. |
|
||||
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
|
||||
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it (the one-click script cleans it). |
|
||||
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
|
||||
| No bash / curl on Windows | Use the PowerShell one-click command, or install Git Bash / WSL and run the bash commands. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install from source / develop (optional — alternative to the npm flow)</b></summary>
|
||||
|
||||
To debug local changes or track the dev branch, point the dependency at a local clone and build it yourself:
|
||||
|
||||
```text
|
||||
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-better-sidebar": "link:<absolute path of the clone>"
|
||||
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
4. Run pnpm install in ~/.dsh/profiles/web
|
||||
5. Restart DSH and hard-refresh
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.1"` and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install via plugin-registry (optional — use either this or the main flow)</b></summary>
|
||||
|
||||
Prerequisite: DSH with [plugin-registry](https://github.com/dsh-external/plugin-registry) integrated (`dsh registry` available). **Enabling both channels double-mounts** (the Node half loads twice, the page gets two sidebars).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # assemble the registry/ staging (manifest + artifacts + README, not committed)
|
||||
dsh registry install ./registry # install (disabled by default)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`. Remove the other channel's mount before switching.
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
|
||||
| Action | Keys |
|
||||
|---|---|
|
||||
| Save edits | `Ctrl/Cmd + S` |
|
||||
| Git commit | `Ctrl + Enter` |
|
||||
| Close tab | Middle mouse button |
|
||||
| Split / merge panes | Drag tab to pane edge / middle |
|
||||
| Reference file to input | Hover the `@file` button at end of line |
|
||||
| Copy file path | Right-click row → copy relative/absolute path |
|
||||
|
||||
## 🔌 Service: register tabs & file viewers
|
||||
|
||||
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 9 viewers go through the same service, eating our own dog food):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
v0.12.1+ base capabilities (complete type exports, capability detection, state subscription, tab badge, lifecycle callbacks, targeted open, plugin-owned settings, etc.) — see the integration docs below.
|
||||
|
||||
Full integration docs:
|
||||
- **[`AGENTS.md`](./AGENTS.md)** — the in-repo integration doc (full fields, matching algorithm, HMR pitfalls, declarative settings, version detection);
|
||||
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)** — the external-plugin guide (with a complete minimal example).
|
||||
|
||||
### ➕ Add Plugins (recommended plugin catalog)
|
||||
|
||||
The dashed cards at the end of the "Sidebar content" / "File viewers" grids in the "Side Cards" settings section open the **Add tab plugins** / **Add preview plugins** modals: each declares its open extension point, offers a "**Browse more plugins on GitHub**" button (the [GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar)), and lists the recommended catalog (name / repo / description / install script) — "**Open**" jumps to the repo, "**Copy**" writes the install command to the clipboard.
|
||||
|
||||
**Curating a new plugin**: append a `PluginEntry` to [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) (tab registrations) or [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) (file-previewer registrations) and tag your repo with the `dsh-better-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
|
||||
|
||||
## 🛠️ Development & Build
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.6, published) — no token needed
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest (includes manifest consistency guard; build first)
|
||||
pnpm watch # tsdown --watch
|
||||
```
|
||||
|
||||
**Architecture**: a single npm package with host/client halves — host (`src/index.ts`): `/sidebar/api/*` JSON API, `/sidebar/file` media route, `/sidebar/html` preview route, `/sidebar/ws/terminal` WebSocket (fs / git / pty / preview, all session-scoped with a trust fence); client (`src/client/index.tsx`): portal sidebar + views + interception; state persisted per session in localStorage. Organized per DSH official conventions (no default export, dual client bundles); no dependency on npm / checkout at runtime (`@deepseek-ai/*` provided by the web profile).
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; media/preview routes only serve files inside the session cwd; git only shells out to the CLI and never sets identity
|
||||
- HTML preview and browser tab content render in **opaque-origin sandboxed iframes** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds; the address bar rejects `javascript:`/`data:`/`file:` and local addresses like localhost
|
||||
- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the settings page can disable the sandbox per feature (disabled by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content
|
||||
|
||||
## ⚠️ Known Limitations
|
||||
|
||||
- Git has no push/pull/fetch; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted
|
||||
- Dragging a terminal tab to another pane remounts it (shell restarts)
|
||||
- Office-suite preview (.docx/.xlsx/.pptx) moved to the recommended office plugin (see the "Add plugins" modals in settings); without it these files fall through to the code/download fallbacks
|
||||
- Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack
|
||||
- HTML preview renders the saved file (not unsaved drafts)
|
||||
- No bottom panel on mobile (<768px): on narrow screens its tabs merge into the right sidebar once (after migrating back to desktop they stay in the right sidebar); the desktop bottom panel is only available on wide viewports; auto-open terminal on first bottom-panel expand does not trigger on mobile
|
||||
|
||||
## 🖥️ Platform Support
|
||||
|
||||
Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT).
|
||||
|
||||
## 🔗 Friends
|
||||
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
|
||||
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install
|
||||
- [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily
|
||||
@@ -0,0 +1,19 @@
|
||||
# dsh-better-sidebar bundle patch
|
||||
#
|
||||
# This file is the `dsh.bundle.patch` layer of the published npm package: when
|
||||
# the plugin is installed through the official CLI —
|
||||
#
|
||||
# dsh plugin --profile <name> add dsh-better-sidebar@<version>
|
||||
#
|
||||
# — the command reconciles `dsh.profile.bundles` against installed packages
|
||||
# and, seeing this declaration, appends `dsh-better-sidebar` to the bundle
|
||||
# stack. The profile boot then merges THIS patch (a single `insert` of the
|
||||
# plugin row) exactly like the manual cordis.patch.yml mount line used
|
||||
# before. No profile file edits needed — one command installs and mounts.
|
||||
#
|
||||
# If the profile still carries the old manual mount line in its own
|
||||
# cordis.patch.yml, remove it before switching to the bundle channel to
|
||||
# avoid double-mounting (two Node halves, two sidebars).
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
+197
-29
@@ -2,8 +2,8 @@
|
||||
|
||||
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
>
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置部分需要 **v0.4.1+**。当前版本 v0.4.3。
|
||||
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 6 tab + 8 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;本文档其余新 API(badge/生命周期/定向打开/插件设置/版本探测等)**v0.12.0+**。当前版本 v0.12.0。
|
||||
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 7 tab + 9 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
|
||||
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
|
||||
|
||||
内置的 6 个 tab(explorer / git / subagent / terminal / editor / diff)和 8 个 viewer(image / pdf / docx / xlsx / pptx / markdown / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
|
||||
内置的 7 个 tab(explorer / git / subagent / terminal / browser / editor / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
|
||||
|
||||
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
|
||||
|
||||
@@ -32,7 +32,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类型合并
|
||||
```
|
||||
|
||||
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §9)。
|
||||
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。
|
||||
|
||||
### 2.2 package.json 声明
|
||||
|
||||
@@ -69,6 +69,17 @@ import type {
|
||||
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
|
||||
```
|
||||
|
||||
v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
|
||||
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
|
||||
} from 'dsh-better-sidebar/client/service'
|
||||
```
|
||||
|
||||
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。
|
||||
|
||||
---
|
||||
|
||||
## 3. 最小骨架(client half)
|
||||
@@ -149,18 +160,73 @@ interface TabDescriptor {
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):见 §7。
|
||||
* 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有
|
||||
* 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
settings?: SidebarSettingsDeclaration
|
||||
/**
|
||||
* tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
|
||||
* string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
|
||||
* 抛错会被吞掉(不显示角标,不影响渲染)。
|
||||
*/
|
||||
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
|
||||
/**
|
||||
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
|
||||
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开);
|
||||
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
|
||||
* - onClose:closeTab 关闭 tab 后。
|
||||
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发
|
||||
* 回调——但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走
|
||||
* service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。
|
||||
*/
|
||||
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onClose?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
/** 声明式设置声明(v0.12.0 完整形状;行控件见 §8)。 */
|
||||
interface SidebarSettingsDeclaration {
|
||||
/** 宿主 prefs 字段行(key 必须是宿主 PrefsSchema 的字段)。 */
|
||||
toggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: 'switch' | 'text' | 'number' // v0.11.0+;缺省 'switch'
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
unit?: string
|
||||
}[]
|
||||
/** 插件自有设置行(v0.12.0+):形状同 toggles,key 插件局部,
|
||||
* 持久化在 pluginSettings[<descriptor id>],无需宿主 schema 字段。 */
|
||||
pluginToggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
unit?: string
|
||||
}[]
|
||||
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。 */
|
||||
render?: (props: SidebarSettingsRenderProps) => ReactNode
|
||||
}
|
||||
|
||||
/** settings.render 收到的 props(v0.12.0+)。 */
|
||||
interface SidebarSettingsRenderProps {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
prefs: SidebarPrefs
|
||||
/** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */
|
||||
pluginSettings: Record<string, unknown>
|
||||
/** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */
|
||||
updatePluginSetting(key: string, value: unknown): void
|
||||
/** 关闭设置弹窗。 */
|
||||
close(): void
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `TabComponentProps`(你的页面组件收到的 props)
|
||||
@@ -263,6 +329,7 @@ ctx.effect(() =>
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) |
|
||||
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
|
||||
@@ -289,10 +356,12 @@ interface FileViewerDescriptor {
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' 时的加载函数 */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
|
||||
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
|
||||
/** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
|
||||
* 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
|
||||
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
|
||||
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(v0.12.0 起 viewer
|
||||
* 卡片也有齿轮按钮,pluginToggles/render 同样可用) */
|
||||
settings?: SidebarSettingsDeclaration
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
@@ -321,7 +390,7 @@ interface FileViewerProps {
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
@@ -336,7 +405,7 @@ interface FileViewerProps {
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 5.5 注册示例
|
||||
@@ -453,14 +522,79 @@ interface BetterSidebarService {
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子);
|
||||
* 聚焦既有 tab 时 url 不会覆写其 path。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
|
||||
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
|
||||
* 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为
|
||||
* 与之前完全一致。注意:available 不拦截 openTab。
|
||||
* 内容型打开(带 path/url seed)自动展开承载面板,保证落点可见。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
|
||||
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
|
||||
closeTab(tabId: string, scope?: SessionScope): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
||||
/** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */
|
||||
readonly version: string
|
||||
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
|
||||
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
|
||||
* 'pluginSettings'——用 `features.includes('xxx')` 按能力 gate。 */
|
||||
readonly features: readonly string[]
|
||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||
* session 未激活时 state/sessionId 为 undefined。 */
|
||||
getSnapshot(): SidebarSnapshot
|
||||
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
|
||||
subscribeState(listener: () => void): () => void
|
||||
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
|
||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
|
||||
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
||||
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
}
|
||||
|
||||
/** openTab 的 seed(v0.12.0 起导出命名类型)。 */
|
||||
interface OpenTabSeed {
|
||||
type: string
|
||||
title?: string
|
||||
path?: string
|
||||
diff?: SidebarTab['diff']
|
||||
id?: string
|
||||
url?: string
|
||||
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */
|
||||
meta?: unknown
|
||||
}
|
||||
```
|
||||
|
||||
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
|
||||
|
||||
```ts
|
||||
if (ctx.betterSidebar.features.includes('badge')) {
|
||||
// 使用 TabDescriptor.badge
|
||||
}
|
||||
if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ }
|
||||
```
|
||||
|
||||
**生命周期示例**(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 `onClose`:
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: 'Database',
|
||||
single: true,
|
||||
badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */,
|
||||
onOpen: (tab, scope) => { startWatcher(scope.sessionId) },
|
||||
onClose: (tab, scope) => { stopWatcher(scope.sessionId) },
|
||||
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -472,7 +606,9 @@ interface BetterSidebarService {
|
||||
- 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
|
||||
- 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。
|
||||
- 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。
|
||||
- `settings.toggles`(可选):在卡片行下追加**嵌套开关**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑(复选框行)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools`。
|
||||
- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑。行控件 v0.11.0 起不限于布尔:`type: 'switch' | 'text' | 'number'`(缺省 'switch';text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools` + 自定义字体行。
|
||||
- `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有齿轮按钮)。
|
||||
- `settings.render`(可选,v0.12.0+):**自定义设置面板**——给出时齿轮弹窗渲染它而非行列表。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
@@ -481,18 +617,36 @@ ctx.effect(() =>
|
||||
title: 'Database',
|
||||
order: 50,
|
||||
settings: {
|
||||
// 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制)
|
||||
toggles: [{
|
||||
key: 'myPluginAutoConnect', // ⚠️ 必须是宿主 PrefsSchema 的字段,见下
|
||||
title: 'Auto connect',
|
||||
desc: 'Connect on open',
|
||||
key: 'autoOpenSubagent', // 宿主内置键
|
||||
title: 'Auto-open',
|
||||
desc: 'Open when a subagent appears',
|
||||
}],
|
||||
// 插件自有设置行:key 插件局部,持久化在 pluginSettings['my-plugin:db']
|
||||
pluginToggles: [{
|
||||
key: 'pageSize',
|
||||
title: 'Page size',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 100,
|
||||
unit: 'rows',
|
||||
}],
|
||||
// 或完全自定义面板(给定时代替行列表)
|
||||
render: ({ store, service, prefs, pluginSettings, updatePluginSetting, close }) => (
|
||||
<MySettingsPanel
|
||||
values={pluginSettings}
|
||||
onChange={(key, value) => { updatePluginSetting(key, value) }}
|
||||
onDone={close}
|
||||
/>
|
||||
),
|
||||
},
|
||||
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
> ⚠️ **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`)。外部插件的自定义键会被 settings seam 丢弃——当前版本嵌套开关只支持宿主预置的布尔偏好;想给用户暴露你自己的设置,走 DSH 自己的 settings 服务(`ctx.settings`),不要依赖这里的 `toggles`。
|
||||
> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
|
||||
|
||||
---
|
||||
|
||||
@@ -516,6 +670,7 @@ ctx.effect(() =>
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 |
|
||||
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
|
||||
| **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
|
||||
| **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-better-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) |
|
||||
|
||||
---
|
||||
|
||||
@@ -598,7 +753,7 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`;
|
||||
2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`);
|
||||
3. 在 profile 目录 `pnpm install`;
|
||||
4. 重启 `dsh web` + 浏览器硬刷新(Cmd/Ctrl+Shift+R)。
|
||||
4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
|
||||
|
||||
---
|
||||
|
||||
@@ -606,13 +761,26 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:6 个内置 tab(tabs.tsx)+ 8 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
|
||||
- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 6 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
|
||||
- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据)
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
---
|
||||
|
||||
## 11. 真实接入案例
|
||||
|
||||
第一个通过 `ctx.betterSidebar` 接入的三方插件:[dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) —— 条件驱动的 agent 唤醒系统(文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话)。
|
||||
|
||||
- **接入方式**:可选软依赖——client half 本地重述最小服务契约(`registerTab`),未安装 better-sidebar 时注册静默跳过,插件原有表面不受影响;
|
||||
- **注册内容**:`dsh-sentinel:watches` tab(order 60,单实例):全服务器监控表 + 最近触发历史;
|
||||
- **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
|
||||
- **实测**:v0.3.0 起,真实 web profile 验证通过。
|
||||
|
||||
更多插件接入后欢迎在此登记(一句话 + 链接)。
|
||||
|
||||
@@ -135,3 +135,14 @@ isViewerEnabled(id: string): boolean
|
||||
4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。
|
||||
5. **开关改为卡片点击(v0.4.1 追加)**:清单项与嵌套相关设置、`openByDefault` 全部从"复选框行"改为**可点击卡片**——`<button aria-pressed>`,整卡即开关,视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + `IconCheckOutline16` 勾选徽标;禁用 = 中性 hairline + 文字降级)。嵌套相关设置渲染为更小的缩进卡片;宽度输入保持原生设置行(它是数值不是开关)。设置导航齿轮图标为宿主 shell 硬编码(`navIcon` 按 section id 映射,无插件注入点),用户选择不改宿主,保持现状。
|
||||
6. **小卡片响应式网格 + 二级设置弹窗(v0.4.1 再追加)**:清单项改为**小卡片**,置于 `repeat(auto-fill, minmax(148px, 1fr))` 响应式网格(一行自适应多个,随宽度换行);卡片结构 = 顶行(图标 + 标题省略 + **勾选徽标钉在最右端**)+ 类型/扩展名描述行(省略号截断,`title` 属性给出完整值)。声明了 `settings.toggles` 的卡片右下角渲染**齿轮角标按钮**(`IconSettingsOutline16`,仅父级启用时显示),点击打开**原生 `Modal` 弹窗**,内含 DSH 原生设置行(标题/描述 + 品牌强调原生复选框 + hairline 分隔,末行去分隔线)——二级设置不再内嵌于网格。实现细节:Modal 采用条件挂载(`settingsFor !== null && <Modal open …>`),因为 Modal 原语无条件调用 hooks,常闭挂载在测试的 dual-react(react 18.2 server / 18.3 bundled)分裂环境下会崩;弹窗行体抽成 `FeatureSettingsRows` 供 renderToString 直测。
|
||||
|
||||
---
|
||||
|
||||
## 8. v0.12.0 设置 seam 开放记录(2026-08-14,feat/plugin-api-v012)
|
||||
|
||||
设计正文「key 必须是宿主 PrefsSchema 的字段」的限制在 v0.12.0 被打开(纯增量,`toggles` 的宿主 key 语义不变):
|
||||
|
||||
- **持久化**:`SidebarPrefs` 增加 `pluginSettings: Record<string, Record<string, unknown>>`(开放嵌套 map,schema `z.dict(z.dict(z.any())).default({})`,按 descriptor id 分桶)——外部插件的自有设置不再需要宿主 schema 字段、不再被 settings seam 丢弃。`parsePrefs` 增加 `pluginSettingsMapOf` 校验(非对象整体回退 `{}`)。
|
||||
- **声明**:`SidebarSettingsDeclaration` 增加 `pluginToggles`(声明式行,控件同 toggles 的 switch/text/number,值须 JSON 可序列化)与 `render?: (props: SidebarSettingsRenderProps) => ReactNode`(自定义面板:store/service/prefs/pluginSettings/updatePluginSetting/close;抛错被吞并显示内联错误)。
|
||||
- **渲染**:齿轮弹窗体抽为 `SettingsBody`(`render` 存在时优先,否则 toggles 行 + pluginToggles 行,后者把 pluginSettings 投影到 prefs 面复用 `FeatureSettingsRows`);`settingsFor` 状态从 `TabDescriptor | null` 扩为 `TabDescriptor | FileViewerDescriptor | null`——viewer 卡片 v0.12.0 起同样有齿轮设置弹窗(§4.6 原仅 tab)。
|
||||
- **向后兼容**:旧设置文档无 `pluginSettings` 字段 → schema default `{}`;`toggles` 行为与 v0.11.0 完全一致。
|
||||
|
||||
@@ -414,3 +414,19 @@ export function apply(ctx: Context): void {
|
||||
### 17.10 测试
|
||||
|
||||
新增 `tests/builtins.spec.ts`(内置注册清单)、`tests/orphaned-tab.spec.tsx`(sanitize 保留未注册类型 + 占位渲染)、`tests/editor-load.spec.ts`(策略分发纯函数);`tests/browser-globals.ts` 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。`matchFileViewer` 相关断言按 17.3 语义重写。
|
||||
|
||||
---
|
||||
|
||||
## 18. v0.12.0 基座化扩展记录(2026-08-14,feat/plugin-api-v012)
|
||||
|
||||
§5 的服务接口在 v0.12.0 做了**纯增量**扩展(无任何签名删除 / 语义变更,向上兼容),全部记录于此,文档以本节为准:
|
||||
|
||||
- **类型导出补齐**:`./client/service` 入口 re-export 公共状态词汇——`SidebarTab` / `SidebarState` / `SidebarStore` / `SidebarSnapshot` / `SidebarDiffRef` / `TabType` / `SessionScope` / `SidebarPrefs` / `OpenTabSeed` / `SidebarSettingsRenderProps`。此前消费者无法命名这些类型(TS2459),只能靠推断。
|
||||
- **client 声明图零 Node 依赖**:`context-types.ts` 的 `SidebarWebRoute` / `SidebarWebUpgradeRoute` 改用结构镜像类型(`SidebarHttpRequest` / `SidebarHttpResponse` / `SidebarUpgradeSocket` / `SidebarUpgradeHead`),宿主在 ws 边界显式转型(`src/index.ts` 两处)。外部消费插件无需 `@types/node`、`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 双层校验守护)。
|
||||
- **新服务方法**:`version` / `features`(单调能力清单,只增不删);`getSnapshot` / `subscribeState`(状态订阅,委托 store);`updateTab(tabId, patch)`(title/path/meta);`activateTab(tabId)`(跨树激活,tab 栏点击路径);`openFile(scope, path, title?)`(editor 打开,id 按路径派生与内置拦截一致);`openTab(seed, scope?)`(定向到指定 session:`SidebarStore.reduceFor` 加载/变更/持久化目标 session 而不切换 snapshot,UI 不扰动;scope 指向当前 session 时走原 reduce 路径保证 notify)。
|
||||
- **新 descriptor 字段**:`badge`(tab 角标:number 计数 99+ 封顶 / string 文本 / null 不显示,每次 tab 栏渲染调用,抛错吞掉);`onOpen` / `onActivate` / `onClose`(生命周期回调,service 路径派发——dedupe/id 安全网聚焦触发 onActivate 而非 onOpen;内置 diff 拆分与 agent 终端 reconcile 直接改 state 不触发,但只作用于内置类型;回调抛错只 console.error);`SidebarTab.meta`(JSON 可序列化自定义状态,`sanitizeNode` 宽松保留,跨刷新原样恢复)。
|
||||
- **设置 seam 开放**:`PrefsSchema` 增加 `pluginSettings: z.dict(z.dict(z.any())).default({})` 开放嵌套 map;`settings.pluginToggles`(插件自有声明式行)与 `settings.render`(自定义面板,props 见 §5)替代「自定义 key 被 seam 丢弃」的限制;viewer 卡片 v0.12.0 起同样有齿轮设置弹窗。
|
||||
- **`FileViewerDescriptor.load` 第三参 `signal?: AbortSignal`**:EditorHost 在卸载/重匹配时 abort(忽略 signal 的 load 照常工作)。
|
||||
- 测试:`tests/consumer-types.ts`(消费面编译门)、`tests/api-surface.spec.ts`(client 图零 node 依赖守护 + 能力常量)、`tests/service.spec.ts` 新增生命周期 / 定向打开 / updateTab / activateTab / openFile / meta 持久化用例、`tests/unit.spec.ts` 新增 `reduceFor` 与 sanitize meta 用例。
|
||||
|
||||
实现偏差:§5.1 的 `openTab(seed)` 单参签名保留为 `openTab(seed, scope?)` 的可选参数(非破坏);`SidebarSnapshot` 类型 re-export 自 `./client/service` 而非新建独立文件。
|
||||
|
||||
@@ -50,6 +50,11 @@ DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
|
||||
|
||||
- 无。计划与实施一致。
|
||||
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。
|
||||
- v0.8.2 后补遗:md 预览(`TextEditor.tsx`)的代码块复制按钮文案——DSH 的
|
||||
`MarkdownText`/`CodeBlock` 是 cordis-free 组件,未传 `codeLabels` 时回退到硬编码中文
|
||||
「复制/复制成功」。修复为渲染时传入 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`
|
||||
(对齐 DSH 聊天区 `AssistantMarkdown` 的做法),新增 `tests/markdown-copy-labels.spec.tsx`
|
||||
钉住 zh/en 跟随。
|
||||
|
||||
## 验收
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 「添加插件」弹窗 + 推荐插件列表 + 复制安装命令 设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已批准,已实施
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.12.0(分支 `feat/plugin-api-v012`,已合入 main 后 rebase)
|
||||
**目标版本**:v0.12.0(不 bump)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 为「侧边栏内容(Tab)」与「文件预览(viewer)」两类扩展点分别增加**添加入口**:DSH 设置页「Side card」分区的两个响应式网格(侧边栏内容 / 文件预览)末尾各新增一个与其他卡片同尺寸的**虚线边框**添加卡片(「添加 Tab 插件」/「添加预览插件」)。
|
||||
2. 点击后弹出对应 kind 的原生 Modal:声明该类扩展点可由插件扩展(`ctx.betterSidebar` 服务),提供「在 GitHub 上浏览更多插件」**按钮**(打开 `https://github.com/topics/dsh-better-sidebar`,新标签页),并展示仓库**推荐**的同 kind 插件列表(名字 / url / 简介 / 安装脚本)。
|
||||
3. 每个条目两个动作:**「跳转」按钮**(`window.open` 新标签页直达插件仓库,绕过链接接管)与**「复制」按钮**——点击只把安装脚本写入剪贴板(`writeClipboard`,按钮闪现「已复制」反馈),用户自行粘贴到 DSH 所在环境的终端执行。
|
||||
4. **不关闭任何窗口、不打开终端、无失败路径**:复制是纯 client 动作,不依赖会话/终端/配额,弹窗保持打开可连续复制多个插件。
|
||||
5. 推荐列表为仓库静态数据、**分为两个文件**(`plugins-tabs.ts` Tab 注册目录 / `plugins-viewers.ts` 文件预览注册目录),初始在 viewer 目录收录唯一真实三方插件 `@huanlin/dsh-plugin-better-sidebar-plugin-office`(简介不注版本前提);后续插件只加一条数据。
|
||||
6. 内置 Office 预览(docx/xlsx/pptx)**从本体移除**——已迁至上述推荐插件(见偏差记录),`docx/xlsx/pptx` 文件在未装插件时落 code/下载兜底。
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- **不打开终端**:不做终端预填安装流(曾实现的 `OpenTabSeed.expand`、`TerminalSeedMeta` 预填/提示条、service meta 透传等已全部删除回滚)。
|
||||
- 不加 + 菜单入口、不加编辑器空态入口(入口只在设置页)。
|
||||
- 不做插件在线拉取 / 动态发现(列表为仓库推荐静态数据)。
|
||||
- 不改 host 半的服务契约(`service.ts` 相对 v0.12.0 无 API 变化;host 半仅在 office 移除时缩减了 chunk 白名单)。
|
||||
|
||||
## 3. 实施要点
|
||||
|
||||
### 3.1 数据层:两个目录文件 + 共享词汇
|
||||
|
||||
- `src/client/plugins-shared.ts`:`PluginEntry { id, name, url, description, install }` + `PLUGIN_TOPIC_URL`。
|
||||
- `src/client/plugins-tabs.ts`:`builtinTabPlugins`(收录 dsh-sentinel——条件驱动的 agent 唤醒系统,注册 `dsh-sentinel:watches` Tab;install 用官方 github: 源一键命令)。
|
||||
- `src/client/plugins-viewers.ts`:`builtinViewerPlugins`(种子:office 插件,install = `cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office`)。
|
||||
|
||||
### 3.2 UI 层:`src/client/add-plugin-modal.tsx` + `SideCardSection`
|
||||
|
||||
- 两个网格末尾各一个虚线卡片(复用 `.card` 配方 + `IconPlusOutline16`),`addPluginsOpen: PluginKind | null` 状态;Modal 仅在打开时条件挂载(Modal 原语无条件跑 hooks,SSR 规则同 settingsFor)。
|
||||
- `PluginListBody`(提取组件,直接可测):topic 按钮(`window.open`)+「推荐插件」分组(`groupHeading` + count)+ 条目(名字链接 / 简介 / mono 安装命令 / 跳转按钮 / 复制按钮)+ 空态。
|
||||
- **复制**:`void writeClipboard(entry.install)` + `copiedId` 状态闪现 `t('copied')`(1.5s 后还原)。不关闭、不打开、不抛错。
|
||||
|
||||
### 3.3 i18n
|
||||
|
||||
新增键:`addPluginsTabCard/TabCardDesc/ViewerCard/ViewerCardDesc/TabDesc/ViewerDesc`、`addPluginsBrowseMore`、`addPluginsRecommended`(推荐插件)、`addPluginsEmpty`、`openPlugin`(跳转)、`copyInstall`(复制安装命令,按钮 aria-label);复用 `copy`/`copied`/`close`/`settingsDone`。已删除废弃键:`install`、`installNotice`、`installError*`、office 相关键(`viewerDocx/Xlsx/Pptx`、`officeTooLarge/Corrupt/Encrypted/LoadFailed`、`previousSlide/nextSlide/zoom/zoomHint`)。
|
||||
|
||||
### 3.4 内置 Office 预览移除(与推荐插件衔接)
|
||||
|
||||
删除 3 个 viewer 描述符 + 10 个源文件(docx-view/xlsx-view/PptxView/office-view/office-shared/office-types/xlsx-to-univer + chunks/docx|xlsx|pptx)+ 3 个图标 + office CSS + office locale 键;chunk 白名单 5→2(terminal/editor,同步 `bundle-route.ts` / `chunk-loader.ts` / `tsdown.config.ts` / `scripts/package-registry.mjs`);`tests/builtins.spec.ts` 断言 6 个 viewer 且不含 office id。`.docx`(zip、无 NUL)未装插件时落 code 兜底。
|
||||
|
||||
## 4. 边界情况与失败模式
|
||||
|
||||
- **复制失败**:`writeClipboard` 尽力而为(navigator.clipboard → execCommand 兜底),失败静默;按钮反馈照常闪现。无会话/无终端/配额等一切前置条件均不涉及——复制与它们无关。
|
||||
- **空目录**:目录可为空(弹窗渲染空态 + topic 按钮);当前 Tab/Viewer 目录各有 1 条。
|
||||
- **版本号**:v0.12.0(`package.json` / `dsh.plugin.json` / `service.ts` 的 `SIDEBAR_SERVICE_VERSION` 三处一致,manifest-consistency 测试守护)。
|
||||
|
||||
## 5. 实施偏差记录
|
||||
|
||||
- **评审修改(第一批)**:入口由 + 菜单/编辑器空态改为**仅设置页虚线卡片**;安装命令并入「安装按钮」单一交互。
|
||||
- **评审修改(第二批)**:① topic 链接改为**按钮**(`window.open` 新标签页);② 添加按钮拆为**两个**(Tab 注册 / 文件预览注册),列表数据拆为**两个文件**;③ 安装错误只内联显示在设置内(预检 + try/catch);④ 终端提示条用品牌高亮色。
|
||||
- **评审修改(第三批)**:内置 Office 预览整体移除(迁至推荐插件);「内置插件」全部改名**「推荐插件」**。
|
||||
- **评审修改(第四批,最终形态)**:设置窗口关闭问题无法根治(openTab 的 store 涟漪会复活弹窗),且用户实测弹窗关闭不可靠——**放弃终端安装流**:安装按钮改为**只复制安装脚本**(`writeClipboard` + 「已复制」反馈),弹窗保持打开;随之删除整个终端预填流:`terminal-meta.ts`、`TerminalView` 的 meta/预填/提示条、`tabs.tsx` 的 meta 透传与 `uiTerminalCount` 导出、`service.ts` 的 `OpenTabSeed.expand` 与 createTab meta 透传(全部回滚到 v0.12.0 原始 API 面)、`.terminalNotice` CSS、相关测试与文档;`install`/`installNotice`/`installError*` 文案键删除,新增 `copyInstall`。
|
||||
- 测试中 `AddPluginModal` 不能 renderToString(React 18 server renderer 拒绝 portal),改为 createRoot + act 挂载后断言 `document.body`(portal 落点)。
|
||||
|
||||
## 6. 验收清单
|
||||
|
||||
- [x] 设置页两个网格末尾各出现虚线边框添加卡片,尺寸与既有卡片一致
|
||||
- [x] 两个弹窗(kind 区分):声明文案 + GitHub topic **按钮** + 对应推荐插件条目(名字/url/简介/安装命令/跳转按钮/复制按钮)
|
||||
- [x] 点「复制」:安装脚本写入剪贴板,按钮闪现「已复制」,弹窗不关闭、不打开终端、无失败路径
|
||||
- [x] 点「跳转」:`window.open` 新标签页直达插件仓库
|
||||
- [x] 内置 Office 预览已移除;chunk 白名单 2 个;无「内置插件」残留文案
|
||||
- [x] 版本号 v0.12.0 三处一致;`pnpm typecheck` / `pnpm test` / `pnpm build` 全绿
|
||||
- [x] README/README_EN/AGENTS.md 更新到位
|
||||
@@ -0,0 +1,61 @@
|
||||
# Issue #42 诊断与收尾记录(底部面板首展自动终端 → 整面板空白)
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已诊断、已收尾(无新代码修复;回归测试 + 文档交付)
|
||||
**关联**:issue #42、issue #25(同根因)、issue #31(放大器)、PR #27 / commit 37d91be、commit 9aeb046
|
||||
**目标版本**:v0.11.0(根因修复已发布)
|
||||
|
||||
## 1. 问题报告
|
||||
|
||||
[issue #42](https://github.com/omdsh-dev/DSH-better-sidebar/issues/42)(Windows 11 / QQBrowser / dsh-launcher 宿主 / 插件 0.10.3):
|
||||
|
||||
1. 全新安装后重启 + 硬刷新,首次展开底部面板(`bottomPanelAutoTerminal` 默认开)→ **整个底部面板空白,连标签栏都没有**;
|
||||
2. 控制台两条关键报错:
|
||||
- `Uncaught TypeError: Cannot read properties of undefined (reading 'dimensions')`(`Viewport._innerRefresh`)
|
||||
- `Uncaught TypeError: Cannot read properties of undefined (reading 'handleResize')`(`IdleTaskQueue._process`)
|
||||
3. 关闭「底部面板首次展开自动开终端」后一切正常;右侧栏终端一直正常;
|
||||
4. 附带问题:Windows 无控制台宿主下每次关闭终端,服务器日志出现 `Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)。
|
||||
|
||||
## 2. 诊断结论
|
||||
|
||||
### 2.1 根因 = issue #25 的同一 bug
|
||||
|
||||
与 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25)(WKWebView 底部面板终端空白)完全同源,证据链:
|
||||
|
||||
- **触发路径一致**:`Sidebar.tsx` 的「底部面板首次展开自动开终端」effect(`bottomOpenedOnce` 过渡)在面板 slide-in transition 的**同一次 commit** 挂载 `TerminalView`。`docs/plans/2026-08-14-terminal-open-when-sized-design.md` §3 在设计 #25 修复时已明确把该路径列为零尺寸挂载路径之一。
|
||||
- **崩溃签名逐字一致**:0.10.3 的 `TerminalView` 挂载 effect 同步执行 `term.open(host); fit.fit()`(无尺寸守卫)。xterm 5.3.0 在零尺寸容器 open 时渲染器创建失败,`RenderService._renderer` 保持 undefined → `Viewport._innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)、idle 任务读 `handleResize` 全部抛 "Cannot read properties of undefined"——与 #42 两条报错逐字对应。
|
||||
- **平台差异只是时序**:#25 设计文档注明「Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现」。QQBrowser(Chromium fork)在 Windows + launcher 启动的组合下命中同一零尺寸窗口期,属边界时序,不是新缺陷。
|
||||
- **版本差**:#42 报告针对 **0.10.3**(2026-08-13 发布);根因修复 37d91be(PR #27)于 2026-08-14 合并并随 **v0.11.0** 发布(早于 #42 提交约 1 小时,晚于 reporter 安装的版本)。
|
||||
|
||||
### 2.2 修复覆盖路径(无需新代码)
|
||||
|
||||
| 层 | 机制 | 位置 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 根因(零尺寸 open) | `openWhenSized`:`term.open + fit + sendResize` 推迟到容器有尺寸后恰好执行一次;RO/字体订阅的 `fit()` 全部 try/catch 加固 | `src/client/open-when-sized.ts` + `TerminalView.tsx`(37d91be,v0.11.0) | 已发布 |
|
||||
| 根因单测 | 手动步进 rAF 的 6 个用例(延迟/一次性/单维缺失/cancel/脱离文档/异常传播) | `tests/open-when-sized.spec.ts` | 已提交 |
|
||||
| 「整面板空白」放大器 | per-tab `RenderBoundary` 崩溃隔离(单 tab 崩溃只显示该 pane 内条带,toggle 簇/其他 tab/面板存活)+ 根 boundary 兜底 + layout-push CSS 变量卸载清理 | `src/client/RenderBoundary.tsx`、`Sidebar.tsx`、`index.tsx`(9aeb046,#31) | 已提交 |
|
||||
| 隔离测试 | 崩溃条带/重试恢复/布局变量清理 | `tests/sidebar-crash.spec.tsx` | 已提交 |
|
||||
| **#42 触发链回归** | 底部面板首展自动开终端:恰好一个 terminal tab 落在底部工作树、`bottomOpenedOnce` 原子置位、pref / 类型禁用双门控、面板完整存活 | `tests/bottom-auto-terminal.spec.tsx`(本次新增,4 例) | 本次提交 |
|
||||
|
||||
三者合起来构成 #42 全链回归保护:触发链(本测试)→ 零尺寸延迟(openWhenSized 单测)→ 崩溃隔离(containment 测试)。
|
||||
|
||||
## 3. 附带问题:AttachConsole 报错(仅文档化)
|
||||
|
||||
`Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)是 **node-pty 上游行为**:`kill()` 清理路径 fork 的 console-list 辅助进程在无控制台宿主(launcher/服务方式启动的 `dsh`)下 `AttachConsole(shellPid)` 失败。终端功能本身不受影响(spawn/输入输出正常),仅刷服务器日志。
|
||||
|
||||
- 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道;
|
||||
- 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
- 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例);
|
||||
- `pnpm typecheck`(`tsc --noEmit`)通过;
|
||||
- `pnpm build`(`tsc -p tsconfig.build.json && tsdown`)通过。
|
||||
|
||||
## 5. Issue #42 回复草稿(供 maintainer 发布/关闭用)
|
||||
|
||||
> 感谢复现与详细报告。诊断结论:**这是 issue #25 同一根因在不同平台的复现**——底部面板首次展开自动开终端时,终端在容器尚为零尺寸(面板 slide-in 窗口期)就执行了 xterm `Terminal.open()`,渲染器创建失败导致后续 `Viewport._innerRefresh` / 空闲任务读取 `.dimensions` / `.handleResize` 崩溃(与 #25 的控制台报错逐字一致)。
|
||||
>
|
||||
> 该根因已在 **v0.11.0** 修复(终端延迟到容器有尺寸后再 open,PR #27);「整个面板空白」的放大器也已在最新 main 消除(单 tab 崩溃隔离 + 布局变量清理,issue #31)。**请升级到 v0.11.0 验证**:预期首次展开自动开终端不再空白;若仍复现,请提供 v0.11.0 下的控制台报错。
|
||||
>
|
||||
> 附带问题(`AttachConsole failed` 日志噪音)确认为 node-pty 在无控制台宿主(launcher 启动)下的上游行为,无功能影响,插件侧无法在不改动 node-pty/DSH 的前提下消除;已文档化(`docs/plans/2026-08-14-issue-42-verify-design.md`),并已补 #42 触发链回归测试。
|
||||
@@ -0,0 +1,111 @@
|
||||
# 终端自定义字体(设置页二级设置)设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.11.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在 DSH 设置页「侧边卡片」分区中,终端 tab 卡片的二级设置弹窗(齿轮按钮,README 所称"终端卡片二级设置")新增两行:
|
||||
|
||||
1. **终端字体**(text 输入):自定义 CSS font-family stack,如 `"JetBrains Mono", monospace`;留空 = 跟随主题等宽字体(`--ds-font-family-code` token)。
|
||||
2. **终端字号**(number 输入):9–32px,默认 13,带 `px` 后缀。
|
||||
|
||||
配置持久化到 `SidebarPrefs`(`dsh-better-sidebar` 命名空间,host 侧 PrefsSchema 校验),对**所有已打开终端实时生效**(UI tab 与 agent tab 共用 TerminalView):设置页提交 → `store.setPrefs` 通知 → xterm `options.fontFamily/fontSize` 更新 + 重排网格。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不在终端视图内加菜单/按钮(用户明确选择仅设置页齿轮弹窗)。
|
||||
- 不做 per-tab / per-terminal 隔离(全局偏好,与 SidebarPrefs 现有语义一致)。
|
||||
- 不自动给字体族加引号、不做字体合法性嗅探(用户负责合法 CSS font-family;placeholder 示范格式)。
|
||||
- 不改 pty / WS / 终端连接生命周期:字体变化不重连、不重启 shell。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- 终端描述符(`builtins/tabs.tsx`)的 `settings.toggles` 只支持**布尔开关**(`SidebarSettingToggle` 无类型字段),`FeatureSettingsRows`(`SideCardSection.tsx`)只渲染 Switch 行。
|
||||
- `SidebarPrefs` / `PrefsSchema` / `parsePrefs` 三处同步维护(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)。
|
||||
- `TerminalView` 硬编码 `fontSize: 13` + `fontFamily: tokenValue('--ds-font-family-code') || 内置栈`,且不响应偏好变化。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
// …
|
||||
terminalFontFamily: string // '' = 跟随主题等宽字体
|
||||
terminalFontSize: number // 9–32,默认 13
|
||||
}
|
||||
|
||||
// prefs-shared.ts
|
||||
export const TERMINAL_FONT_SIZE_MIN = 9
|
||||
export const TERMINAL_FONT_SIZE_MAX = 32
|
||||
export const TERMINAL_FONT_SIZE_DEFAULT = 13
|
||||
export function clampTerminalFontSize(value: number): number // round + clamp(仿 clampWidthPercent)
|
||||
```
|
||||
|
||||
- `PrefsSchema`:`terminalFontFamily: z.string().default('')`、`terminalFontSize: z.number().step(1).min(9).max(32).default(13)` —— 旧文档解析自动补默认,无迁移。
|
||||
- `parsePrefs`:family 需 string 否则默认;size 需有限数并 `clampTerminalFontSize` 否则默认。
|
||||
|
||||
### 4.2 声明式 API 扩展(`client/service.ts`,向后兼容)
|
||||
|
||||
```ts
|
||||
type SidebarSettingToggleType = 'switch' | 'text' | 'number'
|
||||
|
||||
interface SidebarSettingToggle {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: SidebarSettingToggleType // 缺省 'switch',外部插件现有用法不变
|
||||
min?: number // number 行的下限(提交时钳制)
|
||||
max?: number // number 行的上限
|
||||
placeholder?: string // text 行的占位符
|
||||
unit?: string // 输入框后的单位后缀(如 'px')
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 终端描述符声明(`builtins/tabs.tsx`)
|
||||
|
||||
`settings.toggles` 追加两行:`{ key: 'terminalFontFamily', type: 'text', placeholder: … }` 与 `{ key: 'terminalFontSize', type: 'number', min: 9, max: 32, unit: 'px' }`(标题/描述走 locales)。
|
||||
|
||||
### 4.4 设置行渲染(`SideCardSection.tsx` + CSS)
|
||||
|
||||
- `FeatureSettingsRows` 按 `toggle.type` 分支:`switch` 走现有 Switch 行;`text`/`number` 走新 `TypedRow`(局部 draft state,`Input` 原语,blur/Enter 提交,number 行带 unit 后缀)。
|
||||
- `TypedRow` 的 `key` 含**已提交值**:提交失败 → prefs 回滚 → key 变化 → 行重挂载为存储值(输入中 draft 不因 key 变化被打断,因为 key 只随已提交值变)。
|
||||
- 父级 `onCommitSetting(toggle, raw): string`:number 解析+钳制(非法/空 → 走 `Number('')=0` 钳到下限,与宽度行 `commitWidth` 先例一致);text 原样;乐观 `setPrefs` → `commit` → 失败 `applyOutcome` 回滚(复用既有 `applyPref` 管道,原 `togglePref` 更名)。
|
||||
|
||||
### 4.5 终端消费(`client/terminal-font.ts` 新模块 + `TerminalView.tsx`)
|
||||
|
||||
```ts
|
||||
// terminal-font.ts(纯函数,可单测)
|
||||
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
|
||||
export function resolveTerminalFont(prefs, themeFontFamily):
|
||||
{ fontFamily: 非空用户值 ?? themeFontFamily ?? 内置栈, fontSize: clampTerminalFontSize(prefs.terminalFontSize) }
|
||||
```
|
||||
|
||||
- `TerminalView` 构造 Terminal 时用解析值;主 effect 内 `store.subscribe` 回调 diff `term.options.fontFamily/fontSize`,变化则更新 options + `fit.fit()` + `sendResize()`(网格尺寸随字体变化);cleanup 退订。懒加载 chunk 无新增依赖(store 为运行时 prop)。
|
||||
|
||||
### 4.6 i18n(`locales.ts`)
|
||||
|
||||
zh/en 各新增:`settingsFontFamilyTitle/Desc/Placeholder`、`settingsFontSizeTitle/Desc/Suffix`(px)。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- `tests/unit.spec.ts`:`parsePrefs`/`loadPrefs` 新字段(合法 string 直通、越界钳制 9/32、非 number 回退、缺省默认);全等字面量补字段。
|
||||
- `tests/plugin-shape.spec.ts`:PrefsSchema 默认值 + overridden 全等。
|
||||
- `tests/builtins.spec.ts`:终端 toggles keys = 4 个,后两个 type/min/max 断言。
|
||||
- `tests/smoke.spec.ts`:settings.get 全默认字面量补字段。
|
||||
- `tests/side-card-section.spec.tsx`(SSR):text/number 行渲染(value/min/max/placeholder/unit,非 checkbox)。
|
||||
- `tests/side-card-section-rows.spec.tsx`(新,jsdom + createRoot):blur 提交回调、number 钳制、空输入钳到下限、提交失败回滚草稿(key 重挂载)。
|
||||
- `tests/terminal-font.spec.ts`(新):`resolveTerminalFont` 回退链 + `clampTerminalFontSize`。
|
||||
|
||||
## 6. 限制与取舍
|
||||
|
||||
- 外部插件的自定义 toggle key 照旧被 host PrefsSchema 校验丢弃(现有 seam 行为);`type: 'text'/'number'` 是公开 API 扩展,外部插件可用但 key 仍须是宿主 schema 字段。
|
||||
- number 输入的"非法值"在浏览器里只会以空串出现(number input 拒绝非数字字符),因此 `Number('')=0 → 钳到下限` 是唯一可达路径,与宽度行先例一致。
|
||||
- 字号实时生效依赖 xterm 的 options setter + re-fit;若未来 xterm 版本行为变化,回退方案是重建 Terminal(不推荐,会丢滚动缓冲)。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- 计划中的"非法输入回退当前存储值"用例在实现中发现不可达(浏览器 number input 的值清洗),改为断言"空输入钳到下限"(与宽度行一致)。
|
||||
- `togglePref` 更名为 `applyPref`(它本就接受任意 patch,不只是布尔翻转)。
|
||||
@@ -0,0 +1,90 @@
|
||||
# 终端延迟初始化(零尺寸容器 xterm open 崩溃修复)设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.11.x(issue #25 修复)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
修复 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25):WKWebView 下点击「展开底部面板」后底部面板内容空白、无终端,控制台报 `Cannot read properties of undefined (reading 'dimensions')` 与 `(reading 'handleResize')`。
|
||||
|
||||
根因:`TerminalView` 的挂载 effect 在容器可能为零尺寸时**同步**执行 `term.open(host); fit.fit()`。xterm 5.3.0 在零尺寸容器里 open 时渲染器创建失败,`RenderService._renderer.value` 保持 undefined,随后 Viewport 的 `_innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)崩溃。Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现。
|
||||
|
||||
修复:把 `term.open(host)` + `fit.fit()` 推迟到容器有实际尺寸之后再执行(xterm 在隐藏/零尺寸容器初始化的标准修法),open 恰好执行一次。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不换 xterm 渲染器(打包产物只有 DomRenderer 是现状,不是本次根因;`rendererType` 保持默认)。
|
||||
- 不改 pty / WS / 终端连接生命周期:推迟 open 不推迟连接、不重启 shell、不丢 transcript。
|
||||
- 不改 host 半与 chunk 打包结构(新模块随 terminal chunk 打入 `lib/client-terminal.js`)。
|
||||
- 不 bump 版本号(release 流程负责)。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `src/client/TerminalView.tsx` 第 99–101 行同步 `term.open(host); fit.fit()`。
|
||||
- 零尺寸挂载路径真实存在且不止一条:
|
||||
- 底部面板首次展开自动开终端(`Sidebar.tsx` 的 `bottomOpenedOnce` effect)在面板 slide-in transition 的同一次 commit 挂载 TerminalView,WKWebView 下此刻 host 高度为 0;
|
||||
- 所有 tab 保持挂载(`split-pane.tsx`:非激活 tab 用 `.paneTabHidden { display: none }` 隐藏),面板折叠时保持挂载(`.panelHidden`)——任何 `display:none` / 零尺寸祖先都会踩同一坑。
|
||||
- 前提已验证(xterm 5.3.0 与 @xterm/addon-fit 0.10.0 源码):
|
||||
- `FitAddon.fit()` 在 open 之前是安全 no-op(`proposeDimensions` 先查 `this._terminal.element`,undefined 即返回)。
|
||||
- `WriteBuffer.write()` 不依赖 open 状态:数据解析进 buffer,open 后渲染——推迟 open 不丢初始 transcript。
|
||||
- `TerminalView` 已有的 ResizeObserver / 字体订阅里的 `fit.fit()` 在 open 前 no-op、open 后照常 re-fit,无需改动。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 新模块 `src/client/open-when-sized.ts`(纯逻辑,可单测)
|
||||
|
||||
```ts
|
||||
export function openWhenSized(
|
||||
host: HTMLElement,
|
||||
open: () => void,
|
||||
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
|
||||
caf: (id: number) => void = cancelAnimationFrame,
|
||||
): () => void
|
||||
```
|
||||
|
||||
- 单条代码路径:`raf(step)` → step 检查 `host.isConnected`(false 则停止,防卸载后空转)→ `clientWidth > 0 && clientHeight > 0` 则调 `open()` 并停止;否则再排一帧。
|
||||
- 返回 cancel:`caf(frame)` 并置空(幂等),cleanup 调用。
|
||||
- helper 不 try/catch(保持纯净),异常由调用方处理。
|
||||
|
||||
### 4.2 消费点 `src/client/TerminalView.tsx`
|
||||
|
||||
- 移除 effect 顶部的 `term.open(host); fit.fit()`(保留 `term.loadAddon(fit)`)。
|
||||
- 在 effect 尾部(`sendResize` / `connect` 定义之后、`connect()` 调用之前)注册:
|
||||
|
||||
```ts
|
||||
const cancelOpen = openWhenSized(host, () => {
|
||||
try {
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] xterm open failed:', error)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- 回调里补 `sendResize()`:推迟路径下 socket 可能已先连上(`connect()` 在 effect 尾部同步调用),open+fit 后必须把真实 cols/rows 发出去,否则 pty 停留在默认 80x24 直到下次 resize;已尺寸快速路径下 socket 尚未 open,`sendResize` 内部守卫(`readyState === OPEN`)自然 no-op,无行为变化。
|
||||
- cleanup 里在 `term.dispose()` 前加 `cancelOpen()`。
|
||||
- 其余(connect / observer / fontSub / schemeSub / onData / 主题)不动。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
`tests/open-when-sized.spec.ts`(jsdom + 注入手动步进的假 raf/caf;`Object.defineProperty` 桩 `clientWidth/clientHeight`;`appendChild/remove` 控制 `isConnected`):
|
||||
|
||||
1. 已尺寸 → tick 一次后 open 恰好一次,之后不再排帧;
|
||||
2. 零尺寸 → 多次 tick 不 open,设尺寸后下一次 tick open 一次,后续 tick 不重复;
|
||||
3. 只有一维缺失 → 补齐后 open;
|
||||
4. cancel 后永不 open、无残留帧、cancel 幂等;
|
||||
5. host 脱离文档 → 轮询停止、不 open;
|
||||
6. open 抛错 → 异常向调用方传播(调用方负责 try/catch),不重复调用。
|
||||
|
||||
## 6. 限制与取舍
|
||||
|
||||
- rAF 轮询在 host 长期隐藏(`display:none` 祖先)期间持续排帧,每帧只读两个属性,开销可忽略;open 后立即停止。用 ResizeObserver 触发也可行,但轮询是 issue 中建议的标准修法,且不依赖 observer 对 transition 中间态的回调时序。
|
||||
- 已尺寸快速路径比原行为晚一帧(rAF 回调时机),无感知差异。
|
||||
- 若容器永远零尺寸(异常布局),终端永不 open、不崩溃——对不可见内容是可接受的降级。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
无(按设计实施)。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.8.2",
|
||||
"version": "0.12.1",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+57
-40
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.8.2",
|
||||
"version": "0.12.1",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
@@ -34,6 +40,9 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
@@ -49,44 +58,55 @@
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/client-registry.js",
|
||||
"lib/client-docx.js",
|
||||
"lib/client-xlsx.js",
|
||||
"lib/client-pptx.js",
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src"
|
||||
"src",
|
||||
"scripts/install.sh",
|
||||
"scripts/install.ps1",
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"README_EN.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
||||
"prepublishOnly": "pnpm build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch",
|
||||
"prepare": "tsdown",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"cordis": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -108,33 +128,30 @@
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@univerjs/preset-sheets-core": "^0.25.1",
|
||||
"@univerjs/presets": "^0.25.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
|
||||
Generated
+1324
-4499
File diff suppressed because it is too large
Load Diff
@@ -65,3 +65,66 @@ minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
|
||||
- '@deepseek-ai/schemastery@3.18.1-rc.1'
|
||||
- '@deepseek-ai/cordis-plugin-include@1.0.6'
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.2'
|
||||
- '@deepseek-ai/cordis@4.0.1'
|
||||
- '@deepseek-ai/cosmokit@1.8.2'
|
||||
- '@deepseek-ai/dsh-agent-default-model@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-agent-presets@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-agent@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-api-gateway@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-api-remotes@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-attachment@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-brand@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-connection@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-locale@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-runtime@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-schema-form@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-attachment@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-conversation@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-input-trigger@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-primitives@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-settings@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-web-react@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-code-runtime@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-commands@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-compaction@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-cordis-host-runner@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-credentials@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-goal@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-home-paths@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-apiproxy@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-directory-picker@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-plugin-inventory@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-webserver@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-invariants@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-jobs@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-llm-retry@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-llm@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-message-feedback@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-native-command@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-scope@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-projection-cache@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-projection@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-query@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-stats@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-title@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-settings@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-skill@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-storage-domain@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-storage@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-subagent@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-system-prompt@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-timeout@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-token-meter@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-tools@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-typert-registry@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-user-approval@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-user-questions@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-workspace@0.1.0-rc.6'
|
||||
- '@deepseek-ai/schemastery@3.18.1'
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Consumer-facing type surface check: build a tiny standalone consumer that
|
||||
# imports `dsh-better-sidebar/client/service` WITHOUT @types/node and with
|
||||
# skipLibCheck: false, then type-check it with the repo's tsc.
|
||||
#
|
||||
# This is the exact scenario the v0.12.0 API work cares about: the shipped
|
||||
# declaration graph (service.d.ts -> context-types.d.ts -> state/api) must
|
||||
# resolve and type-check for a browser-only plugin author. Run AFTER
|
||||
# `pnpm build` (the check reads lib/types).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# The check reads the BUILT declaration surface (lib/types) — fail loudly
|
||||
# instead of silently passing when the build output is missing.
|
||||
if [ ! -f lib/types/client/service.d.ts ]; then
|
||||
echo "[check-consumer-types] FAIL: lib/types/client/service.d.ts not found — run 'pnpm build' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
mkdir -p "$WORK/node_modules"
|
||||
ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar"
|
||||
|
||||
cat > "$WORK/check.ts" <<'EOF'
|
||||
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service'
|
||||
import type {} from 'dsh-better-sidebar/client/service'
|
||||
import type {
|
||||
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
|
||||
TabComponentProps, TabDescriptor,
|
||||
} from 'dsh-better-sidebar/client/service'
|
||||
import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-better-sidebar/client/service'
|
||||
import type { SidebarPrefs } from 'dsh-better-sidebar/client/service'
|
||||
|
||||
declare const ctx: { betterSidebar: BetterSidebarService }
|
||||
const tab: TabDescriptor = {
|
||||
id: 'x:tab',
|
||||
title: 'X',
|
||||
single: true,
|
||||
badge: (_ctx, _scope, state: SidebarState) => state.expanded.length,
|
||||
onOpen: (_tab: SidebarTab, _scope: SessionScope) => {},
|
||||
onClose: (_tab: SidebarTab, _scope: SessionScope) => {},
|
||||
createTab: (state: SidebarState) => ({ tab: { id: 'x:1', type: 'x:tab', title: 'X', meta: {} } }),
|
||||
settings: {
|
||||
toggles: [{ key: 'autoOpenSubagent', title: 'A' }],
|
||||
pluginToggles: [{ key: 'k', title: 'K', type: 'number', min: 0, max: 9 }],
|
||||
render: (props: SidebarSettingsRenderProps) => { props.updatePluginSetting('a', 1); return null },
|
||||
},
|
||||
component: (props: TabComponentProps) => null,
|
||||
}
|
||||
ctx.betterSidebar.registerTab(tab)
|
||||
const viewer: FileViewerDescriptor = {
|
||||
id: 'x:csv', exts: ['csv'], fetchStrategy: 'custom',
|
||||
load: (_path, _scope, signal?: AbortSignal) => { void signal; return Promise.resolve([]) },
|
||||
component: () => null,
|
||||
}
|
||||
ctx.betterSidebar.registerFileViewer(viewer)
|
||||
const seed: OpenTabSeed = { type: 'x:tab', title: 'X', meta: { a: 1 } }
|
||||
ctx.betterSidebar.openTab(seed, { sessionId: 's1' })
|
||||
ctx.betterSidebar.openFile({ sessionId: 's1' }, '/p/a.csv')
|
||||
ctx.betterSidebar.updateTab('t', { meta: { b: 2 } })
|
||||
const snap: SidebarSnapshot = ctx.betterSidebar.getSnapshot()
|
||||
const prefs: SidebarPrefs = snap.prefs
|
||||
const store: SidebarStore = null as unknown as SidebarStore
|
||||
void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features
|
||||
void SIDEBAR_SERVICE_VERSION; void SIDEBAR_FEATURES
|
||||
EOF
|
||||
|
||||
cat > "$WORK/tsconfig.json" <<'EOF'
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023", "module": "esnext", "moduleResolution": "bundler",
|
||||
"strict": true, "noEmit": true, "skipLibCheck": false, "types": [],
|
||||
"jsx": "react-jsx", "lib": ["ES2023", "DOM"]
|
||||
},
|
||||
"include": ["check.ts"]
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "[check-consumer-types] type-checking a browser-only consumer (no @types/node)..."
|
||||
echo "[check-consumer-types] pass 1: skipLibCheck: true (the realistic consumer experience) — must be fully clean"
|
||||
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck true
|
||||
echo "[check-consumer-types] pass 2: skipLibCheck: false — OUR declaration surface (lib/types/**) must be clean"
|
||||
echo "[check-consumer-types] (upstream cordis/cosmokit declaration noise is filtered out; it predates this plugin)"
|
||||
set +e
|
||||
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck false > "$WORK/strict.log" 2>&1
|
||||
STATUS=$?
|
||||
set -e
|
||||
# Fail only on errors that mention OUR package / declarations / the fixture.
|
||||
if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -v "node_modules/cordis\|node_modules/cosmokit"; then
|
||||
echo "[check-consumer-types] FAIL: errors in the dsh-better-sidebar declaration surface:" >&2
|
||||
grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$STATUS" -ne 0 ]; then
|
||||
echo "[check-consumer-types] pass 2 note: strict mode only reports upstream declaration noise:"
|
||||
grep -v "dsh-better-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5
|
||||
fi
|
||||
echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained."
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
|
||||
#
|
||||
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
|
||||
# dsh plugin --profile web add dsh-better-sidebar@<version>
|
||||
#
|
||||
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
|
||||
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
|
||||
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
|
||||
# 独立包被 profile 引用。
|
||||
#
|
||||
# 用法(任选其一):
|
||||
# # 默认最新版
|
||||
# irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
# # 指定版本 / 装完重启
|
||||
# & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart
|
||||
# # 本地保存后运行
|
||||
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun
|
||||
#
|
||||
# 参数:
|
||||
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
|
||||
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
|
||||
# -DryRun 只打印将要执行的操作,不写任何文件。
|
||||
#
|
||||
# 环境变量(均可省略):
|
||||
# DSH_HOME 默认 %USERPROFILE%\.dsh
|
||||
# REGISTRY 默认 https://registry.npmjs.org
|
||||
# DSH_CMD 默认优先 PATH 上的 dsh,缺省回退 npx -y --package @deepseek-ai/dsh
|
||||
#
|
||||
# 说明:
|
||||
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
|
||||
# `dsh plugin add` 非零退出。脚本会先把这两个构建许可写进 profile 的
|
||||
# pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
|
||||
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
|
||||
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
|
||||
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载。
|
||||
# 脚本会幂等移除 better-sidebar 挂载行。
|
||||
# =============================================================================
|
||||
param(
|
||||
[string]$Version = '',
|
||||
[switch]$Restart,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$PKG = 'dsh-better-sidebar'
|
||||
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
|
||||
|
||||
# DSH_HOME:DSH_HOME 环境变量 > %USERPROFILE% > $HOME
|
||||
if ($env:DSH_HOME) {
|
||||
$DSH_HOME = $env:DSH_HOME
|
||||
} elseif ($env:USERPROFILE) {
|
||||
$DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
|
||||
} else {
|
||||
$DSH_HOME = Join-Path $HOME '.dsh'
|
||||
}
|
||||
$PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web'
|
||||
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
|
||||
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
|
||||
|
||||
function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
|
||||
function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
|
||||
function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# 解析版本 -> npm spec("x.y.z" / "^x.y.z" / latest)
|
||||
function Resolve-Spec {
|
||||
param([string]$Given)
|
||||
if ([string]::IsNullOrWhiteSpace($Given) -or $Given -eq 'latest') {
|
||||
$v = $null
|
||||
foreach ($tool in @('npm', 'pnpm')) {
|
||||
if (Get-Command $tool -ErrorAction SilentlyContinue) {
|
||||
$v = (& $tool view $PKG version "--registry=$REGISTRY" 2>$null | Select-Object -Last 1)
|
||||
if ($v) { break }
|
||||
}
|
||||
}
|
||||
if ($v) { return ([string]$v).Trim() }
|
||||
Warn '无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。'
|
||||
Warn '若已知版本号,可显式传入:-Version 0.10.2'
|
||||
return 'latest'
|
||||
}
|
||||
return $Given
|
||||
}
|
||||
|
||||
# 组装 dsh CLI:优先 PATH 上的 dsh,缺省 npx 拉官方包
|
||||
function Get-DshCli {
|
||||
if ($env:DSH_CMD) { return $env:DSH_CMD }
|
||||
if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
|
||||
if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
|
||||
return $null
|
||||
}
|
||||
|
||||
# 前置校验
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
|
||||
}
|
||||
if (-not (Test-Path $PROFILE_DIR)) {
|
||||
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
|
||||
}
|
||||
if (-not (Test-Path $WS_YML)) {
|
||||
Die "找不到 $WS_YML(请先初始化 web profile)"
|
||||
}
|
||||
|
||||
$SPEC = Resolve-Spec $Version
|
||||
$CLI = Get-DshCli
|
||||
if (-not $CLI) {
|
||||
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
|
||||
}
|
||||
Say "目标:$CLI plugin --profile web add $PKG@$SPEC(profile: $PROFILE_DIR)"
|
||||
|
||||
if ($DryRun) {
|
||||
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
|
||||
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@$SPEC(安装 + bundle 自动注册)"
|
||||
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
|
||||
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
|
||||
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
|
||||
$wsScript = @'
|
||||
const fs = require("fs");
|
||||
const p = process.argv[2];
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
const before = t;
|
||||
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
|
||||
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
|
||||
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
|
||||
} else {
|
||||
for (const k of ["node-pty", "protobufjs"]) {
|
||||
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
|
||||
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
|
||||
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
|
||||
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
|
||||
} else {
|
||||
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
|
||||
}
|
||||
}
|
||||
if (t !== before) fs.writeFileSync(p, t);
|
||||
console.log(t === before ? "unchanged" : "updated");
|
||||
'@
|
||||
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
|
||||
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
|
||||
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
|
||||
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
|
||||
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
|
||||
$wsOut = node $wsJs "$WS_YML" 2>&1
|
||||
$wsCode = $LASTEXITCODE
|
||||
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
|
||||
$wsResult = (($wsOut | Out-String)).Trim()
|
||||
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
|
||||
if ($wsResult -eq 'updated') {
|
||||
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
|
||||
} else {
|
||||
Say 'workspace 设置已就绪,跳过'
|
||||
}
|
||||
|
||||
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
|
||||
if ($CLI -eq 'dsh') {
|
||||
$cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
|
||||
} else {
|
||||
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
|
||||
}
|
||||
Say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
|
||||
$addOut = & $CLI @cliArgs 2>&1
|
||||
$addCode = $LASTEXITCODE
|
||||
$addOut | ForEach-Object { $_ }
|
||||
if ($addCode -ne 0) {
|
||||
Warn 'dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:'
|
||||
Warn ' - 网络/登录问题:npm registry 不可达或需要登录。'
|
||||
Warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR; pnpm install。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 步骤 3:校验 bundle 已注册(挂载生效的判据)
|
||||
$pkgJson = Get-Content -Raw (Join-Path $PROFILE_DIR 'package.json') | ConvertFrom-Json
|
||||
$bundles = $pkgJson.dsh.profile.bundles
|
||||
if ($bundles -notcontains $PKG) {
|
||||
Warn 'dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
|
||||
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
|
||||
exit 1
|
||||
}
|
||||
Say "bundle 已注册:dsh.profile.bundles 包含 $PKG(下次启动自动挂载)"
|
||||
|
||||
# 步骤 4:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
|
||||
$mountScript = @'
|
||||
const fs = require("fs");
|
||||
const p = process.argv[2];
|
||||
const lines = fs.readFileSync(p, "utf8").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let removed = false;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^[ \t]*- insert:\s*$/.test(line)) {
|
||||
const block = [line];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
|
||||
block.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
|
||||
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
|
||||
i = j;
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
if (!removed) {
|
||||
console.log("none");
|
||||
} else {
|
||||
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
fs.writeFileSync(p, t);
|
||||
console.log("removed");
|
||||
}
|
||||
'@
|
||||
$mountJs = Join-Path $env:TEMP ("dshbs-mount-" + [guid]::NewGuid().ToString("N") + ".js")
|
||||
Set-Content -LiteralPath $mountJs -Value $mountScript -Encoding UTF8
|
||||
$mountOut = node $mountJs "$PATCH_YML" 2>&1
|
||||
$mountCode = $LASTEXITCODE
|
||||
Remove-Item -LiteralPath $mountJs -Force -ErrorAction SilentlyContinue
|
||||
$mountResult = (($mountOut | Out-String)).Trim()
|
||||
if ($mountCode -ne 0) { Die "处理 $PATCH_YML 失败(node 退出码 $mountCode):$mountResult" }
|
||||
if ($mountResult -eq 'removed') {
|
||||
Say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)"
|
||||
} else {
|
||||
Say '无旧手动挂载行,跳过'
|
||||
}
|
||||
|
||||
Say "安装完成:$PKG@$SPEC"
|
||||
|
||||
# 步骤 5:重启提示
|
||||
if ($Restart) {
|
||||
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
|
||||
Say '重启 dsh-web(pm2)...'
|
||||
pm2 restart dsh-web
|
||||
if ($LASTEXITCODE -ne 0) { Warn 'pm2 restart 失败,请手动重启 DSH' }
|
||||
} else {
|
||||
Warn '未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)'
|
||||
}
|
||||
} else {
|
||||
Say '下一步:重启 DSH 并硬刷新(Ctrl+Shift+R / Cmd+Shift+R)使新副本生效。'
|
||||
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
|
||||
Say '本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)'
|
||||
}
|
||||
}
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
|
||||
#
|
||||
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
|
||||
# dsh plugin --profile web add dsh-better-sidebar@<version>
|
||||
#
|
||||
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
|
||||
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
|
||||
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
|
||||
# 独立包被 profile 引用。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/install.sh [版本] [--restart] [--dry-run]
|
||||
#
|
||||
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
|
||||
# 示例:0.10.2、^0.10.2、~0.10.2、latest
|
||||
# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。
|
||||
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
|
||||
# --dry-run 只打印将要执行的操作,不写任何文件。
|
||||
# -h/--help 打印本帮助。
|
||||
#
|
||||
# 环境(均可省略,脚本会自动探测):
|
||||
# DSH_HOME 默认 ~/.dsh(Windows Git Bash 下回退 $USERPROFILE/.dsh)
|
||||
# REGISTRY 默认 https://registry.npmjs.org(发布源;装依赖仍走 pnpm 配置)
|
||||
# DSH_CMD 默认优先用 PATH 上的 `dsh`,缺省回退 npx -y --package @deepseek-ai/dsh
|
||||
#
|
||||
# 说明:
|
||||
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
|
||||
# `dsh plugin add` 非零退出(bundle 协调因此被跳过)。脚本会先把这两个
|
||||
# 构建许可写进 profile 的 pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
|
||||
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
|
||||
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
|
||||
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载
|
||||
# (Node 半挂两次、页面两个侧边栏)。脚本会幂等移除 better-sidebar 挂载行。
|
||||
# - 回滚:dsh plugin --profile web remove dsh-better-sidebar,或把 profile 依赖
|
||||
# 改回 "dsh-better-sidebar": "link:<路径>" 再 pnpm install。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# 帮助请求优先处理
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
|
||||
cat <<'EOF'
|
||||
dsh-better-sidebar 一键安装脚本
|
||||
|
||||
用法:bash scripts/install.sh [版本] [--restart] [--dry-run]
|
||||
|
||||
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
|
||||
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
|
||||
--dry-run 只打印将要执行的操作,不写任何文件
|
||||
|
||||
环境变量(可省略):DSH_HOME(默认 ~/.dsh)、REGISTRY(npm 源)、DSH_CMD(dsh 命令)
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
|
||||
PROFILE_DIR="$DSH_HOME/profiles/web"
|
||||
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
|
||||
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
|
||||
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
|
||||
PKG="dsh-better-sidebar"
|
||||
DSH_CMD="${DSH_CMD:-dsh}"
|
||||
|
||||
RESTART=false
|
||||
DRY_RUN=false
|
||||
VERSION_SPEC=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--restart) RESTART=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
-h|--help) : ;; # 已在上面处理
|
||||
-*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;;
|
||||
*) VERSION_SPEC="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[32m[install]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
|
||||
resolve_spec() {
|
||||
local given="${1:-latest}"
|
||||
case "$given" in
|
||||
latest)
|
||||
local v=""
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
v="$(npm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
|
||||
fi
|
||||
if [ -z "$v" ] && command -v pnpm >/dev/null 2>&1; then
|
||||
v="$(pnpm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
|
||||
fi
|
||||
if [ -n "$v" ]; then
|
||||
printf '%s' "$v"
|
||||
else
|
||||
warn "无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。"
|
||||
warn "若已知版本号,可显式传入:bash scripts/install.sh 0.10.2"
|
||||
printf 'latest'
|
||||
fi
|
||||
;;
|
||||
*) printf '%s' "$given" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 组装 dsh CLI 调用:优先 PATH 上的 dsh,缺省 npx 拉官方包
|
||||
dsh_cli() {
|
||||
if command -v "$DSH_CMD" >/dev/null 2>&1; then
|
||||
printf '%s' "$DSH_CMD"
|
||||
elif command -v npx >/dev/null 2>&1; then
|
||||
printf 'npx -y --package @deepseek-ai/dsh dsh'
|
||||
else
|
||||
die "未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定 dsh 路径。"
|
||||
fi
|
||||
}
|
||||
|
||||
# 前置校验
|
||||
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。"
|
||||
|
||||
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)"
|
||||
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 web profile)"
|
||||
|
||||
SPEC="$(resolve_spec "$VERSION_SPEC")"
|
||||
CLI="$(dsh_cli)"
|
||||
say "目标:$CLI plugin --profile web add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})"
|
||||
say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@${SPEC}(安装 + bundle 自动注册)"
|
||||
say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
|
||||
say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
|
||||
if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
|
||||
WS_RESULT="$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
const before = t;
|
||||
// allowBuilds:把 node-pty/protobufjs 归位为 true
|
||||
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
|
||||
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
|
||||
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
|
||||
} else {
|
||||
for (const k of ["node-pty", "protobufjs"]) {
|
||||
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
|
||||
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
|
||||
}
|
||||
}
|
||||
}
|
||||
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
|
||||
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
|
||||
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
|
||||
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
|
||||
} else {
|
||||
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
|
||||
}
|
||||
}
|
||||
if (t !== before) fs.writeFileSync(p, t);
|
||||
console.log(t === before ? "unchanged" : "updated");
|
||||
' "$WS_YML")"
|
||||
[ "$WS_RESULT" = "updated" ] \
|
||||
&& say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|
||||
|| say "workspace 设置已就绪,跳过"
|
||||
|
||||
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
|
||||
say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
|
||||
if ! $CLI plugin --profile web add "$PKG@$SPEC" 2>&1 | tail -n +1; then
|
||||
warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
|
||||
warn " - 网络/登录问题:npm registry 不可达或需要登录。"
|
||||
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 校验 bundle 已注册(挂载生效的判据)
|
||||
if ! node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const bundles = p.dsh?.profile?.bundles ?? [];
|
||||
process.exit(bundles.includes(process.argv[2]) ? 0 : 1);
|
||||
' "$PROFILE_DIR/package.json" "$PKG"; then
|
||||
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
|
||||
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
|
||||
exit 1
|
||||
fi
|
||||
say "bundle 已注册:dsh.profile.bundles 包含 ${PKG}(下次启动自动挂载)"
|
||||
|
||||
# 步骤 3:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
|
||||
MOUNT_RESULT="$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
const lines = fs.readFileSync(p, "utf8").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let removed = false;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^[ \t]*- insert:\s*$/.test(line)) {
|
||||
const block = [line];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
|
||||
block.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
|
||||
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
|
||||
i = j;
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
if (!removed) {
|
||||
console.log("none");
|
||||
} else {
|
||||
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
fs.writeFileSync(p, t);
|
||||
console.log("removed");
|
||||
}
|
||||
' "$PATCH_YML")"
|
||||
[ "$MOUNT_RESULT" = "removed" ] \
|
||||
&& say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)" \
|
||||
|| say "无旧手动挂载行,跳过"
|
||||
|
||||
say "安装完成:$PKG@$SPEC"
|
||||
|
||||
# 步骤 4:重启提示
|
||||
if [ "$RESTART" = true ]; then
|
||||
if command -v pm2 >/dev/null 2>&1; then
|
||||
say "重启 dsh-web(pm2)..."
|
||||
pm2 restart dsh-web || warn "pm2 restart 失败,请手动重启 DSH"
|
||||
else
|
||||
warn "未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)"
|
||||
fi
|
||||
else
|
||||
say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使新副本生效。"
|
||||
if command -v pm2 >/dev/null 2>&1; then
|
||||
say "本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)"
|
||||
fi
|
||||
fi
|
||||
@@ -31,12 +31,6 @@ const files = [
|
||||
'lib/client-registry.js.map',
|
||||
// Lazy chunks: the client fetches these from the host's /sidebar/bundle
|
||||
// route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts).
|
||||
'lib/client-docx.js',
|
||||
'lib/client-docx.js.map',
|
||||
'lib/client-xlsx.js',
|
||||
'lib/client-xlsx.js.map',
|
||||
'lib/client-pptx.js',
|
||||
'lib/client-pptx.js.map',
|
||||
'lib/client-terminal.js',
|
||||
'lib/client-terminal.js.map',
|
||||
'lib/client-editor.js',
|
||||
|
||||
+6
-7
@@ -16,11 +16,10 @@ import { createHash } from 'node:crypto'
|
||||
import { stat, readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { Context } from './context-types.ts'
|
||||
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
||||
|
||||
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
||||
export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
|
||||
export const CHUNK_NAMES = ['terminal', 'editor'] as const
|
||||
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
||||
|
||||
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
||||
@@ -68,9 +67,9 @@ async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undef
|
||||
* chunk scripts live in (overridable for tests).
|
||||
*/
|
||||
export function createBundleRouteHandler(
|
||||
fence: (req: IncomingMessage) => boolean,
|
||||
fence: (req: SidebarHttpRequest) => boolean,
|
||||
chunkDir: string = LIB_DIR,
|
||||
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
|
||||
return async (req, res): Promise<void> => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
@@ -121,8 +120,8 @@ export function createBundleRouteHandler(
|
||||
}
|
||||
|
||||
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
||||
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
|
||||
return ctx.httpServer.register({
|
||||
export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
|
||||
return ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/bundle',
|
||||
handler: createBundleRouteHandler(fence),
|
||||
|
||||
@@ -31,6 +31,9 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
// Aborts the matched viewer's `load` when the editor tears down (tab
|
||||
// closed, path changed, session switched) or re-matches the viewer.
|
||||
const controller = new AbortController()
|
||||
setLoad({ status: 'loading' })
|
||||
const mediaUrlOf = (): string => mediaUrl(scope, path)
|
||||
const apply = (action: EditorLoadAction): void => {
|
||||
@@ -50,7 +53,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
})
|
||||
return
|
||||
case 'customLoad':
|
||||
void action.viewer.load?.(path, scope).then((data) => {
|
||||
void action.viewer.load?.(path, scope, controller.signal).then((data) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
|
||||
}).catch((error: unknown) => {
|
||||
@@ -77,7 +80,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
|
||||
}
|
||||
}
|
||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true }
|
||||
return () => { cancelled = true; controller.abort() }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/** High-fidelity browser-native PPTX preview with slide navigation. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type PptxViewerInstance = import('@aiden0z/pptx-renderer').PptxViewer
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; count: number }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
export function PptxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<PptxViewerInstance | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [slide, setSlide] = useState(0)
|
||||
const [navigating, setNavigating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
setLoad({ status: 'loading' })
|
||||
setSlide(0)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (controller.signal.aborted) return
|
||||
const { PptxViewer, RECOMMENDED_ZIP_LIMITS } = await import('@aiden0z/pptx-renderer')
|
||||
if (controller.signal.aborted) return
|
||||
const viewer = await PptxViewer.open(bytes, host, {
|
||||
renderMode: 'slide',
|
||||
fitMode: 'contain',
|
||||
lazyMedia: true,
|
||||
lazySlides: true,
|
||||
pdfjs: false,
|
||||
signal: controller.signal,
|
||||
zipLimits: RECOMMENDED_ZIP_LIMITS,
|
||||
onSlideChange: (index) => {
|
||||
if (!controller.signal.aborted) setSlide(index)
|
||||
},
|
||||
})
|
||||
if (controller.signal.aborted) {
|
||||
viewer.destroy()
|
||||
return
|
||||
}
|
||||
viewerRef.current = viewer
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
setLoad({ status: 'ready', count: viewer.slideCount })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Partially initialized viewer — ignore disposal errors.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
controller.abort()
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Already destroyed.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
const navigate = (target: number): void => {
|
||||
const viewer = viewerRef.current
|
||||
if (viewer === null || load.status !== 'ready' || navigating) return
|
||||
const next = Math.max(0, Math.min(load.count - 1, target))
|
||||
if (next === slide) return
|
||||
setNavigating(true)
|
||||
void viewer.goToSlide(next, { behavior: 'auto' }).then(() => {
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
}).catch((error: unknown) => {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}).finally(() => {
|
||||
setNavigating(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.editorPptx} aria-label={title}>
|
||||
<div className={css.editorPptxToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide <= 0 || navigating}
|
||||
onClick={() => { navigate(slide - 1) }}
|
||||
>
|
||||
{t('previousSlide')}
|
||||
</button>
|
||||
<span className={css.editorPptxPosition}>
|
||||
{load.status === 'ready' ? `${slide + 1} / ${load.count}` : '– / –'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide >= load.count - 1 || navigating}
|
||||
onClick={() => { navigate(slide + 1) }}
|
||||
>
|
||||
{t('nextSlide')}
|
||||
</button>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={css.editorPptxStage}>
|
||||
<div className={css.editorPptxHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The generic render error boundary for the sidebar tree: a render error in
|
||||
* the wrapped subtree shows a dismissible error strip (retry re-renders the
|
||||
* children) instead of blanking the shell. Used at two scopes:
|
||||
*
|
||||
* - ROOT (index.tsx, `css.boundaryError`): last-resort containment for
|
||||
* errors in the sidebar shell itself (Workbench, drag layout, …) — a full
|
||||
* swap keeps the page alive.
|
||||
* - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing
|
||||
* viewer/editor shows a strip inside ITS OWN pane; the toggle cluster, the
|
||||
* other tabs, and the panel itself stay alive (issue #31 — a tab crash
|
||||
* must never take down the whole sidebar).
|
||||
*
|
||||
* The className prop selects the strip's geometry: the root's full-height
|
||||
* fixed rail vs. the tab's pane-filling block.
|
||||
*/
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export class RenderBoundary extends Component<{ children?: ReactNode; className?: string }, { error: string | null }> {
|
||||
state = { error: null as string | null }
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: string } {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<div className={this.props.className}>
|
||||
<span>dsh-better-sidebar: {this.state.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { this.setState({ error: null }) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
/**
|
||||
* Side card settings section chrome (DSH-native tokens throughout):
|
||||
* - the section is a column of GROUP CONTAINER cards (the PluginCard recipe:
|
||||
* l2 hairline, 16px radius, layer-3 fill) with a heading + inventory count
|
||||
* badge (the settings catalogHeading recipe), opening with a one-line
|
||||
* intro (the DSH section heading+intro recipe);
|
||||
* - 常规 holds full-width settings rows (the DSH settings-row recipe:
|
||||
* title/desc left + control right, hairline separators) whose toggles are
|
||||
* custom switches — a real checkbox (native semantics and focus) driving
|
||||
* a styled track/thumb;
|
||||
* - the feature inventories are responsive GRIDS of small toggle cards
|
||||
* (auto-fill columns — several cards per row, wrapping);
|
||||
* - a card's on/off state IS its visual state: enabled = brand border +
|
||||
* tinted fill + a check badge pinned to the far right of the top row,
|
||||
* disabled = neutral hairline + dimmed text;
|
||||
* (auto-fill columns — several cards per row, wrapping): icon chip +
|
||||
* title + type id, the card's on/off state IS its visual state
|
||||
* (enabled = brand border + tinted fill + circular check badge,
|
||||
* disabled = neutral hairline + dimmed);
|
||||
* - features that declare related settings carry a gear corner button
|
||||
* opening a native Modal whose rows are the DSH settings-row recipe
|
||||
* (title/desc left + native accent checkbox, hairline separators);
|
||||
* opening a native Modal (wider than the primitive default) whose rows
|
||||
* follow the same row recipe with switch controls and a Done footer;
|
||||
* - the width input stays a native settings row (a value, not a switch).
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
|
||||
never be clipped by the settings shell: fill the shell's options area
|
||||
@@ -24,15 +32,55 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
/* One-line section intro (the DSH section heading+intro recipe). */
|
||||
.intro {
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Group container: the DSH card recipe (PluginCard) — l2 hairline, r16,
|
||||
layer-3 fill; holds its own heading + stacked rows / card grid. */
|
||||
.group {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px 20px 20px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* Group heading: 13/600 + the inventory count badge (the settings
|
||||
catalogHeading recipe: count in tabular-nums, tertiary). */
|
||||
.groupHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 0 2px 6px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Responsive inventory: as many small cards per row as the width allows. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
|
||||
@@ -40,7 +88,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
@@ -52,6 +100,7 @@
|
||||
/* Hover affordance on disabled cards only (enabled cards are already lit). */
|
||||
.card:not(.cardOn):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* Enabled = highlighted: brand border + tinted fill. */
|
||||
@@ -64,9 +113,9 @@
|
||||
.cardMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
@@ -85,18 +134,28 @@
|
||||
.cardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
/* The icon sits in a rounded chip: brand-tinted while the card is on,
|
||||
neutral (layer-2 fill + hairline) while off. */
|
||||
.cardIconChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cardOn .cardIcon {
|
||||
.cardOn .cardIconChip {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 12%, transparent);
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
@@ -105,6 +164,7 @@
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -115,12 +175,18 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
|
||||
/* The check badge: a circular brand-filled pin at the card's far right
|
||||
(enabled only), the icon in the inverted surface token (the .save recipe). */
|
||||
.cardCheck {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
@@ -132,28 +198,64 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The "add plugin" card: same grid cell / chrome as the inventory cards
|
||||
(it shares .card), but a DASHED border — the repo's first dashed surface;
|
||||
the hover accent follows the existing drag-drop dashed outlines
|
||||
(sidebar.module.css .dropCenter / .tabBarDrop). */
|
||||
.addCard {
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-style: dashed;
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.addCard:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.addCard:hover .cardTitle {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.addCard:hover .cardIconChip {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.addCard:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cardOn .cardDesc {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The gear corner button: bottom-right of cards that declare related
|
||||
settings; the desc gets right padding so the ellipsis never runs under it. */
|
||||
/* Cards with a settings gear keep the desc clear of it: the gear sits in
|
||||
the desc row, sharing the check badge's right edge (a vertical column of
|
||||
controls at the card's right side). */
|
||||
.cardWithGear .cardDesc {
|
||||
padding-right: 18px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
/* The gear corner button: directly UNDER the check badge on the card's
|
||||
right edge — the two controls are vertically aligned (same right inset,
|
||||
one row apart) and the SAME 16px size, not side by side. */
|
||||
.cardGear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
right: 12px;
|
||||
top: 46px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
@@ -164,26 +266,21 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
|
||||
.sectionHeading {
|
||||
padding: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native settings row recipe (the width input row + popup rows). */
|
||||
/* Settings-row recipe (the DSH General section rows): title/desc left,
|
||||
control right, hairline separators (stripped on the last row). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -199,45 +296,66 @@
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
|
||||
.toggle {
|
||||
/* Custom switch: a real checkbox (native semantics + focus, visually
|
||||
hidden) driving a styled 36x20 track with a sliding thumb. */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchInput {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Secondary settings popup body: stacked native rows, trailing separator
|
||||
stripped by the trailing-child rule. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
display: flex;
|
||||
.switchTrack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
.switchThumb {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-secondary);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.switch:hover .switchTrack {
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.switchInput:checked + .switchTrack {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.switchInput:checked + .switchTrack .switchThumb {
|
||||
transform: translateX(16px);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.switchInput:focus-visible + .switchTrack {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control {
|
||||
@@ -251,12 +369,74 @@
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
/* Text/number rows in the settings popup: the free-form input (e.g. the
|
||||
terminal font family) gets a generous width; numeric rows (font size)
|
||||
keep the compact width of the general number inputs. */
|
||||
.typedInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.typedInputNumber {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Secondary settings popup: a wider dialog than the primitive default (the
|
||||
dialog portals to document.body; the doubled class keeps the width rule
|
||||
above the primitive's .dialog regardless of stylesheet order). */
|
||||
.popupDialog.popupDialog {
|
||||
width: min(460px, 100%);
|
||||
}
|
||||
|
||||
/* Popup rows: the same settings-row recipe with switch controls. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 2px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.popupRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* The popup's Done button: the settings primary-button recipe
|
||||
(PluginCard .save: label-primary fill, inverted surface text). */
|
||||
.done {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 5px 14px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.done:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 0 2px;
|
||||
font-size: 12px;
|
||||
@@ -264,8 +444,193 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ── "Add plugin" modal ──────────────────────────────────────────────── */
|
||||
|
||||
/* A wide dialog like the settings popup (doubled class keeps the width
|
||||
rule above the primitive's .dialog regardless of stylesheet order). */
|
||||
.pluginModal.pluginModal {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.pluginList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The GitHub topic entry: a full-width secondary BUTTON (not a link — it
|
||||
opens a real new browser tab via window.open, bypassing the sidebar
|
||||
link takeover). */
|
||||
.pluginTopicBtn {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pluginTopicBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pluginTopicBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.pluginEmpty {
|
||||
padding: 20px 2px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pluginEntries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* One catalog entry: name + install button on the head row, description,
|
||||
then the install command in a mono block (the exact script that gets
|
||||
pre-typed into the terminal — review it here before installing). */
|
||||
.pluginEntry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.pluginEntryHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pluginEntryActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* The per-entry "jump" button: opens the plugin repo in a REAL new browser
|
||||
tab (window.open — a button, so the sidebar link takeover cannot reroute
|
||||
it). Secondary style, quieter than the Install button. */
|
||||
.pluginJumpBtn {
|
||||
flex: none;
|
||||
appearance: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 3px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.pluginJumpBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pluginJumpBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The plugin name: a BUTTON (not an anchor — an anchor would be caught by
|
||||
the sidebar link takeover at document capture, ignoring target=_blank)
|
||||
styled like a link; opens the repo in a real new browser tab. */
|
||||
.pluginName {
|
||||
appearance: none;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pluginName:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.pluginDesc {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.pluginInstall {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The per-entry copy button: copies the install script to the
|
||||
clipboard (the settings primary-button recipe, smaller than .done). */
|
||||
.pluginCopyBtn {
|
||||
flex: none;
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 3px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.pluginCopyBtn:hover {
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
|
||||
.pluginCopyBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
.card,
|
||||
.switchTrack,
|
||||
.switchThumb {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+505
-123
@@ -4,21 +4,30 @@
|
||||
*
|
||||
* The section is DECLARATIVE — it renders the enable/disable inventory from
|
||||
* the sidebar service's registries instead of hardcoding rows:
|
||||
* - 常规: new conversations open the panel by default (a toggle card), and
|
||||
* the default panel width as a percent of the window (number input row).
|
||||
* - 常规: new conversations open the panel by default (a toggle row), the
|
||||
* default panel width as a percent of the window (number input row), and
|
||||
* the open-path interception toggle — the DSH settings-row recipe
|
||||
* (title/desc left + control right, hairline separators).
|
||||
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
|
||||
* external plugins alike), laid out in a responsive grid that wraps
|
||||
* several cards per row — icon + title + type id, clicked to toggle the
|
||||
* switch persisted in `prefs.tabsEnabled[id]`.
|
||||
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
|
||||
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
|
||||
* several cards per row — icon chip + title + type id, clicked to toggle
|
||||
* the switch persisted in `prefs.tabsEnabled[id]`.
|
||||
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title
|
||||
* + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
|
||||
*
|
||||
* Every group lives in a container card (the DSH PluginCard recipe: l2
|
||||
* hairline, 16px radius, layer-3 fill) with a heading and an inventory count
|
||||
* badge (the settings catalogHeading recipe); the section opens with a
|
||||
* one-line intro (the DSH section heading+intro recipe).
|
||||
*
|
||||
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
|
||||
* border + tinted fill + a check badge pinned to the card's far right),
|
||||
* disabled = neutral and dimmed. Features that declare `settings.toggles`
|
||||
* carry a gear corner button that opens a native Modal with the related
|
||||
* settings as native checkbox rows (e.g. the Subagent page's "auto-open
|
||||
* when a subagent appears", the Terminal page's model terminal tools).
|
||||
* border + tinted fill + a circular check badge pinned to the card's far
|
||||
* right), disabled = neutral and dimmed. Features that declare
|
||||
* `settings.toggles` carry a gear corner button that opens a native Modal
|
||||
* (wider than the primitive default) with the related settings as
|
||||
* title/desc + custom-switch rows and a Done footer. The toggles
|
||||
* themselves are custom switches: a real checkbox (native semantics and
|
||||
* focus) driving a styled track/thumb.
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
@@ -32,8 +41,7 @@
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconCodeOutline16,
|
||||
IconPanelLeftOutline16,
|
||||
IconPlusOutline16,
|
||||
IconSettingsOutline16,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -50,11 +58,13 @@ import {
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
FileViewerDescriptor,
|
||||
SidebarSettingsRenderProps,
|
||||
SidebarSettingToggle,
|
||||
TabDescriptor,
|
||||
} from './service.ts'
|
||||
@@ -100,46 +110,265 @@ function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
||||
}
|
||||
|
||||
/** Read one boolean pref by declarative key (missing = false). */
|
||||
function prefBool(prefs: SidebarPrefs, key: string): boolean {
|
||||
return (prefs as unknown as Record<string, boolean>)[key] === true
|
||||
/** Whether a feature declares any secondary settings (gear button shows). */
|
||||
function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean {
|
||||
const settings = feature.settings
|
||||
return settings !== undefined && (
|
||||
(settings.toggles?.length ?? 0) > 0
|
||||
|| (settings.pluginToggles?.length ?? 0) > 0
|
||||
|| settings.render !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** A feature's display name (viewers fall back to their id). */
|
||||
function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string {
|
||||
return textOf('title' in feature ? feature.title : undefined) || feature.id
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a feature's secondary settings popup: one native checkbox row
|
||||
* per declared toggle. Extracted so the rows are testable without opening
|
||||
* the Modal (the Modal portal renders only while open).
|
||||
* Merge one plugin-owned setting into a pluginSettings map (pure, v0.12.0+).
|
||||
* Sequential merges are additive: each call spreads the map it was GIVEN,
|
||||
* so building from the latest optimistic map keeps earlier keys intact
|
||||
* (two same-tick writes must not drop each other).
|
||||
*/
|
||||
export function mergePluginSetting(
|
||||
pluginSettings: Record<string, Record<string, unknown>>,
|
||||
descriptorId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Record<string, Record<string, unknown>> {
|
||||
return {
|
||||
...pluginSettings,
|
||||
[descriptorId]: { ...(pluginSettings[descriptorId] ?? {}), [key]: value },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a custom settings panel (`settings.render`) with error containment:
|
||||
* a throwing panel shows an inline error line instead of breaking the whole
|
||||
* settings page.
|
||||
*/
|
||||
function SettingsRender(props: {
|
||||
render: (renderProps: SidebarSettingsRenderProps) => ReactNode
|
||||
renderProps: SidebarSettingsRenderProps
|
||||
}) {
|
||||
let content: ReactNode
|
||||
try {
|
||||
content = props.render(props.renderProps)
|
||||
} catch (error) {
|
||||
content = (
|
||||
<div className={css.error} role="alert">
|
||||
{t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <>{content}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom switch: a real checkbox (hidden, native semantics and focus)
|
||||
* driving a styled track/thumb. Used by the general toggle rows and the
|
||||
* secondary settings popup rows.
|
||||
*/
|
||||
function Switch(props: {
|
||||
checked: boolean
|
||||
onChange: (next: boolean) => void
|
||||
label: string
|
||||
}) {
|
||||
const { checked, onChange, label } = props
|
||||
return (
|
||||
<label className={css.switch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.switchInput}
|
||||
checked={checked}
|
||||
aria-label={label}
|
||||
onChange={event => { onChange(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span className={css.switchTrack} aria-hidden="true">
|
||||
<span className={css.switchThumb} />
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a feature's secondary settings popup: one row (title/desc +
|
||||
* control) per declared setting. Switches render the custom switch; text and
|
||||
* number rows render a free-form / numeric input committed on blur/Enter
|
||||
* (clamped to the declared min/max). Extracted so the rows are testable
|
||||
* without opening the Modal (the Modal portal renders only while open).
|
||||
*/
|
||||
export function FeatureSettingsRows(props: {
|
||||
toggles: readonly SidebarSettingToggle[]
|
||||
prefs: SidebarPrefs
|
||||
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
/** Commit one text/number row; returns the canonical value the row should
|
||||
* display (clamped for numbers, the current pref when the input is
|
||||
* invalid). Optional: rows with no handler keep their draft. */
|
||||
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
/** Explicit value source (v0.12.0+): when given, rows read their values
|
||||
* from it instead of the `prefs` face — plugin-owned rows read their
|
||||
* own blob, so a plugin key can never collide with (or silently read)
|
||||
* a host pref of the same name. (Named `valueSource`, not `valueOf`:
|
||||
* the latter collides with the inherited Object.prototype.valueOf.) */
|
||||
valueSource?: (key: string) => unknown
|
||||
}) {
|
||||
const { toggles, prefs, onToggle } = props
|
||||
const { toggles, prefs, onToggle, onCommit, valueSource } = props
|
||||
const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key])
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.map(toggle => {
|
||||
const title = textOf(toggle.title)
|
||||
if ((toggle.type ?? 'switch') === 'switch') {
|
||||
return (
|
||||
<div key={toggle.key} className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<Switch
|
||||
label={title}
|
||||
checked={read(toggle.key) === true}
|
||||
onChange={(next) => { onToggle(toggle, next) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const value = String(read(toggle.key) ?? '')
|
||||
// Keyed by the committed value: a failed commit reverts prefs, the
|
||||
// key changes, and the row remounts with the stored value (typing
|
||||
// never changes the key, so mid-edit drafts survive re-renders).
|
||||
return (
|
||||
<label key={toggle.key} className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefBool(prefs, toggle.key)}
|
||||
aria-label={title}
|
||||
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<TypedRow
|
||||
key={`${toggle.key}:${value}`}
|
||||
toggle={toggle}
|
||||
title={title}
|
||||
value={value}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One text/number row: a controlled input whose draft is local state,
|
||||
* committed on blur/Enter through the parent's onCommit. The parent's
|
||||
* canonical return is adopted (clamped numbers, stored value for invalid
|
||||
* input); a `unit` suffix renders after the input (e.g. 'px').
|
||||
*/
|
||||
function TypedRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
value: string
|
||||
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
}) {
|
||||
const { toggle, title, value, onCommit } = props
|
||||
const [draft, setDraft] = useState(value)
|
||||
const commit = (): void => {
|
||||
const canonical = onCommit?.(toggle, draft) ?? draft
|
||||
setDraft(canonical)
|
||||
}
|
||||
const number = toggle.type === 'number'
|
||||
return (
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type={number ? 'number' : 'text'}
|
||||
className={number ? css.typedInputNumber : css.typedInput}
|
||||
value={draft}
|
||||
min={toggle.min}
|
||||
max={toggle.max}
|
||||
step={1}
|
||||
placeholder={toggle.placeholder}
|
||||
aria-label={title}
|
||||
onChange={event => { setDraft(event.currentTarget.value) }}
|
||||
onBlur={commit}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
{toggle.unit !== undefined && <span className={css.suffix}>{toggle.unit}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The secondary settings popup body of one feature (tab or viewer):
|
||||
* - `settings.render` (custom panel) when declared — rendered with the
|
||||
* shared store/service, the live prefs, the descriptor's own plugin
|
||||
* settings blob, a persistence helper, and a close callback;
|
||||
* - otherwise the host-prefs `toggles` rows, then the plugin-owned
|
||||
* `pluginToggles` rows (their values live in `pluginSettings[feature.id]`,
|
||||
* projected onto the prefs face so the shared row renderer reads them).
|
||||
*/
|
||||
export function SettingsBody(props: {
|
||||
feature: TabDescriptor | FileViewerDescriptor
|
||||
prefs: SidebarPrefs
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
onPluginWrite: (key: string, value: unknown) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props
|
||||
const render = feature.settings?.render
|
||||
if (render !== undefined) {
|
||||
return (
|
||||
<SettingsRender
|
||||
render={render}
|
||||
renderProps={{
|
||||
store,
|
||||
service,
|
||||
prefs,
|
||||
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
|
||||
updatePluginSetting: onPluginWrite,
|
||||
close: onClose,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const toggles = feature.settings?.toggles ?? []
|
||||
const pluginToggles = feature.settings?.pluginToggles ?? []
|
||||
if (toggles.length === 0 && pluginToggles.length === 0) return null
|
||||
// Plugin rows read their values from the descriptor's OWN blob through
|
||||
// an explicit value source — no projection onto the prefs face, so a
|
||||
// plugin key can never collide with (or silently read) a host pref of
|
||||
// the same name.
|
||||
const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={toggles}
|
||||
prefs={prefs}
|
||||
onToggle={onToggle}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
)}
|
||||
{pluginToggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={pluginToggles}
|
||||
prefs={prefs}
|
||||
onToggle={onPluginToggle}
|
||||
onCommit={onPluginCommit}
|
||||
valueSource={(key) => pluginBlob[key]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store/service).
|
||||
@@ -150,7 +379,19 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Which feature's secondary settings popup is open (null = closed).
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null)
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
// Whether the "add plugin" modal (a dashed card at the end of the
|
||||
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
|
||||
// (null = closed).
|
||||
const [addPluginsOpen, setAddPluginsOpen] = useState<PluginKind | null>(null)
|
||||
// The LATEST optimistic prefs, kept in sync with the state. Nested-map
|
||||
// merges (tabsEnabled / viewersEnabled / pluginSettings) MUST build from
|
||||
// this ref, not from the render-time `prefs`: two same-tick writes (e.g.
|
||||
// a settings panel updating several plugin keys at once) would otherwise
|
||||
// both spread the stale map and the later patch would drop the earlier
|
||||
// key even though the commits are serialized.
|
||||
const optimisticRef = useRef(prefs)
|
||||
useEffect(() => { optimisticRef.current = prefs }, [prefs])
|
||||
|
||||
// The declarative inventory: the registered tab types and file viewers.
|
||||
// Local state + service.subscribe (registry changes are rare — plugin
|
||||
@@ -219,31 +460,82 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
/** Optimistically flip one boolean pref, then commit (revert on failure). */
|
||||
const togglePref = (patch: Record<string, unknown>): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, ...patch } as SidebarPrefs)
|
||||
/** Optimistically apply one pref patch, then commit (revert on failure). */
|
||||
const applyPref = (patch: Record<string, unknown>): void => {
|
||||
const previous = optimisticRef.current
|
||||
const next = { ...previous, ...patch } as SidebarPrefs
|
||||
optimisticRef.current = next
|
||||
setPrefs(next)
|
||||
setError(null)
|
||||
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
togglePref({ openByDefault: next })
|
||||
applyPref({ openByDefault: next })
|
||||
}
|
||||
|
||||
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
||||
const onToggleTab = (id: string, next: boolean): void => {
|
||||
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
|
||||
applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
|
||||
const onToggleViewer = (id: string, next: boolean): void => {
|
||||
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
|
||||
applyPref({ viewersEnabled: { ...optimisticRef.current.viewersEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
|
||||
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
|
||||
togglePref({ [toggle.key]: next })
|
||||
applyPref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit one declaratively-declared text/number row. Numbers are parsed
|
||||
* and clamped to the toggle's declared min/max (an unparsable input falls
|
||||
* back to the CURRENT stored value, mirroring the width row); text rows
|
||||
* persist as-is (empty is meaningful, e.g. the theme-default font).
|
||||
* Returns the canonical value the row should display.
|
||||
*/
|
||||
const onCommitSetting = (toggle: SidebarSettingToggle, raw: string): string => {
|
||||
if (toggle.type === 'number') {
|
||||
const parsed = Number(raw)
|
||||
const fallback = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
let clamped = Math.round(parsed)
|
||||
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
|
||||
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
|
||||
applyPref({ [toggle.key]: clamped })
|
||||
return String(clamped)
|
||||
}
|
||||
applyPref({ [toggle.key]: raw })
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
|
||||
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
|
||||
applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) })
|
||||
}
|
||||
|
||||
/** Flip one plugin-owned switch row (same row shape, plugin-scoped key). */
|
||||
const onPluginToggle = (descriptorId: string, toggle: SidebarSettingToggle, next: boolean): void => {
|
||||
applyPluginSetting(descriptorId, toggle.key, next)
|
||||
}
|
||||
|
||||
/** Commit one plugin-owned text/number row (clamped like the host rows). */
|
||||
const onPluginCommitSetting = (descriptorId: string, toggle: SidebarSettingToggle, raw: string): string => {
|
||||
if (toggle.type === 'number') {
|
||||
const parsed = Number(raw)
|
||||
const blob = prefs.pluginSettings[descriptorId] ?? {}
|
||||
const fallback = String(blob[toggle.key] ?? '')
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
let clamped = Math.round(parsed)
|
||||
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
|
||||
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
|
||||
applyPluginSetting(descriptorId, toggle.key, clamped)
|
||||
return String(clamped)
|
||||
}
|
||||
applyPluginSetting(descriptorId, toggle.key, raw)
|
||||
return raw
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
@@ -262,9 +554,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
|
||||
/**
|
||||
* One SMALL toggle card for the responsive inventory grid: the card's main
|
||||
* area is the switch (click to flips, visual state IS the state), the
|
||||
* check badge sits at the far right, and a feature that declares related
|
||||
* settings carries a gear corner button opening its settings popup.
|
||||
* area is the switch (click to flips, visual state IS the state), the icon
|
||||
* sits in a rounded chip, the check badge pins to the far right, and a
|
||||
* feature that declares related settings carries a gear corner button
|
||||
* opening its settings popup.
|
||||
*/
|
||||
const renderCard = (props: {
|
||||
title: string
|
||||
@@ -289,12 +582,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
{props.icon !== null && props.icon !== undefined && (
|
||||
<span className={css.cardIcon}>{props.icon}</span>
|
||||
<span className={css.cardIconChip}>{props.icon}</span>
|
||||
)}
|
||||
<span className={css.cardTitle}>{props.title}</span>
|
||||
{props.enabled && (
|
||||
<span className={css.cardCheck}>
|
||||
<IconCheckOutline16 size={14} />
|
||||
<IconCheckOutline16 size={12} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -308,7 +601,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
title={t('settingsPopup')}
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
<IconSettingsOutline16 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -317,105 +610,194 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenTitle'),
|
||||
desc: t('settingsOpenDesc'),
|
||||
icon: <IconPanelLeftOutline16 size={16} />,
|
||||
enabled: prefs.openByDefault,
|
||||
onToggle,
|
||||
})}
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
<p className={css.intro}>{t('settingsIntro')}</p>
|
||||
|
||||
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenTitle')}
|
||||
checked={prefs.openByDefault}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenPathTitle')}
|
||||
checked={prefs.interceptOpenPath}
|
||||
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenPathTitle'),
|
||||
desc: t('settingsOpenPathDesc'),
|
||||
icon: <IconCodeOutline16 size={16} />,
|
||||
enabled: prefs.interceptOpenPath,
|
||||
onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
|
||||
})}
|
||||
|
||||
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
||||
grid; features declaring `settings.toggles` open their settings in
|
||||
the popup (gear corner button) instead of nested inline rows. */}
|
||||
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderCard({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
enabled: prefs.tabsEnabled[tab.id] !== false,
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
// The settings gear only while the feature is enabled: its
|
||||
// related settings are dormant while the feature is off.
|
||||
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
|
||||
&& (tab.settings?.toggles?.length ?? 0) > 0
|
||||
? () => { setSettingsFor(tab) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('settingsTabsTitle')}</span>
|
||||
<span className={css.count}>{tabs.length}</span>
|
||||
</div>
|
||||
<div className={css.grid}>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderCard({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
enabled: prefs.tabsEnabled[tab.id] !== false,
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
// The settings gear only while the feature is enabled: its
|
||||
// related settings are dormant while the feature is off.
|
||||
onOpenSettings: prefs.tabsEnabled[tab.id] !== false && hasSettings(tab)
|
||||
? () => { setSettingsFor(tab) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{/* The "add tab plugin" entry: same card size as the inventory,
|
||||
but a dashed border — it opens the TAB-registration plugin
|
||||
modal instead of toggling a feature. */}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.card, css.addCard)}
|
||||
onClick={() => { setAddPluginsOpen('tab') }}
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
<span className={css.cardIconChip}>
|
||||
<IconPlusOutline16 size={16} />
|
||||
</span>
|
||||
<span className={css.cardTitle}>{t('addPluginsTabCard')}</span>
|
||||
</span>
|
||||
<span className={css.cardDesc}>{t('addPluginsTabCardDesc')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件预览: one small card per registered file viewer. */}
|
||||
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderCard({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('settingsViewersTitle')}</span>
|
||||
<span className={css.count}>{viewers.length}</span>
|
||||
</div>
|
||||
<div className={css.grid}>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderCard({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer)
|
||||
? () => { setSettingsFor(viewer) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{/* The "add preview plugin" entry: dashed card opening the
|
||||
FILE-PREVIEWER registration modal. */}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.card, css.addCard)}
|
||||
onClick={() => { setAddPluginsOpen('viewer') }}
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
<span className={css.cardIconChip}>
|
||||
<IconPlusOutline16 size={16} />
|
||||
</span>
|
||||
<span className={css.cardTitle}>{t('addPluginsViewerCard')}</span>
|
||||
</span>
|
||||
<span className={css.cardDesc}>{t('addPluginsViewerCardDesc')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The secondary settings popup: a feature's declared related settings
|
||||
as native checkbox rows (Modal chrome is the app's own). Mounted
|
||||
only while a feature is open — the Modal primitive runs hooks
|
||||
unconditionally, so a closed-but-mounted Modal would break SSR
|
||||
(and the renderToString spec) under the test dual-react split. */}
|
||||
as title/desc + switch rows in a wider-than-default Modal with a
|
||||
Done footer (Modal chrome is the app's own). Mounted only while a
|
||||
feature is open — the Modal primitive runs hooks unconditionally,
|
||||
so a closed-but-mounted Modal would break SSR (and the
|
||||
renderToString spec) under the test dual-react split.
|
||||
Content: `settings.render` (custom panel) when declared, else the
|
||||
host-prefs `toggles` rows followed by the plugin-owned
|
||||
`pluginToggles` rows (their values live in pluginSettings[id]). */}
|
||||
{settingsFor !== null && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
title={textOf(settingsFor.title)}
|
||||
title={featureNameOf(settingsFor)}
|
||||
description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })}
|
||||
closeLabel={t('close')}
|
||||
className={css.popupDialog}
|
||||
footer={(
|
||||
<button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
|
||||
{t('settingsDone')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={settingsFor.settings?.toggles ?? []}
|
||||
<SettingsBody
|
||||
feature={settingsFor}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
|
||||
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
|
||||
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
store={store}
|
||||
service={service}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The "add plugin" modal (opened by the dashed cards above): declares
|
||||
the extension point of the clicked kind, opens the GitHub topic,
|
||||
and lists the matching recommended plugin catalog with per-entry
|
||||
install buttons (the install flow opens a ~/.dsh terminal with
|
||||
the command pre-typed; failures render inline here, in settings
|
||||
only). Mounted only while open (Modal runs hooks unconditionally
|
||||
— same SSR rule as the settings popup above). */}
|
||||
{addPluginsOpen !== null && (
|
||||
<AddPluginModal
|
||||
service={service}
|
||||
onClose={() => { setAddPluginsOpen(null) }}
|
||||
kind={addPluginsOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
|
||||
+191
-69
@@ -25,13 +25,14 @@
|
||||
* drawer floats). Widening does not migrate back: the tabs keep living in
|
||||
* the right tree.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
||||
import type { Context, SidebarSessionList } from '../context-types.ts'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import {
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
@@ -43,8 +44,9 @@ import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { detectNewTask } from './subagent-tasks.ts'
|
||||
import { detectNewJob } from './subagent-jobs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api, type SessionScope } from './api.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -76,10 +78,22 @@ function TabContent(props: {
|
||||
if (descriptor === undefined) {
|
||||
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
|
||||
}
|
||||
return descriptor.component({
|
||||
ctx, store, scope, tab, visible, expanded,
|
||||
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
||||
})
|
||||
// One boundary per tab: a render crash in a viewer/editor shows a strip in
|
||||
// THIS tab's pane only — the toggle cluster, the other tabs, and the panel
|
||||
// stay alive (issue #31). The tab strip (close button) lives outside, so a
|
||||
// crashing tab stays closable; the root boundary in index.tsx remains the
|
||||
// last resort for errors in the sidebar shell itself. The descriptor is
|
||||
// rendered as a REAL element (not called directly): a direct call would
|
||||
// throw inside TabContent's own render, which the boundary cannot catch —
|
||||
// as a child fiber, every render error (top-level or deep) lands in it.
|
||||
return createElement(
|
||||
RenderBoundary,
|
||||
{ className: css.tabBoundaryError },
|
||||
createElement(descriptor.component, {
|
||||
ctx, store, scope, tab, visible, expanded,
|
||||
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
@@ -141,6 +155,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const sessionId = snapshot.sessionId
|
||||
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
|
||||
|
||||
// The collapsed toggle cluster reclaims the top-right corner, so the DSH
|
||||
// session header's right-aligned utilities (the "Session log" download
|
||||
// capsule) must yield. layout.css keys off this body attribute to push the
|
||||
// header's right padding out past the cluster. Only the CLOSED panel needs
|
||||
// it — an open panel already squeezes `#root` left, moving the header clear.
|
||||
const collapsed = state === undefined || !state.panelOpen
|
||||
useEffect(() => {
|
||||
if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
|
||||
else document.body.removeAttribute('data-dsh-sidebar-collapsed')
|
||||
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
|
||||
}, [collapsed])
|
||||
|
||||
/**
|
||||
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||
@@ -252,21 +278,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Task auto-activation: the moment a NEW background task appears for the
|
||||
* current conversation (a task id the previous snapshot lacked), the
|
||||
* auto-open pref is on, and the Tasks tab type is enabled, open the panel
|
||||
* (if collapsed) and focus the Tasks page. Unlike the subagent trigger
|
||||
* (0 → N only), ANY new task id triggers: the agent may start several
|
||||
* tasks in one session, and each should surface. A fresh page load never
|
||||
* Job auto-activation: the moment a NEW background job appears for the
|
||||
* current conversation (a job id the previous snapshot lacked), the
|
||||
* auto-open pref is on, and the Jobs tab type is enabled, open the panel
|
||||
* (if collapsed) and focus the Jobs page. Unlike the subagent trigger
|
||||
* (0 → N only), ANY new job id triggers: the agent may start several
|
||||
* jobs in one session, and each should surface. A fresh page load never
|
||||
* triggers — its baseline starts at the current snapshot.
|
||||
*/
|
||||
const taskBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = taskBaselineRef.current
|
||||
taskBaselineRef.current = sessionList
|
||||
const prev = jobBaselineRef.current
|
||||
jobBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewTask(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenTasks) return
|
||||
if (!detectNewJob(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenJobs) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
@@ -294,29 +320,81 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
// The app shell's own columns: the bottom panel spans ONLY the center
|
||||
// column ("squeezes the agent output area") — it starts at the app
|
||||
// sidebar's right edge and ends at the details column's left edge (the
|
||||
// details column sits between the center and the right panel). Measured
|
||||
// from the AppFrame's DOM (the plugin already couples to #root in
|
||||
// layout.css); a missing frame degrades to 0 (full width).
|
||||
const [appSidebarWidth, setAppSidebarWidth] = useState(0)
|
||||
const [detailsWidth, setDetailsWidth] = useState(0)
|
||||
useEffect(() => {
|
||||
const frame = document.querySelector('#root > div')
|
||||
const sidebarCol = frame?.children[0] as HTMLElement | undefined
|
||||
const detailsCol = frame?.children[2] as HTMLElement | undefined
|
||||
if (sidebarCol === undefined || detailsCol === undefined) return
|
||||
const measure = (): void => {
|
||||
setAppSidebarWidth(sidebarCol.getBoundingClientRect().width)
|
||||
setDetailsWidth(detailsCol.getBoundingClientRect().width)
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(sidebarCol)
|
||||
observer.observe(detailsCol)
|
||||
return () => { observer.disconnect() }
|
||||
// The app shell's center column: the bottom panel spans ONLY that column
|
||||
// ("squeezes the agent output area") — it starts at the app sidebar's
|
||||
// right edge and ends at the details column's left edge (the details
|
||||
// column sits between the center and the right panel). Measured directly
|
||||
// from the AppFrame's center column DOM (the parent of the
|
||||
// [data-slot="conversation"] wrapper — layout.css's center column) so the
|
||||
// bottom panel tracks the column's real
|
||||
// horizontal edges — including the animated margin-right push while the
|
||||
// right panel opens/closes; a frame that never appears keeps the initial
|
||||
// zero-size fallback (the panel renders at 0 width until measured).
|
||||
const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
|
||||
// Refs keep the measure step stable across renders and let it skip work
|
||||
// mid-drag: during a width/corner drag the layout push resizes the center
|
||||
// column every frame, and reacting (setCenterRect → re-render) would
|
||||
// re-introduce the drag lag this shell deliberately avoids. applyDrag
|
||||
// writes the bottom panel's edges directly, so measurement pauses then.
|
||||
const centerColRef = useRef<HTMLElement | null>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const measureCenter = useCallback((): void => {
|
||||
if (draggingRef.current) return
|
||||
const col = centerColRef.current
|
||||
if (col === null) return
|
||||
const rect = col.getBoundingClientRect()
|
||||
// The bottom panel only cares about the horizontal edges: a pure height
|
||||
// change (the bottom panel itself opening/closing) must not re-render,
|
||||
// so keep the previous object when left/right are unchanged.
|
||||
setCenterRect(prev =>
|
||||
prev.left === rect.left && prev.right === rect.right
|
||||
? prev
|
||||
: { left: rect.left, right: rect.right })
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let observer: ResizeObserver | undefined
|
||||
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
|
||||
// [data-slot] containers: the conversation slot wrapper
|
||||
// ([data-slot="conversation"]) sits directly inside the center column,
|
||||
// so its parent IS that column — no hashed-class or positional
|
||||
// dependency (layout.css uses the same anchor). The shell swaps the
|
||||
// boot page for the AppFrame only AFTER boot settles, so the first
|
||||
// query may miss it. Never give up: watch #root's children (the swap
|
||||
// mutates them) and re-run this locator — querying once and bailing
|
||||
// would strand the panel at the zero-size fallback forever (observed:
|
||||
// a 1px sliver at the viewport's left edge).
|
||||
const locate = (): void => {
|
||||
if (disposed) return
|
||||
const col = document.querySelector('#root [data-slot="conversation"]')
|
||||
?.parentElement as HTMLElement | undefined
|
||||
if (col === undefined) {
|
||||
if (centerColRef.current !== null) {
|
||||
centerColRef.current = null
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
if (centerColRef.current !== col) {
|
||||
centerColRef.current = col
|
||||
observer?.disconnect()
|
||||
observer = new ResizeObserver(measureCenter)
|
||||
observer.observe(col)
|
||||
}
|
||||
measureCenter()
|
||||
}
|
||||
locate()
|
||||
const watcher = new MutationObserver(locate)
|
||||
const root = document.getElementById('root')
|
||||
if (root !== null) watcher.observe(root, { childList: true })
|
||||
return () => {
|
||||
disposed = true
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
centerColRef.current = null
|
||||
}
|
||||
}, [measureCenter])
|
||||
|
||||
/**
|
||||
* Bottom-panel first-expansion auto terminal: the FIRST time the user
|
||||
@@ -366,6 +444,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const [draggingCorner, setDraggingCorner] = useState(false)
|
||||
const anyDragging = draggingWidth || draggingBottom || draggingCorner
|
||||
|
||||
// Pause center-column measurement while dragging, and re-measure once the
|
||||
// drag settles at its committed size. The store commit lands on release and
|
||||
// the final width equals the last drag width, so no ResizeObserver event
|
||||
// fires to refresh centerRect — this explicit re-measure covers that gap.
|
||||
useEffect(() => {
|
||||
draggingRef.current = anyDragging
|
||||
if (!anyDragging) measureCenter()
|
||||
}, [anyDragging, measureCenter])
|
||||
|
||||
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
|
||||
// re-clamps on commit; these keep the panels from overshooting mid-drag).
|
||||
const clampWidth = (width: number): number =>
|
||||
@@ -380,7 +467,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const applyDrag = (width: number, height: number): void => {
|
||||
panelRef.current?.style.setProperty('width', `${width}px`)
|
||||
bottomRef.current?.style.setProperty('height', `${height}px`)
|
||||
bottomRef.current?.style.setProperty('right', `${width + detailsWidth}px`)
|
||||
// centerRect.right is the center column's right edge at the committed
|
||||
// width (innerWidth - state.width - detailsWidth), so this equals
|
||||
// `width + detailsWidth` — derived from the measured column, keeping the
|
||||
// drag write-only (no React re-render mid-drag).
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
if (cornerRef.current !== null) {
|
||||
@@ -434,6 +525,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
: 0
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
// Unmount must release the push (issue #31): when the boundary swaps the
|
||||
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
||||
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
||||
// keeps squeezing #root with a stale margin — "the sidebar cannot be
|
||||
// hidden" until a full reload. removeProperty restores the CSS fallback
|
||||
// (var(--dsh-sidebar-width, 0px)); React re-runs cleanup+setup in the
|
||||
// same commit on state changes, so there is no visible flicker.
|
||||
return () => {
|
||||
document.documentElement.style.removeProperty('--dsh-sidebar-width')
|
||||
document.documentElement.style.removeProperty('--dsh-sidebar-height')
|
||||
}
|
||||
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
|
||||
useEffect(() => {
|
||||
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
||||
@@ -451,9 +553,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// TerminalView on unmount), and the agent-pty.close HTTP route is the
|
||||
// fallback when the WS is down.
|
||||
const current = store.getSnapshot().state
|
||||
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
|
||||
// Terminal tabs may live in EITHER tree (the bottom panel hosts them
|
||||
// too) — the pty-release lookup covers both, or the HTTP fallback is
|
||||
// skipped for a bottom-panel terminal whose WS frame never arrived.
|
||||
const leaf = current === undefined
|
||||
? undefined
|
||||
: leafWithTab(current.splits, tabId) ?? leafWithTab(current.bottomSplits, tabId)
|
||||
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
|
||||
store.reduce(s => closeTab(s, paneId, tabId))
|
||||
// Route through the service: the tab-bar close is the canonical close
|
||||
// path (finds the pane itself, fires descriptor.onClose); the session
|
||||
// scope (with its cwd) rides to the callback.
|
||||
ctx.betterSidebar?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
|
||||
if (tab?.type === 'terminal') {
|
||||
if (isAgentTabId(tabId)) {
|
||||
const uuid = agentUuidOf(tabId)
|
||||
@@ -464,13 +574,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}
|
||||
},
|
||||
activateTab: (paneId, tabId) => {
|
||||
store.reduce(s => ({
|
||||
...s,
|
||||
activePane: paneId,
|
||||
splits: mapLeaf(s.splits, paneId, (leaf) => {
|
||||
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
|
||||
}),
|
||||
}))
|
||||
// Route through the service: same reducer (finds the pane in EITHER
|
||||
// tree, sets the active pane) and fires descriptor.onActivate; the
|
||||
// session scope (with its cwd) rides to the callback.
|
||||
ctx.betterSidebar?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
|
||||
},
|
||||
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
|
||||
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
|
||||
@@ -502,18 +609,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
*/
|
||||
const referenceInChat = useCallback((path: string): void => {
|
||||
if (sessionId === undefined) return
|
||||
try {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) return
|
||||
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
||||
if (conversation === undefined) return
|
||||
const input = conversation.input.for(actx)
|
||||
const mention = `@${relativeTo(cwd ?? '', path)}`
|
||||
const draft = input.state.getSnapshot().draft
|
||||
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] reference insert failed:', error)
|
||||
}
|
||||
appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
|
||||
}, [ctx, sessionId, cwd])
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
@@ -540,7 +636,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const descriptor = service?.getTab(optionId)
|
||||
if (descriptor === undefined) return
|
||||
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
|
||||
service.openTab({ type: optionId, title })
|
||||
// The session scope rides along: lifecycle callbacks receive it (and
|
||||
// the open stays in the current session, as before).
|
||||
service.openTab({ type: optionId, title }, { sessionId, cwd })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -556,6 +654,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab badge from the tab-type registry: a count (99+ capped) or a
|
||||
* short text pill. A throwing badge is swallowed (no pill) — the tab
|
||||
* strip must never break because a plugin's badge computation failed.
|
||||
*/
|
||||
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
if (descriptor?.badge === undefined) return null
|
||||
let value: string | number | null | undefined
|
||||
try {
|
||||
value = descriptor.badge(ctx, { sessionId, cwd }, state)
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] tab badge error:', error)
|
||||
return null
|
||||
}
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
|
||||
return <span className={css.tabBadge}>{text}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one tab's content. `active` (from the workbench) tells whether
|
||||
* this tab is the active one in its pane; combined with the panel's
|
||||
@@ -666,6 +784,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={tabIconOf}
|
||||
getTabBadge={tabBadgeOf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -684,11 +803,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
left: appSidebarWidth,
|
||||
// Ends at the details column's left edge (the details column sits
|
||||
// between the center and the right panel) plus the right panel's
|
||||
// own width — the bottom panel squeezes ONLY the center column.
|
||||
right: detailsWidth + (state.panelOpen ? Math.min(state.width, window.innerWidth) : 0),
|
||||
left: centerRect.left,
|
||||
// Direct from the center column's measured right edge: the bottom
|
||||
// panel spans ONLY the center column, ending exactly at the
|
||||
// details column's left edge (the details column sits between the
|
||||
// center and the right panel, and the right panel's margin-right
|
||||
// push is already baked into centerRect.right).
|
||||
right: window.innerWidth - centerRect.right,
|
||||
// The seam against the open right panel needs its own hairline
|
||||
// (the right panel's border-left alone is covered by this panel's
|
||||
// fill — without it the corner looks cut off).
|
||||
@@ -743,6 +864,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
onNewTab={onNewTab}
|
||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
|
||||
getTabIcon={tabIconOf}
|
||||
getTabBadge={tabBadgeOf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -274,15 +274,15 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* ── Background-task section (below the topology tree) ────────────────── */
|
||||
/* ── Background-job section (below the topology tree) ────────────────── */
|
||||
|
||||
.tasks {
|
||||
.jobs {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.tasksHeader {
|
||||
.jobsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -290,7 +290,7 @@
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.tasksTitle {
|
||||
.jobsTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
@@ -300,13 +300,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasksCount {
|
||||
.jobsCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksList {
|
||||
.jobsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@@ -315,30 +315,30 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* One task row; the whole row lights up on hover, the expandable target
|
||||
/* One job row; the whole row lights up on hover, the expandable target
|
||||
stays transparent (the kill button rides on the row's right edge). */
|
||||
.tasksRow {
|
||||
.jobsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tasksRow:hover {
|
||||
.jobsRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksRowSettled {
|
||||
.jobsRowSettled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The row whose output is shown in the bottom dock. */
|
||||
.tasksRowSelected,
|
||||
.tasksRowSelected:hover {
|
||||
.jobsRowSelected,
|
||||
.jobsRowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.tasksRowMain {
|
||||
.jobsRowMain {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -355,15 +355,15 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tasksRowMain:focus-visible {
|
||||
.jobsRowMain:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksDot {
|
||||
.jobsDot {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.tasksContent {
|
||||
.jobsContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
@@ -372,14 +372,14 @@
|
||||
|
||||
/* The command line: a kind badge followed by the command itself in the code
|
||||
face, like the terminal's live lines. */
|
||||
.tasksLabelLine {
|
||||
.jobsLabelLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasksKind {
|
||||
.jobsKind {
|
||||
flex: none;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
@@ -393,7 +393,7 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksLabel {
|
||||
.jobsLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -405,7 +405,7 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tasksSecondary {
|
||||
.jobsSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -413,7 +413,7 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksKill {
|
||||
.jobsKill {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -428,14 +428,14 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasksKill:hover {
|
||||
.jobsKill:hover {
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The armed (first-click) state: a filled confirm chip instead of the icon. */
|
||||
.tasksKillArmed,
|
||||
.tasksKillArmed:hover {
|
||||
.jobsKillArmed,
|
||||
.jobsKillArmed:hover {
|
||||
width: auto;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
@@ -445,12 +445,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasksKill:disabled {
|
||||
.jobsKill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tasksKillError {
|
||||
.jobsKillError {
|
||||
flex: none;
|
||||
margin-right: 4px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
@@ -460,9 +460,9 @@
|
||||
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
|
||||
|
||||
/* Direct child of the scrolling body, so it docks at the scrollport's bottom
|
||||
edge while the task list scrolls above it; a solid background keeps the
|
||||
edge while the job list scrolls above it; a solid background keeps the
|
||||
tree from showing through. */
|
||||
.tasksPane {
|
||||
.jobsPane {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
@@ -474,7 +474,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tasksPaneHeader {
|
||||
.jobsPaneHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -483,11 +483,11 @@
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.tasksPaneDot {
|
||||
.jobsPaneDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tasksPaneLabel {
|
||||
.jobsPaneLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -499,13 +499,13 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tasksPaneStatus {
|
||||
.jobsPaneStatus {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksPaneClose {
|
||||
.jobsPaneClose {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -519,11 +519,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tasksPaneClose:hover {
|
||||
.jobsPaneClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tasksPanePre {
|
||||
.jobsPanePre {
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
@@ -536,12 +536,12 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tasksPaneHint {
|
||||
.jobsPaneHint {
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tasksPaneError {
|
||||
.jobsPaneError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
+103
-103
@@ -33,7 +33,7 @@ import type {
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentDiagnosticEntry,
|
||||
SidebarTaskView,
|
||||
SidebarJobView,
|
||||
} from '../context-types.ts'
|
||||
import {
|
||||
collectBranchIds,
|
||||
@@ -42,15 +42,15 @@ import {
|
||||
} from './subagent-detect.ts'
|
||||
import { lastActivity } from './subagent-activity.ts'
|
||||
import {
|
||||
collectTreeTasks,
|
||||
formatTaskDuration,
|
||||
isTaskLive,
|
||||
orderTasks,
|
||||
taskDotState,
|
||||
taskStatusLabel,
|
||||
type TreeTask,
|
||||
} from './subagent-tasks.ts'
|
||||
import { api, type TaskOutputResult } from './api.ts'
|
||||
collectTreeJobs,
|
||||
formatJobDuration,
|
||||
isJobLive,
|
||||
orderJobs,
|
||||
jobDotState,
|
||||
jobStatusLabel,
|
||||
type TreeJob,
|
||||
} from './subagent-jobs.ts'
|
||||
import { api, type JobOutputResult } from './api.ts'
|
||||
import { IconStopOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SubagentView.module.css'
|
||||
@@ -59,10 +59,10 @@ import css from './SubagentView.module.css'
|
||||
const POLL_MS = 3000
|
||||
/** Preview cap of one tool-call argument line. */
|
||||
const ARGS_PREVIEW = 60
|
||||
/** Refresh cadence of an expanded task-output panel while its task runs. */
|
||||
const TASK_POLL_MS = 2000
|
||||
/** Refresh cadence of an expanded job-output panel while its job runs. */
|
||||
const JOB_POLL_MS = 2000
|
||||
/** How long the kill button stays armed before it needs re-confirming. */
|
||||
const TASK_KILL_ARM_MS = 3000
|
||||
const JOB_KILL_ARM_MS = 3000
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows). */
|
||||
function directChildren(
|
||||
@@ -358,24 +358,24 @@ function CatalogRows({
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared output dock of the tasks section: ONE pane at the bottom of the
|
||||
* sidebar body (sticky, terminal-like) shows the SELECTED task's output as
|
||||
* The shared output dock of the jobs section: ONE pane at the bottom of the
|
||||
* sidebar body (sticky, terminal-like) shows the SELECTED job's output as
|
||||
* the MODEL has read it so far (replayed from the owner session's event
|
||||
* log), refreshed every {@link TASK_POLL_MS} while the task runs and the
|
||||
* page is visible. The model's `task_output` cursor is never touched — the
|
||||
* log), refreshed every {@link JOB_POLL_MS} while the job runs and the
|
||||
* page is visible. The model's `job_output` cursor is never touched — the
|
||||
* pane can never steal the agent's bytes, and it stays empty until the
|
||||
* agent reads the task. A single dock — not a panel per row — keeps the
|
||||
* task list compact and stable when many tasks are running.
|
||||
* agent reads the job. A single dock — not a panel per row — keeps the
|
||||
* job list compact and stable when many jobs are running.
|
||||
*/
|
||||
function TaskOutputPane(props: {
|
||||
function JobOutputPane(props: {
|
||||
ownerSessionId: string
|
||||
task: SidebarTaskView
|
||||
job: SidebarJobView
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { ownerSessionId, task, active, onClose } = props
|
||||
const [state, setState] = useState<'loading' | TaskOutputResult | 'error'>('loading')
|
||||
const { ownerSessionId, job, active, onClose } = props
|
||||
const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
@@ -384,44 +384,44 @@ function TaskOutputPane(props: {
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const result = await api.taskOutput({ sessionId: ownerSessionId }, task.id, controller.signal)
|
||||
const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal)
|
||||
setState(result)
|
||||
} catch {
|
||||
// A newer pull aborted this one, or the wire failed: keep the last
|
||||
// known output; only a dock that never loaded anything shows an error.
|
||||
setState(current => (current === 'loading' ? 'error' : current))
|
||||
}
|
||||
}, [ownerSessionId, task.id])
|
||||
}, [ownerSessionId, job.id])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (!active || !isTaskLive(task)) return
|
||||
const timer = window.setInterval(() => { void load() }, TASK_POLL_MS)
|
||||
if (!active || !isJobLive(job)) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, active, task.status])
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
// Terminal-tail behavior: while the task runs, each refresh pins the view
|
||||
// Terminal-tail behavior: while the job runs, each refresh pins the view
|
||||
// to the newest output; a settled dock leaves scrolling to the reader.
|
||||
useEffect(() => {
|
||||
if (!isTaskLive(task) || typeof state !== 'object' || state.text.length === 0) return
|
||||
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
}, [state, task.status])
|
||||
}, [state, job.status])
|
||||
|
||||
return (
|
||||
<div className={css.tasksPane} role="region" aria-label={`${task.label} ${t('tasks')}`}>
|
||||
<div className={css.tasksPaneHeader}>
|
||||
<StateDot state={taskDotState(task.status)} className={css.tasksPaneDot} />
|
||||
<span className={css.tasksPaneLabel} title={task.label}>{task.label}</span>
|
||||
<span className={css.tasksPaneStatus}>
|
||||
{taskStatusLabel(task.status, t)}
|
||||
{task.detail !== undefined && task.detail !== '' ? ` · ${task.detail}` : ''}
|
||||
<div className={css.jobsPane} role="region" aria-label={`${job.label} ${t('jobs')}`}>
|
||||
<div className={css.jobsPaneHeader}>
|
||||
<StateDot state={jobDotState(job.status)} className={css.jobsPaneDot} />
|
||||
<span className={css.jobsPaneLabel} title={job.label}>{job.label}</span>
|
||||
<span className={css.jobsPaneStatus}>
|
||||
{jobStatusLabel(job.status, t)}
|
||||
{job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tasksPaneClose}
|
||||
className={css.jobsPaneClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
onClick={onClose}
|
||||
@@ -429,18 +429,18 @@ function TaskOutputPane(props: {
|
||||
<IconStopOutline16 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.tasksPaneHint}>{t('loading')}</div>}
|
||||
{state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
|
||||
{state === 'error' && (
|
||||
<div className={`${css.tasksPaneHint} ${css.tasksPaneError}`}>{t('taskOutputError')}</div>
|
||||
<div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
|
||||
)}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre>
|
||||
? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>
|
||||
: <div className={css.tasksPaneHint}>{t('taskNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>}
|
||||
? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -448,24 +448,24 @@ function TaskOutputPane(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-task section of the Subagent page: every task of the whole
|
||||
* The background-job section of the Subagent page: every job of the whole
|
||||
* current tree (main agent + subagents, owner-labeled), fed by the harness
|
||||
* `session/tasks` push mirror. Clicking a row feeds its model-read output to
|
||||
* `session/jobs` push mirror. Clicking a row feeds its model-read output to
|
||||
* the shared bottom dock (event replay — never the model's cursor); live
|
||||
* rows carry a two-click-confirm kill button. Renders nothing while the
|
||||
* tree has no tasks.
|
||||
* tree has no jobs.
|
||||
*/
|
||||
function TasksSection(props: {
|
||||
function JobsSection(props: {
|
||||
byId: SidebarSessionList['byId']
|
||||
tasksBySession: SidebarSessionList['tasksBySession']
|
||||
jobsBySession: SidebarSessionList['jobsBySession']
|
||||
rootId: string | undefined
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { byId, tasksBySession, rootId, active } = props
|
||||
const { byId, jobsBySession, rootId, active } = props
|
||||
const rows = useMemo(
|
||||
() => orderTasks(collectTreeTasks(byId, tasksBySession, rootId)),
|
||||
[byId, tasksBySession, rootId],
|
||||
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
|
||||
[byId, jobsBySession, rootId],
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
||||
const [armedId, setArmedId] = useState<string | undefined>(undefined)
|
||||
@@ -475,12 +475,12 @@ function TasksSection(props: {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => (selectedId === undefined ? undefined : rows.find(row => row.task.id === selectedId)),
|
||||
() => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)),
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
const liveCount = useMemo(
|
||||
() => rows.reduce((count, row) => count + (isTaskLive(row.task) ? 1 : 0), 0),
|
||||
() => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0),
|
||||
[rows],
|
||||
)
|
||||
const multiOwner = useMemo(
|
||||
@@ -491,7 +491,7 @@ function TasksSection(props: {
|
||||
// The kill button stays armed only briefly; a stray click must never kill.
|
||||
useEffect(() => {
|
||||
if (armedId === undefined) return
|
||||
const timer = window.setTimeout(() => { setArmedId(undefined) }, TASK_KILL_ARM_MS)
|
||||
const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [armedId])
|
||||
|
||||
@@ -502,7 +502,7 @@ function TasksSection(props: {
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [liveCount])
|
||||
|
||||
// The docked output pane follows its task: when the selected task leaves
|
||||
// The docked output pane follows its job: when the selected job leaves
|
||||
// the mirror (settled and dropped, or the tree switched), close the dock.
|
||||
useEffect(() => {
|
||||
if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
|
||||
@@ -511,13 +511,13 @@ function TasksSection(props: {
|
||||
// NOTE: every hook must live ABOVE the empty-state return — a hook below it
|
||||
// would flip this component's hook count when the mirror empties and crash
|
||||
// React with "Rendered fewer hooks than expected" (the #300 regression).
|
||||
const kill = useCallback(async (row: TreeTask): Promise<void> => {
|
||||
setKillingId(row.task.id)
|
||||
const kill = useCallback(async (row: TreeJob): Promise<void> => {
|
||||
setKillingId(row.job.id)
|
||||
setKillErrorId(undefined)
|
||||
try {
|
||||
await api.taskKill({ sessionId: row.ownerSessionId }, row.task.id)
|
||||
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
|
||||
} catch {
|
||||
setKillErrorId(row.task.id)
|
||||
setKillErrorId(row.job.id)
|
||||
} finally {
|
||||
setKillingId(undefined)
|
||||
setArmedId(undefined)
|
||||
@@ -527,84 +527,84 @@ function TasksSection(props: {
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const countLabel = liveCount > 0
|
||||
? t('tasksCountRunning', { count: rows.length, running: liveCount })
|
||||
: t('tasksCount', { count: rows.length })
|
||||
? t('jobsCountRunning', { count: rows.length, running: liveCount })
|
||||
: t('jobsCount', { count: rows.length })
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={css.tasks} aria-label={t('tasks')}>
|
||||
<div className={css.tasksHeader}>
|
||||
<span className={css.tasksTitle}>{t('tasks')}</span>
|
||||
<span className={css.tasksCount}>{countLabel}</span>
|
||||
<section className={css.jobs} aria-label={t('jobs')}>
|
||||
<div className={css.jobsHeader}>
|
||||
<span className={css.jobsTitle}>{t('jobs')}</span>
|
||||
<span className={css.jobsCount}>{countLabel}</span>
|
||||
</div>
|
||||
<ul className={css.tasksList} aria-label={t('tasks')}>
|
||||
<ul className={css.jobsList} aria-label={t('jobs')}>
|
||||
{rows.map((row) => {
|
||||
const { task } = row
|
||||
const live = isTaskLive(task)
|
||||
const selected = selectedId === task.id
|
||||
const armed = armedId === task.id
|
||||
const killing = killingId === task.id
|
||||
const killFailed = killErrorId === task.id
|
||||
const { job } = row
|
||||
const live = isJobLive(job)
|
||||
const selected = selectedId === job.id
|
||||
const armed = armedId === job.id
|
||||
const killing = killingId === job.id
|
||||
const killFailed = killErrorId === job.id
|
||||
const elapsed = live
|
||||
? now - task.startedAt
|
||||
: (task.finishedAt ?? task.startedAt) - task.startedAt
|
||||
? now - job.startedAt
|
||||
: (job.finishedAt ?? job.startedAt) - job.startedAt
|
||||
const secondary = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
taskStatusLabel(task.status, t),
|
||||
...(task.detail !== undefined && task.detail !== '' ? [task.detail] : []),
|
||||
formatTaskDuration(elapsed, t),
|
||||
jobStatusLabel(job.status, t),
|
||||
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
|
||||
formatJobDuration(elapsed, t),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
key={job.id}
|
||||
className={clsx(
|
||||
css.tasksRow,
|
||||
!live && css.tasksRowSettled,
|
||||
selected && css.tasksRowSelected,
|
||||
css.jobsRow,
|
||||
!live && css.jobsRowSettled,
|
||||
selected && css.jobsRowSelected,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tasksRowMain}
|
||||
className={css.jobsRowMain}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${task.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : task.id) }}
|
||||
aria-label={`${job.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : job.id) }}
|
||||
>
|
||||
<StateDot state={taskDotState(task.status)} className={css.tasksDot} />
|
||||
<span className={css.tasksContent}>
|
||||
<span className={css.tasksLabelLine}>
|
||||
<span className={css.tasksKind}>{task.kind}</span>
|
||||
<span className={css.tasksLabel} title={task.label}>{task.label}</span>
|
||||
<StateDot state={jobDotState(job.status)} className={css.jobsDot} />
|
||||
<span className={css.jobsContent}>
|
||||
<span className={css.jobsLabelLine}>
|
||||
<span className={css.jobsKind}>{job.kind}</span>
|
||||
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
|
||||
</span>
|
||||
<span className={css.tasksSecondary}>{secondary}</span>
|
||||
<span className={css.jobsSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</button>
|
||||
{task.status === 'running' && (
|
||||
{job.status === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className={armed ? `${css.tasksKill} ${css.tasksKillArmed}` : css.tasksKill}
|
||||
aria-label={armed ? t('taskKillConfirm') : t('taskKill')}
|
||||
title={armed ? t('taskKillConfirm') : t('taskKill')}
|
||||
className={armed ? `${css.jobsKill} ${css.jobsKillArmed}` : css.jobsKill}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(task.id)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
{armed ? t('taskKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{killFailed && <span className={css.tasksKillError}>{t('taskKillError')}</span>}
|
||||
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{selectedRow !== undefined && (
|
||||
<TaskOutputPane
|
||||
<JobOutputPane
|
||||
ownerSessionId={selectedRow.ownerSessionId}
|
||||
task={selectedRow.task}
|
||||
job={selectedRow.job}
|
||||
active={active}
|
||||
onClose={() => { setSelectedId(undefined) }}
|
||||
/>
|
||||
@@ -854,9 +854,9 @@ export function SubagentView(props: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TasksSection
|
||||
<JobsSection
|
||||
byId={byId}
|
||||
tasksBySession={list.tasksBySession}
|
||||
jobsBySession={list.jobsBySession}
|
||||
rootId={rootId}
|
||||
active={active}
|
||||
/>
|
||||
|
||||
+28
-3
@@ -5,7 +5,7 @@
|
||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||
* dropping on the strip background appends to this pane.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
@@ -63,12 +63,36 @@ export function TabBar(props: {
|
||||
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
||||
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
|
||||
* resolver returns the rendered pill or null). */
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
|
||||
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
|
||||
// native NON-passive listener: React registers onWheel passively at the
|
||||
// root, where preventDefault() is a no-op. Modifier keys keep their native
|
||||
// meaning (shift = horizontal scroll, ctrl/cmd = zoom), and a strip that
|
||||
// does not overflow leaves the event alone so the page scrolls normally.
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (el === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
|
||||
if (el.scrollWidth <= el.clientWidth) return
|
||||
event.preventDefault()
|
||||
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1
|
||||
el.scrollLeft += (event.deltaX + event.deltaY) * unit
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { el.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
||||
@@ -104,7 +128,7 @@ export function TabBar(props: {
|
||||
if (payload !== null) onDropTab(payload, null)
|
||||
}}
|
||||
>
|
||||
<div className={css.tabList}>
|
||||
<div ref={listRef} className={css.tabList}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
@@ -136,6 +160,7 @@ export function TabBar(props: {
|
||||
}}
|
||||
>
|
||||
{getTabIcon?.(tab) ?? null}
|
||||
{getTabBadge?.(tab) ?? null}
|
||||
<span className={css.tabTitle}>{tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -24,9 +24,11 @@ import { Terminal, type ITheme } from 'xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import { openWhenSized } from './open-when-sized.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||
import { resolveTerminalFont } from './terminal-font.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
@@ -82,10 +84,13 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
// The custom font prefs (side card settings, terminal card) resolve at
|
||||
// mount; store changes re-apply them live below.
|
||||
const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: font.fontSize,
|
||||
fontFamily: font.fontFamily,
|
||||
allowTransparency: true,
|
||||
convertEol: false,
|
||||
scrollback: 4000,
|
||||
@@ -93,8 +98,6 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
// Re-theme in place when the app's scheme flips (tokens + palette).
|
||||
const applyTheme = (): void => {
|
||||
term.options.theme = xtermTheme()
|
||||
@@ -185,11 +188,52 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
observer.observe(host)
|
||||
|
||||
// Custom font prefs (the terminal card's secondary settings) apply LIVE:
|
||||
// on any store change re-resolve and diff the two options, re-fitting
|
||||
// when they moved (the grid dimensions may change with the font). The
|
||||
// subscribe fires on every store change (tabs, panels…), so the diff is
|
||||
// what keeps this cheap.
|
||||
const fontSub = store.subscribe(() => {
|
||||
const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
|
||||
if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
|
||||
term.options.fontFamily = next.fontFamily
|
||||
term.options.fontSize = next.fontSize
|
||||
try {
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch {
|
||||
// The terminal may be mid-dispose; ignore.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The terminal must not be opened in a zero-size container: xterm's
|
||||
// renderer creation fails there and the next Viewport refresh crashes
|
||||
// reading `.dimensions` off the undefined renderer (blank terminal on
|
||||
// WKWebView when the bottom panel's expand slide leaves the host at
|
||||
// height 0; any display:none-hidden ancestor does the same). Defer
|
||||
// open+fit until the host has a real size — writes arriving meanwhile
|
||||
// are buffered by xterm's WriteBuffer and render once open, and
|
||||
// FitAddon.fit() is a safe no-op before open. sendResize() here covers
|
||||
// the deferred path where the socket may already be open with the
|
||||
// default 80x24 dims.
|
||||
const cancelOpen = openWhenSized(host, () => {
|
||||
try {
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] xterm open failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
cancelOpen()
|
||||
window.clearTimeout(retry)
|
||||
observer.disconnect()
|
||||
fontSub()
|
||||
schemeSub()
|
||||
inputSub.dispose()
|
||||
// The close frame tells the host the owning tab is GONE (immediate
|
||||
|
||||
+150
-4
@@ -10,6 +10,7 @@
|
||||
* row below the host's title bar, VSCode-style.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
@@ -20,6 +21,8 @@ import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -27,6 +30,13 @@ import css from './sidebar.module.css'
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
|
||||
/** The floating "add to conversation" action: payload + viewport anchor. */
|
||||
interface SelectionPopup {
|
||||
insert: string
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
|
||||
* preview must stay in an opaque origin — with the route's own origin it
|
||||
@@ -37,7 +47,7 @@ type ViewMode = 'preview' | 'edit'
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
export function TextEditor(props: FileViewerProps) {
|
||||
const { scope, path, viewerId, content, truncated } = props
|
||||
const { ctx, scope, path, viewerId, content, truncated } = props
|
||||
const [mode, setMode] = useState<ViewMode>('preview')
|
||||
/** The editor's current text (null while clean); preview renders this. */
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
@@ -50,6 +60,36 @@ export function TextEditor(props: FileViewerProps) {
|
||||
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
||||
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
||||
const [dark, setDark] = useState(() => isDarkScheme())
|
||||
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
|
||||
const [popup, setPopup] = useState<SelectionPopup | null>(null)
|
||||
/** Live mirror of the popup state for click-time reads (no re-render race). */
|
||||
const popupRef = useRef<SelectionPopup | null>(null)
|
||||
/** The markdown preview container (selection-containment + line lookup). */
|
||||
const mdRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const hidePopup = (): void => {
|
||||
popupRef.current = null
|
||||
setPopup(null)
|
||||
}
|
||||
|
||||
/** Anchor the popup above the selection center; clamp inside the viewport. */
|
||||
const showPopup = (insert: string, left: number, top: number): void => {
|
||||
const next: SelectionPopup = {
|
||||
insert,
|
||||
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
|
||||
top,
|
||||
}
|
||||
popupRef.current = next
|
||||
setPopup(next)
|
||||
}
|
||||
|
||||
/** The popup button's click: insert the stored payload into the draft. */
|
||||
const commitPopup = (): void => {
|
||||
const current = popupRef.current
|
||||
if (current === null) return
|
||||
appendToDraft(ctx, scope.sessionId, current.insert)
|
||||
hidePopup()
|
||||
}
|
||||
|
||||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||||
|
||||
@@ -59,6 +99,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
hidePopup()
|
||||
}, [content])
|
||||
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
@@ -100,6 +141,49 @@ export function TextEditor(props: FileViewerProps) {
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
// Selection popup (the catch-all code viewer only): a non-empty
|
||||
// selection anchors the floating "add to conversation" button above
|
||||
// its head. Scrolling (geometry/viewport change) or losing focus
|
||||
// hides it; typing collapses the selection and hides it too.
|
||||
...(viewerId === 'code' ? [
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.geometryChanged || update.viewportChanged) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
if (!update.view.hasFocus) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
|
||||
const sel = update.state.selection.main
|
||||
if (sel.empty) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const text = update.state.sliceDoc(sel.from, sel.to)
|
||||
if (text.trim() === '') {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
// Page coordinates (the document root may scroll); the popup is
|
||||
// position:fixed, so convert to viewport coordinates.
|
||||
const rect = update.view.coordsAtPos(sel.head)
|
||||
if (rect === null) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const doc = update.state.doc
|
||||
showPopup(
|
||||
buildSelectionInsert(path, scope.cwd, {
|
||||
start: doc.lineAt(sel.from).number,
|
||||
end: doc.lineAt(sel.to).number,
|
||||
}, text),
|
||||
rect.left - window.scrollX + (rect.right - rect.left) / 2,
|
||||
rect.top - window.scrollY,
|
||||
)
|
||||
}),
|
||||
] : []),
|
||||
],
|
||||
})
|
||||
const view = new CodeMirrorView({ state, parent: host })
|
||||
@@ -124,8 +208,10 @@ export function TextEditor(props: FileViewerProps) {
|
||||
}, [dark])
|
||||
|
||||
// The editor may have been display:none while previewing; re-measure when
|
||||
// it becomes visible again (CodeMirror sizes itself on reveal).
|
||||
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
|
||||
// flip also invalidates any anchored selection popup.
|
||||
useEffect(() => {
|
||||
hidePopup()
|
||||
if (mode === 'edit') viewRef.current?.requestMeasure()
|
||||
}, [mode])
|
||||
|
||||
@@ -147,6 +233,39 @@ export function TextEditor(props: FileViewerProps) {
|
||||
|
||||
const markdown = viewerId === 'markdown'
|
||||
const html = viewerId === 'html'
|
||||
|
||||
/**
|
||||
* Selection popup for the markdown preview: a mouse-up inside the preview
|
||||
* container anchors the floating "add to conversation" button above the
|
||||
* selection. Line numbers come from a best-effort reverse-search of the
|
||||
* selected text in the source ({@link linesOfSelection} — an ambiguous or
|
||||
* missing hit omits them). The button's own mousedown preventDefaults so
|
||||
* the selection survives until the click commits.
|
||||
*/
|
||||
const handlePreviewMouseUp = (): void => {
|
||||
const sel = window.getSelection()
|
||||
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const host = mdRef.current
|
||||
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const text = sel.toString()
|
||||
if (text.trim() === '') {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect()
|
||||
const lines = linesOfSelection(draft ?? content ?? '', text)
|
||||
showPopup(
|
||||
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
)
|
||||
}
|
||||
const editable = content !== undefined
|
||||
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
||||
// Per-feature sandbox escape hatch: the global side card setting (warned)
|
||||
@@ -204,8 +323,21 @@ export function TextEditor(props: FileViewerProps) {
|
||||
</>
|
||||
)}
|
||||
{markdown && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
<div
|
||||
className={css.editorMd}
|
||||
ref={mdRef}
|
||||
onMouseUp={handlePreviewMouseUp}
|
||||
onScroll={hidePopup}
|
||||
>
|
||||
{/* The fence copy-button labels must come from this plugin's own
|
||||
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
|
||||
fall back to hardcoded Chinese otherwise (same pattern as the
|
||||
chat's AssistantMarkdown). Render-time t() keeps them following
|
||||
the active locale on live switches. */}
|
||||
<MarkdownText
|
||||
text={draft ?? content ?? ''}
|
||||
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
@@ -231,6 +363,20 @@ export function TextEditor(props: FileViewerProps) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{popup !== null && createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selectionPopup}
|
||||
style={{ left: popup.left, top: popup.top }}
|
||||
// Keep the selection (and CodeMirror focus) alive until the click
|
||||
// commits — without this the popup unmounts before click lands.
|
||||
onMouseDown={(event) => { event.preventDefault() }}
|
||||
onClick={commitPopup}
|
||||
>
|
||||
{t('addToConversation')}
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* The "add plugin" modals (Side card settings → the dashed cards at the
|
||||
* end of the 侧边栏内容 / 文件预览 grids): declare that the sidebar's
|
||||
* extension points — tab pages and file previewers — are open to plugins
|
||||
* (registered through `ctx.betterSidebar`), point at the GitHub topic page
|
||||
* for discovery, and show the repo's recommended plugin catalog of the
|
||||
* matching kind (name / url / description / install script).
|
||||
*
|
||||
* Per entry there are two actions:
|
||||
* - 「跳转」opens the plugin's repo in a REAL new browser tab (window.open
|
||||
* — a button, so the sidebar link takeover cannot reroute it);
|
||||
* - 「安装」only COPIES the install script to the clipboard (writeClipboard)
|
||||
* with a transient "已复制" feedback on the button — the user pastes and
|
||||
* runs it wherever they manage their DSH profile. No terminal is opened,
|
||||
* nothing is closed, nothing can fail outward.
|
||||
*
|
||||
* The body is extracted as {@link PluginListBody} so tests render it
|
||||
* directly — the Modal primitive runs hooks unconditionally, so an open
|
||||
* Modal must never be renderToString'd (same rule as the settingsFor popup
|
||||
* in SideCardSection); the modal itself mounts only while open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { BetterSidebarService } from './service.ts'
|
||||
import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts'
|
||||
import { builtinTabPlugins } from './plugins-tabs.ts'
|
||||
import { builtinViewerPlugins } from './plugins-viewers.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Which extension point the modal is adding a plugin for. */
|
||||
export type PluginKind = 'tab' | 'viewer'
|
||||
|
||||
/** The catalog of one kind (kept in two repo files: plugins-tabs.ts /
|
||||
* plugins-viewers.ts). */
|
||||
function catalogOf(kind: PluginKind): readonly PluginEntry[] {
|
||||
return kind === 'tab' ? builtinTabPlugins : builtinViewerPlugins
|
||||
}
|
||||
|
||||
/** How long the "已复制" feedback stays on the copy button. */
|
||||
const COPIED_FEEDBACK_MS = 1500
|
||||
|
||||
/** The modal body: the GitHub topic button + the recommended plugin list
|
||||
* with per-entry jump/copy buttons (extracted for direct testing). */
|
||||
export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) {
|
||||
const { service, kind } = props
|
||||
// Which entry's copy button currently shows the "已复制" feedback.
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
/** Copy the entry's install script to the clipboard and flash the button's
|
||||
* "已复制" label for a moment. The feedback ONLY appears after a
|
||||
* successful write — when the clipboard is unavailable or denied
|
||||
* (writeClipboard resolves false) nothing is shown, so the user is never
|
||||
* told to paste a command that was not placed on the clipboard. Never
|
||||
* closes anything, never throws outward. */
|
||||
const copy = async (entry: PluginEntry): Promise<void> => {
|
||||
const written = await writeClipboard(entry.install)
|
||||
if (!written) return
|
||||
setCopiedId(entry.id)
|
||||
window.setTimeout(() => {
|
||||
setCopiedId(current => (current === entry.id ? null : current))
|
||||
}, COPIED_FEEDBACK_MS)
|
||||
}
|
||||
|
||||
/** Open the plugin's repo in a REAL new browser tab (window.open — a
|
||||
* button, so the sidebar link takeover cannot reroute it). */
|
||||
const jump = (entry: PluginEntry): void => {
|
||||
window.open(entry.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.pluginList}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginTopicBtn}
|
||||
onClick={() => { window.open(PLUGIN_TOPIC_URL, '_blank', 'noopener') }}
|
||||
>
|
||||
{t('addPluginsBrowseMore')}
|
||||
</button>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('addPluginsRecommended')}</span>
|
||||
<span className={css.count}>{catalogOf(kind).length}</span>
|
||||
</div>
|
||||
{catalogOf(kind).length === 0 ? (
|
||||
<div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
|
||||
) : (
|
||||
<div className={css.pluginEntries}>
|
||||
{catalogOf(kind).map(entry => (
|
||||
<div key={entry.id} className={css.pluginEntry}>
|
||||
<div className={css.pluginEntryHead}>
|
||||
{/* The name is a BUTTON on the same window.open path as the
|
||||
jump button: as an anchor it would be caught by the
|
||||
document-capture link takeover (which ignores
|
||||
target=_blank) and land in the sidebar browser. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginName}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
<span className={css.pluginEntryActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginJumpBtn}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{t('openPlugin')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginCopyBtn}
|
||||
aria-label={`${t('copyInstall')}: ${entry.name}`}
|
||||
onClick={() => { copy(entry) }}
|
||||
>
|
||||
{copiedId === entry.id ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.pluginDesc}>
|
||||
{typeof entry.description === 'function' ? entry.description() : entry.description}
|
||||
</div>
|
||||
<code className={css.pluginInstall}>{entry.install}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The modal itself (mounted only while open — see the module comment). */
|
||||
export function AddPluginModal(props: { service: BetterSidebarService; onClose: () => void; kind: PluginKind }) {
|
||||
const { service, onClose, kind } = props
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={kind === 'tab' ? t('addPluginsTabCard') : t('addPluginsViewerCard')}
|
||||
description={kind === 'tab' ? t('addPluginsTabDesc') : t('addPluginsViewerDesc')}
|
||||
closeLabel={t('close')}
|
||||
className={css.pluginModal}
|
||||
footer={(
|
||||
<button type="button" className={css.done} onClick={onClose}>
|
||||
{t('settingsDone')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<PluginListBody service={service} kind={kind} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+14
-14
@@ -61,17 +61,17 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/**
|
||||
* One tasks.output response: the output the MODEL has read so far for the
|
||||
* task (replayed from the owner session's event log — the model's
|
||||
* task_output cursor is never touched, so the pane can never steal the
|
||||
* One jobs.output response: the output the MODEL has read so far for the
|
||||
* job (replayed from the owner session's event log — the model's
|
||||
* job_output cursor is never touched, so the pane can never steal the
|
||||
* agent's bytes). `read` is false until the model actually called
|
||||
* task_output for the task.
|
||||
* job_output for the job.
|
||||
*/
|
||||
export interface TaskOutputResult {
|
||||
export interface JobOutputResult {
|
||||
text: string
|
||||
/** True when the host capped the text at its output limit. */
|
||||
truncated: boolean
|
||||
/** Whether the model has read the task at least once. */
|
||||
/** Whether the model has read the job at least once. */
|
||||
read: boolean
|
||||
}
|
||||
|
||||
@@ -161,15 +161,15 @@ export const api = {
|
||||
agentPtyClose: (uuid: string) =>
|
||||
call<{ ok: true }>('agent-pty.close', { uuid }),
|
||||
/**
|
||||
* The output the model has read so far for one background task (replayed
|
||||
* from the owner session's event log — never the model's task_output
|
||||
* cursor). The scope MUST be the task's OWNER session.
|
||||
* The output the model has read so far for one background job (replayed
|
||||
* from the owner session's event log — never the model's job_output
|
||||
* cursor). The scope MUST be the job's OWNER session.
|
||||
*/
|
||||
taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
||||
call<TaskOutputResult>('tasks.output', scopePayload(scope, { id }), signal),
|
||||
/** Request cancellation of one background task (live tasks flip to stopping). */
|
||||
taskKill: (scope: SessionScope, id: string, reason?: string) =>
|
||||
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('tasks.kill', scopePayload(scope, {
|
||||
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
||||
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
|
||||
/** Request cancellation of one background job (live jobs flip to stopping). */
|
||||
jobKill: (scope: SessionScope, id: string, reason?: string) =>
|
||||
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
|
||||
id,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
})),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { BrowserView } from '../BrowserView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
|
||||
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
@@ -110,16 +111,16 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switches render under this row in
|
||||
// the Side card settings page (the Tasks page's own related settings).
|
||||
// the Side card settings page (the Jobs page's own related settings).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}, {
|
||||
key: 'autoOpenTasks',
|
||||
title: () => t('settingsTasksTitle'),
|
||||
desc: () => t('settingsTasksDesc'),
|
||||
key: 'autoOpenJobs',
|
||||
title: () => t('settingsJobsTitle'),
|
||||
desc: () => t('settingsJobsDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
@@ -137,10 +138,11 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
// Declarative settings: the model-facing terminal tools switch and the
|
||||
// bottom-panel first-expansion auto-terminal switch render under this
|
||||
// row in the Side card settings page (the Terminal page's own related
|
||||
// settings; the host gates the toolset on the tools one independently).
|
||||
// Declarative settings: the model-facing terminal tools switch, the
|
||||
// bottom-panel first-expansion auto-terminal switch, and the custom
|
||||
// font family/size rows render under this card in the Side card
|
||||
// settings page (the host gates the toolset on the tools one
|
||||
// independently; the font rows apply live to every terminal).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'agentTerminalTools',
|
||||
@@ -150,6 +152,20 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
key: 'bottomPanelAutoTerminal',
|
||||
title: () => t('settingsBottomTerminalTitle'),
|
||||
desc: () => t('settingsBottomTerminalDesc'),
|
||||
}, {
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
title: () => t('settingsFontFamilyTitle'),
|
||||
desc: () => t('settingsFontFamilyDesc'),
|
||||
placeholder: t('settingsFontFamilyPlaceholder'),
|
||||
}, {
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => t('settingsFontSizeTitle'),
|
||||
desc: () => t('settingsFontSizeDesc'),
|
||||
min: TERMINAL_FONT_SIZE_MIN,
|
||||
max: TERMINAL_FONT_SIZE_MAX,
|
||||
unit: 'px',
|
||||
}],
|
||||
},
|
||||
createTab: (state) => {
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* The 9 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / html /
|
||||
* code / binary-download), exactly like external plugins register theirs.
|
||||
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
* The 6 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / markdown / html / code /
|
||||
* binary-download), exactly like external plugins register theirs. Office
|
||||
* previews (.docx / .xlsx / .pptx) are NOT built in anymore — they moved to
|
||||
* the recommended office plugin (see plugins-viewers.ts), which registers
|
||||
* the same ids through this service.
|
||||
*
|
||||
* The heavy viewers (docx/xlsx/pptx and the CodeMirror-backed
|
||||
* markdown/html/code) render through {@link lazyChunkComponent} wrappers —
|
||||
* their libraries are fetched only when such a file is first opened (see
|
||||
* chunk-loader.ts). The descriptor metadata (id/exts/priority/detect) is
|
||||
* identical either way, so matching semantics and external-plugin
|
||||
* overrides are unaffected; the `component` wrapper keeps the descriptor
|
||||
* contract `(props) => ReactNode`.
|
||||
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves legacy doc/xls/ppt by extension; `code` is the
|
||||
* catch-all (`exts: []`, lowest priority) that claims any file no other
|
||||
* viewer did.
|
||||
*
|
||||
* The heavy viewers (the CodeMirror-backed markdown/html/code) render
|
||||
* through {@link lazyChunkComponent} wrappers — their libraries are fetched
|
||||
* only when such a file is first opened (see chunk-loader.ts). The
|
||||
* descriptor metadata (id/exts/priority/detect) is identical either way,
|
||||
* so matching semantics and external-plugin overrides are unaffected; the
|
||||
* `component` wrapper keeps the descriptor contract `(props) => ReactNode`.
|
||||
*
|
||||
* Every viewer carries the declarative settings-surface fields — `title`
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
@@ -26,9 +30,6 @@ import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
IconHtmlOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { ComponentType } from 'react'
|
||||
@@ -37,17 +38,14 @@ import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/**
|
||||
* Lazy wrappers over the chunk-resident viewer components. The `pick`
|
||||
* functions are module-level (stable identity — the wrapper effect depends
|
||||
* on them); the cast bridges the chunk exports record to the descriptor
|
||||
* prop shape (the views read only their own subset of FileViewerProps).
|
||||
* Lazy wrapper over the chunk-resident viewer component. The `pick`
|
||||
* function is module-level (stable identity — the wrapper effect depends
|
||||
* on it); the cast bridges the chunk exports record to the descriptor prop
|
||||
* shape (the view reads only its own subset of FileViewerProps).
|
||||
*/
|
||||
const LazyDocx = lazyChunkComponent<FileViewerProps>('docx', (mod) => mod.DocxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyXlsx = lazyChunkComponent<FileViewerProps>('xlsx', (mod) => mod.XlsxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyPptx = lazyChunkComponent<FileViewerProps>('pptx', (mod) => mod.PptxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
|
||||
|
||||
/** The 9 built-in file viewer descriptors. */
|
||||
/** The 6 built-in file viewer descriptors. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
@@ -72,30 +70,6 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
title: () => t('viewerDocx'),
|
||||
icon: (size: number) => <IconDocxOutline16 size={size} />,
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyDocx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
title: () => t('viewerXlsx'),
|
||||
icon: (size: number) => <IconXlsxOutline16 size={size} />,
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyXlsx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
title: () => t('viewerPptx'),
|
||||
icon: (size: number) => <IconPptxOutline16 size={size} />,
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyPptx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: () => t('viewerMarkdown'),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Lazy chunk loader for the client bundle. The heavy preview/terminal
|
||||
* libraries (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — the
|
||||
* office/terminal/editor stacks, tens of MB) live in separate build-time
|
||||
* bundles (`lib/client-<name>.js`) fetched only on first use of the feature
|
||||
* that needs them, so startup downloads/parses only the ~1MB core bundle.
|
||||
* libraries (CodeMirror, xterm — the editor/terminal stacks, several MB)
|
||||
* live in separate build-time bundles (`lib/client-<name>.js`) fetched only
|
||||
* on first use of the feature that needs them, so startup downloads/parses
|
||||
* only the ~1MB core bundle. (The office stack — Univer / docx-preview /
|
||||
* pptx-renderer — is no longer bundled here: Office previews moved to the
|
||||
* recommended office plugin, see plugins-viewers.ts.)
|
||||
*
|
||||
* How a chunk script works (see tsdown.config.ts chunkBundle):
|
||||
*
|
||||
@@ -42,7 +44,7 @@
|
||||
* open. Chunk-only source edits still need a manual page refresh (the HMR
|
||||
* poll watches only client.js).
|
||||
*/
|
||||
export type ChunkName = 'docx' | 'xlsx' | 'pptx' | 'terminal' | 'editor'
|
||||
export type ChunkName = 'terminal' | 'editor'
|
||||
|
||||
/** The module exports a chunk factory provides (namespace-ish record). */
|
||||
export type ChunkExports = Record<string, unknown>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .docx preview (docx-preview + jszip). Built as
|
||||
* `lib/client-docx.js` and registered under `dsh-better-sidebar/docx` —
|
||||
* fetched only when a .docx is first opened (see chunk-loader.ts and
|
||||
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
|
||||
* from the core bundle: it pulls the office library into the startup path.
|
||||
*/
|
||||
export { DocxView } from '../docx-view.tsx'
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .pptx preview (pptx-renderer). Built as
|
||||
* `lib/client-pptx.js` and registered under `dsh-better-sidebar/pptx` —
|
||||
* fetched only when a .pptx is first opened (see chunk-loader.ts and
|
||||
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
|
||||
* from the core bundle: it pulls the renderer into the startup path.
|
||||
*/
|
||||
export { PptxView } from '../PptxView.tsx'
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .xlsx preview (Univer family + SheetJS, several MB).
|
||||
* Built as `lib/client-xlsx.js` and registered under
|
||||
* `dsh-better-sidebar/xlsx` — fetched only when a .xlsx is first opened
|
||||
* (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* Never import this module from the core bundle: it pulls Univer into the
|
||||
* startup path.
|
||||
*/
|
||||
export { XlsxView } from '../xlsx-view.tsx'
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Append text to the current session's composer draft through the
|
||||
* conversation service — the shared path behind the explorer's @-reference
|
||||
* button and the viewer selection popup. The service is resolved lazily
|
||||
* through `ctx.get` (the inject-free read the app's own plugins use); a
|
||||
* missing service or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
import type { Context, SidebarConversation } from '../context-types.ts'
|
||||
|
||||
/**
|
||||
* Append `text` to the session's composer draft (space-separated, like the
|
||||
* @-mentions). Returns false — and logs — when the conversation service or
|
||||
* the session scope is unavailable.
|
||||
*/
|
||||
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
|
||||
try {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) return false
|
||||
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
||||
if (conversation === undefined) return false
|
||||
const input = conversation.input.for(actx)
|
||||
const draft = input.state.getSnapshot().draft
|
||||
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] draft insert failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* The .docx preview component: renders via docx-preview (preserved
|
||||
* styles/images/tables). Lives in its own module (split out of the former
|
||||
* office-view.tsx) so it builds as a standalone lazy chunk
|
||||
* (`lib/client-docx.js`) — the library is loaded only when a .docx is first
|
||||
* opened, and never pulls in the Univer/xlsx stack.
|
||||
*
|
||||
* Errors degrade to the shared download-button affordance, so a corrupted /
|
||||
* encrypted / oversized file always leaves the user with a way to get it.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/**
|
||||
* Render a .docx file via docx-preview. The library renders into a container
|
||||
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||
* the DOM is enough.
|
||||
*/
|
||||
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = viewportRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (container === null || wrap === null) return
|
||||
setZoom(100)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
// docx-preview ships its own CSS through the className option; the
|
||||
// wrapper div scopes its render output.
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
await renderAsync(buf, wrap, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
experimental: false,
|
||||
})
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Tear down the rendered DOM so a reopen starts clean.
|
||||
if (wrap !== null) wrap.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (viewport === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.altKey) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? 10 : -10
|
||||
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||
}
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorDocx}>
|
||||
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
{load.status !== 'error' && (
|
||||
<div
|
||||
className={css.editorDocxWrap}
|
||||
ref={wrapRef}
|
||||
aria-label={title}
|
||||
style={{ zoom: zoom / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.editorDocxZoom}>
|
||||
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||
<input
|
||||
className={css.editorDocxZoomRange}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
value={zoom}
|
||||
aria-label={t('zoom')}
|
||||
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||
/>
|
||||
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-28
@@ -52,7 +52,7 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop glyph for the background-task kill button: a filled square in the
|
||||
* Stop glyph for the background-job kill button: a filled square in the
|
||||
* app's outline scale (16), the universal "halt this work" mark.
|
||||
*/
|
||||
export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
@@ -83,33 +83,6 @@ export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Word viewer glyph: a document frame with a "W". */
|
||||
export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Excel viewer glyph: a spreadsheet grid. */
|
||||
export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
|
||||
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PowerPoint viewer glyph: a chart with rising bars. */
|
||||
export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
|
||||
export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
+14
-40
@@ -8,7 +8,7 @@
|
||||
* bundle itself is a module-table consumer only (react + ui-primitives +
|
||||
* xterm, all provided or inlined).
|
||||
*/
|
||||
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { createSidebarStore } from './state.ts'
|
||||
@@ -16,6 +16,7 @@ import { createBetterSidebarService } from './service.ts'
|
||||
import { resetChunks } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
@@ -31,40 +32,12 @@ import './layout.css'
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
|
||||
|
||||
/**
|
||||
* Error boundary over the sidebar tree: a render error must never blank the
|
||||
* whole panel silently — it shows a dismissible error strip and logs the
|
||||
* stack for diagnosis.
|
||||
* Error boundary over the sidebar tree (root scope): a render error in the
|
||||
* sidebar SHELL itself must never blank the page silently — the shared
|
||||
* RenderBoundary shows a dismissible error strip and logs the stack. The
|
||||
* per-tab scope (Sidebar.tsx) catches viewer/editor crashes first; this root
|
||||
* boundary stays as the last resort for Workbench/shell errors.
|
||||
*/
|
||||
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
||||
state = { error: null as string | null }
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: string } {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<div className={css.boundaryError}>
|
||||
<span>dsh-better-sidebar: {this.state.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { this.setState({ error: null }) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body.
|
||||
* @param ctx - the client cordis context (slots, sessions).
|
||||
@@ -141,7 +114,7 @@ export function apply(ctx: Context): void {
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
@@ -203,11 +176,12 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// The IME guard: composition keys (candidate arrows, confirm, cancel)
|
||||
// belong to the input method, never to page JS. Inlined third-party UI
|
||||
// (Univer's office controls) has shipped unguarded keydown handlers that
|
||||
// hijack ArrowUp/ArrowDown and break Chinese input (#562 regression); the
|
||||
// document-capture guard neutralizes the whole class before React or any
|
||||
// native listener sees the event. Registered as early as possible so no
|
||||
// other capture-phase listener can win the ordering race.
|
||||
// (formerly Univer's office controls, #562 regression) has shipped
|
||||
// unguarded keydown handlers that hijack ArrowUp/ArrowDown and break
|
||||
// Chinese input; the document-capture guard neutralizes the whole class
|
||||
// before React or any native listener sees the event. Registered as
|
||||
// early as possible so no other capture-phase listener can win the
|
||||
// ordering race.
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
|
||||
@@ -57,9 +57,22 @@ export function SidebarProducedFiles(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Register the turn-tail interception (returns the disposer). */
|
||||
/**
|
||||
* Register the turn-tail interception (returns the disposer).
|
||||
*
|
||||
* The slot is a CHILD slot the host's ui-conversation declares in its
|
||||
* `conversation.chat.node` children table (kind: chain, scope: session).
|
||||
* Registering it directly races the declaration — the ui-slots core's
|
||||
* load-time validation throws "not declared (a parent entry's children
|
||||
* table must declare it)" when the parent entry is not on the ledger yet.
|
||||
* slots.inject waits for the declaration: the callback runs synchronously
|
||||
* when the slot is already declared, otherwise it runs inside the declaring
|
||||
* register() call once the declaration commits; declaration collapse
|
||||
* disposes the entry and a later declaration re-registers it. This mirrors
|
||||
* @deepseek-ai/dsh-client-ui-deliverables' registration of the same slot.
|
||||
*/
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.register({
|
||||
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
// Decline the takeover while the editor tab type is disabled in the side
|
||||
// card settings: the produced-files row falls back to the default
|
||||
@@ -73,7 +86,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
inject: (sessionId: string) => ({
|
||||
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
}),
|
||||
}, SidebarProducedFiles)
|
||||
}, SidebarProducedFiles))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+26
-11
@@ -6,11 +6,13 @@
|
||||
* conversation output and the input bar, like a VSCode sidebar.
|
||||
*
|
||||
* The bottom panel squeezes ONLY the center column — it must not cover the
|
||||
* app's own left sidebar or the right panel. The AppFrame's grid items are
|
||||
* (#root > div > div:nth-child(2)): sidebarCol, centerCol, detailsCol, so
|
||||
* the vertical push lands on the center column alone. The fixed panels
|
||||
* cover the vacated strips, which looks seamless because #root's background
|
||||
* is the theme base.
|
||||
* app's own left sidebar or the right panel. DSH 0.1.x wraps slot hosts in
|
||||
* [data-slot] containers, so the AppFrame grid lives one level deeper:
|
||||
* #root > div[data-slot="root"] > div (the frame). Its grid items are
|
||||
* (frame > div:nth-child(1..3)): sidebarCol, centerCol, detailsCol, so the
|
||||
* vertical push lands on the center column alone. The fixed panels cover
|
||||
* the vacated strips, which looks seamless because #root's background is
|
||||
* the theme base.
|
||||
*
|
||||
* The sizes ride CSS variables updated by the Sidebar shell (0 while
|
||||
* collapsed); expand/collapse animates both the margins and the panel
|
||||
@@ -23,22 +25,35 @@
|
||||
}
|
||||
|
||||
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
|
||||
1-3 of #root > div) — nth-child(2) is the center column. A stretched grid
|
||||
item shrinks by its margins, so the conversation content (output + input
|
||||
bar) lifts without touching the sidebars. */
|
||||
#root > div > div:nth-child(2) {
|
||||
1-3 of #root > div[data-slot="root"] > div) — nth-child(2) is the center
|
||||
column. A stretched grid item shrinks by its margins, so the conversation
|
||||
content (output + input bar) lifts without touching the sidebars. */
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* When the sidebar is collapsed, the toggle cluster reclaims the top-right
|
||||
corner. Push the DSH session header's right padding out so its right-aligned
|
||||
utilities (the "Session log" download capsule) yield the corner instead of
|
||||
hiding under the cluster. The header default right-pads 28px; the 2-button
|
||||
cluster spans right 10→70px, so 78px clears it with an 8px gap. Anchor on
|
||||
the header's slot host wrapper ([data-slot="conversation.session.header"])
|
||||
rather than a positional path: DSH 0.1.x nests the header several levels
|
||||
under the center column. The Sidebar shell toggles the body attribute with
|
||||
the panel open state. */
|
||||
body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > header {
|
||||
padding-right: 78px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) {
|
||||
body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root,
|
||||
#root > div > div:nth-child(2) {
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+90
-66
@@ -101,10 +101,14 @@ export const zh = {
|
||||
openEditor: '打开编辑器',
|
||||
gitDetail: '查看变更详情',
|
||||
referenceFile: '@文件',
|
||||
addToConversation: '添加到对话',
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsIntro: '管理侧边卡片的显示内容与默认行为',
|
||||
settingsPopupDesc: '为「{feature}」配置相关选项',
|
||||
settingsDone: '完成',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
@@ -116,22 +120,20 @@ export const zh = {
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
downloadToView: '下载查看',
|
||||
officeTooLarge: '文件过大,无法预览',
|
||||
officeCorrupt: '文件损坏或格式无效',
|
||||
officeEncrypted: '不支持加密文件',
|
||||
officeLoadFailed: 'Office 预览组件加载失败',
|
||||
previousSlide: '上一页',
|
||||
nextSlide: '下一页',
|
||||
zoom: '缩放',
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
|
||||
settingsTasksTitle: '有新后台任务时自动展开任务管理页',
|
||||
settingsTasksDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开任务管理页(每个新任务都会触发);关闭后需手动打开',
|
||||
settingsJobsTitle: '有新后台任务时自动展开后台任务页',
|
||||
settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
|
||||
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
|
||||
settingsFontFamilyTitle: '终端字体',
|
||||
settingsFontFamilyDesc: '自定义终端字体族(CSS font-family,如 "JetBrains Mono", monospace;留空跟随主题等宽字体)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: '终端字号',
|
||||
settingsFontSizeDesc: '终端字号(9–32,默认 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsGeneralTitle: '常规',
|
||||
@@ -139,9 +141,6 @@ export const zh = {
|
||||
settingsViewerCatchAll: '兜底:任意文件',
|
||||
viewerImage: '图片',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word 文档',
|
||||
viewerXlsx: 'Excel 表格',
|
||||
viewerPptx: 'PPT 演示',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
@@ -187,26 +186,39 @@ export const zh = {
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
tasks: '后台命令',
|
||||
tasksCount: '{count} 个后台命令',
|
||||
tasksCountRunning: '{count} 个后台命令 · {running} 运行中',
|
||||
taskStatusRunning: '运行中',
|
||||
taskStatusStopping: '终止中',
|
||||
taskStatusCompleted: '已完成',
|
||||
taskStatusKilled: '已终止',
|
||||
taskStatusFailed: '失败',
|
||||
taskDurationSeconds: '{seconds} 秒',
|
||||
taskDurationMinutes: '{minutes} 分 {seconds} 秒',
|
||||
taskDurationHours: '{hours} 小时 {minutes} 分',
|
||||
taskViewOutput: '查看输出',
|
||||
taskHideOutput: '收起输出',
|
||||
taskNoOutput: '暂无输出',
|
||||
taskNotReadYet: '等待模型读取该任务的输出(模型执行 task_output 后,输出会显示在这里)',
|
||||
taskOutputTruncated: '输出过长,已截断显示',
|
||||
taskOutputError: '输出读取失败',
|
||||
taskKill: '终止',
|
||||
taskKillConfirm: '再次点击确认终止',
|
||||
taskKillError: '终止失败',
|
||||
jobs: '后台任务',
|
||||
jobsCount: '{count} 个后台任务',
|
||||
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
|
||||
jobStatusRunning: '运行中',
|
||||
jobStatusStopping: '终止中',
|
||||
jobStatusCompleted: '已完成',
|
||||
jobStatusKilled: '已终止',
|
||||
jobStatusFailed: '失败',
|
||||
jobDurationSeconds: '{seconds} 秒',
|
||||
jobDurationMinutes: '{minutes} 分 {seconds} 秒',
|
||||
jobDurationHours: '{hours} 小时 {minutes} 分',
|
||||
jobViewOutput: '查看输出',
|
||||
jobHideOutput: '收起输出',
|
||||
jobNoOutput: '暂无输出',
|
||||
jobNotReadYet: '等待模型读取该任务的输出(模型执行 job_output 后,输出会显示在这里)',
|
||||
jobOutputTruncated: '输出过长,已截断显示',
|
||||
jobOutputError: '输出读取失败',
|
||||
jobKill: '终止',
|
||||
jobKillConfirm: '再次点击确认终止',
|
||||
jobKillError: '终止失败',
|
||||
addPluginsTabCard: '添加 Tab 插件',
|
||||
addPluginsTabCardDesc: '注册新的侧边栏页面',
|
||||
addPluginsViewerCard: '添加预览插件',
|
||||
addPluginsViewerCardDesc: '注册新的文件类型预览',
|
||||
addPluginsTabDesc: '侧边栏页面(Tab)可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
|
||||
addPluginsViewerDesc: '文件预览器可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
|
||||
addPluginsBrowseMore: '在 GitHub 上浏览更多插件(topic: dsh-better-sidebar)',
|
||||
addPluginsRecommended: '推荐插件',
|
||||
addPluginsEmpty: '暂未收录插件,欢迎在 GitHub topic 下发布你的插件',
|
||||
openPlugin: '跳转',
|
||||
copyInstall: '复制安装命令',
|
||||
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
|
||||
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
|
||||
}
|
||||
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
@@ -301,10 +313,14 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
openEditor: 'Open editor',
|
||||
gitDetail: 'View change details',
|
||||
referenceFile: '@file',
|
||||
addToConversation: 'Add to conversation',
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
download: 'Download',
|
||||
settingsNav: 'Side card',
|
||||
settingsIntro: 'Manage what the side card shows and how it behaves',
|
||||
settingsPopupDesc: 'Configure related options for {feature}',
|
||||
settingsDone: 'Done',
|
||||
settingsOpenTitle: 'Open by default for new conversations',
|
||||
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
|
||||
settingsWidthTitle: 'Default width share',
|
||||
@@ -316,22 +332,20 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
downloadToView: 'Download to view',
|
||||
officeTooLarge: 'File too large to preview',
|
||||
officeCorrupt: 'File is corrupt or in an invalid format',
|
||||
officeEncrypted: 'Encrypted files are not supported',
|
||||
officeLoadFailed: 'Office preview component failed to load',
|
||||
previousSlide: 'Previous',
|
||||
nextSlide: 'Next',
|
||||
zoom: 'Zoom',
|
||||
zoomHint: 'Alt + wheel',
|
||||
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsTasksTitle: 'Auto-open the Tasks page on a new background task',
|
||||
settingsTasksDesc: 'Expand the side card and open the Tasks page whenever a new background task appears for the current conversation (every new task triggers); turn off to open it manually',
|
||||
settingsJobsTitle: 'Auto-open the Jobs page on a new background job',
|
||||
settingsJobsDesc: 'Expand the side card and open the Jobs page whenever a new background job appears for the current conversation (every new job triggers); turn off to open it manually',
|
||||
settingsToolsTitle: 'Inject terminal tools for the model',
|
||||
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
||||
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
|
||||
settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)',
|
||||
settingsFontFamilyTitle: 'Terminal font family',
|
||||
settingsFontFamilyDesc: 'Custom terminal font family (a CSS font-family stack like "JetBrains Mono", monospace; leave empty to follow the theme\'s monospace font)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: 'Terminal font size',
|
||||
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
settingsViewersTitle: 'File viewers',
|
||||
settingsGeneralTitle: 'General',
|
||||
@@ -339,9 +353,6 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsViewerCatchAll: 'Catch-all: any file',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word',
|
||||
viewerXlsx: 'Excel',
|
||||
viewerPptx: 'PowerPoint',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
@@ -387,26 +398,39 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
subagentDiagUnsupported: 'Unsupported',
|
||||
subagentDiagUnavailable: 'Unavailable',
|
||||
subagentThinking: 'Thinking…',
|
||||
tasks: 'Background tasks',
|
||||
tasksCount: '{count} background tasks',
|
||||
tasksCountRunning: '{count} background tasks · {running} running',
|
||||
taskStatusRunning: 'Running',
|
||||
taskStatusStopping: 'Stopping',
|
||||
taskStatusCompleted: 'Completed',
|
||||
taskStatusKilled: 'Killed',
|
||||
taskStatusFailed: 'Failed',
|
||||
taskDurationSeconds: '{seconds}s',
|
||||
taskDurationMinutes: '{minutes}m {seconds}s',
|
||||
taskDurationHours: '{hours}h {minutes}m',
|
||||
taskViewOutput: 'View output',
|
||||
taskHideOutput: 'Hide output',
|
||||
taskNoOutput: 'No output yet',
|
||||
taskNotReadYet: 'Waiting for the model to read this task; its output appears here once the model runs task_output',
|
||||
taskOutputTruncated: 'Output truncated',
|
||||
taskOutputError: 'Failed to read output',
|
||||
taskKill: 'Kill',
|
||||
taskKillConfirm: 'Click again to confirm kill',
|
||||
taskKillError: 'Kill failed',
|
||||
jobs: 'Background jobs',
|
||||
jobsCount: '{count} background jobs',
|
||||
jobsCountRunning: '{count} background jobs · {running} running',
|
||||
jobStatusRunning: 'Running',
|
||||
jobStatusStopping: 'Stopping',
|
||||
jobStatusCompleted: 'Completed',
|
||||
jobStatusKilled: 'Killed',
|
||||
jobStatusFailed: 'Failed',
|
||||
jobDurationSeconds: '{seconds}s',
|
||||
jobDurationMinutes: '{minutes}m {seconds}s',
|
||||
jobDurationHours: '{hours}h {minutes}m',
|
||||
jobViewOutput: 'View output',
|
||||
jobHideOutput: 'Hide output',
|
||||
jobNoOutput: 'No output yet',
|
||||
jobNotReadYet: 'Waiting for the model to read this job; its output appears here once the model runs job_output',
|
||||
jobOutputTruncated: 'Output truncated',
|
||||
jobOutputError: 'Failed to read output',
|
||||
jobKill: 'Kill',
|
||||
jobKillConfirm: 'Click again to confirm kill',
|
||||
jobKillError: 'Kill failed',
|
||||
addPluginsTabCard: 'Add tab plugins',
|
||||
addPluginsTabCardDesc: 'Register a new sidebar page',
|
||||
addPluginsViewerCard: 'Add preview plugins',
|
||||
addPluginsViewerCardDesc: 'Register a file-type preview',
|
||||
addPluginsTabDesc: 'Sidebar pages (tabs) can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
|
||||
addPluginsViewerDesc: 'File previewers can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
|
||||
addPluginsBrowseMore: 'Browse more plugins on GitHub (topic: dsh-better-sidebar)',
|
||||
addPluginsRecommended: 'Recommended plugins',
|
||||
addPluginsEmpty: 'No plugins curated yet — publish yours under the GitHub topic',
|
||||
openPlugin: 'Open',
|
||||
copyInstall: 'Copy install command',
|
||||
pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle',
|
||||
pluginSentinelDesc: 'Condition-driven agent wakeup: file/process/port/http/command/webhook sensors wake dormant sessions when conditions fire; registers a "Sentinel" tab with the server-wide watch table',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Shared pieces of the Office preview views (docx / xlsx): the load-state
|
||||
* union, the shared props, and the download fallback. The two views live in
|
||||
* their own modules (docx-view.tsx / xlsx-view.tsx) so the bundler can emit
|
||||
* them as separate lazy chunks — docx-preview and the Univer family (several
|
||||
* MB) must never share a script (see docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
*/
|
||||
import { downloadUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Loading / ready / error state shared by both views. */
|
||||
export type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Shared props. */
|
||||
export interface OfficeViewProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error / fallback affordance: the failure reason plus a download
|
||||
* link, so the user always has a path to the file. Used by both DocxView and
|
||||
* XlsxView, and matches the binary placeholder download link.
|
||||
*/
|
||||
export function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{message}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Office file type dispatch — pure functions mapping file extensions to
|
||||
* preview kinds. Kept separate from the editor host so the mapping is
|
||||
* unit-testable without mounting React or pulling in docx-preview / Univer.
|
||||
*
|
||||
* The host side never inspects Office extensions: the binary NUL probe in
|
||||
* {@link ../index.ts} (readText) tags every Office file as binary, and the
|
||||
* /sidebar/file media route serves raw bytes for any extension. This module
|
||||
* is the client's sole source of truth for "is this previewable here".
|
||||
*/
|
||||
|
||||
/** Extensions rendered with docx-preview (preserved styles/images/tables). */
|
||||
export const OFFICE_DOCX_EXT = '.docx' as const
|
||||
|
||||
/** Extensions rendered with Univer (sheets: data + formulas + formatting). */
|
||||
export const OFFICE_XLSX_EXT = '.xlsx' as const
|
||||
|
||||
/** Extensions rendered with the browser-native PPTX viewer. */
|
||||
export const OFFICE_PPTX_EXT = '.pptx' as const
|
||||
|
||||
/** Extensions that get a download-only affordance (no client-side renderer). */
|
||||
export const OFFICE_DOWNLOAD_ONLY_EXT = ['.doc', '.xls', '.ppt'] as readonly string[]
|
||||
|
||||
/**
|
||||
* The preview strategy for one path's extension.
|
||||
* - `'docx'` — render via docx-preview.
|
||||
* - `'xlsx'` — render via Univer.
|
||||
* - `'pptx'` — render via pptx-renderer.
|
||||
* - `'download-only'` — show the binary download button (no preview renderer
|
||||
* covers legacy OLE .doc/.xls/.ppt on the client).
|
||||
* - `null` — not an Office file; the caller's existing image / md / text / binary
|
||||
* pipeline handles it.
|
||||
*/
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
|
||||
|
||||
/**
|
||||
* Map a lowercased extension (with leading dot) to its preview kind.
|
||||
*
|
||||
* @param ext - the path extension (with leading dot) (`.docx`,
|
||||
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
|
||||
*/
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === OFFICE_DOCX_EXT) return 'docx'
|
||||
if (ext === OFFICE_XLSX_EXT) return 'xlsx'
|
||||
if (ext === OFFICE_PPTX_EXT) return 'pptx'
|
||||
if (OFFICE_DOWNLOAD_ONLY_EXT.includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Compat re-export shim for the historical `src/client/office-view.tsx`
|
||||
* path. The office preview views were split into docx-view.tsx /
|
||||
* xlsx-view.tsx (+ office-shared.tsx) so the bundler can emit each library
|
||||
* as its own lazy chunk (see docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* This module keeps deep imports of this path resolvable through the
|
||||
* `./src/*` exports subpath — it deliberately re-exports BOTH views, so it
|
||||
* must never be imported from the core bundle or a chunk entry (it would
|
||||
* drag both libraries into that bundle).
|
||||
*/
|
||||
export { DocxView } from './docx-view.tsx'
|
||||
export { XlsxView } from './xlsx-view.tsx'
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Deferred one-shot open for hosts that may not have a real size yet.
|
||||
*
|
||||
* xterm's `Terminal.open()` must not run in a zero-size container: the
|
||||
* renderer creation fails there (the DomRenderer is built from the host's
|
||||
* dimensions), leaving the render service's renderer `undefined`, and the
|
||||
* next Viewport refresh crashes reading `.dimensions` off it. WebKit-based
|
||||
* hosts (WKWebView) reliably report zero while the bottom panel's expand
|
||||
* slide is in flight; any `display:none`-hidden ancestor does the same.
|
||||
*
|
||||
* The caller's `open` callback (open + fit + resize) is invoked exactly
|
||||
* once, on the first frame where the host reports a real size. While the
|
||||
* host stays zero-sized the polling continues every frame; it stops when
|
||||
* the host leaves the document (`isConnected`), so a pending open never
|
||||
* fires after unmount. The returned cancel function drops a pending frame
|
||||
* immediately (idempotent).
|
||||
*
|
||||
* `raf`/`caf` are injectable so tests can drive the polling deterministically.
|
||||
*/
|
||||
export function openWhenSized(
|
||||
host: HTMLElement,
|
||||
open: () => void,
|
||||
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
|
||||
caf: (id: number) => void = cancelAnimationFrame,
|
||||
): () => void {
|
||||
let frame: number | null = null
|
||||
const step = (): void => {
|
||||
frame = null
|
||||
if (!host.isConnected) return
|
||||
if (host.clientWidth > 0 && host.clientHeight > 0) {
|
||||
open()
|
||||
return
|
||||
}
|
||||
frame = raf(step)
|
||||
}
|
||||
frame = raf(step)
|
||||
return () => {
|
||||
if (frame !== null) {
|
||||
caf(frame)
|
||||
frame = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Shared vocabulary of the recommended plugin catalogs: the entry shape and
|
||||
* the GitHub topic URL. The two catalogs live in sibling modules —
|
||||
* `plugins-tabs.ts` (tab registrations) and `plugins-viewers.ts` (file
|
||||
* previewer registrations) — and are shown in the two "add plugin" modals
|
||||
* (Side card settings → the dashed cards at the end of the 侧边栏内容 /
|
||||
* 文件预览 grids).
|
||||
*/
|
||||
|
||||
/** The GitHub topic page listing every repo tagged `dsh-better-sidebar`. */
|
||||
export const PLUGIN_TOPIC_URL = 'https://github.com/topics/dsh-better-sidebar'
|
||||
|
||||
/** One curated plugin entry (name / url / description / install script). */
|
||||
export interface PluginEntry {
|
||||
/** Unique id (the npm package name). */
|
||||
id: string
|
||||
/** Short display name. */
|
||||
name: string
|
||||
/** GitHub repository URL. */
|
||||
url: string
|
||||
/** One-line description (i18n friendly: string or () => string). */
|
||||
description: string | (() => string)
|
||||
/** The full shell command pre-filled into the install terminal (not
|
||||
* executed until the user presses Enter). */
|
||||
install: string
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The built-in catalog of TAB-registration plugins (sidebar pages),
|
||||
* shown in the "add tab plugin" modal (Side card settings → 侧边栏内容 grid
|
||||
* → the dashed card). Adding an entry: append one object here (unique
|
||||
* `id` = npm package name, `url` = GitHub repo, `description` =
|
||||
* i18n-friendly (add a `pluginXxxDesc` key in locales.ts), `install` = the
|
||||
* full one-line install script — it starts with `cd ~/.dsh` so the install
|
||||
* runs with the DSH home as the working directory). Data integrity is
|
||||
* guarded by `tests/plugin-list.spec.ts`.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import type { PluginEntry } from './plugins-shared.ts'
|
||||
|
||||
/** Tab-registration plugins (alphabetical order). */
|
||||
export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
{
|
||||
id: '@dsh-external/dsh-sentinel',
|
||||
name: 'dsh-sentinel 唤醒系统',
|
||||
url: 'https://github.com/fuhefei/dsh-sentinel',
|
||||
description: () => t('pluginSentinelDesc'),
|
||||
// The official one-line bundle-channel install (git source, build
|
||||
// artifacts committed — no build step needed). The `github:…` form is
|
||||
// the upstream's documented command, `cd ~/.dsh` keeps the profile
|
||||
// context consistent with the other entries.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The built-in catalog of FILE-PREVIEWER plugins (file-type previewers),
|
||||
* shown in the "add preview plugin" modal (Side card settings → 文件预览
|
||||
* grid → the dashed card). Adding an entry: append one object here (unique
|
||||
* `id` = npm package name, `url` = GitHub repo, `description` =
|
||||
* i18n-friendly, `install` = the full shell command pre-filled into the
|
||||
* install terminal — it starts with `cd ~/.dsh` so the install runs with
|
||||
* the DSH home as the working directory). Data integrity is guarded by
|
||||
* `tests/plugin-list.spec.ts`.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import type { PluginEntry } from './plugins-shared.ts'
|
||||
|
||||
/** File-previewer plugins (alphabetical order). */
|
||||
export const builtinViewerPlugins: readonly PluginEntry[] = [
|
||||
{
|
||||
id: '@huanlin/dsh-plugin-better-sidebar-plugin-office',
|
||||
name: 'Office 预览插件',
|
||||
url: 'https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office',
|
||||
description: () => t('pluginOfficeDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office',
|
||||
},
|
||||
]
|
||||
+29
-4
@@ -10,12 +10,13 @@
|
||||
*/
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampTerminalFontSize,
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
@@ -40,15 +41,21 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
autoOpenTasks: typeof record.autoOpenTasks === 'boolean'
|
||||
? record.autoOpenTasks
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenTasks,
|
||||
autoOpenJobs: typeof record.autoOpenJobs === 'boolean'
|
||||
? record.autoOpenJobs
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenJobs,
|
||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||
? record.agentTerminalTools
|
||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
|
||||
? record.bottomPanelAutoTerminal
|
||||
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
|
||||
terminalFontFamily: typeof record.terminalFontFamily === 'string'
|
||||
? record.terminalFontFamily
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontFamily,
|
||||
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
|
||||
? clampTerminalFontSize(record.terminalFontSize)
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
@@ -66,9 +73,27 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the plugin-owned settings map (v0.12.0+): `{ descriptorId: { key:
|
||||
* value } }`, nested open maps. Any non-object value (or a malformed whole)
|
||||
* falls back to the empty map — the schema defaults already guard the wire
|
||||
* shape, this is the client's second line.
|
||||
*/
|
||||
function pluginSettingsMapOf(value: unknown): Record<string, Record<string, unknown>> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out: Record<string, Record<string, unknown>> = {}
|
||||
for (const [id, blob] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (blob !== null && typeof blob === 'object' && !Array.isArray(blob)) {
|
||||
out[id] = blob as Record<string, unknown>
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
|
||||
* survive; a non-object or a non-boolean entry falls back to the empty map /
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Pure payload builders for the "add selection to conversation" popup in the
|
||||
* text viewers (markdown preview + the catch-all code viewer). Everything
|
||||
* here is string math — no React, no ctx — so the unit tests cover it
|
||||
* directly.
|
||||
*
|
||||
* Insert shape (agreed with the product owner):
|
||||
* - Selection ≤ SELECTION_LIMIT characters: a fenced code block whose info
|
||||
* line is `相对路径:起止行` and whose body is the selected text.
|
||||
* - Selection over the limit: a single plain-text line `相对路径:起止行`
|
||||
* (no fence, no content).
|
||||
* - The path is relative to the session cwd (the same projection the
|
||||
* explorer's @ button uses); an unknown cwd falls back to the absolute
|
||||
* path.
|
||||
* - Line numbers: single-line selections write `path:12`, multi-line write
|
||||
* `path:12-15`. The markdown preview cannot map rendered DOM back to
|
||||
* source lines directly, so it reverse-searches the selected text in the
|
||||
* source and only reports lines on an unambiguous hit (see
|
||||
* {@link linesOfSelection}).
|
||||
*/
|
||||
import { relativeTo } from './paths.ts'
|
||||
|
||||
/** Max inserted selection length (UTF-16 code units, i.e. JS `.length`). */
|
||||
export const SELECTION_LIMIT = 500
|
||||
|
||||
/** The source line span a selection maps to (1-based, inclusive). */
|
||||
export interface SelectionLines {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The fence info line: `rel[:start[-end]]` — lines are omitted entirely
|
||||
* when unknown (the preview reverse-search missed).
|
||||
*/
|
||||
export function headerOf(path: string, cwd: string | undefined, lines?: SelectionLines): string {
|
||||
const rel = cwd !== undefined ? relativeTo(cwd, path) : path
|
||||
if (lines === undefined) return rel
|
||||
if (lines.end > lines.start) return `${rel}:${lines.start}-${lines.end}`
|
||||
return `${rel}:${lines.start}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The full text appended to the composer draft for one selection.
|
||||
* Over the limit the content is dropped: the plain path line is the whole
|
||||
* payload (an empty fenced block would just occupy the draft).
|
||||
*/
|
||||
export function buildSelectionInsert(
|
||||
path: string,
|
||||
cwd: string | undefined,
|
||||
lines: SelectionLines | undefined,
|
||||
selected: string,
|
||||
): string {
|
||||
const header = headerOf(path, cwd, lines)
|
||||
if (selected.length > SELECTION_LIMIT) return header
|
||||
return `\`\`\`${header}\n${selected}\n\`\`\``
|
||||
}
|
||||
|
||||
/** 1-based line number of a character index in a text. */
|
||||
function lineAt(source: string, index: number): number {
|
||||
let line = 1
|
||||
for (let i = 0; i < index && i < source.length; i++) {
|
||||
if (source[i] === '\n') line++
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-map a rendered-DOM selection back to source line numbers. The
|
||||
* preview selection is plain text (block boundaries come out as `\n`), so
|
||||
* this is a best-effort substring search: a single trailing newline is
|
||||
* stripped first (DOM block selections tend to carry one), and only an
|
||||
* EXACTLY-ONE occurrence yields lines — an ambiguous or missing match
|
||||
* returns null (the header then carries the path without line numbers).
|
||||
*/
|
||||
export function linesOfSelection(source: string, selected: string): SelectionLines | null {
|
||||
const text = selected.endsWith('\n') ? selected.slice(0, -1) : selected
|
||||
if (text === '') return null
|
||||
const at = source.indexOf(text)
|
||||
if (at === -1) return null
|
||||
if (source.indexOf(text, at + 1) !== -1) return null
|
||||
return {
|
||||
start: lineAt(source, at),
|
||||
end: lineAt(source, at + Math.max(text.length - 1, 0)),
|
||||
}
|
||||
}
|
||||
+336
-29
@@ -22,15 +22,40 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
|
||||
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
|
||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/** One declarative boolean setting of a tab/viewer, rendered as a nested
|
||||
* switch row in the Side card settings page (e.g. the Subagent page's
|
||||
* "auto-open when a subagent appears"). */
|
||||
/**
|
||||
* Public state vocabulary re-exported for consumers (type-only; the values
|
||||
* stay internal). External plugins name these types in their descriptors —
|
||||
* e.g. `dedupeKey: (tab: SidebarTab) => tab.id`, `createTab: (state: SidebarState) => …`,
|
||||
* or `badge: (…, state: SidebarState) => …`.
|
||||
*/
|
||||
export type {
|
||||
SidebarTab,
|
||||
SidebarState,
|
||||
SidebarStore,
|
||||
SidebarSnapshot,
|
||||
SidebarDiffRef,
|
||||
TabType,
|
||||
} from './state.ts'
|
||||
export type { SessionScope } from './api.ts'
|
||||
export type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/** The row control a declarative setting renders as in the settings popup. */
|
||||
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
|
||||
|
||||
/** One declarative setting of a tab/viewer, rendered as a nested row in the
|
||||
* Side card settings page (e.g. the Subagent page's "auto-open when a
|
||||
* subagent appears" switch, or the terminal's custom font rows). `type`
|
||||
* selects the control: 'switch' (default) renders the custom switch,
|
||||
* 'text' a free-form input committed on blur/Enter, 'number' a numeric
|
||||
* input clamped to `min`/`max`. */
|
||||
export interface SidebarSettingToggle {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
@@ -38,17 +63,56 @@ export interface SidebarSettingToggle {
|
||||
title: string | (() => string)
|
||||
/** Row description (i18n friendly). */
|
||||
desc?: string | (() => string)
|
||||
/** Row control type; defaults to 'switch' (backward compatible). */
|
||||
type?: SidebarSettingToggleType
|
||||
/** Lower bound for `type: 'number'` rows (clamped on commit). */
|
||||
min?: number
|
||||
/** Upper bound for `type: 'number'` rows (clamped on commit). */
|
||||
max?: number
|
||||
/** Input placeholder for `type: 'text'` rows. */
|
||||
placeholder?: string
|
||||
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
|
||||
unit?: string
|
||||
}
|
||||
|
||||
/** Props of a descriptor's custom settings panel (`settings.render`). */
|
||||
export interface SidebarSettingsRenderProps {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
prefs: SidebarPrefs
|
||||
/** This descriptor's own persisted settings blob (from `pluginSettings[id]`). */
|
||||
pluginSettings: Record<string, unknown>
|
||||
/** Persist one plugin-owned setting of this descriptor. */
|
||||
updatePluginSetting(key: string, value: unknown): void
|
||||
/** Close the settings popup. */
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
* Extra boolean toggles rendered under the feature's own row in the
|
||||
* Extra settings rows rendered under the feature's own row in the
|
||||
* settings page (only while the feature is enabled). Keys must be fields
|
||||
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
||||
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
|
||||
* 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped
|
||||
* by the settings seam.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
/**
|
||||
* Plugin-owned settings rows (v0.12.0+): same row controls as `toggles`
|
||||
* (switch/text/number), but the keys are plugin-local and persisted in
|
||||
* the sidebar's own prefs document under `pluginSettings[<descriptor id>]`
|
||||
* — no host PrefsSchema field needed. Values must be JSON-serializable
|
||||
* (the row controls produce strings / numbers / booleans).
|
||||
*/
|
||||
pluginToggles?: readonly SidebarSettingToggle[]
|
||||
/**
|
||||
* Custom settings panel (v0.12.0+): when given, the gear popup renders
|
||||
* this instead of the row lists (`toggles` / `pluginToggles`). Receives
|
||||
* the shared store/service, the live prefs, the descriptor's own
|
||||
* `pluginSettings` blob, and a persistence helper.
|
||||
*/
|
||||
render?: (props: SidebarSettingsRenderProps) => ReactNode
|
||||
}
|
||||
|
||||
/** Props every tab component receives (builtins and external alike). */
|
||||
@@ -112,6 +176,27 @@ export interface TabDescriptor {
|
||||
* (e.g. the subagent tab's 'autoOpenSubagent').
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
/**
|
||||
* Tab-strip badge (v0.12.0+): a small pill rendered on the tab next to
|
||||
* the icon — a number renders as a count (99+ capped), a string renders
|
||||
* as-is, null/undefined hides the badge. Called on every tab-bar render,
|
||||
* so keep it cheap; a throw is swallowed (no badge shown).
|
||||
*/
|
||||
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
|
||||
/**
|
||||
* Lifecycle callbacks (v0.12.0+). Fired by the SERVICE paths only:
|
||||
* `onOpen` when an open actually creates a tab (a dedupe/id-safety-net
|
||||
* focus is NOT an open — it fires `onActivate` instead), `onActivate`
|
||||
* when a tab is focused (dedupe focus, id-safety-net focus, or the
|
||||
* tab-bar activation), `onClose` when a tab is closed through
|
||||
* `closeTab`. Builtin-only flows that mutate state directly (the diff
|
||||
* split placement, agent-terminal reconcile) never touch external tabs
|
||||
* and fire no callbacks. A throwing callback is logged and never breaks
|
||||
* the open/close/activate flow.
|
||||
*/
|
||||
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
onClose?: (tab: SidebarTab, scope: SessionScope) => void
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
@@ -159,8 +244,9 @@ export interface FileViewerDescriptor {
|
||||
* is consulted before its `exts` (per-descriptor, in priority order).
|
||||
*/
|
||||
detect?: (path: string, head: Uint8Array) => boolean
|
||||
/** fetchStrategy='custom' loader. */
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown>
|
||||
/** fetchStrategy='custom' loader. `signal` (v0.12.0+) aborts on viewer
|
||||
* teardown / re-match; loaders that ignore it keep working. */
|
||||
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered viewer gets an enable/disable switch (icon + title + exts).
|
||||
@@ -169,7 +255,26 @@ export interface FileViewerDescriptor {
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
/** The registry service published as `ctx.betterSidebar`. */
|
||||
/** One `openTab` request. */
|
||||
export interface OpenTabSeed {
|
||||
type: string
|
||||
/** Overrides the descriptor's title when given (the editor tab shows the file name). */
|
||||
title?: string
|
||||
/** A file path (the editor tab's content seed). */
|
||||
path?: string
|
||||
/** A diff reference (the diff tab's content seed). */
|
||||
diff?: SidebarTab['diff']
|
||||
/** Explicit tab id (defaults to the type). */
|
||||
id?: string
|
||||
/** A URL the tab navigates to on mount (the browser tab's seed). */
|
||||
url?: string
|
||||
/** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry service published as `ctx.betterSidebar`.
|
||||
*/
|
||||
export interface BetterSidebarService {
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
@@ -200,18 +305,58 @@ export interface BetterSidebarService {
|
||||
* usually pairs it with a hostname `title`). A disabled tab type is a
|
||||
* no-op.
|
||||
*
|
||||
* `scope` (v0.12.0+) targets a specific session: when given, the open
|
||||
* lands in THAT session's sidebar state (loading it if it has none yet)
|
||||
* without switching the UI's active session; when absent the open lands
|
||||
* in the currently active session (the pre-0.12 behavior).
|
||||
*
|
||||
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
|
||||
* panel hosting the landing pane is collapsed, it is expanded
|
||||
* automatically (the right panel by default, the bottom panel when the
|
||||
* active pane lives there; on narrow viewports the merged drawer opens).
|
||||
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
|
||||
* the panel behavior is their caller's business.
|
||||
*
|
||||
* Note: `available` gates the + menu's disabled state only — it does NOT
|
||||
* refuse `openTab` (only the settings disable switch does).
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** Close a tab by id. */
|
||||
closeTab(tabId: string): void
|
||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||
/**
|
||||
* Close a tab by id (fires descriptor.onClose). An unknown tab id is a
|
||||
* strict no-op (no state churn, no callbacks). `scope` (v0.12.0+) rides
|
||||
* to the callback (its optional cwd included); absent, the callback gets
|
||||
* `{ sessionId }` of the active session.
|
||||
*/
|
||||
closeTab(tabId: string, scope?: SessionScope): void
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
subscribe(listener: () => void): () => void
|
||||
/** The plugin version this service instance was built from ('0.12.0'). */
|
||||
readonly version: string
|
||||
/**
|
||||
* Monotonic capability list (v0.12.0+): 'badge' | 'tabLifecycle' |
|
||||
* 'updateTab' | 'openFile' | 'targetedOpen' | 'stateSubscription' |
|
||||
* 'tabMeta' | 'pluginSettings'. Features are never removed — consumers
|
||||
* gate new API usage on membership.
|
||||
*/
|
||||
readonly features: readonly string[]
|
||||
/**
|
||||
* The current sidebar snapshot: the active session id, its state (panel
|
||||
* geometry, open tabs, expansions), and the side card prefs (v0.12.0+).
|
||||
* `state`/`sessionId` are undefined until a session becomes active.
|
||||
*/
|
||||
getSnapshot(): SidebarSnapshot
|
||||
/** Subscribe to snapshot changes (session switch, state changes, prefs changes). Returns the disposer. */
|
||||
subscribeState(listener: () => void): () => void
|
||||
/** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */
|
||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||
/**
|
||||
* Activate an open tab (the tab-bar activation path; fires
|
||||
* descriptor.onActivate). An unknown tab id is a strict no-op. `scope`
|
||||
* (v0.12.0+) rides to the callback like `closeTab`'s.
|
||||
*/
|
||||
activateTab(tabId: string, scope?: SessionScope): void
|
||||
/** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
|
||||
openFile(scope: SessionScope, path: string, title?: string): void
|
||||
}
|
||||
|
||||
/** Extract the lowercase extension without leading dot from a path. */
|
||||
@@ -222,6 +367,50 @@ function extOfPath(path: string): string {
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
|
||||
/** The file name of a path (both separators). */
|
||||
function baseNameOf(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return at === -1 ? path : path.slice(at + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.12.1'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
* are never removed). Each string names a v0.12.0+ capability:
|
||||
* - 'badge': TabDescriptor.badge
|
||||
* - 'tabLifecycle': TabDescriptor.onOpen/onActivate/onClose
|
||||
* - 'updateTab': BetterSidebarService.updateTab
|
||||
* - 'openFile': BetterSidebarService.openFile
|
||||
* - 'targetedOpen': BetterSidebarService.openTab(seed, scope?)
|
||||
* - 'stateSubscription': getSnapshot/subscribeState
|
||||
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
|
||||
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
|
||||
*/
|
||||
export const SIDEBAR_FEATURES = [
|
||||
'badge',
|
||||
'tabLifecycle',
|
||||
'updateTab',
|
||||
'openFile',
|
||||
'targetedOpen',
|
||||
'stateSubscription',
|
||||
'tabMeta',
|
||||
'pluginSettings',
|
||||
] as const
|
||||
|
||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||
function safeCall(fn: () => void): void {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] plugin callback error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one BetterSidebar service bound to a store. The service owns the
|
||||
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
|
||||
@@ -308,7 +497,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => {
|
||||
const openTab = (seed: OpenTabSeed, scope?: SessionScope): void => {
|
||||
// A type the user disabled in settings never opens — neither from the
|
||||
// + menu nor from derived flows (file opens, subagent auto-open,
|
||||
// external plugins). Already-open tabs keep rendering.
|
||||
@@ -316,9 +505,23 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
|
||||
return
|
||||
}
|
||||
store.reduce((state) => {
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return
|
||||
// A scope targets another session: the open lands in THAT session's
|
||||
// state (loaded on demand) without switching the UI's active session.
|
||||
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (targetSessionId === undefined) return
|
||||
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
|
||||
// Whether this open targets a session that is NOT the one on screen: a
|
||||
// targeted open must not auto-expand panels the user cannot see (the
|
||||
// expansion is about landing "in sight" for the CURRENT viewer).
|
||||
const activeSessionId = store.getSnapshot().sessionId
|
||||
const targetsInactiveSession = scope !== undefined && scope.sessionId !== activeSessionId
|
||||
// Lifecycle capture: `created` when the open minted a NEW tab (a
|
||||
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
|
||||
let created: SidebarTab | undefined
|
||||
let activated: SidebarTab | undefined
|
||||
const reducer = (state: SidebarState): SidebarState => {
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
let tab: SidebarTab
|
||||
let next: SidebarState
|
||||
@@ -337,20 +540,49 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
...(seed.meta !== undefined ? { meta: seed.meta } : {}),
|
||||
}
|
||||
next = applyDedupe(state, tab, descriptor)
|
||||
}
|
||||
// A URL seed pre-fills the tab's path (the browser tab navigates to it
|
||||
// on mount). An explicit seed.title also wins over a createTab-minted
|
||||
// default title (e.g. the sidebar-browser's hostname title).
|
||||
let landed: SidebarState
|
||||
if (seed.url !== undefined) {
|
||||
// Classify the landing against the INPUT state FIRST: a FOCUS fires
|
||||
// onActivate with the tab that is active NOW; a real creation fires
|
||||
// onOpen with the minted tab. Both the dedupeKey match AND the id
|
||||
// match count as a focus — a descriptor deduping by key (e.g. editor
|
||||
// by path) can focus an existing tab for a NEW requested id, and
|
||||
// classifying that as a creation would fire onOpen with a phantom
|
||||
// tab that never closes.
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
const existedByKey = key !== undefined
|
||||
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||
const existedById = tabOpenIn(state, tab.id)
|
||||
const isCreation = !existedByKey && !existedById
|
||||
// A URL seed pre-fills a NEWLY CREATED tab's path (the browser tab
|
||||
// navigates to it on mount); a FOCUS must never have its path
|
||||
// overwritten. An explicit seed.title still wins over a createTab-
|
||||
// minted default title (e.g. the sidebar-browser's hostname title).
|
||||
let landed: SidebarState = next
|
||||
if (seed.url !== undefined && isCreation) {
|
||||
landed = patchTab(next, tab.id, {
|
||||
path: seed.url,
|
||||
...(seed.title !== undefined ? { title: seed.title } : {}),
|
||||
})
|
||||
}
|
||||
// Lifecycle capture (before the auto-expand block, which early-returns).
|
||||
if (isCreation) {
|
||||
// Resolve the ACTUAL landed tab — the url patch mints a new object,
|
||||
// so the callback must see the tab that was really inserted.
|
||||
const landedTabs = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab
|
||||
} else {
|
||||
landed = next
|
||||
// A focus happened: resolve the tab that is actually active now and
|
||||
// report THAT to onActivate (never the caller's un-inserted seed).
|
||||
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
|
||||
activated = key !== undefined
|
||||
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||
: candidates.find(candidate => candidate.id === tab.id)
|
||||
activated ??= tab
|
||||
}
|
||||
// A CONTENT open (file / browser) must land in sight: when the panel
|
||||
// hosting the landing pane is collapsed, expand it. On narrow
|
||||
@@ -361,9 +593,11 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
// agent-terminal auto-tabs) never expand (the panel behavior is their
|
||||
// caller's business). The check runs on the post-dedupe state, so a
|
||||
// content open that merely FOCUSES an existing tab expands the panel
|
||||
// too — the open must never land out of sight.
|
||||
// too — the open must never land out of sight. Opens targeted at an
|
||||
// INACTIVE session never expand (nothing is in sight for the user).
|
||||
if (
|
||||
typeof window !== 'undefined'
|
||||
!targetsInactiveSession
|
||||
&& typeof window !== 'undefined'
|
||||
&& (seed.path !== undefined || seed.url !== undefined)
|
||||
) {
|
||||
if (isNarrowWidth(window.innerWidth)) {
|
||||
@@ -378,15 +612,81 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
}
|
||||
}
|
||||
return landed
|
||||
})
|
||||
}
|
||||
// A scope targeting ANOTHER session lands the open there without
|
||||
// switching the UI; a scope naming the active session (or no scope)
|
||||
// takes the regular reduce path so the UI notifies and re-renders.
|
||||
if (targetsInactiveSession) {
|
||||
store.reduceFor(scope.sessionId, reducer)
|
||||
} else {
|
||||
store.reduce(reducer)
|
||||
}
|
||||
if (created !== undefined) safeCall(() => descriptor.onOpen?.(created!, callbackScope))
|
||||
else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope))
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string): void => {
|
||||
const closeTab = (tabId: string, scope?: SessionScope): void => {
|
||||
let closed: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
// Unknown tab ids are a strict no-op: no state churn, no notify, no
|
||||
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
|
||||
if (!tabOpenIn(state, tabId)) return state
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
if (paneId === '') return state
|
||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||
closed = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
return closeTabReducer(state, paneId, tabId)
|
||||
})
|
||||
if (closed !== undefined) {
|
||||
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (sessionId !== undefined) {
|
||||
const descriptor = tabs.get(closed.type)
|
||||
// An explicit scope (with its optional cwd) rides to the callback.
|
||||
safeCall(() => descriptor?.onClose?.(closed!, scope ?? { sessionId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The snapshot the store publishes (state/prefs carry the active session). */
|
||||
const getSnapshot = (): SidebarSnapshot => store.getSnapshot()
|
||||
|
||||
/** Store changes: session switch, state mutations, prefs writes. */
|
||||
const subscribeState = (listener: () => void): (() => void) => store.subscribe(listener)
|
||||
|
||||
/** Patch an open tab's display fields (a missing tab id is a no-op). */
|
||||
const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
|
||||
store.reduce((state) => patchTab(state, tabId, {
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Activate an open tab (the tab-bar activation path; fires onActivate). */
|
||||
const activateTab = (tabId: string, scope?: SessionScope): void => {
|
||||
let activated: SidebarTab | undefined
|
||||
store.reduce((state) => {
|
||||
// Unknown tab ids are a strict no-op (no state churn / notify).
|
||||
if (!tabOpenIn(state, tabId)) return state
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||
activated = leaf?.tabs.find(tab => tab.id === tabId)
|
||||
return activateTabReducer(state, paneId, tabId)
|
||||
})
|
||||
if (activated !== undefined) {
|
||||
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||
if (sessionId !== undefined) {
|
||||
const descriptor = tabs.get(activated.type)
|
||||
// An explicit scope (with its optional cwd) rides to the callback.
|
||||
safeCall(() => descriptor?.onActivate?.(activated!, scope ?? { sessionId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a file in the sidebar editor of `scope`'s session (title defaults
|
||||
* to the file name; the tab id is path-derived, like the internal
|
||||
* open-path interception, so distinct files open side by side). */
|
||||
const openFile = (scope: SessionScope, path: string, title?: string): void => {
|
||||
openTab({ type: 'editor', title: title ?? baseNameOf(path), path, id: `editor:${path}` }, scope)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -401,6 +701,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
openTab,
|
||||
closeTab,
|
||||
subscribe,
|
||||
version: SIDEBAR_SERVICE_VERSION,
|
||||
features: SIDEBAR_FEATURES,
|
||||
getSnapshot,
|
||||
subscribeState,
|
||||
updateTab,
|
||||
activateTab,
|
||||
openFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +726,7 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
|
||||
// bottom panel focuses an existing instance wherever it lives.
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
|
||||
+54
-141
@@ -522,6 +522,21 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* One tab's descriptor badge (a count capped at 99+, or a short text). */
|
||||
.tabBadge {
|
||||
flex: none;
|
||||
min-width: 16px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.tabClose {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -757,7 +772,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries):
|
||||
/* Binary / download-only files (legacy OLE .doc/.xls/.ppt, unknown binaries):
|
||||
a centered notice plus a download link — the user always gets the file. */
|
||||
.editorBinary {
|
||||
flex: 1;
|
||||
@@ -1067,62 +1082,32 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* Word preview: the document scrolls independently while the zoom control
|
||||
stays pinned to the bottom edge. */
|
||||
.editorDocx {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorDocxViewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* docx-preview renders into a wrapper div with .docx className; the host
|
||||
above scrolls while the wrapper holds the page flow. */
|
||||
.editorDocxWrap {
|
||||
flex: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.editorDocxZoom {
|
||||
flex: none;
|
||||
display: flex;
|
||||
/* The floating "add to conversation" button anchored above a text
|
||||
selection in the preview/code viewers. Portalled to document.body and
|
||||
position:fixed, so the editor's overflow clips cannot crop it; flat
|
||||
chrome like the rest of the sidebar (hairline border, layer-2 fill,
|
||||
hover fill — no shadow). */
|
||||
.selectionPopup {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
transform: translate(-50%, calc(-100% - 8px));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l1);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.editorDocxZoomHint,
|
||||
.editorDocxZoomValue {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorDocxZoomValue {
|
||||
width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.editorDocxZoomRange {
|
||||
flex: 1;
|
||||
min-width: 72px;
|
||||
accent-color: var(--dsw-alias-brand-primary);
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectionPopup:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.editorPdf {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1181,94 +1166,6 @@
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.editorXlsx {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorUniverHost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editorOfficeOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorPptx {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorPptxToolbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.editorPptxToolbar .editorDownloadLink {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editorPptxButton {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editorPptxButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.editorPptxButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editorPptxPosition {
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorPptxStage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorPptxHost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ── Terminal ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.terminalWrap {
|
||||
@@ -1344,6 +1241,22 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The PER-TAB error strip: fills one tab's content area (unlike
|
||||
.boundaryError, the full-height fixed rail for shell-level crashes), so a
|
||||
crashing viewer/editor never hides the toggle cluster or the other tabs. */
|
||||
.tabBoundaryError {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ── Git ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.git {
|
||||
|
||||
@@ -123,8 +123,9 @@ function LeafView(props: {
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||
|
||||
@@ -178,6 +179,7 @@ function LeafView(props: {
|
||||
onNewTab={onNewTab}
|
||||
newTabOptions={newTabOptions}
|
||||
getTabIcon={getTabIcon}
|
||||
getTabBadge={getTabBadge}
|
||||
onDropTab={(payload, before) => {
|
||||
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
|
||||
else actions.moveTabBefore(payload, leaf.id, before)
|
||||
@@ -217,8 +219,9 @@ function NodeView(props: {
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
||||
if (node.kind === 'leaf') {
|
||||
return (
|
||||
<LeafView
|
||||
@@ -228,6 +231,7 @@ function NodeView(props: {
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
getTabBadge={getTabBadge}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -254,6 +258,7 @@ function NodeView(props: {
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
getTabBadge={getTabBadge}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
@@ -274,8 +279,9 @@ export function Workbench(props: {
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
getTabBadge?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
@@ -286,6 +292,7 @@ export function Workbench(props: {
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
getTabBadge={getTabBadge}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+63
-6
@@ -26,13 +26,17 @@ export type SidebarDiffRef =
|
||||
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
|
||||
|
||||
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
|
||||
* `diff` carries the change a diff tab shows. */
|
||||
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
|
||||
* plugin-owned JSON-serializable state, preserved across reloads. */
|
||||
export interface SidebarTab {
|
||||
id: string
|
||||
type: TabType
|
||||
title: string
|
||||
path?: string
|
||||
diff?: SidebarDiffRef
|
||||
/** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
|
||||
* persisted with the layout and restored verbatim on reload. */
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** A tab group. */
|
||||
@@ -432,14 +436,14 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the display fields of one open tab (title / path) without
|
||||
/** Update the display fields of one open tab (title / path / meta) without
|
||||
* re-opening it. The browser tab persists its current URL and hostname
|
||||
* title through this reducer so a reload restores the visited page. A
|
||||
* missing tab id is a no-op. The tab may live in either tree. */
|
||||
export function patchTab(
|
||||
state: SidebarState,
|
||||
tabId: string,
|
||||
patch: { title?: string; path?: string },
|
||||
patch: { title?: string; path?: string; meta?: unknown },
|
||||
): SidebarState {
|
||||
let changed = false
|
||||
const walk = (node: SplitNode): SplitNode => {
|
||||
@@ -451,6 +455,7 @@ export function patchTab(
|
||||
...tab,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
|
||||
}
|
||||
})
|
||||
return tabs === node.tabs ? node : { ...node, tabs }
|
||||
@@ -886,11 +891,15 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
// accept any string type here — an unregistered type renders an
|
||||
// <OrphanedTab/> at view time and recovers if its plugin loads later.
|
||||
if (typeof candidate.type !== 'string') return undefined
|
||||
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
|
||||
// persisted value already went through JSON.parse, so it is inherently
|
||||
// serializable — carry it through verbatim (absent on older states).
|
||||
tabs.push({
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
title: candidate.title,
|
||||
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
|
||||
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
|
||||
})
|
||||
}
|
||||
const active = typeof record.active === 'string' ? record.active : null
|
||||
@@ -929,7 +938,9 @@ export class SidebarStore {
|
||||
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
|
||||
}
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** Per-session persist debounce timers (v0.12.0+: one per session, so a
|
||||
* targeted open never cancels another session's pending write). */
|
||||
private readonly persistTimers = new Map<string, number>()
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
@@ -1014,21 +1025,67 @@ export class SidebarStore {
|
||||
const state = this.snapshot.state
|
||||
if (sessionId === undefined || state === undefined) return
|
||||
const next = reducer(state)
|
||||
// A reducer returning the SAME reference means "no change": skip the
|
||||
// persist + notify entirely — strict no-op paths (unknown tab ids,
|
||||
// patchTab on a missing tab) must not churn the state or rewrite
|
||||
// localStorage.
|
||||
if (next === state) return
|
||||
this.bySession.set(sessionId, next)
|
||||
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
||||
this.schedulePersist(sessionId, next)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a pure reducer to a TARGET session's state (not the active one),
|
||||
* loading it on demand and persisting the result — WITHOUT switching the
|
||||
* active snapshot or notifying (the UI must not follow along). Used by the
|
||||
* service's targeted `openTab(seed, scope)`: the open lands in the target
|
||||
* session's layout and is visible whenever the user switches to it.
|
||||
*/
|
||||
reduceFor(sessionId: string, reducer: (state: SidebarState) => SidebarState): void {
|
||||
// The uid counter is SHARED across sessions, and the ACTIVE session's
|
||||
// safety requires it to never drop below the ids IT minted. Seeding it
|
||||
// from the target's max may LOWER it (a cached target older than the
|
||||
// active session): restoring the pre-call level afterwards keeps the
|
||||
// active session's next mint collision-free — ids minted for the target
|
||||
// only need to exceed the target's own max, which the seed guaranteed.
|
||||
const counterBefore = nextIdCounter
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
state = loadState(sessionId, this.prefs)
|
||||
this.bySession.set(sessionId, state)
|
||||
} else {
|
||||
// Re-seed the uid counter past THIS session's persisted ids, exactly
|
||||
// like setSession's cache-hit path.
|
||||
nextIdCounter = maxCounterId(state)
|
||||
}
|
||||
const next = reducer(state)
|
||||
// Same-reference result = no change: keep the counter restore (it may
|
||||
// have been seeded down) but skip the write.
|
||||
nextIdCounter = Math.max(nextIdCounter, counterBefore)
|
||||
if (next === state) return
|
||||
this.bySession.set(sessionId, next)
|
||||
this.schedulePersist(sessionId, next)
|
||||
}
|
||||
|
||||
private schedulePersist(sessionId: string, state: SidebarState): void {
|
||||
window.clearTimeout(this.persistTimer)
|
||||
this.persistTimer = window.setTimeout(() => {
|
||||
// Per-session debounce timers: one session's pending write must never
|
||||
// cancel another's (targeted opens schedule writes for INACTIVE
|
||||
// sessions while the active session may already have one pending —
|
||||
// a shared timer would drop the earlier write and the reload would
|
||||
// lose that session's layout).
|
||||
const existing = this.persistTimers.get(sessionId)
|
||||
if (existing !== undefined) window.clearTimeout(existing)
|
||||
const timer = window.setTimeout(() => {
|
||||
this.persistTimers.delete(sessionId)
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
|
||||
} catch {
|
||||
// Storage full or unavailable: layout memory is best-effort.
|
||||
}
|
||||
}, 200)
|
||||
this.persistTimers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
/**
|
||||
* Pure derivations for the Subagent page's background-task section. Kept
|
||||
* framework-free so the node test environment can unit-test them: the task
|
||||
* rows arrive through the harness `session/tasks` push mirror
|
||||
* (`tasksBySession` in the sessions list feed) — nothing here issues
|
||||
* Pure derivations for the Subagent page's background-job section. Kept
|
||||
* framework-free so the node test environment can unit-test them: the job
|
||||
* rows arrive through the harness `session/jobs` push mirror
|
||||
* (`jobsBySession` in the sessions list feed) — nothing here issues
|
||||
* requests, and the row ordering / status mapping mirror the official
|
||||
* ui-task header list.
|
||||
* ui-jobs header list.
|
||||
*/
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarTaskStatus,
|
||||
SidebarTaskView,
|
||||
SidebarJobStatus,
|
||||
SidebarJobView,
|
||||
} from '../context-types.ts'
|
||||
import type { CopyKey } from './locales.ts'
|
||||
|
||||
/** One row of the tasks section: the task plus its owning session's title. */
|
||||
export interface TreeTask {
|
||||
/** One row of the jobs section: the job plus its owning session's title. */
|
||||
export interface TreeJob {
|
||||
ownerSessionId: string
|
||||
ownerTitle: string
|
||||
task: SidebarTaskView
|
||||
job: SidebarJobView
|
||||
}
|
||||
|
||||
/** Whether the registry still holds the task open (its duration ticks). */
|
||||
export function isTaskLive(task: SidebarTaskView): boolean {
|
||||
return task.status === 'running' || task.status === 'stopping'
|
||||
/** Whether the registry still holds the job open (its duration ticks). */
|
||||
export function isJobLive(job: SidebarJobView): boolean {
|
||||
return job.status === 'running' || job.status === 'stopping'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ export function isTaskLive(task: SidebarTaskView): boolean {
|
||||
* each session whose uninterrupted subagent-origin chain reaches it — same
|
||||
* lineage semantics as {@link countSubagentDescendants}; cycles fail soft).
|
||||
* Sessions outside the tree (orphans, other trees) are excluded, so the
|
||||
* tasks section never shows foreign work.
|
||||
* jobs section never shows foreign work.
|
||||
*/
|
||||
export function treeSessionIds(
|
||||
byId: SidebarSessionList['byId'],
|
||||
@@ -58,67 +58,67 @@ export function treeSessionIds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a NEW background task appeared for one session between two
|
||||
* consecutive list snapshots (a task id the previous snapshot lacked).
|
||||
* Unlike the subagent auto-open (0 → N only), ANY new task id triggers: the
|
||||
* agent may start several tasks over a session, and each new one should
|
||||
* surface the Tasks page (a fresh page load never triggers — its baseline
|
||||
* Whether a NEW background job appeared for one session between two
|
||||
* consecutive list snapshots (a job id the previous snapshot lacked).
|
||||
* Unlike the subagent auto-open (0 → N only), ANY new job id triggers: the
|
||||
* agent may start several jobs over a session, and each new one should
|
||||
* surface the Jobs page (a fresh page load never triggers — its baseline
|
||||
* starts at the current snapshot).
|
||||
*/
|
||||
export function detectNewTask(
|
||||
export function detectNewJob(
|
||||
prev: SidebarSessionList,
|
||||
next: SidebarSessionList,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
const prevIds = new Set((prev.tasksBySession?.[sessionId] ?? []).map(task => task.id))
|
||||
return (next.tasksBySession?.[sessionId] ?? []).some(task => !prevIds.has(task.id))
|
||||
const prevIds = new Set((prev.jobsBySession?.[sessionId] ?? []).map(job => job.id))
|
||||
return (next.jobsBySession?.[sessionId] ?? []).some(job => !prevIds.has(job.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the background tasks of the whole current tree, owner-labeled.
|
||||
* Collect the background jobs of the whole current tree, owner-labeled.
|
||||
* Sessions without a mirror entry contribute nothing; an absent mirror
|
||||
* (runtime older than the tasks feed) yields an empty list.
|
||||
* (runtime older than the jobs feed) yields an empty list.
|
||||
*/
|
||||
export function collectTreeTasks(
|
||||
export function collectTreeJobs(
|
||||
byId: SidebarSessionList['byId'],
|
||||
tasksBySession: Readonly<Record<string, readonly SidebarTaskView[]>> | undefined,
|
||||
jobsBySession: Readonly<Record<string, readonly SidebarJobView[]>> | undefined,
|
||||
rootId: string | undefined,
|
||||
): TreeTask[] {
|
||||
const rows: TreeTask[] = []
|
||||
if (tasksBySession === undefined) return rows
|
||||
): TreeJob[] {
|
||||
const rows: TreeJob[] = []
|
||||
if (jobsBySession === undefined) return rows
|
||||
for (const sessionId of treeSessionIds(byId, rootId)) {
|
||||
const tasks = tasksBySession[sessionId]
|
||||
if (tasks === undefined || tasks.length === 0) continue
|
||||
const jobs = jobsBySession[sessionId]
|
||||
if (jobs === undefined || jobs.length === 0) continue
|
||||
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
|
||||
for (const task of tasks) rows.push({ ownerSessionId: sessionId, ownerTitle, task })
|
||||
for (const job of jobs) rows.push({ ownerSessionId: sessionId, ownerTitle, job })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Live rows first in start order, then settled rows newest-first (mirror of
|
||||
* the official ui-task ordering); a tie falls back to start order so the
|
||||
* the official ui-jobs ordering); a tie falls back to start order so the
|
||||
* sort never depends on the host's map iteration.
|
||||
*/
|
||||
export function orderTasks(rows: readonly TreeTask[]): TreeTask[] {
|
||||
export function orderJobs(rows: readonly TreeJob[]): TreeJob[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const liveLeft = isTaskLive(left.task)
|
||||
if (liveLeft !== isTaskLive(right.task)) return liveLeft ? -1 : 1
|
||||
if (liveLeft) return left.task.startedAt - right.task.startedAt
|
||||
const finished = (right.task.finishedAt ?? right.task.startedAt) - (left.task.finishedAt ?? left.task.startedAt)
|
||||
return finished !== 0 ? finished : left.task.startedAt - right.task.startedAt
|
||||
const liveLeft = isJobLive(left.job)
|
||||
if (liveLeft !== isJobLive(right.job)) return liveLeft ? -1 : 1
|
||||
if (liveLeft) return left.job.startedAt - right.job.startedAt
|
||||
const finished = (right.job.finishedAt ?? right.job.startedAt) - (left.job.finishedAt ?? left.job.startedAt)
|
||||
return finished !== 0 ? finished : left.job.startedAt - right.job.startedAt
|
||||
})
|
||||
}
|
||||
|
||||
/** The sidebar's StateDot states for the five wire statuses. */
|
||||
export type TaskDotState = 'ongoing' | 'warning' | 'done' | 'error'
|
||||
export type JobDotState = 'ongoing' | 'warning' | 'done' | 'error'
|
||||
|
||||
/**
|
||||
* Status marker semantics. `stopping` and `killed` share the attention
|
||||
* color: both mean the work ended (or is ending) on request rather than on
|
||||
* its own.
|
||||
*/
|
||||
export function taskDotState(status: SidebarTaskStatus): TaskDotState {
|
||||
export function jobDotState(status: SidebarJobStatus): JobDotState {
|
||||
switch (status) {
|
||||
case 'running': return 'ongoing'
|
||||
case 'stopping': return 'warning'
|
||||
@@ -129,25 +129,25 @@ export function taskDotState(status: SidebarTaskStatus): TaskDotState {
|
||||
}
|
||||
|
||||
/** Human status word of one wire status (localized through the passed translator). */
|
||||
export function taskStatusLabel(
|
||||
status: SidebarTaskStatus,
|
||||
export function jobStatusLabel(
|
||||
status: SidebarJobStatus,
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'running': return t('taskStatusRunning')
|
||||
case 'stopping': return t('taskStatusStopping')
|
||||
case 'completed': return t('taskStatusCompleted')
|
||||
case 'killed': return t('taskStatusKilled')
|
||||
case 'failed': return t('taskStatusFailed')
|
||||
case 'running': return t('jobStatusRunning')
|
||||
case 'stopping': return t('jobStatusStopping')
|
||||
case 'completed': return t('jobStatusCompleted')
|
||||
case 'killed': return t('jobStatusKilled')
|
||||
case 'failed': return t('jobStatusFailed')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elapsed time in at most two adjacent units (mirror of the official
|
||||
* ui-task duration wording). A background task that outlives an hour is
|
||||
* ui-jobs duration wording). A background job that outlives an hour is
|
||||
* already exceptional, so hours is the widest unit.
|
||||
*/
|
||||
export function formatTaskDuration(
|
||||
export function formatJobDuration(
|
||||
elapsedMs: number,
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
@@ -155,7 +155,7 @@ export function formatTaskDuration(
|
||||
const seconds = total % 60
|
||||
const minutes = Math.floor(total / 60) % 60
|
||||
const hours = Math.floor(total / 3_600)
|
||||
if (hours > 0) return t('taskDurationHours', { hours, minutes })
|
||||
if (minutes > 0) return t('taskDurationMinutes', { minutes, seconds })
|
||||
return t('taskDurationSeconds', { seconds })
|
||||
if (hours > 0) return t('jobDurationHours', { hours, minutes })
|
||||
if (minutes > 0) return t('jobDurationMinutes', { minutes, seconds })
|
||||
return t('jobDurationSeconds', { seconds })
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Terminal font resolution: the user's custom font prefs (SidebarPrefs,
|
||||
* configured under the terminal card's secondary settings) turned into the
|
||||
* xterm options. Kept as a pure module (no DOM, no xterm) so the fallback
|
||||
* chain and clamping are unit-testable without mounting a terminal.
|
||||
*/
|
||||
import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/** The built-in fallback stack when neither the user nor the theme sets one. */
|
||||
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
|
||||
|
||||
/**
|
||||
* Resolve the xterm font options for the given prefs.
|
||||
* @param prefs - the current side card preferences.
|
||||
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
|
||||
* token value, read live by the caller); undefined when the token is absent.
|
||||
* @returns the `fontFamily` / `fontSize` xterm options.
|
||||
*/
|
||||
export function resolveTerminalFont(
|
||||
prefs: SidebarPrefs,
|
||||
themeFontFamily: string | undefined,
|
||||
): { fontFamily: string; fontSize: number } {
|
||||
const custom = prefs.terminalFontFamily.trim()
|
||||
return {
|
||||
fontFamily: custom !== '' ? custom : (themeFontFamily || DEFAULT_TERMINAL_FONT_FAMILY),
|
||||
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* Convert a SheetJS (xlsx) workbook into Univer's {@link IWorkbookData} so
|
||||
* {@link XlsxView} can render it through the Univer sheets preset.
|
||||
*
|
||||
* v1 scope (matches docs/plans/2026-08-10-office-preview-design.md §3.6):
|
||||
* - Sheet names + order
|
||||
* - Cell values, typed (string / number / boolean / error → force-string)
|
||||
* - Cell formulas (text form; Univer's formula engine computes them)
|
||||
* - Merged cells (!merges → mergeData)
|
||||
* - Column widths (!cols → columnData) and row heights (!rows → rowData)
|
||||
*
|
||||
* Out of scope for v1 (SheetJS community edition does not parse them and the
|
||||
* Pro edition is commercial): cell styles (font/fill/border/alignment),
|
||||
* conditional formatting, charts. Users who need style fidelity get the
|
||||
* download button on the binary placeholder.
|
||||
*
|
||||
* The function is pure (no DOM, no Univer imports) so it is unit-testable
|
||||
* without jsdom or canvas.
|
||||
*/
|
||||
import type * as XLSX from 'xlsx'
|
||||
// Types come from @univerjs/presets (which re-exports @univerjs/core's type
|
||||
// surface). The enum values BooleanNumber / CellValueType are imported as
|
||||
// runtime values from the same module — the bundler inlines them, and the
|
||||
// test environment has @univerjs/presets available as a dependency.
|
||||
import { BooleanNumber, CellValueType, type LocaleType, type ICellData, type IColumnData, type IRowData, type IWorkbookData, type IWorksheetData, type IObjectArrayPrimitiveType, type IObjectMatrixPrimitiveType, type IRange } from '@univerjs/presets'
|
||||
|
||||
/** SheetJS workbook (the slice of the module we use). */
|
||||
type XLSXWorkbook = XLSX.WorkBook
|
||||
|
||||
/**
|
||||
* Build a Univer workbook snapshot from a SheetJS workbook.
|
||||
*
|
||||
* @param wb - the parsed workbook from `XLSX.read(buf, { type: 'array' })`
|
||||
* @param appVersion - the Univer package version, written into `appVersion`
|
||||
* (Univer requires the field but does not gate on it).
|
||||
* @param locale - the locale Univer should render in.
|
||||
*/
|
||||
export function xlsxWorkbookToUniver(
|
||||
wb: XLSXWorkbook,
|
||||
appVersion: string,
|
||||
locale: LocaleType,
|
||||
): IWorkbookData {
|
||||
const sheetOrder: string[] = []
|
||||
const sheets: Record<string, Partial<IWorksheetData>> = {}
|
||||
|
||||
wb.SheetNames.forEach((name, index) => {
|
||||
const sheetId = `sheet-${index}`
|
||||
sheetOrder.push(sheetId)
|
||||
sheets[sheetId] = worksheetToUniver(wb.Sheets[name] ?? {}, name, sheetId)
|
||||
})
|
||||
|
||||
// Univer requires at least one sheet; an empty workbook gets a placeholder.
|
||||
if (sheetOrder.length === 0) {
|
||||
const fallbackId = 'sheet-0'
|
||||
sheetOrder.push(fallbackId)
|
||||
sheets[fallbackId] = {
|
||||
id: fallbackId,
|
||||
name: 'Sheet1',
|
||||
rowCount: 20,
|
||||
columnCount: 26,
|
||||
cellData: {},
|
||||
rowData: {},
|
||||
columnData: {},
|
||||
mergeData: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'workbook',
|
||||
name: wb.Props?.Title || 'Workbook',
|
||||
appVersion,
|
||||
locale,
|
||||
styles: {},
|
||||
sheetOrder,
|
||||
sheets,
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one SheetJS worksheet to a Univer IWorksheetData. */
|
||||
function worksheetToUniver(
|
||||
ws: XLSX.WorkSheet,
|
||||
name: string,
|
||||
sheetId: string,
|
||||
): Partial<IWorksheetData> {
|
||||
const cellData: IObjectMatrixPrimitiveType<ICellData> = {}
|
||||
const merges: IRange[] = []
|
||||
|
||||
// Cell values + formulas: iterate every cell address (skip the !-prefixed
|
||||
// special keys SheetJS uses for sheet-level metadata).
|
||||
for (const addr of Object.keys(ws)) {
|
||||
if (addr.startsWith('!')) continue
|
||||
const cell = ws[addr] as XLSX.CellObject | undefined
|
||||
if (cell === undefined) continue
|
||||
const { r, c } = decodeAddr(addr)
|
||||
const converted = convertCell(cell)
|
||||
if (converted === null) continue
|
||||
let row = cellData[r]
|
||||
if (row === undefined) {
|
||||
row = {}
|
||||
cellData[r] = row
|
||||
}
|
||||
row[c] = converted
|
||||
}
|
||||
|
||||
// Merged cells: SheetJS's Range uses { s: { r, c }, e: { r, c } } (0-indexed),
|
||||
// which is exactly Univer's IRange shape — copy verbatim.
|
||||
if (Array.isArray(ws['!merges'])) {
|
||||
for (const m of ws['!merges']!) {
|
||||
if (m === undefined || m === null) continue
|
||||
merges.push({ startRow: m.s.r, endRow: m.e.r, startColumn: m.s.c, endColumn: m.e.c })
|
||||
}
|
||||
}
|
||||
|
||||
const { rowCount, columnCount } = dimensions(ws, cellData)
|
||||
|
||||
return {
|
||||
id: sheetId,
|
||||
name,
|
||||
tabColor: '',
|
||||
hidden: BooleanNumber.FALSE,
|
||||
freeze: { startRow: -1, startColumn: -1, xSplit: 0, ySplit: 0 },
|
||||
rowCount,
|
||||
columnCount,
|
||||
defaultColumnWidth: 96,
|
||||
defaultRowHeight: 22,
|
||||
mergeData: merges,
|
||||
cellData,
|
||||
rowData: convertRows(ws['!rows']),
|
||||
columnData: convertCols(ws['!cols']),
|
||||
rowHeader: { width: 46 },
|
||||
columnHeader: { height: 20 },
|
||||
showGridlines: BooleanNumber.TRUE,
|
||||
rightToLeft: BooleanNumber.FALSE,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a SheetJS CellObject onto Univer's ICellData (or null for empty). */
|
||||
function convertCell(cell: XLSX.CellObject): ICellData | null {
|
||||
const out: ICellData = {}
|
||||
// Type mapping: b→BOOLEAN, n→NUMBER, s→STRING, d→STRING, e→FORCE_STRING, z→skip
|
||||
switch (cell.t) {
|
||||
case 'b':
|
||||
out.t = CellValueType.BOOLEAN
|
||||
out.v = cell.v === true
|
||||
break
|
||||
case 'n':
|
||||
out.t = CellValueType.NUMBER
|
||||
out.v = typeof cell.v === 'number' ? cell.v : Number(cell.v)
|
||||
break
|
||||
case 'e':
|
||||
// Excel error strings like #REF! — display verbatim, no formula engine.
|
||||
out.t = CellValueType.FORCE_STRING
|
||||
out.v = String(cell.w ?? cell.v ?? '')
|
||||
break
|
||||
case 's':
|
||||
case 'd':
|
||||
case 'z':
|
||||
default:
|
||||
// String / date / stub. SheetJS renders dates as ISO strings in `w`
|
||||
// when cellDates is on; otherwise `v` is the serial number. Use `w`
|
||||
// (formatted text) when present so users see what Excel showed.
|
||||
out.t = CellValueType.STRING
|
||||
out.v = cell.w ?? (cell.v !== undefined ? String(cell.v) : '')
|
||||
break
|
||||
}
|
||||
// Formula: keep as text; Univer's formula engine computes on load.
|
||||
if (typeof cell.f === 'string' && cell.f !== '') {
|
||||
out.f = cell.f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert SheetJS `!cols` (ColInfo[]) to Univer columnData (sparse, by index). */
|
||||
function convertCols(cols: XLSX.ColInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IColumnData>> {
|
||||
if (!Array.isArray(cols)) return {}
|
||||
const out: IObjectArrayPrimitiveType<Partial<IColumnData>> = {}
|
||||
cols.forEach((info, index) => {
|
||||
if (info === undefined || info === null) return
|
||||
const w = info.wpx ?? (info.width !== undefined ? Math.round(info.width * 7) : undefined)
|
||||
out[index] = {
|
||||
...(w !== undefined ? { w } : {}),
|
||||
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert SheetJS `!rows` (RowInfo[]) to Univer rowData (sparse, by index). */
|
||||
function convertRows(rows: XLSX.RowInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IRowData>> {
|
||||
if (!Array.isArray(rows)) return {}
|
||||
const out: IObjectArrayPrimitiveType<Partial<IRowData>> = {}
|
||||
rows.forEach((info, index) => {
|
||||
if (info === undefined || info === null) return
|
||||
const h = info.hpx ?? info.hpt
|
||||
out[index] = {
|
||||
...(h !== undefined ? { h: Math.round(h) } : {}),
|
||||
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the worksheet dimensions. SheetJS's `!ref` is authoritative when
|
||||
* present; otherwise fall back to the max row/col we actually wrote, with a
|
||||
* small floor so the canvas has somewhere to paint.
|
||||
*/
|
||||
function dimensions(
|
||||
ws: XLSX.WorkSheet,
|
||||
cellData: IObjectMatrixPrimitiveType<ICellData>,
|
||||
): { rowCount: number; columnCount: number } {
|
||||
const ref = ws['!ref']
|
||||
if (typeof ref === 'string' && ref !== '') {
|
||||
const range = decodeRange(ref)
|
||||
return {
|
||||
rowCount: Math.max(range.endRow + 1, 20),
|
||||
columnCount: Math.max(range.endColumn + 1, 10),
|
||||
}
|
||||
}
|
||||
let maxRow = -1
|
||||
let maxCol = -1
|
||||
for (const r of Object.keys(cellData)) {
|
||||
const rowNum = Number(r)
|
||||
if (rowNum > maxRow) maxRow = rowNum
|
||||
const rowObj = cellData[rowNum]!
|
||||
for (const c of Object.keys(rowObj)) {
|
||||
const colNum = Number(c)
|
||||
if (colNum > maxCol) maxCol = colNum
|
||||
}
|
||||
}
|
||||
return {
|
||||
rowCount: Math.max(maxRow + 1, 20),
|
||||
columnCount: Math.max(maxCol + 1, 10),
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an A1-style address (e.g. "AB12") to 0-indexed {row, col}. */
|
||||
function decodeAddr(addr: string): { r: number; c: number } {
|
||||
let col = 0
|
||||
let i = 0
|
||||
while (i < addr.length) {
|
||||
const ch = addr.charCodeAt(i)
|
||||
if (ch >= 65 && ch <= 90) {
|
||||
col = col * 26 + (ch - 64)
|
||||
i += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
const row = Number(addr.slice(i)) - 1
|
||||
return { r: row, c: col - 1 }
|
||||
}
|
||||
|
||||
/** Decode an A1-style range (e.g. "A1:AB12") to 0-indexed bounds. */
|
||||
function decodeRange(range: string): { startRow: number; endRow: number; startColumn: number; endColumn: number } {
|
||||
const at = range.indexOf(':')
|
||||
const start = decodeAddr(at === -1 ? range : range.slice(0, at))
|
||||
const end = at === -1 ? start : decodeAddr(range.slice(at + 1))
|
||||
return { startRow: start.r, endRow: end.r, startColumn: start.c, endColumn: end.c }
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* The .xlsx preview component: renders via the Univer sheets preset (data +
|
||||
* formulas + formatting). Lives in its own module (split out of the former
|
||||
* office-view.tsx) so it builds as a standalone lazy chunk
|
||||
* (`lib/client-xlsx.js`) — the Univer family and SheetJS pull in several MB
|
||||
* but are loaded only when a .xlsx is first opened. The docx stack never
|
||||
* shares this script.
|
||||
*
|
||||
* Errors degrade to the shared download-button affordance, so a corrupted /
|
||||
* encrypted / oversized file always leaves the user with a way to get it.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { mediaUrl } from './api.ts'
|
||||
import { isZh, t } from './locales.ts'
|
||||
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
|
||||
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||
import css from './sidebar.module.css'
|
||||
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||
// are present before the first .xlsx opens.
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
/**
|
||||
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||
* xterm dispose discipline in TerminalView).
|
||||
*/
|
||||
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamic imports — collapsed into the chunk by codeSplitting:false.
|
||||
// The dynamic form keeps the source readable: each lib is only pulled
|
||||
// in when an .xlsx is actually opened (semantically; the chunk still
|
||||
// contains all of them — the chunk itself only loads on first .xlsx).
|
||||
const XLSX = await import('xlsx')
|
||||
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||
// Locales follow the DSH i18n system (the active locale); falls
|
||||
// back to the browser language, then en-US.
|
||||
const localePack = await (isZh()
|
||||
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const locale = isZh() ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale,
|
||||
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||
presets: [UniverSheetsCorePreset({ container: host })],
|
||||
})
|
||||
univerRef.current = univer
|
||||
univerAPI.createWorkbook(workbookData)
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Partially initialized instance — ignore disposal errors.
|
||||
}
|
||||
univerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Already torn down — ignore.
|
||||
}
|
||||
univerRef.current = null
|
||||
// Clear the host in case dispose left DOM behind.
|
||||
if (host !== null) host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
return (
|
||||
<div className={css.editorXlsx} aria-label={title}>
|
||||
{/* Univer exclusively owns this element's descendants. React only owns
|
||||
the sibling overlay, so Univer cannot remove a React child. */}
|
||||
<div className={css.editorUniverHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+14
-1
@@ -9,6 +9,9 @@ import z from 'schemastery'
|
||||
import {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
@@ -18,6 +21,9 @@ import {
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
@@ -79,9 +85,11 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
openByDefault: z.boolean().default(true),
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
autoOpenTasks: z.boolean().default(true),
|
||||
autoOpenJobs: z.boolean().default(true),
|
||||
agentTerminalTools: z.boolean().default(false),
|
||||
bottomPanelAutoTerminal: z.boolean().default(true),
|
||||
terminalFontFamily: z.string().default(''),
|
||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
||||
@@ -92,4 +100,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
tabsEnabled: z.dict(z.boolean()).default({}),
|
||||
viewersEnabled: z.dict(z.boolean()).default({}),
|
||||
// Plugin-owned settings blobs (v0.12.0+) are an OPEN nested map: any
|
||||
// descriptor id may carry any JSON-serializable values. This is the
|
||||
// "settings seam" opening — without it the seam would drop third-party
|
||||
// keys as unknown schema fields.
|
||||
pluginSettings: z.dict(z.dict(z.any())).default({}),
|
||||
})
|
||||
|
||||
+90
-43
@@ -6,37 +6,70 @@
|
||||
* the npm cordis package does not declare the DSH-vendored runtime members
|
||||
* (`ctx.effect`, service properties). The members below mirror the actual
|
||||
* runtime shapes this plugin touches:
|
||||
* - httpServer: @deepseek-ai/dsh-host-webserver
|
||||
* - webServer: @deepseek-ai/dsh-host-webserver (the WebServer)
|
||||
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
|
||||
* side the runtime ISessions list feed
|
||||
* - conversation: client side ui-conversation's IConversation (composer
|
||||
* draft), read lazily through `ctx.get` — cross-plugin service reads need
|
||||
* an inject declaration, so the direct property is never typed here
|
||||
* - loader: @cordisjs/plugin-loader (entry options)
|
||||
* - slots: the client runtime SlotsService
|
||||
* - webRuntime: @deepseek-ai/dsh-web-app (bind-derived trusted hosts)
|
||||
* - slots: the client runtime SlotRegistry
|
||||
* - effect: the DSH-vendored cordis lifecycle helper
|
||||
* Drift from upstream is contained to this file.
|
||||
*
|
||||
* This file must stay FREE of Node.js types (`node:http`, `node:stream`,
|
||||
* `Buffer`): it is part of the CLIENT-reachable declaration graph (the
|
||||
* `Context` in `TabComponentProps` and the `declare module` augmentation),
|
||||
* so a Node import here would leak into browser-only consumer builds. The
|
||||
* webServer faces below are therefore structural mirrors with plain
|
||||
* interfaces (the host casts to real Node types at the few boundaries that
|
||||
* need them — e.g. the `ws` upgrade hook in src/index.ts).
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import type { Context } from 'cordis'
|
||||
import type { BetterSidebarService } from './client/service.ts'
|
||||
|
||||
/** The request face route handlers see (structural subset of node's
|
||||
* IncomingMessage: the URL/method/header reads and the async body
|
||||
* iteration `readJsonBody` uses). */
|
||||
export interface SidebarHttpRequest {
|
||||
url?: string
|
||||
method?: string
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
[Symbol.asyncIterator](): AsyncIterator<string | Uint8Array>
|
||||
}
|
||||
|
||||
/** The response face route handlers write to (structural subset of node's
|
||||
* ServerResponse: the status/header/body writes the routes use). */
|
||||
export interface SidebarHttpResponse {
|
||||
statusCode: number
|
||||
writeHead(status: number, headers?: Record<string, string>): void
|
||||
end(body?: string | Uint8Array): void
|
||||
}
|
||||
|
||||
/** The upgrade socket face (structural subset: the destroy the fences use). */
|
||||
export interface SidebarUpgradeSocket {
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
/** The upgrade head bytes (Buffer at runtime; typed as bytes so no Node
|
||||
* global leaks into the declaration graph). */
|
||||
export type SidebarUpgradeHead = Uint8Array
|
||||
|
||||
/** One named webserver route (mirror of the host-webserver WebRoute). */
|
||||
export interface SidebarWebRoute {
|
||||
kind: 'exact' | 'prefix'
|
||||
path: string
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
||||
handler: (req: SidebarHttpRequest, res: SidebarHttpResponse) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */
|
||||
export interface SidebarWebUpgradeRoute {
|
||||
path: string
|
||||
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
||||
handler: (req: SidebarHttpRequest, socket: SidebarUpgradeSocket, head: SidebarUpgradeHead) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** The httpServer service face this plugin uses. */
|
||||
export interface SidebarHttpServer {
|
||||
/** The webServer service face this plugin uses. */
|
||||
export interface SidebarWebServer {
|
||||
register(route: SidebarWebRoute): () => void
|
||||
registerUpgrade(route: SidebarWebUpgradeRoute): () => void
|
||||
}
|
||||
@@ -53,20 +86,20 @@ export interface SidebarSessionStore {
|
||||
/**
|
||||
* The live session's append-only event log (immutable snapshot; absent
|
||||
* on sessions the runtime has not hydrated). Read-only access — the
|
||||
* tasks.output route replays `task_output` tool/result rows from it.
|
||||
* jobs.output route replays `job_output` tool/result rows from it.
|
||||
*/
|
||||
events?: readonly SidebarSessionEvent[]
|
||||
} | undefined
|
||||
}
|
||||
|
||||
/** One loader entry's options slice (the connection row's resolved config). */
|
||||
export interface SidebarLoaderEntry {
|
||||
options: { name: string; config?: unknown }
|
||||
}
|
||||
|
||||
/** The loader face used to read the connection row's trustedHosts config. */
|
||||
export interface SidebarLoader {
|
||||
entries(): Iterable<SidebarLoaderEntry>
|
||||
/**
|
||||
* The web runtime service face (mirror of @deepseek-ai/dsh-web-app's
|
||||
* WebRuntimeValues): the bind-derived trust list the /api gateway's fence
|
||||
* accepts — LAN IP literals sampled when the server binds all interfaces,
|
||||
* plus explicit `--trusted-host` authorities.
|
||||
*/
|
||||
export interface SidebarWebRuntime {
|
||||
trustedHosts: readonly string[]
|
||||
}
|
||||
|
||||
/** Registration options the sidebar passes to `ctx.slots.register` (subset of the real options). */
|
||||
@@ -91,7 +124,7 @@ export interface SidebarSlotsService {
|
||||
register(options: SidebarSlotRegisterOptions, component: unknown): () => void
|
||||
/**
|
||||
* Run a callback for each declaration lifetime of a slot (the runtime
|
||||
* SlotsService.inject): a no-op while the slot is undeclared, so the
|
||||
* SlotRegistry.inject): a no-op while the slot is undeclared, so the
|
||||
* settings section registration waits for the settings shell.
|
||||
*/
|
||||
inject(key: string, callback: () => () => void): () => void
|
||||
@@ -158,33 +191,33 @@ export interface SidebarHistoryEntry {
|
||||
view?: unknown
|
||||
}
|
||||
|
||||
/** Lifecycle status set of one background task (closed wire union). */
|
||||
export type SidebarTaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
/** Lifecycle status set of one background job (closed wire union). */
|
||||
export type SidebarJobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* One background task as the client mirror sees it (wire `TaskView` shape:
|
||||
* One background job as the client mirror sees it (wire `JobView` shape:
|
||||
* id/kind/label/status/detail?/startedAt/finishedAt?).
|
||||
*/
|
||||
export interface SidebarTaskView {
|
||||
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
|
||||
export interface SidebarJobView {
|
||||
/** Registry-issued `<kind>-N` identity, stable for the job's whole life. */
|
||||
id: string
|
||||
/** Producer kind (`bash`, `pwsh`, `subagent`, …; open string by design). */
|
||||
kind: string
|
||||
/** Producer-supplied one-line label: the command, or the delegation description. */
|
||||
label: string
|
||||
/** Current lifecycle state. */
|
||||
status: SidebarTaskStatus
|
||||
status: SidebarJobStatus
|
||||
/** Kind-specific status detail ('exit code: 3'), present once supplied. */
|
||||
detail?: string
|
||||
/** Epoch ms when the task was registered. */
|
||||
/** Epoch ms when the job was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the task settled; absent while live. */
|
||||
/** Epoch ms when the job settled; absent while live. */
|
||||
finishedAt?: number
|
||||
}
|
||||
|
||||
/** The host tasks registry face the sidebar routes touch (structural mirror of `TaskService`). */
|
||||
export interface SidebarTasksService {
|
||||
/** Request cancellation; throws for an unknown or foreign task. */
|
||||
/** The host jobs registry face the sidebar routes touch (structural mirror of `JobRegistry`). */
|
||||
export interface SidebarJobsService {
|
||||
/** Request cancellation; throws for an unknown or foreign job. */
|
||||
kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished'
|
||||
}
|
||||
|
||||
@@ -222,11 +255,11 @@ export interface SidebarSessionList {
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
/**
|
||||
* Background tasks per session, last-wins from the harness's `session/tasks`
|
||||
* Background jobs per session, last-wins from the harness's `session/jobs`
|
||||
* push (a missing key is an empty set). Absent on runtime snapshots older
|
||||
* than the tasks mirror — the sidebar simply shows no task rows.
|
||||
* than the jobs mirror — the sidebar simply shows no job rows.
|
||||
*/
|
||||
tasksBySession?: Readonly<Record<string, readonly SidebarTaskView[]>>
|
||||
jobsBySession?: Readonly<Record<string, readonly SidebarJobView[]>>
|
||||
}
|
||||
|
||||
/** The client sessions service face (only the list feed is needed). */
|
||||
@@ -267,7 +300,7 @@ export interface SidebarSessionsService {
|
||||
|
||||
/**
|
||||
* The client locale service face (mirror of @deepseek-ai/dsh-client-locale's
|
||||
* LocaleService — only the slices the sidebar touches). The sidebar follows
|
||||
* LocaleRuntime — only the slices the sidebar touches). The sidebar follows
|
||||
* the DSH i18n system: the active locale is the Host-backed preference
|
||||
* (`locale.preference` in settings.yaml) rather than the raw browser
|
||||
* language, and the sidebar's zh/en dictionaries register into the service's
|
||||
@@ -312,7 +345,7 @@ export interface SidebarWorkspacesService {
|
||||
|
||||
/**
|
||||
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
|
||||
* InvariantService). The upstream augmentation does not reach this Context
|
||||
* InvariantRegistry). The upstream augmentation does not reach this Context
|
||||
* (dual-cordis-instance resolution), so the register signature is restated
|
||||
* structurally, exactly like the other service faces above.
|
||||
*/
|
||||
@@ -324,7 +357,7 @@ export interface SidebarInvariantsService {
|
||||
): () => void
|
||||
}
|
||||
|
||||
/** The settings service face (mirror of @deepseek-ai/dsh-settings' Settings). */
|
||||
/** The settings service face (mirror of @deepseek-ai/dsh-settings' SettingsProvider). */
|
||||
export interface SidebarSettingsService {
|
||||
/**
|
||||
* Register one namespace schema (the resolved value layers schema defaults,
|
||||
@@ -354,7 +387,7 @@ export interface SidebarSettingsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry).
|
||||
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRuntime).
|
||||
* The host half registers model-facing tools here; the registry attaches the
|
||||
* returned disposer to the contributing fiber so unloading unregisters them.
|
||||
*/
|
||||
@@ -380,10 +413,10 @@ export interface SidebarAgent {
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
httpServer: SidebarHttpServer
|
||||
webServer: SidebarWebServer
|
||||
sessions: SidebarSessionStore & SidebarSessionsService
|
||||
connection: SidebarConnectionHandle
|
||||
loader: SidebarLoader
|
||||
webRuntime: SidebarWebRuntime
|
||||
slots: SidebarSlotsService
|
||||
workspaces: SidebarWorkspacesService
|
||||
settings: SidebarSettingsService
|
||||
@@ -396,13 +429,13 @@ declare module 'cordis' {
|
||||
*/
|
||||
locale: SidebarLocaleService
|
||||
/**
|
||||
* The host background-task registry (`ctx.get('tasks')`; optional — the
|
||||
* The host background-job registry (`ctx.get('jobs')`; optional — the
|
||||
* sidebar routes degrade to a 503 when the deployment lacks it).
|
||||
*/
|
||||
tasks: SidebarTasksService
|
||||
jobs: SidebarJobsService
|
||||
/**
|
||||
* The host live-agent registry (`ctx.get('agents')`; optional — used to
|
||||
* resolve the caller the tasks fence compares against).
|
||||
* resolve the caller the jobs fence compares against).
|
||||
*/
|
||||
agents: SidebarAgentsService
|
||||
/**
|
||||
@@ -415,7 +448,7 @@ declare module 'cordis' {
|
||||
* Subscribe to the session append feed (mirror of the cordis event API):
|
||||
* the listener receives every appended session event with the LIVE
|
||||
* Session instance that appended it. The api-proxy pushes the same feed
|
||||
* to browsers; the sidebar uses it to mirror task_output events the
|
||||
* to browsers; the sidebar uses it to mirror job_output events the
|
||||
* session store's own log can lag behind (restart divergence). Returns
|
||||
* the disposer.
|
||||
*/
|
||||
@@ -428,4 +461,18 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual cordis-scope augmentation: DSH's runtime (and the public packages)
|
||||
* resolve against the vendored `@deepseek-ai/cordis` scope, which DSH's own
|
||||
* packages already augment with `effect`/`on`/service members. This side only
|
||||
* adds the sidebar service member so consumers importing `Context` from
|
||||
* `@deepseek-ai/cordis` (instead of the public `cordis` above) still see
|
||||
* `ctx.betterSidebar`.
|
||||
*/
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
betterSidebar: BetterSidebarService
|
||||
}
|
||||
}
|
||||
|
||||
export type { Context }
|
||||
|
||||
+10
-1
@@ -66,5 +66,14 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
||||
if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
|
||||
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
||||
}
|
||||
return { ok: true, ref: { sessionId, path: `/${pathSegments.join('/')}` } }
|
||||
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
|
||||
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
|
||||
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
|
||||
// the html route's isWithin(cwd) fence would then reject every drive path.
|
||||
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
|
||||
const first = pathSegments[0] ?? ''
|
||||
const path = /^[A-Za-z]:$/.test(first)
|
||||
? pathSegments.join('/')
|
||||
: `/${pathSegments.join('/')}`
|
||||
return { ok: true, ref: { sessionId, path } }
|
||||
}
|
||||
|
||||
+37
-41
@@ -4,9 +4,10 @@
|
||||
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
|
||||
* and the terminal WebSocket upgrade. Every route passes the same
|
||||
* browser-trust fence as the /api gateway — Host-header loopback or the
|
||||
* connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP
|
||||
* literals per boot) — with the trustedHosts read live from the connection
|
||||
* loader row so the fence never drifts from the deployment's.
|
||||
* web runtime's `trustedHosts` (LAN IP literals sampled at boot plus
|
||||
* `--trusted-host` authorities), read per request from the live service
|
||||
* value so the fence tracks the same trust source the /api gateway derives
|
||||
* its list from.
|
||||
*
|
||||
* All operations are conversation-scoped: requests carry a sessionId, the
|
||||
* session's authoritative cwd comes from the session store, and terminal
|
||||
@@ -15,8 +16,9 @@
|
||||
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
import type { Context } from './context-types.ts'
|
||||
import type { Context, SidebarHttpRequest } from './context-types.ts'
|
||||
import {
|
||||
Config,
|
||||
PrefsSchema,
|
||||
@@ -36,7 +38,7 @@ import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { buildTasksApi, type SidebarTasksRoutes } from './tasks-routes.ts'
|
||||
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
export { Config }
|
||||
@@ -58,8 +60,8 @@ export type {
|
||||
/** Plugin identity for cordis.yml rows. */
|
||||
export const name = 'dsh-better-sidebar'
|
||||
|
||||
/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
|
||||
export const inject = ['httpServer', 'sessions', 'loader', 'tools']
|
||||
/** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */
|
||||
export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
|
||||
|
||||
/** Content types for the media route, by extension. */
|
||||
const MEDIA_TYPES: Record<string, string> = {
|
||||
@@ -82,17 +84,6 @@ export function mediaTypeForPath(path: string): string {
|
||||
return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
|
||||
function trustedHostsOf(ctx: Context): string[] {
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
if (entry.options.name === 'connection') {
|
||||
const config = entry.options.config as { trustedHosts?: string[] } | undefined
|
||||
return config?.trustedHosts ?? []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session's authoritative working directory. The attached session
|
||||
* header wins; while the session is still hydrating from persistence (the
|
||||
@@ -199,12 +190,12 @@ function buildApi(
|
||||
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
|
||||
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
||||
}
|
||||
// Background tasks: the LIST rides the harness's `session/tasks` push
|
||||
// Background jobs: the LIST rides the harness's `session/jobs` push
|
||||
// mirror, so these routes only replay output the model has read (from the
|
||||
// session's own event log — no DSH source is touched, the model's
|
||||
// task_output cursor is never consumed) and kill (the registry's stock
|
||||
// API). A deployment without the tasks registry downgrades kill to a 503.
|
||||
const tasksApi: SidebarTasksRoutes = buildTasksApi(ctx, resolved.readLimit)
|
||||
// job_output cursor is never consumed) and kill (the registry's stock
|
||||
// API). A deployment without the jobs registry downgrades kill to a 503.
|
||||
const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
|
||||
return {
|
||||
'session.cwd': (payload) => {
|
||||
const { sessionId, cwd } = cwdOf(payload)
|
||||
@@ -334,14 +325,14 @@ function buildApi(
|
||||
agentPtyRegistry.close(uuid)
|
||||
return { ok: true }
|
||||
},
|
||||
// Background tasks: read one task's output (a REPLAY of what the model
|
||||
// Background jobs: read one job's output (a REPLAY of what the model
|
||||
// has read so far, from the owner session's event log — the model's
|
||||
// task_output cursor is never touched, so the human pane can never steal
|
||||
// the agent's bytes), and kill one task. The task LIST itself arrives
|
||||
// through the harness's session/tasks push mirror, so no list route
|
||||
// exists. Kill is fenced to the owning session by the tasks registry.
|
||||
'tasks.output': (payload) => tasksApi.output(payload),
|
||||
'tasks.kill': (payload) => tasksApi.kill(payload),
|
||||
// job_output cursor is never touched, so the human pane can never steal
|
||||
// the agent's bytes), and kill one job. The job LIST itself arrives
|
||||
// through the harness's session/jobs push mirror, so no list route
|
||||
// exists. Kill is fenced to the owning session by the jobs registry.
|
||||
'jobs.output': (payload) => jobsApi.output(payload),
|
||||
'jobs.kill': (payload) => jobsApi.kill(payload),
|
||||
// The side card preferences. The settings service is optional in the
|
||||
// composition; while absent the routes report undefined and the client
|
||||
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
||||
@@ -425,7 +416,7 @@ function buildApi(
|
||||
|
||||
/**
|
||||
* Plugin body: mount the fenced routes and the pty lifecycle.
|
||||
* @param ctx - host plugin context (httpServer, sessions, loader).
|
||||
* @param ctx - host plugin context (webServer, sessions, webRuntime).
|
||||
* @param config - deployment-provided limits; the Loader validates against
|
||||
* {@link Config} and fills defaults, direct callers get them from
|
||||
* {@link resolveSidebarConfig}.
|
||||
@@ -435,8 +426,11 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// restore it before any terminal can spawn (idempotent).
|
||||
ensureSpawnHelper()
|
||||
const resolved = resolveSidebarConfig(config)
|
||||
const trustedHosts = trustedHostsOf(ctx)
|
||||
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
|
||||
// The web runtime's bind-derived trust list (boot-sampled LAN literals
|
||||
// plus --trusted-host authorities) — the authoritative source the /api
|
||||
// gateway fence derives its list from. Read per request from the live
|
||||
// service value; a replaced list takes effect without a plugin restart.
|
||||
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
|
||||
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
|
||||
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
|
||||
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
|
||||
@@ -503,7 +497,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/api',
|
||||
handler: async (req, res) => {
|
||||
@@ -541,7 +535,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
|
||||
|
||||
// ── Media route (images for the editor) ─────────────────────────────────
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/file',
|
||||
handler: async (req, res) => {
|
||||
@@ -599,7 +593,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// boundary, this header is defense-in-depth so even a top-level load of
|
||||
// the URL (e.g. a popup opened by a previewed page) stays in an opaque
|
||||
// origin with no same-origin access to the GUI.
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/html',
|
||||
handler: async (req, res) => {
|
||||
@@ -662,14 +656,16 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// terminals, scheduled-0 for UI tabs which keep the same reconnect grace
|
||||
// contract the host has always had).
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
ctx.effect(() => ctx.webServer.registerUpgrade({
|
||||
path: '/sidebar/ws/terminal',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
// The structural request/socket/head faces satisfy the shared fence;
|
||||
// the `ws` package wants the real Node types — cast at this boundary.
|
||||
wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
|
||||
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
|
||||
})
|
||||
},
|
||||
@@ -684,14 +680,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// sends `{type:'close'}` on the terminal WS, which kills the pty, which
|
||||
// fires a change here, which converges the view).
|
||||
const agentListWss = new WebSocketServer({ noServer: true })
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
ctx.effect(() => ctx.webServer.registerUpgrade({
|
||||
path: '/sidebar/ws/agent-terminals',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
agentListWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
agentListWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
|
||||
void attachAgentList(agentPtyRegistry, ws, req)
|
||||
})
|
||||
},
|
||||
@@ -710,7 +706,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
async function attachAgentList(
|
||||
registry: AgentPtyRegistry,
|
||||
ws: WebSocket,
|
||||
req: IncomingMessage,
|
||||
req: SidebarHttpRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
@@ -750,7 +746,7 @@ async function attachTerminal(
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ws: WebSocket,
|
||||
req: IncomingMessage,
|
||||
req: SidebarHttpRequest,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
): Promise<void> {
|
||||
try {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the sidebar owns no service state or event protocol
|
||||
* of its own — every route is mounted under the host's httpServer fence, the
|
||||
* of its own — every route is mounted under the host's webServer fence, the
|
||||
* pty lifecycle is exercised by the smoke spec, and the panel's store is a
|
||||
* plain snapshot registry asserted by the behavior specs. The route fence,
|
||||
* pty quota, and store semantics are each observed through their seams.
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
/**
|
||||
* Background-task routes of the /sidebar JSON API ('tasks.output' /
|
||||
* 'tasks.kill'). The task LIST needs no route: it arrives through the
|
||||
* harness's `session/tasks` push mirror (`tasksBySession` in the sessions
|
||||
* Background-job routes of the /sidebar JSON API ('jobs.output' /
|
||||
* 'jobs.kill'). The job LIST needs no route: it arrives through the
|
||||
* harness's `session/jobs` push mirror (`jobsBySession` in the sessions
|
||||
* list feed). The routes:
|
||||
*
|
||||
* - 'tasks.output' — REPLAYS the output the MODEL has read so far for one
|
||||
* task. The source is the owner session's own event log: `tool/call` rows
|
||||
* of `task_output` name the task via `arguments.task_id`, and the paired
|
||||
* - 'jobs.output' — REPLAYS the output the MODEL has read so far for one
|
||||
* job. The source is the owner session's own event log: `tool/call` rows
|
||||
* of `job_output` name the job via `arguments.job_id`, and the paired
|
||||
* `tool/result` rows carry the finalized content the model received.
|
||||
* Because the session store's in-memory log can lag the live append feed
|
||||
* after a host restart (the store session stays frozen at its
|
||||
* rehydration boundary), the plugin ALSO mirrors task_output events from
|
||||
* rehydration boundary), the plugin ALSO mirrors job_output events from
|
||||
* the live `session/event` feed and merges both sources (deduped by seq).
|
||||
* This touches NO DSH source: the model's `task_output` cursor is never
|
||||
* consumed, and the pane stays empty until the agent reads the task.
|
||||
* - 'tasks.kill' — the registry's stock `kill` (a pristine DSH API),
|
||||
* This touches NO DSH source: the model's `job_output` cursor is never
|
||||
* consumed, and the pane stays empty until the agent reads the job.
|
||||
* - 'jobs.kill' — the registry's stock `kill` (a pristine DSH API),
|
||||
* fenced by the owning session via the live agent caller. Absent registry
|
||||
* → 503, mirroring the settings routes' optional-service downgrade.
|
||||
*/
|
||||
import type { Context, SidebarSessionEvent } from './context-types.ts'
|
||||
import { requireString, SidebarError } from './wire.ts'
|
||||
|
||||
/** The two background-task routes of the sidebar API. */
|
||||
export interface SidebarTasksRoutes {
|
||||
/** The output the model has read so far for one task (event replay, capped). */
|
||||
/** The two background-job routes of the sidebar API. */
|
||||
export interface SidebarJobsRoutes {
|
||||
/** The output the model has read so far for one job (event replay, capped). */
|
||||
output(payload: unknown): { text: string; truncated: boolean; read: boolean }
|
||||
/** Request cancellation of one task (live tasks flip to stopping). */
|
||||
/** Request cancellation of one job (live jobs flip to stopping). */
|
||||
kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' }
|
||||
}
|
||||
|
||||
@@ -77,40 +77,40 @@ function resultIsError(message: ToolResultMessageLike): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether a task_output result carries no new output — the controller's
|
||||
/** Whether a job_output result carries no new output — the controller's
|
||||
* model-facing "(no new output)" body, noise for the human pane. */
|
||||
function isNoNewOutput(text: string): boolean {
|
||||
return text.startsWith('(no new output)')
|
||||
}
|
||||
|
||||
/** One compact task_output trace (a tool/call or its paired tool/result). */
|
||||
interface TaskOutputTrace {
|
||||
/** One compact job_output trace (a tool/call or its paired tool/result). */
|
||||
interface JobOutputTrace {
|
||||
seq: number
|
||||
kind: 'call' | 'result'
|
||||
/** The tool call identity pairing the two rows. */
|
||||
callId: string
|
||||
/** tool/call: the task id parsed from the model arguments. */
|
||||
taskId?: string
|
||||
/** tool/call: the job id parsed from the model arguments. */
|
||||
jobId?: string
|
||||
/** tool/result: the finalized text the model received. */
|
||||
text?: string
|
||||
/** tool/result: whether the result was an error (read counts, text skipped). */
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
/** Extract the task_output trace of one raw session event (undefined = unrelated). */
|
||||
function traceOf(event: SidebarSessionEvent): TaskOutputTrace | undefined {
|
||||
/** Extract the job_output trace of one raw session event (undefined = unrelated). */
|
||||
function traceOf(event: SidebarSessionEvent): JobOutputTrace | undefined {
|
||||
if (event.type === 'tool/call') {
|
||||
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
|
||||
if (data.name !== 'task_output' || typeof data.callId !== 'string') return undefined
|
||||
let taskId: string | undefined
|
||||
if (data.name !== 'job_output' || typeof data.callId !== 'string') return undefined
|
||||
let jobId: string | undefined
|
||||
try {
|
||||
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { task_id?: unknown }
|
||||
if (typeof args.task_id === 'string') taskId = args.task_id
|
||||
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { job_id?: unknown }
|
||||
if (typeof args.job_id === 'string') jobId = args.job_id
|
||||
} catch {
|
||||
// Malformed model arguments: not a task_output pair.
|
||||
// Malformed model arguments: not a job_output pair.
|
||||
}
|
||||
if (taskId === undefined) return undefined
|
||||
return { seq: event.seq, kind: 'call', callId: data.callId, taskId }
|
||||
if (jobId === undefined) return undefined
|
||||
return { seq: event.seq, kind: 'call', callId: data.callId, jobId }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
|
||||
@@ -132,17 +132,17 @@ function traceOf(event: SidebarSessionEvent): TaskOutputTrace | undefined {
|
||||
const MIRROR_MAX_ENTRIES = 200
|
||||
|
||||
/**
|
||||
* The live task_output mirror: subscribes to the session append feed and
|
||||
* caches the task_output traces the session store's own log can lag behind
|
||||
* The live job_output mirror: subscribes to the session append feed and
|
||||
* caches the job_output traces the session store's own log can lag behind
|
||||
* (after a host restart the store session stays frozen at its rehydration
|
||||
* boundary, so `session.events` misses everything appended since — the very
|
||||
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
|
||||
* same feed to browsers.
|
||||
*/
|
||||
function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): readonly TaskOutputTrace[] } {
|
||||
const perSession = new Map<string, TaskOutputTrace[]>()
|
||||
function createJobOutputMirror(ctx: Context): { entries(sessionId: string): readonly JobOutputTrace[] } {
|
||||
const perSession = new Map<string, JobOutputTrace[]>()
|
||||
// tool/call identities per session, so unrelated tool/result rows are
|
||||
// never cached (only task_output results pair with a cached call).
|
||||
// never cached (only job_output results pair with a cached call).
|
||||
const callIds = new Map<string, Set<string>>()
|
||||
if (typeof ctx.on !== 'function') {
|
||||
// Test doubles without the event API degrade to seed-only replay.
|
||||
@@ -165,9 +165,9 @@ function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): rea
|
||||
push(sessionId, trace)
|
||||
}
|
||||
})
|
||||
ctx.effect(() => dispose, 'dsh-better-sidebar: task-output event mirror')
|
||||
ctx.effect(() => dispose, 'dsh-better-sidebar: job-output event mirror')
|
||||
|
||||
const push = (sessionId: string, trace: TaskOutputTrace): void => {
|
||||
const push = (sessionId: string, trace: JobOutputTrace): void => {
|
||||
let list = perSession.get(sessionId)
|
||||
if (list === undefined) perSession.set(sessionId, list = [])
|
||||
list.push(trace)
|
||||
@@ -187,43 +187,43 @@ function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): rea
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tasks routes bound to the plugin context. `output` merges the
|
||||
* owner session's own event log with the live task_output mirror; `kill`
|
||||
* reads the tasks/agents services lazily and degrades to a 503 when the
|
||||
* Build the jobs routes bound to the plugin context. `output` merges the
|
||||
* owner session's own event log with the live job_output mirror; `kill`
|
||||
* reads the jobs/agents services lazily and degrades to a 503 when the
|
||||
* deployment lacks the registry.
|
||||
* @param ctx - host plugin context.
|
||||
* @param outputLimit - response cap for one output replay in bytes; longer
|
||||
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
|
||||
*/
|
||||
export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes {
|
||||
const tasks = ctx.get('tasks')
|
||||
export function buildJobsApi(ctx: Context, outputLimit: number): SidebarJobsRoutes {
|
||||
const jobs = ctx.get('jobs')
|
||||
const agents = ctx.get('agents')
|
||||
const mirror = createTaskOutputMirror(ctx)
|
||||
const mirror = createJobOutputMirror(ctx)
|
||||
/** The live caller whose session id the registry fence compares against. */
|
||||
const callerOf = (sessionId: string) => agents?.get(sessionId)
|
||||
/** Registry refusals become a 404 task-error; unknown and foreign ids are indistinguishable. */
|
||||
/** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
|
||||
const registryError = (error: unknown): SidebarError =>
|
||||
new SidebarError('task-error', error instanceof Error ? error.message : String(error), 404)
|
||||
new SidebarError('job-error', error instanceof Error ? error.message : String(error), 404)
|
||||
return {
|
||||
output(payload) {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const id = requireString(payload, 'id')
|
||||
// Merge the store's event log (durable seed + whatever it received)
|
||||
// with the live mirror, deduped by seq — a trace never double-counts.
|
||||
const bySeq = new Map<number, TaskOutputTrace>()
|
||||
const bySeq = new Map<number, JobOutputTrace>()
|
||||
for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
|
||||
const trace = traceOf(event)
|
||||
if (trace !== undefined) bySeq.set(trace.seq, trace)
|
||||
}
|
||||
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace)
|
||||
// Pair calls with results in seq order: the model's reads, oldest first.
|
||||
const taskOf = new Map<string, string>()
|
||||
const jobOf = new Map<string, string>()
|
||||
const parts: string[] = []
|
||||
let read = false
|
||||
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) {
|
||||
if (trace.kind === 'call') {
|
||||
if (trace.taskId !== undefined) taskOf.set(trace.callId, trace.taskId)
|
||||
} else if (taskOf.get(trace.callId) === id) {
|
||||
if (trace.jobId !== undefined) jobOf.set(trace.callId, trace.jobId)
|
||||
} else if (jobOf.get(trace.callId) === id) {
|
||||
read = true
|
||||
if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) {
|
||||
parts.push(trace.text)
|
||||
@@ -238,8 +238,8 @@ export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRo
|
||||
}
|
||||
},
|
||||
kill(payload) {
|
||||
if (tasks === undefined) {
|
||||
throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503)
|
||||
if (jobs === undefined) {
|
||||
throw new SidebarError('job-error', 'the background-job registry is not mounted in this deployment', 503)
|
||||
}
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const id = requireString(payload, 'id')
|
||||
@@ -248,7 +248,7 @@ export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRo
|
||||
? record.reason
|
||||
: 'user requested via sidebar'
|
||||
try {
|
||||
return { ok: true, outcome: tasks.kill(id, callerOf(sessionId), reason) }
|
||||
return { ok: true, outcome: jobs.kill(id, callerOf(sessionId), reason) }
|
||||
} catch (error) {
|
||||
throw registryError(error)
|
||||
}
|
||||
+38
-4
@@ -22,10 +22,10 @@ export interface SidebarPrefs {
|
||||
autoOpenSubagent: boolean
|
||||
/**
|
||||
* Whether the sidebar auto-activates (opens the panel) and expands the
|
||||
* Tasks page when a NEW background task appears for the current
|
||||
* conversation (any new task id, not just the first one).
|
||||
* Jobs page when a NEW background job appears for the current
|
||||
* conversation (any new job id, not just the first one).
|
||||
*/
|
||||
autoOpenTasks: boolean
|
||||
autoOpenJobs: boolean
|
||||
/**
|
||||
* Whether the model-facing agent terminal tools (terminal_create / list /
|
||||
* send / read / wait_for / resize / signal / close) are injected into the
|
||||
@@ -33,6 +33,18 @@ export interface SidebarPrefs {
|
||||
* user explicitly enables it in the side card settings.
|
||||
*/
|
||||
agentTerminalTools: boolean
|
||||
/**
|
||||
* Custom terminal font-family stack (a CSS font-family value, e.g.
|
||||
* `'JetBrains Mono', monospace`). Empty string follows the app's theme
|
||||
* monospace font (`--ds-font-family-code`). Applied live to every
|
||||
* terminal tab; configured under the terminal card's secondary settings.
|
||||
*/
|
||||
terminalFontFamily: string
|
||||
/**
|
||||
* Custom terminal font size in px (9–32). Applied live to every terminal
|
||||
* tab; configured under the terminal card's secondary settings.
|
||||
*/
|
||||
terminalFontSize: number
|
||||
/**
|
||||
* Whether expanding the bottom panel for the FIRST time in a session tries
|
||||
* to open a fresh terminal tab there (the terminal quota/type still gates
|
||||
@@ -97,6 +109,15 @@ export interface SidebarPrefs {
|
||||
* next matching viewer (or the download button when none match).
|
||||
*/
|
||||
viewersEnabled: Record<string, boolean>
|
||||
/**
|
||||
* Plugin-owned settings blobs (v0.12.0+), keyed by descriptor id: each
|
||||
* registered tab/viewer that declares `settings.pluginToggles` (or writes
|
||||
* through `settings.render`'s `updatePluginSetting`) persists its values
|
||||
* here — an open map, so third-party keys need no host PrefsSchema field.
|
||||
* Values are JSON-serializable (the row controls produce strings /
|
||||
* numbers / booleans; custom panels are responsible for their own).
|
||||
*/
|
||||
pluginSettings: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
@@ -104,14 +125,21 @@ export const WIDTH_PERCENT_MIN = 20
|
||||
export const WIDTH_PERCENT_MAX = 60
|
||||
export const WIDTH_PERCENT_DEFAULT = 30
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
|
||||
export const TERMINAL_FONT_SIZE_MIN = 9
|
||||
export const TERMINAL_FONT_SIZE_MAX = 32
|
||||
export const TERMINAL_FONT_SIZE_DEFAULT = 13
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenTasks: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
@@ -119,9 +147,15 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
pluginSettings: {},
|
||||
}
|
||||
|
||||
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
||||
export function clampWidthPercent(value: number): number {
|
||||
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
/** Clamp one terminal font size into the contract range (shared by schema and client reads). */
|
||||
export function clampTerminalFontSize(value: number): number {
|
||||
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ const TRANSCRIPT_LIMIT = 1 << 20
|
||||
* Restore the executable bit pnpm strips from node-pty's prebuilt
|
||||
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
|
||||
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
|
||||
* @deepseek-ai/dsh-pty-local's ensure-spawn-helper postinstall, run at
|
||||
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
|
||||
* plugin activation so link-installed deployments get the fix too.
|
||||
*/
|
||||
export function ensureSpawnHelper(): void {
|
||||
|
||||
+9
-7
@@ -4,7 +4,7 @@
|
||||
* `{ok: true, value}` on success and `{ok: false, error: {code, message}}`
|
||||
* (HTTP 4xx/5xx matching the code) on failure.
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
||||
|
||||
/** Machine-readable error codes of the sidebar API. */
|
||||
export type SidebarErrorCode =
|
||||
@@ -15,7 +15,7 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'task-error'
|
||||
| 'job-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
@@ -41,11 +41,13 @@ export interface SidebarOk<T> { ok: true; value: T }
|
||||
export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } }
|
||||
|
||||
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
|
||||
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
export async function readJsonBody(req: SidebarHttpRequest): Promise<unknown> {
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
for await (const chunk of req) {
|
||||
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
||||
// The structural request yields string | Uint8Array; Buffer.from accepts
|
||||
// both (and the real runtime chunks are node Buffers anyway).
|
||||
const buffer = Buffer.from(chunk)
|
||||
total += buffer.length
|
||||
if (total > MAX_BODY_BYTES) {
|
||||
throw new SidebarError('bad-request', 'request body too large')
|
||||
@@ -62,19 +64,19 @@ export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
}
|
||||
|
||||
/** Write a JSON response with the given status. */
|
||||
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
export function writeJson(res: SidebarHttpResponse, status: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
/** Write the success envelope. */
|
||||
export function writeOk(res: ServerResponse, value: unknown): void {
|
||||
export function writeOk(res: SidebarHttpResponse, value: unknown): void {
|
||||
writeJson(res, 200, { ok: true, value })
|
||||
}
|
||||
|
||||
/** Write the failure envelope for any thrown value (unknown → internal 500). */
|
||||
export function writeError(res: ServerResponse, error: unknown): void {
|
||||
export function writeError(res: SidebarHttpResponse, error: unknown): void {
|
||||
if (error instanceof SidebarError) {
|
||||
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
|
||||
return
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* "Add plugin" modal tests: the body component (PluginListBody) renders the
|
||||
* GitHub topic BUTTON + the recommended plugin catalog of the matching kind
|
||||
* with per-entry jump/copy buttons. The copy button COPIES the install
|
||||
* script to the clipboard (writeClipboard) with a transient "Copied"
|
||||
* feedback — nothing is opened, nothing is closed, nothing can fail
|
||||
* outward.
|
||||
*
|
||||
* The body is tested directly — the Modal primitive runs hooks
|
||||
* unconditionally, so an open Modal must never be renderToString'd (same
|
||||
* rule as the settingsFor popup). The click paths are exercised with
|
||||
* createRoot + act().
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { PLUGIN_TOPIC_URL, type PluginEntry } from '../src/client/plugins-shared.ts'
|
||||
import { builtinTabPlugins } from '../src/client/plugins-tabs.ts'
|
||||
import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts'
|
||||
import { AddPluginModal, PluginListBody } from '../src/client/add-plugin-modal.tsx'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const officeEntry = builtinViewerPlugins[0] as PluginEntry
|
||||
|
||||
describe('PluginListBody (render)', () => {
|
||||
it('viewer kind renders the topic button and the office catalog entry', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const html = renderToString(createElement(PluginListBody, { service, kind: 'viewer' }))
|
||||
// The topic is a BUTTON (window.open in a new tab), not an anchor.
|
||||
expect(html).toContain('Browse more plugins on GitHub')
|
||||
// The name is a BUTTON on the same window.open path (an anchor would be
|
||||
// caught by the sidebar link takeover) — no href anywhere in the body.
|
||||
expect(html).not.toContain('href=')
|
||||
// The seeded catalog entry: name, description, install script,
|
||||
// a jump button (opens the repo in a NEW browser tab) and a copy button.
|
||||
expect(html).toContain(officeEntry.name)
|
||||
expect(html).toContain(officeEntry.install.replaceAll('&', '&'))
|
||||
expect(html).toContain('Open')
|
||||
expect(html).toContain('Copy')
|
||||
})
|
||||
|
||||
it('tab kind renders the sentinel entry (its own catalog, no office entry)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const html = renderToString(createElement(PluginListBody, { service, kind: 'tab' }))
|
||||
expect(html).toContain('dsh-sentinel 唤醒系统')
|
||||
expect(html).toContain('github:fuhefei/dsh-sentinel')
|
||||
expect(html).not.toContain(officeEntry.name)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddPluginModal wiring', () => {
|
||||
it('mounts the body through the Modal for each kind (client render)', () => {
|
||||
// The Modal primitive renders a portal into document.body — the server
|
||||
// renderer refuses portals, so mount it client-side (createRoot + act
|
||||
// in jsdom) and assert on the portaled body HTML.
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
for (const kind of ['tab', 'viewer'] as const) {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(AddPluginModal, { service, onClose: () => {}, kind }))
|
||||
})
|
||||
const html = document.body.innerHTML
|
||||
if (kind === 'viewer') {
|
||||
expect(html).toContain(officeEntry.name)
|
||||
} else {
|
||||
expect(html).toContain('dsh-sentinel 唤醒系统')
|
||||
}
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** Mount PluginListBody and click its copy button (the first one). */
|
||||
function mountBody(service: ReturnType<typeof createBetterSidebarService>, kind: 'tab' | 'viewer' = 'viewer'):
|
||||
{ clickCopy: () => void; buttonLabel: () => string | null; unmount: () => void } {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(PluginListBody, { service, kind }))
|
||||
})
|
||||
return {
|
||||
clickCopy: async () => {
|
||||
const button = container.querySelector('button[aria-label^="Copy install command:"]')!
|
||||
await act(async () => { (button as HTMLButtonElement).click() })
|
||||
},
|
||||
buttonLabel: () => {
|
||||
const button = container.querySelector('button[aria-label^="Copy install command:"]')
|
||||
return button?.textContent ?? null
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('PluginListBody copy click (interactive)', () => {
|
||||
it('clicking Copy writes the install script to the clipboard and flashes "Copied"', async () => {
|
||||
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
|
||||
const body = mountBody(service)
|
||||
expect(body.buttonLabel()).toBe('Copy')
|
||||
await body.clickCopy()
|
||||
|
||||
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
|
||||
expect(body.buttonLabel()).toBe('Copied')
|
||||
body.unmount()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('copying does NOT close anything and the modal body stays mounted', async () => {
|
||||
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
|
||||
const body = mountBody(service)
|
||||
await body.clickCopy()
|
||||
|
||||
// The entry is still rendered (nothing closed, no navigation, no open).
|
||||
expect(body.buttonLabel()).toBe('Copied')
|
||||
expect(service.getSnapshot().state).toBeUndefined()
|
||||
body.unmount()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('a DENIED clipboard write shows no "Copied" feedback (never claims a copy that did not happen)', async () => {
|
||||
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(false)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
|
||||
const body = mountBody(service)
|
||||
await body.clickCopy()
|
||||
|
||||
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
|
||||
expect(body.buttonLabel()).toBe('Copy')
|
||||
body.unmount()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('both the name button and the jump button open the repo in a NEW browser tab (window.open, not a link)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const opened: Array<[string, string, string]> = []
|
||||
const original = window.open
|
||||
window.open = ((url: string, target: string, features: string) => {
|
||||
opened.push([url, target, features])
|
||||
return null
|
||||
}) as typeof window.open
|
||||
try {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
|
||||
})
|
||||
const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')]
|
||||
expect(jumpButtons).toHaveLength(2) // the name + the jump button
|
||||
act(() => { (jumpButtons[0] as HTMLButtonElement).click() })
|
||||
act(() => { (jumpButtons[1] as HTMLButtonElement).click() })
|
||||
expect(opened).toEqual([
|
||||
[officeEntry.url, '_blank', 'noopener'],
|
||||
[officeEntry.url, '_blank', 'noopener'],
|
||||
])
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
} finally {
|
||||
window.open = original
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Public API surface guard (v0.12.0+):
|
||||
* - The CLIENT-REACHABLE source graph stays free of Node.js types: the
|
||||
* shipped `Context` (TabComponentProps / the cordis augmentation) must
|
||||
* not leak `node:*` imports or the `Buffer` global into browser-only
|
||||
* consumer builds. context-types.ts is the shared file — a regression
|
||||
* here breaks external consumers with `skipLibCheck: false`.
|
||||
* - The version/features constants exist and are consistent (the detailed
|
||||
* package.json lockstep assertion lives in service.spec.ts).
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The client-reachable source files (the declaration graph consumers pull). */
|
||||
function clientGraphFiles(): string[] {
|
||||
const files: string[] = []
|
||||
const walk = (dir: string): void => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) {
|
||||
if (name !== 'chunks') walk(path)
|
||||
} else if (/\.(ts|tsx)$/.test(name)) {
|
||||
files.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(join(ROOT, 'src', 'client'))
|
||||
for (const extra of ['src/context-types.ts', 'src/html-route.ts', 'src/prefs-shared.ts']) {
|
||||
files.push(join(ROOT, extra))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
describe('public API surface (v0.12.0)', () => {
|
||||
it('the client-reachable graph imports no node:* modules', () => {
|
||||
const offenders = clientGraphFiles().filter(path => {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
return /from\s+['"]node:/.test(text)
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('the client-reachable graph never references the Buffer global', () => {
|
||||
const offenders = clientGraphFiles().filter(path => {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
return text.split('\n').some(line =>
|
||||
/\bBuffer\b/.test(line)
|
||||
&& !/^\s*\*/.test(line) // doc comments
|
||||
&& !/^\s*\/\//.test(line) // line comments
|
||||
&& !/\/\*/.test(line), // block comment openers
|
||||
)
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('advertises the capability list and version (values come from service.ts)', () => {
|
||||
// Full releases are bare x.y.z; pre-releases (beta) carry -<tag>.<n>.
|
||||
expect(SIDEBAR_SERVICE_VERSION).toMatch(/^\d+\.\d+\.\d+(-[a-z0-9.]+)?$/)
|
||||
expect(SIDEBAR_FEATURES.length).toBeGreaterThanOrEqual(8)
|
||||
for (const feature of ['badge', 'tabLifecycle', 'updateTab', 'openFile', 'targetedOpen', 'stateSubscription', 'tabMeta', 'pluginSettings']) {
|
||||
expect(SIDEBAR_FEATURES).toContain(feature)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Bottom-panel first-expand auto-terminal tests — the TRIGGER CHAIN behind
|
||||
* issue #42 (bottom panel first expand + auto terminal → whole panel blank).
|
||||
*
|
||||
* #42's crash itself was the zero-size xterm open (the same root cause as
|
||||
* #25, fixed by openWhenSized — see tests/open-when-sized.spec.ts), and the
|
||||
* "whole panel blank" amplification is covered by the per-tab containment
|
||||
* tests (tests/sidebar-crash.spec.tsx). What no test pinned was the trigger:
|
||||
* the Sidebar effect that opens a terminal tab the FIRST time the bottom
|
||||
* panel expands (`bottomOpenedOnce`), its pref / enable-switch gates, and
|
||||
* the once-per-session semantics. These tests render the REAL Sidebar shell
|
||||
* against a minimal fake context (the repo's jsdom pattern) and drive the
|
||||
* bottom panel through the store, asserting the tab lands in the bottom
|
||||
* workbench, renders, and the panel itself survives.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
import { Sidebar } from '../src/client/Sidebar.tsx'
|
||||
import { allLeaves, createSidebarStore, toggleBottomPanel, type SidebarStore } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import { t } from '../src/client/locales.ts'
|
||||
|
||||
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
|
||||
class FakeWebSocket {
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
close = (): void => {}
|
||||
constructor(_url: string) {}
|
||||
}
|
||||
|
||||
interface MountedSidebar {
|
||||
container: HTMLDivElement
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
/** Mount the real Sidebar shell against a minimal context (real store + service). */
|
||||
function mountSidebar(): MountedSidebar {
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
// Fresh-session seed: the right panel starts OPEN, the bottom panel closed
|
||||
// (bottomOpen false → the first expansion is a false→true TRANSITION).
|
||||
store.setSession('s1')
|
||||
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
|
||||
// services return stable objects) — a fresh object per call loops forever.
|
||||
const localeSnapshot = { active: 'en' }
|
||||
const sessionsSnapshot = {
|
||||
current: 's1',
|
||||
// cwd present → api.sessionCwd is never called in these tests.
|
||||
byId: { s1: { cwd: '/tmp' } },
|
||||
}
|
||||
const ctx = {
|
||||
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
||||
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
|
||||
betterSidebar: service,
|
||||
}
|
||||
const root: Root = createRoot(container)
|
||||
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
|
||||
return {
|
||||
container,
|
||||
store,
|
||||
service,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Every tab currently living in the bottom workbench. */
|
||||
function bottomTabs(store: SidebarStore): Array<{ type: string; title: string }> {
|
||||
const state = store.getSnapshot().state
|
||||
if (state === undefined) return []
|
||||
return allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||
}
|
||||
|
||||
/** The stub terminal tab: counts renders so the test can see it actually mount. */
|
||||
function registerStubTerminal(service: BetterSidebarService, renders: { count: number }): void {
|
||||
service.registerTab({
|
||||
id: 'terminal',
|
||||
title: () => 'Terminal',
|
||||
component: () => {
|
||||
renders.count += 1
|
||||
return createElement('div', null, 'terminal-stub-content')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('bottom-panel first-expand auto terminal (issue #42 trigger chain)', () => {
|
||||
it('auto-opens exactly one terminal tab in the bottom workbench on the FIRST expansion', () => {
|
||||
const { container, store, service } = mountSidebar()
|
||||
const renders = { count: 0 }
|
||||
registerStubTerminal(service, renders)
|
||||
|
||||
act(() => { store.reduce(toggleBottomPanel) })
|
||||
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.bottomOpen).toBe(true)
|
||||
// The once-flag is set atomically with the first fire.
|
||||
expect(state.bottomOpenedOnce).toBe(true)
|
||||
// The terminal tab landed in the BOTTOM workbench (the effect pins the
|
||||
// active pane to the bottom tree's first leaf before opening).
|
||||
expect(bottomTabs(store).map(tab => tab.type)).toEqual(['terminal'])
|
||||
// The tab actually mounted and rendered — the chain is live end to end.
|
||||
expect(renders.count).toBeGreaterThanOrEqual(1)
|
||||
expect(container.textContent).toContain('terminal-stub-content')
|
||||
// The panel itself survived (the #42 symptom was a WHOLE blank panel):
|
||||
// the close control is present and the layout push for the bottom panel
|
||||
// height is live.
|
||||
expect(container.querySelector(`[aria-label="${t('collapseBottomPanel')}"]`)).not.toBeNull()
|
||||
expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')).toBe(
|
||||
`${state.bottomHeight}px`,
|
||||
)
|
||||
})
|
||||
|
||||
it('never repeats the auto-open on later expansions (once per session)', () => {
|
||||
const { store, service } = mountSidebar()
|
||||
registerStubTerminal(service, { count: 0 })
|
||||
|
||||
act(() => { store.reduce(toggleBottomPanel) })
|
||||
expect(bottomTabs(store)).toHaveLength(1)
|
||||
|
||||
// Collapse → expand again: the once-flag suppresses any second terminal.
|
||||
act(() => { store.reduce(toggleBottomPanel) })
|
||||
expect(store.getSnapshot().state!.bottomOpen).toBe(false)
|
||||
act(() => { store.reduce(toggleBottomPanel) })
|
||||
expect(bottomTabs(store)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not auto-open when the bottomPanelAutoTerminal pref is off', () => {
|
||||
const { store, service } = mountSidebar()
|
||||
registerStubTerminal(service, { count: 0 })
|
||||
// setPrefs REPLACES the prefs record — spread the current one so only
|
||||
// the toggle moves.
|
||||
act(() => { store.setPrefs({ ...store.getPrefs(), bottomPanelAutoTerminal: false }) })
|
||||
|
||||
act(() => { store.reduce(toggleBottomPanel) })
|
||||
expect(store.getSnapshot().state!.bottomOpen).toBe(true)
|
||||
expect(bottomTabs(store)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not auto-open when the terminal tab type is disabled in settings', () => {
|
||||
const { store, service } = mountSidebar()
|
||||
registerStubTerminal(service, { count: 0 })
|
||||
act(() => { store.setPrefs({ ...store.getPrefs(), tabsEnabled: { terminal: false } }) })
|
||||
|
||||
act(() => { store.reduce(toggleBottomPanel) })
|
||||
expect(store.getSnapshot().state!.bottomOpen).toBe(true)
|
||||
expect(bottomTabs(store)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
+30
-15
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 9 file
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 6 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||||
* and the html/browser sandbox settings pin the registry's behavior.
|
||||
* and the html sandbox settings pin the registry's behavior. (Office
|
||||
* previews are NOT built in — they moved to the recommended office plugin,
|
||||
* see src/client/plugins-viewers.ts.)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// First import: browser globals before the xterm-carrying builtin graph loads.
|
||||
@@ -44,13 +46,25 @@ describe('built-in tab registrations', () => {
|
||||
it('the subagent tab declares its auto-open related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenTasks'])
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools related setting', () => {
|
||||
it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal'])
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalFontFamily', 'terminalFontSize'])
|
||||
// The font rows are text/number inputs (not switches), with the size
|
||||
// row bounded by the shared 9–32 contract.
|
||||
expect(toggles[2]?.type).toBe('text')
|
||||
expect(toggles[2]?.title).toBeDefined()
|
||||
expect(toggles[2]?.placeholder).toBeDefined()
|
||||
expect(toggles[3]?.type).toBe('number')
|
||||
expect(toggles[3]?.min).toBe(9)
|
||||
expect(toggles[3]?.max).toBe(32)
|
||||
expect(toggles[3]?.unit).toBe('px')
|
||||
// The first two rows stay plain boolean switches.
|
||||
expect(toggles[0]?.type ?? 'switch').toBe('switch')
|
||||
expect(toggles[1]?.type ?? 'switch').toBe('switch')
|
||||
})
|
||||
|
||||
it('the browser tab declares its sandbox and link-takeover related settings', () => {
|
||||
@@ -85,11 +99,16 @@ describe('built-in tab registrations', () => {
|
||||
})
|
||||
|
||||
describe('built-in file viewer registrations', () => {
|
||||
it('registers the 9 built-in file viewers', () => {
|
||||
it('registers the 6 built-in file viewers (office previews live in the recommended office plugin)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
|
||||
['binary-download', 'code', 'docx', 'html', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
|
||||
['binary-download', 'code', 'html', 'image', 'markdown', 'pdf'],
|
||||
)
|
||||
// Office previews are not built in: docx/xlsx/pptx files fall through to
|
||||
// the download-only binary viewer (or a registered office plugin).
|
||||
expect(service.getFileViewers().map(v => v.id)).not.toContain('docx')
|
||||
expect(service.getFileViewers().map(v => v.id)).not.toContain('xlsx')
|
||||
expect(service.getFileViewers().map(v => v.id)).not.toContain('pptx')
|
||||
})
|
||||
|
||||
it('code is the catch-all at the lowest priority', () => {
|
||||
@@ -126,11 +145,14 @@ describe('built-in file viewer registrations', () => {
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
})
|
||||
|
||||
it('binary-download claims legacy office by extension', () => {
|
||||
it('binary-download claims legacy office by extension (office previews are not built in)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
|
||||
// Modern office files (zip containers, NUL-free) fall through to the
|
||||
// catch-all code viewer without an office plugin registered.
|
||||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
|
||||
@@ -143,13 +165,6 @@ describe('built-in file viewer registrations', () => {
|
||||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
|
||||
const { service } = setup()
|
||||
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
|
||||
// claims it before binary-download (-50) is consulted.
|
||||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
|
||||
})
|
||||
|
||||
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
|
||||
const { service } = setup()
|
||||
for (const viewer of service.getFileViewers()) {
|
||||
|
||||
+13
-11
@@ -42,7 +42,7 @@ function req(method: string, url: string, headers: Record<string, string> = {}):
|
||||
/** One handler instance over a scratch dir with a fake chunk file. */
|
||||
function setup(): { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>; dir: string; cleanup: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-'))
|
||||
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 0;')
|
||||
writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 0;')
|
||||
const handler = createBundleRouteHandler(() => true, dir)
|
||||
return { handler, dir, cleanup: () => { rmSync(dir, { recursive: true, force: true }) } }
|
||||
}
|
||||
@@ -52,7 +52,7 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toBe('text/javascript; charset=utf-8')
|
||||
expect(res.headers['cache-control']).toBe('no-cache')
|
||||
@@ -67,10 +67,10 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const first = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
|
||||
const etag = first.headers.etag!
|
||||
const second = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
|
||||
expect(second.status).toBe(304)
|
||||
expect(second.body).toBe('')
|
||||
expect(second.headers.etag).toBe(etag)
|
||||
@@ -83,10 +83,10 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, dir, cleanup } = setup()
|
||||
try {
|
||||
const first = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
|
||||
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 1;')
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
|
||||
writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 1;')
|
||||
const second = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
|
||||
expect(second.status).toBe(200)
|
||||
expect(second.headers.etag).not.toBe(first.headers.etag)
|
||||
expect(second.body).toContain('&& 1')
|
||||
@@ -112,7 +112,9 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
// 'terminal' is allowlisted but the scratch fixture only ships
|
||||
// client-editor.js — the built file is missing → 404.
|
||||
await handler(req('GET', '/sidebar/bundle/terminal.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(404)
|
||||
} finally {
|
||||
cleanup()
|
||||
@@ -123,7 +125,7 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('POST', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
await handler(req('POST', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(405)
|
||||
} finally {
|
||||
cleanup()
|
||||
@@ -135,7 +137,7 @@ describe('/sidebar/bundle route', () => {
|
||||
try {
|
||||
const handler = createBundleRouteHandler(() => false, dir)
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(403)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
@@ -143,6 +145,6 @@ describe('/sidebar/bundle route', () => {
|
||||
})
|
||||
|
||||
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
|
||||
expect([...CHUNK_NAMES]).toEqual(['docx', 'xlsx', 'pptx', 'terminal', 'editor'])
|
||||
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
|
||||
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
|
||||
const CHUNKS = ['terminal', 'editor']
|
||||
|
||||
describe('built chunk artifacts', () => {
|
||||
it('each chunk assigns its global registry slot when executed as a script', () => {
|
||||
|
||||
+21
-21
@@ -61,29 +61,29 @@ beforeEach(() => {
|
||||
describe('test-registry path (vitest / jsdom-less environments)', () => {
|
||||
it('returns the registered chunk exports, loading once even for concurrent callers', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('docx', async () => {
|
||||
registerChunkForTests('editor', async () => {
|
||||
calls += 1
|
||||
return { DocxView: 'docx-view' }
|
||||
return { TextEditor: 'text-editor' }
|
||||
})
|
||||
const [a, b, c] = await Promise.all([loadChunk('docx'), loadChunk('docx'), loadChunk('docx')])
|
||||
expect(a).toEqual({ DocxView: 'docx-view' })
|
||||
const [a, b, c] = await Promise.all([loadChunk('editor'), loadChunk('editor'), loadChunk('editor')])
|
||||
expect(a).toEqual({ TextEditor: 'text-editor' })
|
||||
expect(b).toBe(a)
|
||||
expect(c).toBe(a)
|
||||
expect(calls).toBe(1)
|
||||
// Memoized after resolution too.
|
||||
expect(await loadChunk('docx')).toBe(a)
|
||||
expect(await loadChunk('editor')).toBe(a)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('a failed load clears the cache so the next call retries', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('pptx', async () => {
|
||||
registerChunkForTests('terminal', async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('boom')
|
||||
return { PptxView: 'pptx-view' }
|
||||
return { TerminalView: 'terminal-view' }
|
||||
})
|
||||
await expect(loadChunk('pptx')).rejects.toThrow('boom')
|
||||
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
|
||||
await expect(loadChunk('terminal')).rejects.toThrow('boom')
|
||||
await expect(loadChunk('terminal')).resolves.toEqual({ TerminalView: 'terminal-view' })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -94,15 +94,15 @@ describe('production path (script injection + global registry + externals requir
|
||||
const loaded: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => {
|
||||
loaded.push(src)
|
||||
simulateScript('xlsx', (require) => ({ XlsxView: `view:${String(require('react'))}` }))
|
||||
simulateScript('editor', (require) => ({ TextEditor: `view:${String(require('react'))}` }))
|
||||
})
|
||||
const exports = await loadChunk('xlsx')
|
||||
expect(loaded).toEqual(['/sidebar/bundle/xlsx.js'])
|
||||
expect(exports).toEqual({ XlsxView: 'view:[object Object]' })
|
||||
const exports = await loadChunk('editor')
|
||||
expect(loaded).toEqual(['/sidebar/bundle/editor.js'])
|
||||
expect(exports).toEqual({ TextEditor: 'view:[object Object]' })
|
||||
// Externals resolved through the module system's seed branch, once.
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
// Memoized: no second script injection, no second externals resolution.
|
||||
await loadChunk('xlsx')
|
||||
await loadChunk('editor')
|
||||
expect(loaded).toHaveLength(1)
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
})
|
||||
@@ -123,9 +123,9 @@ describe('production path (script injection + global registry + externals requir
|
||||
it('a chunk requiring an unresolvable externals spec fails loudly', async () => {
|
||||
installModuleSystem()
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
simulateScript('docx', (require) => { require('not-in-the-table'); return {} })
|
||||
simulateScript('editor', (require) => { require('not-in-the-table'); return {} })
|
||||
})
|
||||
await expect(loadChunk('docx')).rejects.toThrow('missed the module table')
|
||||
await expect(loadChunk('editor')).rejects.toThrow('missed the module table')
|
||||
})
|
||||
|
||||
it('a script load failure rejects without materializing', async () => {
|
||||
@@ -149,14 +149,14 @@ describe('production path (script injection + global registry + externals requir
|
||||
let calls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
calls += 1
|
||||
simulateScript('pptx', () => {
|
||||
simulateScript('editor', () => {
|
||||
if (calls === 1) throw new Error('materialize boom')
|
||||
return { PptxView: 'pptx-view' }
|
||||
return { TextEditor: 'text-editor' }
|
||||
})
|
||||
})
|
||||
await expect(loadChunk('pptx')).rejects.toThrow('materialize boom')
|
||||
await expect(loadChunk('editor')).rejects.toThrow('materialize boom')
|
||||
// Retry: script re-injected, slot overwritten, materialization succeeds.
|
||||
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
|
||||
await expect(loadChunk('editor')).resolves.toEqual({ TextEditor: 'text-editor' })
|
||||
expect(calls).toBe(2)
|
||||
expect(modules.import).toHaveBeenCalled()
|
||||
})
|
||||
@@ -164,7 +164,7 @@ describe('production path (script injection + global registry + externals requir
|
||||
it('fails loudly when no module system is installed (before touching the network)', async () => {
|
||||
const loaded: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => { loaded.push(src) })
|
||||
await expect(loadChunk('docx')).rejects.toThrow('client module system unavailable')
|
||||
await expect(loadChunk('editor')).rejects.toThrow('client module system unavailable')
|
||||
expect(loaded).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Consumer-facing type surface compile gate (v0.12.0+): this file exercises
|
||||
* EVERY public type and descriptor field exactly the way an external plugin
|
||||
* would, so `pnpm typecheck` fails the moment the shipped declaration
|
||||
* surface (service.ts re-exports / descriptor fields / service methods)
|
||||
* drifts from what consumers can name. Type-only — erased at runtime, never
|
||||
* executed by vitest (no `*.spec` suffix).
|
||||
*
|
||||
* Mirrors the "external consumer" fixture: what is importable here from
|
||||
* `../src/client/service.ts` must also be importable from
|
||||
* `dsh-better-sidebar/client/service` in the built package.
|
||||
*/
|
||||
import type {} from '../src/client/service.ts'
|
||||
import {
|
||||
SIDEBAR_FEATURES,
|
||||
SIDEBAR_SERVICE_VERSION,
|
||||
} from '../src/client/service.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
FileFetchStrategy,
|
||||
FileViewerDescriptor,
|
||||
FileViewerProps,
|
||||
OpenTabSeed,
|
||||
SidebarSettingsDeclaration,
|
||||
SidebarSettingsRenderProps,
|
||||
SidebarSettingToggle,
|
||||
SidebarSettingToggleType,
|
||||
TabComponentProps,
|
||||
TabDescriptor,
|
||||
} from '../src/client/service.ts'
|
||||
import type {
|
||||
SessionScope,
|
||||
SidebarDiffRef,
|
||||
SidebarPrefs,
|
||||
SidebarSnapshot,
|
||||
SidebarState,
|
||||
SidebarStore,
|
||||
SidebarTab,
|
||||
TabType,
|
||||
} from '../src/client/service.ts'
|
||||
|
||||
/** A full-featured external tab descriptor using every v0.12.0 field. */
|
||||
const tab: TabDescriptor = {
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: (size: number) => null,
|
||||
order: 50,
|
||||
hidden: false,
|
||||
available: (ctx, scope, state) => scope.sessionId !== '' && state.panelOpen && ctx !== null,
|
||||
single: false,
|
||||
dedupeKey: (t: SidebarTab) => t.id,
|
||||
createTab: (state: SidebarState) => ({
|
||||
tab: { id: `my-plugin:db:${state.nextTerminal}`, type: 'my-plugin:db', title: 'DB', meta: { n: state.nextTerminal } },
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}),
|
||||
badge: (ctx, scope, state) => (state.expanded.length > 0 ? state.expanded.length : null),
|
||||
onOpen: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
|
||||
onActivate: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
|
||||
onClose: (tab: SidebarTab, scope: SessionScope) => { void tab; void scope },
|
||||
settings: {
|
||||
toggles: [{ key: 'autoOpenSubagent', title: 'Auto-open', type: 'switch' }],
|
||||
pluginToggles: [{
|
||||
key: 'pageSize',
|
||||
title: 'Page size',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 100,
|
||||
unit: 'rows',
|
||||
}],
|
||||
render: (props: SidebarSettingsRenderProps) => {
|
||||
props.updatePluginSetting('refresh', true)
|
||||
props.close()
|
||||
return null
|
||||
},
|
||||
},
|
||||
component: (props: TabComponentProps) => {
|
||||
const { ctx, store, scope, tab, visible } = props
|
||||
void ctx; void store; void scope; void tab; void visible
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
/** A full-featured external viewer using the v0.12.0 load signal. */
|
||||
const viewer: FileViewerDescriptor = {
|
||||
id: 'my-plugin:csv',
|
||||
title: 'CSV',
|
||||
exts: ['csv'],
|
||||
priority: 10,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head: Uint8Array) => head.length > 0,
|
||||
load: async (path: string, scope: SessionScope, signal?: AbortSignal) => {
|
||||
void path; void scope; void signal
|
||||
return { rows: [] }
|
||||
},
|
||||
settings: { pluginToggles: [{ key: 'delimiter', title: 'Delimiter', type: 'text', placeholder: ',' }] },
|
||||
component: (props: FileViewerProps) => {
|
||||
const { customData, content, truncated, mediaUrl, viewerId, path, title } = props
|
||||
void customData; void content; void truncated; void mediaUrl; void viewerId; void path; void title
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
/** The full service surface, exercised exactly as consumers call it. */
|
||||
declare const ctx: { betterSidebar: BetterSidebarService }
|
||||
const service: BetterSidebarService = ctx.betterSidebar
|
||||
service.registerTab(tab)
|
||||
service.registerFileViewer(viewer)
|
||||
service.getTabs()
|
||||
service.getFileViewers()
|
||||
service.getTab('my-plugin:db')
|
||||
service.isTabEnabled('my-plugin:db')
|
||||
service.isViewerEnabled('my-plugin:csv')
|
||||
service.matchFileViewer('a.csv', new Uint8Array([1]))
|
||||
service.closeTab('tab:1')
|
||||
service.subscribe(() => {})
|
||||
const seed: OpenTabSeed = { type: 'my-plugin:db', title: 'DB', path: '/p', id: 'x', meta: { a: 1 } }
|
||||
service.openTab(seed)
|
||||
service.openTab(seed, { sessionId: 's1', cwd: '/p' })
|
||||
service.version
|
||||
service.features.includes('badge')
|
||||
const snapshot: SidebarSnapshot | undefined = service.getSnapshot()
|
||||
void snapshot
|
||||
service.subscribeState(() => {})
|
||||
service.updateTab('tab:1', { title: 'T', path: '/p', meta: 1 })
|
||||
service.activateTab('tab:1')
|
||||
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/a.csv', 'Data')
|
||||
|
||||
/** Named state vocabulary stays importable (the pre-0.12 gap). */
|
||||
const diff: SidebarDiffRef = { kind: 'worktree', path: '/p/a.ts', staged: false }
|
||||
const prefs: SidebarPrefs = { ...({} as SidebarPrefs) }
|
||||
const store: SidebarStore = null as unknown as SidebarStore
|
||||
const toggleType: SidebarSettingToggleType = 'number'
|
||||
const toggle: SidebarSettingToggle = { key: 'k', title: 'K', type: toggleType }
|
||||
const declaration: SidebarSettingsDeclaration = { toggles: [toggle], pluginToggles: [toggle] }
|
||||
const typeName: TabType = 'my-plugin:db'
|
||||
const version: string = SIDEBAR_SERVICE_VERSION
|
||||
const features: readonly string[] = SIDEBAR_FEATURES
|
||||
void version; void features
|
||||
const strategy: FileFetchStrategy = 'mediaUrl'
|
||||
void diff; void prefs; void store; void declaration; void typeName; void strategy
|
||||
@@ -6,6 +6,7 @@
|
||||
* the session scope intact (the WHY of the path-encoding design).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
|
||||
|
||||
describe('encodeHtmlUrl', () => {
|
||||
@@ -38,11 +39,25 @@ describe('decodeHtmlUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes a Windows round-trip', () => {
|
||||
it('decodes a Windows round-trip (drive path WITHOUT a leading slash, so node resolve() keeps the drive)', () => {
|
||||
const url = encodeHtmlUrl('sess-1', 'C:\\Users\\me\\a.html')
|
||||
expect(decodeHtmlUrl(url)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: '/C:/Users/me/a.html' },
|
||||
ref: { sessionId: 'sess-1', path: 'C:/Users/me/a.html' },
|
||||
})
|
||||
// The host runs requireAbsolute() over the decoded path: with the old
|
||||
// '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive
|
||||
// doubled) on Windows and the isWithin(cwd) fence rejected every drive
|
||||
// path. The slash-free form must resolve back to the original path.
|
||||
if (process.platform === 'win32') {
|
||||
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes a lowercase-drive Windows path without a leading slash', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/html/s/d%3A/work/x.html')).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 's', path: 'd:/work/x.html' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* Host route tests for the background-task API ('tasks.output' / 'tasks.kill').
|
||||
* 'tasks.output' REPLAYS the output the model has read so far from the owner
|
||||
* session's event log (tool/call + tool/result pairs of task_output calls) —
|
||||
* the model's cursor is never touched, so nothing is stolen and unread tasks
|
||||
* report `read: false`. 'tasks.kill' uses the registry's stock kill, fenced
|
||||
* Host route tests for the background-job API ('jobs.output' / 'jobs.kill').
|
||||
* 'jobs.output' REPLAYS the output the model has read so far from the owner
|
||||
* session's event log (tool/call + tool/result pairs of job_output calls) —
|
||||
* the model's cursor is never touched, so nothing is stolen and unread jobs
|
||||
* report `read: false`. 'jobs.kill' uses the registry's stock kill, fenced
|
||||
* by the owning session, with a 503 when the registry is absent.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildTasksApi } from '../src/tasks-routes.ts'
|
||||
import { buildJobsApi } from '../src/jobs-routes.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import type { Context, SidebarSessionEvent } from '../src/context-types.ts'
|
||||
|
||||
/** A context whose `get` serves only the tasks/agents faces, with a session store. */
|
||||
function ctxWith(sessions: unknown, tasks: unknown, agents: unknown): Context {
|
||||
/** A context whose `get` serves only the jobs/agents faces, with a session store. */
|
||||
function ctxWith(sessions: unknown, jobs: unknown, agents: unknown): Context {
|
||||
return {
|
||||
sessions,
|
||||
get: (key: string) => (key === 'tasks' ? tasks : key === 'agents' ? agents : undefined),
|
||||
get: (key: string) => (key === 'jobs' ? jobs : key === 'agents' ? agents : undefined),
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ function ctxWithFeed(sessions: unknown): {
|
||||
/** A stub live agent (the fence compares `id` only). */
|
||||
const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } })
|
||||
|
||||
/** One task_output tool/call event. */
|
||||
function taskOutputCall(seq: number, callId: string, taskId: string): SidebarSessionEvent {
|
||||
return { type: 'tool/call', seq, time: seq, data: { name: 'task_output', callId, arguments: JSON.stringify({ task_id: taskId }) } }
|
||||
/** One job_output tool/call event. */
|
||||
function jobOutputCall(seq: number, callId: string, jobId: string): SidebarSessionEvent {
|
||||
return { type: 'tool/call', seq, time: seq, data: { name: 'job_output', callId, arguments: JSON.stringify({ job_id: jobId }) } }
|
||||
}
|
||||
|
||||
/** One tool/result event carrying the finalized text the model received. */
|
||||
function taskOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
|
||||
function jobOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
|
||||
return {
|
||||
type: 'tool/result',
|
||||
seq,
|
||||
@@ -74,18 +74,18 @@ function session(events: SidebarSessionEvent[]): unknown {
|
||||
return { header: { cwd: '/p' }, events }
|
||||
}
|
||||
|
||||
describe('tasks.output route (event replay)', () => {
|
||||
it('concatenates the task_output results the model read for the task, oldest first', () => {
|
||||
describe('jobs.output route (event replay)', () => {
|
||||
it('concatenates the job_output results the model read for the job, oldest first', () => {
|
||||
const events = [
|
||||
taskOutputCall(0, 'c1', 'bash-1'),
|
||||
taskOutputResult(1, 'c1', 'line1\nline2\n[status: running]'),
|
||||
taskOutputCall(2, 'c2', 'bash-1'),
|
||||
taskOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'),
|
||||
// Another task's reads are ignored.
|
||||
taskOutputCall(4, 'c3', 'bash-2'),
|
||||
taskOutputResult(5, 'c3', 'other task output'),
|
||||
jobOutputCall(0, 'c1', 'bash-1'),
|
||||
jobOutputResult(1, 'c1', 'line1\nline2\n[status: running]'),
|
||||
jobOutputCall(2, 'c2', 'bash-1'),
|
||||
jobOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'),
|
||||
// Another job's reads are ignored.
|
||||
jobOutputCall(4, 'c3', 'bash-2'),
|
||||
jobOutputResult(5, 'c3', 'other job output'),
|
||||
]
|
||||
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
|
||||
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]',
|
||||
truncated: false,
|
||||
@@ -95,14 +95,14 @@ describe('tasks.output route (event replay)', () => {
|
||||
|
||||
it('skips the controller\'s "(no new output)" deltas and error results', () => {
|
||||
const events = [
|
||||
taskOutputCall(0, 'c1', 'bash-1'),
|
||||
taskOutputResult(1, 'c1', 'real output\n[status: running]'),
|
||||
taskOutputCall(2, 'c2', 'bash-1'),
|
||||
taskOutputResult(3, 'c2', '(no new output)\n[status: running]'),
|
||||
taskOutputCall(4, 'c3', 'bash-1'),
|
||||
taskOutputResult(5, 'c3', 'boom', { isError: true }),
|
||||
jobOutputCall(0, 'c1', 'bash-1'),
|
||||
jobOutputResult(1, 'c1', 'real output\n[status: running]'),
|
||||
jobOutputCall(2, 'c2', 'bash-1'),
|
||||
jobOutputResult(3, 'c2', '(no new output)\n[status: running]'),
|
||||
jobOutputCall(4, 'c3', 'bash-1'),
|
||||
jobOutputResult(5, 'c3', 'boom', { isError: true }),
|
||||
]
|
||||
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
|
||||
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'real output\n[status: running]',
|
||||
truncated: false,
|
||||
@@ -110,36 +110,36 @@ describe('tasks.output route (event replay)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports read:false until the model reads the task (no registry call at all)', () => {
|
||||
const events = [taskOutputCall(0, 'c1', 'bash-2')]
|
||||
const tasks = { kill: vi.fn() }
|
||||
const api = buildTasksApi(ctxWith({ get: () => session(events) }, tasks, undefined), 100)
|
||||
it('reports read:false until the model reads the job (no registry call at all)', () => {
|
||||
const events = [jobOutputCall(0, 'c1', 'bash-2')]
|
||||
const jobs = { kill: vi.fn() }
|
||||
const api = buildJobsApi(ctxWith({ get: () => session(events) }, jobs, undefined), 100)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
// The replay never touches the registry — the model's cursor is safe by construction.
|
||||
expect(tasks.kill).not.toHaveBeenCalled()
|
||||
expect(jobs.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mirrors live task_output events the store log misses (restart divergence)', () => {
|
||||
it('mirrors live job_output events the store log misses (restart divergence)', () => {
|
||||
// The store session is frozen at its rehydration boundary (no events);
|
||||
// the read exists only on the live session/event feed.
|
||||
const { ctx, emit } = ctxWithFeed({ get: () => session([]) })
|
||||
const api = buildTasksApi(ctx, 512 * 1024)
|
||||
const api = buildJobsApi(ctx, 512 * 1024)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
|
||||
// The live feed delivers the task_output call and its result.
|
||||
emit({ id: 's1' }, taskOutputCall(100, 'c-live', 'bash-1'))
|
||||
emit({ id: 's1' }, taskOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]'))
|
||||
// The live feed delivers the job_output call and its result.
|
||||
emit({ id: 's1' }, jobOutputCall(100, 'c-live', 'bash-1'))
|
||||
emit({ id: 's1' }, jobOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]'))
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'live line\n[status: completed, exit code: 0]',
|
||||
truncated: false,
|
||||
read: true,
|
||||
})
|
||||
|
||||
// Unrelated results are never cached (no task_output call paired them),
|
||||
// Unrelated results are never cached (no job_output call paired them),
|
||||
// and another session's feed does not leak into this one.
|
||||
emit({ id: 's1' }, taskOutputResult(102, 'c-other', 'unpaired line'))
|
||||
emit({ id: 's2' }, taskOutputCall(103, 'c-s2', 'bash-1'))
|
||||
emit({ id: 's2' }, taskOutputResult(104, 'c-s2', 'other session line'))
|
||||
emit({ id: 's1' }, jobOutputResult(102, 'c-other', 'unpaired line'))
|
||||
emit({ id: 's2' }, jobOutputCall(103, 'c-s2', 'bash-1'))
|
||||
emit({ id: 's2' }, jobOutputResult(104, 'c-s2', 'other session line'))
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'live line\n[status: completed, exit code: 0]',
|
||||
truncated: false,
|
||||
@@ -156,11 +156,11 @@ describe('tasks.output route (event replay)', () => {
|
||||
// A seed read (seq 5) in the store log plus a live read (seq 106) that
|
||||
// ALSO reached the store log would double-count — the seq dedupe keeps
|
||||
// exactly one copy of each.
|
||||
const events = [taskOutputCall(5, 'c-seed', 'bash-1'), taskOutputResult(6, 'c-seed', 'seed line')]
|
||||
const events = [jobOutputCall(5, 'c-seed', 'bash-1'), jobOutputResult(6, 'c-seed', 'seed line')]
|
||||
const { ctx, emit } = ctxWithFeed({ get: () => session(events) })
|
||||
const api = buildTasksApi(ctx, 512 * 1024)
|
||||
emit({ id: 's1' }, taskOutputCall(106, 'c-live', 'bash-1'))
|
||||
emit({ id: 's1' }, taskOutputResult(107, 'c-live', 'live line'))
|
||||
const api = buildJobsApi(ctx, 512 * 1024)
|
||||
emit({ id: 's1' }, jobOutputCall(106, 'c-live', 'bash-1'))
|
||||
emit({ id: 's1' }, jobOutputResult(107, 'c-live', 'live line'))
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
|
||||
text: 'seed line\nlive line',
|
||||
truncated: false,
|
||||
@@ -169,8 +169,8 @@ describe('tasks.output route (event replay)', () => {
|
||||
})
|
||||
|
||||
it('caps oversized replays with the truncated flag', () => {
|
||||
const events = [taskOutputCall(0, 'c1', 'bash-1'), taskOutputResult(1, 'c1', 'x'.repeat(10_000))]
|
||||
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100)
|
||||
const events = [jobOutputCall(0, 'c1', 'bash-1'), jobOutputResult(1, 'c1', 'x'.repeat(10_000))]
|
||||
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100)
|
||||
const value = api.output({ sessionId: 's1', id: 'bash-1' })
|
||||
expect(value.text).toBe('x'.repeat(100))
|
||||
expect(value.truncated).toBe(true)
|
||||
@@ -178,41 +178,41 @@ describe('tasks.output route (event replay)', () => {
|
||||
})
|
||||
|
||||
it('rejects a missing sessionId or id as bad-request', () => {
|
||||
const api = buildTasksApi(ctxWith({ get: () => undefined }, undefined, undefined), 100)
|
||||
const api = buildJobsApi(ctxWith({ get: () => undefined }, undefined, undefined), 100)
|
||||
expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
|
||||
expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('tasks.kill route', () => {
|
||||
describe('jobs.kill route', () => {
|
||||
it('kills with the forwarded reason and the live caller', () => {
|
||||
const tasks = { kill: vi.fn(() => 'requested' as const) }
|
||||
const jobs = { kill: vi.fn(() => 'requested' as const) }
|
||||
const agents = { get: vi.fn((id: string) => agent(id)) }
|
||||
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, agents), 100)
|
||||
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, agents), 100)
|
||||
expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' }))
|
||||
.toEqual({ ok: true, outcome: 'requested' })
|
||||
expect(tasks.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop')
|
||||
expect(jobs.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop')
|
||||
})
|
||||
|
||||
it('defaults the reason when none is supplied', () => {
|
||||
const tasks = { kill: vi.fn(() => 'already-finished' as const) }
|
||||
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100)
|
||||
const jobs = { kill: vi.fn(() => 'already-finished' as const) }
|
||||
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100)
|
||||
expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' })
|
||||
expect(tasks.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar')
|
||||
expect(jobs.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar')
|
||||
})
|
||||
|
||||
it('maps registry refusals to a 404 task-error', () => {
|
||||
const tasks = { kill: vi.fn(() => { throw new Error('unknown task bash-9') }) }
|
||||
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100)
|
||||
it('maps registry refusals to a 404 job-error', () => {
|
||||
const jobs = { kill: vi.fn(() => { throw new Error('unknown job bash-9') }) }
|
||||
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100)
|
||||
expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'task-error', status: 404 }),
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 404 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to a 503 when the tasks registry is absent (output keeps working)', () => {
|
||||
const api = buildTasksApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100)
|
||||
it('degrades to a 503 when the jobs registry is absent (output keeps working)', () => {
|
||||
const api = buildJobsApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100)
|
||||
expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'task-error', status: 503 }),
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 503 }),
|
||||
)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
})
|
||||
@@ -67,11 +67,11 @@ describe('lazyChunkComponent', () => {
|
||||
|
||||
it('shows the failure reason with a retry that recovers', async () => {
|
||||
let fail = true
|
||||
registerChunkForTests('docx', async () => {
|
||||
registerChunkForTests('editor', async () => {
|
||||
if (fail) throw new Error('boom')
|
||||
return { DocxView: Marker }
|
||||
return { TextEditor: Marker }
|
||||
})
|
||||
const Wrapper = lazyChunkComponent<Record<string, never>>('docx', (mod) => mod.DocxView as ComponentType<Record<string, never>> | undefined)
|
||||
const Wrapper = lazyChunkComponent<Record<string, never>>('editor', (mod) => mod.TextEditor as ComponentType<Record<string, never>> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, {}))
|
||||
await act(async () => {})
|
||||
expect(container.textContent).toContain('boom')
|
||||
@@ -87,8 +87,8 @@ describe('lazyChunkComponent', () => {
|
||||
|
||||
it('props flow through to the chunk component', async () => {
|
||||
const Recorder = (props: { label: string }): ReactNode => createElement('div', { 'data-testid': 'rec', 'data-label': props.label })
|
||||
registerChunkForTests('pptx', async () => ({ PptxView: Recorder }))
|
||||
const Wrapper = lazyChunkComponent<{ label: string }>('pptx', (mod) => mod.PptxView as ComponentType<{ label: string }> | undefined)
|
||||
registerChunkForTests('terminal', async () => ({ TerminalView: Recorder }))
|
||||
const Wrapper = lazyChunkComponent<{ label: string }>('terminal', (mod) => mod.TerminalView as ComponentType<{ label: string }> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, { label: 'hello' }))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
|
||||
@@ -99,7 +99,7 @@ describe('lazyChunkComponent', () => {
|
||||
describe('built-in descriptor contract (render-prop functions)', () => {
|
||||
it('every heavy built-in viewer component is callable as a plain function without a chunk (returns an element)', () => {
|
||||
const viewers = builtinViewers()
|
||||
for (const id of ['docx', 'xlsx', 'pptx', 'markdown', 'html', 'code']) {
|
||||
for (const id of ['markdown', 'html', 'code']) {
|
||||
const descriptor = viewers.find(viewer => viewer.id === id)
|
||||
expect(descriptor, id).toBeDefined()
|
||||
// No chunk registered: calling must not throw — it returns the lazy
|
||||
|
||||
@@ -65,7 +65,7 @@ function bundleId(file: string): string {
|
||||
}
|
||||
|
||||
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
|
||||
const CHUNK_FILES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'].map(name => `lib/client-${name}.js`)
|
||||
const CHUNK_FILES = ['terminal', 'editor'].map(name => `lib/client-${name}.js`)
|
||||
|
||||
/** The global registry slot a built chunk script assigns (its factory key). */
|
||||
function chunkSlot(file: string): string {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Markdown preview copy-label spec: the fence copy buttons ("复制" / "Copy")
|
||||
* must follow the DSH locale service through `codeLabels` — the DSH
|
||||
* MarkdownText/CodeBlock are cordis-free and fall back to HARDCODED Chinese
|
||||
* when the caller omits the labels, so TextEditor must pass its own
|
||||
* dictionary copy (`t('copy')` / `t('copied')`), re-evaluated per render.
|
||||
* Rendered with renderToString (the initial preview mode never mounts
|
||||
* CodeMirror, so no DOM/effects are needed).
|
||||
*/
|
||||
import { describe, expect, it, afterEach } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import './browser-globals.ts'
|
||||
import { TextEditor } from '../src/client/TextEditor.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { attachLocale } from '../src/client/locales.ts'
|
||||
import type { FileViewerProps } from '../src/client/service.ts'
|
||||
|
||||
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
|
||||
class FakeLocale {
|
||||
active: string = 'zh'
|
||||
getSnapshot(): { active: string } {
|
||||
return { active: this.active }
|
||||
}
|
||||
subscribe(_fn: () => void): () => void {
|
||||
return () => {}
|
||||
}
|
||||
register(_ns: string, _locale: string, _dict: Record<string, string>): () => void {
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const CTX = {} as Parameters<typeof TextEditor>[0]['ctx']
|
||||
|
||||
/** A markdown source with one fenced code block (the copy-button surface). */
|
||||
const MD_WITH_FENCE = '```ts\nconst a = 1\n```'
|
||||
|
||||
function viewerProps(overrides: Partial<FileViewerProps> = {}): FileViewerProps {
|
||||
return {
|
||||
ctx: CTX,
|
||||
store: createSidebarStore(),
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
path: '/p/a/README.md',
|
||||
title: 'README.md',
|
||||
viewerId: 'markdown',
|
||||
content: MD_WITH_FENCE,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
attachLocale(undefined)
|
||||
})
|
||||
|
||||
describe('markdown preview code-block copy labels (DSH i18n following)', () => {
|
||||
it('renders the fence copy button with the zh dictionary label by default', () => {
|
||||
const locale = new FakeLocale()
|
||||
locale.active = 'zh'
|
||||
attachLocale(locale)
|
||||
const html = renderToString(createElement(TextEditor, viewerProps()))
|
||||
expect(html).toContain('复制')
|
||||
expect(html).not.toContain('Copy')
|
||||
})
|
||||
|
||||
it('follows the attached locale service live: en renders "Copy" instead of the zh label', () => {
|
||||
const locale = new FakeLocale()
|
||||
locale.active = 'en'
|
||||
attachLocale(locale)
|
||||
const html = renderToString(createElement(TextEditor, viewerProps()))
|
||||
expect(html).toContain('Copy')
|
||||
expect(html).not.toContain('复制')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* openWhenSized tests: the deferred one-shot open guard for hosts that may
|
||||
* not have a real size yet (xterm crashes when opened in a zero-size
|
||||
* container — the WKWebView bottom-panel blank-terminal bug, issue #25).
|
||||
* The polling must open exactly once, only once the host reports a real
|
||||
* size, stop when the host leaves the document, and cancel cleanly.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { openWhenSized } from '../src/client/open-when-sized.ts'
|
||||
|
||||
/** A manually-stepped raf/caf pair so the polling is deterministic. */
|
||||
function makeScheduler(): {
|
||||
raf: (cb: FrameRequestCallback) => number
|
||||
caf: (id: number) => void
|
||||
tick: () => void
|
||||
pending: () => number
|
||||
} {
|
||||
let nextId = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
return {
|
||||
raf: (cb) => { const id = ++nextId; frames.set(id, cb); return id },
|
||||
caf: (id) => { frames.delete(id) },
|
||||
tick: () => {
|
||||
const cbs = [...frames.values()]
|
||||
frames.clear()
|
||||
for (const cb of cbs) cb(0)
|
||||
},
|
||||
pending: () => frames.size,
|
||||
}
|
||||
}
|
||||
|
||||
/** A host whose reported size can be changed between ticks. */
|
||||
function makeHost(width: number, height: number): {
|
||||
el: HTMLElement
|
||||
setSize: (w: number, h: number) => void
|
||||
} {
|
||||
const el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
const setSize = (w: number, h: number): void => {
|
||||
Object.defineProperty(el, 'clientWidth', { value: w, configurable: true })
|
||||
Object.defineProperty(el, 'clientHeight', { value: h, configurable: true })
|
||||
}
|
||||
setSize(width, height)
|
||||
return { el, setSize }
|
||||
}
|
||||
|
||||
describe('openWhenSized', () => {
|
||||
it('opens on the first tick when the host already has a size, then stops', () => {
|
||||
const { raf, caf, tick, pending } = makeScheduler()
|
||||
const host = makeHost(320, 200)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
expect(pending()).toBe(0)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('defers while the host is zero-sized and opens exactly once once sized', () => {
|
||||
const { raf, caf, tick, pending } = makeScheduler()
|
||||
const host = makeHost(0, 0)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
tick()
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
expect(pending()).toBe(1)
|
||||
host.setSize(320, 200)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
expect(pending()).toBe(0)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('opens when only one dimension was missing', () => {
|
||||
const { raf, caf, tick } = makeScheduler()
|
||||
const host = makeHost(320, 0)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
host.setSize(320, 120)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('cancels a pending open and leaves no scheduled frames', () => {
|
||||
const { raf, caf, tick, pending } = makeScheduler()
|
||||
const host = makeHost(0, 0)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
cancel()
|
||||
expect(pending()).toBe(0)
|
||||
host.setSize(320, 200)
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
// Idempotent: cancelling again is a no-op.
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('stops polling when the host leaves the document', () => {
|
||||
const { raf, caf, tick, pending } = makeScheduler()
|
||||
const host = makeHost(0, 0)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
host.el.remove()
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
expect(pending()).toBe(0)
|
||||
// Even if the host somehow got a size after detaching, nothing opens.
|
||||
host.setSize(320, 200)
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('does not swallow exceptions from open (the caller owns error handling)', () => {
|
||||
const { raf, caf, tick } = makeScheduler()
|
||||
const host = makeHost(320, 200)
|
||||
let calls = 0
|
||||
const cancel = openWhenSized(host.el, () => {
|
||||
calls += 1
|
||||
throw new Error('boom')
|
||||
}, raf, caf)
|
||||
expect(() => tick()).toThrow('boom')
|
||||
expect(calls).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Built-in plugin catalog data-integrity tests (both files: the TAB
|
||||
* catalog in plugins-tabs.ts and the FILE-PREVIEWER catalog in
|
||||
* plugins-viewers.ts). Every entry must be installable as data — a unique
|
||||
* id (npm package name), a GitHub URL, a non-empty localized description
|
||||
* (string or () => string), and an install script that mentions the dsh
|
||||
* plugin CLI. The catalogs are the discovery surface of the two "add
|
||||
* plugin" modals; a malformed entry would break the install flow, so the
|
||||
* shape is pinned here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { builtinTabPlugins } from '../src/client/plugins-tabs.ts'
|
||||
import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts'
|
||||
import type { PluginEntry } from '../src/client/plugins-shared.ts'
|
||||
|
||||
/** Resolve an i18n-friendly string-or-function value (mirror of textOf). */
|
||||
function textOf(value: string | (() => string)): string {
|
||||
return typeof value === 'function' ? value() : value
|
||||
}
|
||||
|
||||
const catalogs: Array<[string, readonly PluginEntry[]]> = [
|
||||
['tab catalog', builtinTabPlugins],
|
||||
['viewer catalog', builtinViewerPlugins],
|
||||
]
|
||||
|
||||
describe('builtin plugin catalogs', () => {
|
||||
it('the viewer catalog has the office plugin, the tab catalog has the sentinel plugin', () => {
|
||||
const ids = (list: readonly PluginEntry[]): string[] => list.map(p => p.id)
|
||||
expect(ids(builtinViewerPlugins)).toContain('@huanlin/dsh-plugin-better-sidebar-plugin-office')
|
||||
expect(ids(builtinTabPlugins)).toContain('@dsh-external/dsh-sentinel')
|
||||
expect(ids(builtinTabPlugins)).not.toContain('@huanlin/dsh-plugin-better-sidebar-plugin-office')
|
||||
})
|
||||
|
||||
for (const [name, list] of catalogs) {
|
||||
describe(name, () => {
|
||||
it('every entry has a unique id (the npm package name)', () => {
|
||||
const ids = list.map(p => p.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const id of ids) {
|
||||
expect(id.length).toBeGreaterThan(0)
|
||||
expect(id).not.toContain(' ')
|
||||
}
|
||||
})
|
||||
|
||||
it('every entry has a name, a GitHub URL, and an install script', () => {
|
||||
for (const entry of list) {
|
||||
expect(entry.name.length).toBeGreaterThan(0)
|
||||
expect(entry.url.startsWith('https://github.com/')).toBe(true)
|
||||
expect(entry.install.length).toBeGreaterThan(0)
|
||||
expect(entry.install).toContain('dsh plugin')
|
||||
}
|
||||
})
|
||||
|
||||
it('every description resolves to a non-empty localized string', () => {
|
||||
for (const entry of list) {
|
||||
expect(textOf(entry.description).length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('every install script starts at the DSH home (cd ~/.dsh) as the modal promises', () => {
|
||||
for (const entry of list) {
|
||||
expect(entry.install.startsWith('cd ~/.dsh')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(sidebar)
|
||||
expect(unwrapped.name).toBe('dsh-better-sidebar')
|
||||
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader', 'tools'])
|
||||
expect(unwrapped.inject).toEqual(['webServer', 'sessions', 'webRuntime', 'tools'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
@@ -45,11 +45,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
expect(resolved.openByDefault).toBe(true)
|
||||
expect(resolved.defaultWidthPercent).toBe(30)
|
||||
expect(resolved.autoOpenSubagent).toBe(true)
|
||||
// A new background task auto-opens the Tasks page too.
|
||||
expect(resolved.autoOpenTasks).toBe(true)
|
||||
// A new background job auto-opens the Jobs page too.
|
||||
expect(resolved.autoOpenJobs).toBe(true)
|
||||
// The terminal tools default OFF (the feature is dormant until the user
|
||||
// enables it in the side card settings).
|
||||
expect(resolved.agentTerminalTools).toBe(false)
|
||||
// The terminal font customizations default to the theme (empty family)
|
||||
// and 13px.
|
||||
expect(resolved.terminalFontFamily).toBe('')
|
||||
expect(resolved.terminalFontSize).toBe(13)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
expect(resolved.tabsEnabled).toEqual({})
|
||||
expect(resolved.viewersEnabled).toEqual({})
|
||||
@@ -59,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Pure payload logic for the viewer selection popup ("add to
|
||||
* conversation"): the fence header (relative path + line span), the
|
||||
* over-limit plain-line fallback, and the best-effort reverse-search that
|
||||
* maps a preview selection back to source lines.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SELECTION_LIMIT,
|
||||
buildSelectionInsert,
|
||||
headerOf,
|
||||
linesOfSelection,
|
||||
} from '../src/client/selection-payload.ts'
|
||||
|
||||
describe('headerOf', () => {
|
||||
it('projects the path relative to the session cwd', () => {
|
||||
expect(headerOf('/p/docs/plan.md', '/p', { start: 12, end: 12 })).toBe('docs/plan.md:12')
|
||||
})
|
||||
|
||||
it('falls back to the absolute path when the cwd is unknown', () => {
|
||||
expect(headerOf('/p/docs/plan.md', undefined, { start: 3, end: 3 })).toBe('/p/docs/plan.md:3')
|
||||
})
|
||||
|
||||
it('keeps the absolute path for files outside the cwd', () => {
|
||||
expect(headerOf('/other/x.md', '/p', { start: 1, end: 1 })).toBe('/other/x.md:1')
|
||||
})
|
||||
|
||||
it('writes a single line number for single-line selections', () => {
|
||||
expect(headerOf('/p/a.md', '/p', { start: 5, end: 5 })).toBe('a.md:5')
|
||||
})
|
||||
|
||||
it('writes the start-end range for multi-line selections', () => {
|
||||
expect(headerOf('/p/a.md', '/p', { start: 5, end: 9 })).toBe('a.md:5-9')
|
||||
})
|
||||
|
||||
it('omits the line suffix entirely when the lines are unknown', () => {
|
||||
expect(headerOf('/p/a.md', '/p', undefined)).toBe('a.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSelectionInsert', () => {
|
||||
it('wraps a small selection in a fenced block with the path:line info line', () => {
|
||||
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 2, end: 4 }, 'const x = 1')).toBe(
|
||||
'```a.ts:2-4\nconst x = 1\n```',
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a selection exactly at the limit (fenced)', () => {
|
||||
const text = 'x'.repeat(SELECTION_LIMIT)
|
||||
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 1, end: 1 }, text)).toBe(
|
||||
`\`\`\`a.ts:1\n${text}\n\`\`\``,
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the content past the limit: one plain path line, no fence', () => {
|
||||
const text = 'x'.repeat(SELECTION_LIMIT + 1)
|
||||
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 7, end: 7 }, text)).toBe('a.ts:7')
|
||||
})
|
||||
|
||||
it('keeps the plain-line form when past the limit without line numbers', () => {
|
||||
const text = 'x'.repeat(SELECTION_LIMIT + 1)
|
||||
expect(buildSelectionInsert('/p/a.ts', '/p', undefined, text)).toBe('a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('linesOfSelection', () => {
|
||||
const source = 'alpha\nbeta\ngamma alpha\ndelta\n'
|
||||
|
||||
it('maps a unique hit to its source line span', () => {
|
||||
expect(linesOfSelection(source, 'beta')).toEqual({ start: 2, end: 2 })
|
||||
expect(linesOfSelection(source, 'gamma alpha')).toEqual({ start: 3, end: 3 })
|
||||
})
|
||||
|
||||
it('spans the end line when the selection crosses line breaks', () => {
|
||||
expect(linesOfSelection(source, 'beta\ngamma')).toEqual({ start: 2, end: 3 })
|
||||
})
|
||||
|
||||
it('returns null when the text is missing from the source', () => {
|
||||
expect(linesOfSelection(source, 'nope')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on an ambiguous (multi-hit) match', () => {
|
||||
expect(linesOfSelection(source, 'alpha')).toBeNull()
|
||||
})
|
||||
|
||||
it('strips a single trailing newline before searching (DOM block selections)', () => {
|
||||
expect(linesOfSelection(source, 'delta\n')).toEqual({ start: 4, end: 4 })
|
||||
})
|
||||
|
||||
it('returns null for an empty selection', () => {
|
||||
expect(linesOfSelection(source, '')).toBeNull()
|
||||
expect(linesOfSelection(source, '\n')).toBeNull()
|
||||
})
|
||||
})
|
||||
+388
-2
@@ -20,8 +20,8 @@ if (g.localStorage === undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts'
|
||||
import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts'
|
||||
|
||||
describe('BetterSidebar service', () => {
|
||||
it('registerTab adds to the registry and dispose removes it', () => {
|
||||
@@ -597,3 +597,389 @@ describe('service.openTab auto-expand for content opens', () => {
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('version and feature detection (v0.12.0)', () => {
|
||||
it('reports the plugin version in lockstep with package.json', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pkg = JSON.parse(require('node:fs').readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
|
||||
expect(SIDEBAR_SERVICE_VERSION).toBe(pkg.version)
|
||||
expect(createBetterSidebarService(createSidebarStore()).version).toBe(SIDEBAR_SERVICE_VERSION)
|
||||
})
|
||||
|
||||
it('advertises every v0.12.0 capability in the features list', () => {
|
||||
const service = createBetterSidebarService(createSidebarStore())
|
||||
for (const feature of SIDEBAR_FEATURES) {
|
||||
expect(service.features).toContain(feature)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('state subscription (v0.12.0)', () => {
|
||||
it('getSnapshot mirrors the store snapshot (sessionId/state/prefs)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
store.setSession('s1')
|
||||
const snapshot = service.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('s1')
|
||||
expect(snapshot.state).toBeDefined()
|
||||
expect(snapshot.prefs.openByDefault).toBe(true)
|
||||
})
|
||||
|
||||
it('subscribeState fires on state changes but NOT on registry changes', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
store.setSession('s1')
|
||||
let calls = 0
|
||||
const unsub = service.subscribeState(() => { calls++ })
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
expect(calls).toBe(1)
|
||||
service.registerTab({ id: 'x', title: 'X', component: () => null })
|
||||
expect(calls).toBe(1) // registry changes are the registry subscription's job
|
||||
unsub()
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTab (v0.12.0)', () => {
|
||||
it('patches title / path / meta of an open tab', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'doc', title: 'Doc', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:1' })
|
||||
service.updateTab('doc:1', { title: 'Compiling…', meta: { progress: 42 } })
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'doc:1')!
|
||||
expect(tab.title).toBe('Compiling…')
|
||||
expect(tab.meta).toEqual({ progress: 42 })
|
||||
// A missing tab id is a no-op (does not throw).
|
||||
service.updateTab('doc:missing', { title: 'X' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('activateTab (v0.12.0)', () => {
|
||||
it('activates a tab in either tree and fires onActivate with the session scope', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const seen: Array<{ tab: string; sessionId: string }> = []
|
||||
service.registerTab({
|
||||
id: 'git',
|
||||
title: 'Git',
|
||||
single: true,
|
||||
onActivate: (tab, scope) => { seen.push({ tab: tab.id, sessionId: scope.sessionId }) },
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
// Land in the bottom tree by switching the active pane.
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id }))
|
||||
service.openTab({ type: 'git', title: 'Git' })
|
||||
const gitTab = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')!
|
||||
expect(gitTab).toBeDefined()
|
||||
service.activateTab(gitTab.id)
|
||||
expect(seen).toEqual([{ tab: gitTab.id, sessionId: 's1' }])
|
||||
// The active pane followed the tab into the bottom tree.
|
||||
expect(store.getSnapshot().state!.activePane).toBe(gitTab.id === '' ? null : allLeaves(store.getSnapshot().state!.bottomSplits).find(l => l.tabs.some(t => t.id === gitTab.id))!.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('targeted openTab (v0.12.0)', () => {
|
||||
it('lands the open in the target session without switching the UI session', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'notes', title: 'Notes', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' })
|
||||
// The UI snapshot still shows s1, untouched (its default explorer tab
|
||||
// is the only one — no notes tab landed there).
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('s1')
|
||||
expect(allLeaves(snapshot.state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')).toHaveLength(0)
|
||||
// Switching to s2 reveals the tab.
|
||||
store.setSession('s2')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
|
||||
expect(tabs.map(t => t.id)).toContain('notes:1')
|
||||
})
|
||||
|
||||
it('a scope naming the active session behaves exactly like a plain open (notify included)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
store.setSession('s1')
|
||||
let calls = 0
|
||||
store.subscribe(() => { calls++ })
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' }, { sessionId: 's1' })
|
||||
expect(calls).toBe(1)
|
||||
expect(store.getSnapshot().state!.splits).not.toBe(undefined)
|
||||
})
|
||||
|
||||
it('dedupe runs against the TARGET session (opens there focus an existing tab of that session)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'notes', title: 'Notes', single: true, component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
|
||||
service.openTab({ type: 'notes', title: 'Notes' }, { sessionId: 's2' })
|
||||
store.setSession('s2')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'notes')
|
||||
expect(tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openFile (v0.12.0)', () => {
|
||||
it('opens the file in the editor tab of the scope session with a basename title', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts')
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
expect(tab?.path).toBe('/p/src/main.ts')
|
||||
// Windows separators are handled too.
|
||||
service.openFile({ sessionId: 's1' }, 'C:\\x\\y\\spec.ts', 'custom title')
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')
|
||||
expect(tabs[tabs.length - 1]?.title).toBe('custom title')
|
||||
expect(tabs[tabs.length - 1]?.path).toBe('C:\\x\\y\\spec.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab lifecycle callbacks (v0.12.0)', () => {
|
||||
/** A descriptor with recording callbacks, plus a count of onOpen/onActivate/onClose. */
|
||||
const setup = () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const events: string[] = []
|
||||
service.registerTab({
|
||||
id: 'life',
|
||||
title: 'Life',
|
||||
single: true,
|
||||
onOpen: () => { events.push('open') },
|
||||
onActivate: () => { events.push('activate') },
|
||||
onClose: () => { events.push('close') },
|
||||
component: () => null,
|
||||
})
|
||||
return { store, service, events }
|
||||
}
|
||||
|
||||
it('onOpen fires on creation; a dedupe-focus fires onActivate instead', () => {
|
||||
const { store, service, events } = setup()
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'life', title: 'Life' })
|
||||
expect(events).toEqual(['open'])
|
||||
service.openTab({ type: 'life', title: 'Life' })
|
||||
expect(events).toEqual(['open', 'activate'])
|
||||
})
|
||||
|
||||
it('onClose fires when closeTab closes the tab', () => {
|
||||
const { store, service, events } = setup()
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'life', title: 'Life' })
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')!
|
||||
events.length = 0
|
||||
service.closeTab(tab.id)
|
||||
expect(events).toEqual(['close'])
|
||||
// Closing a missing tab fires nothing.
|
||||
events.length = 0
|
||||
service.closeTab('nope')
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('lifecycle callbacks receive the session scope (cwd included when given)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
let seen: { sessionId: string; cwd?: string } | undefined
|
||||
service.registerTab({
|
||||
id: 'scoped',
|
||||
title: 'Scoped',
|
||||
onOpen: (_tab, scope) => { seen = scope },
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'scoped', title: 'Scoped' }, { sessionId: 's1', cwd: '/work' })
|
||||
expect(seen).toEqual({ sessionId: 's1', cwd: '/work' })
|
||||
})
|
||||
|
||||
it('a throwing callback is swallowed and never breaks the open', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'boom',
|
||||
title: 'Boom',
|
||||
onOpen: () => { throw new Error('plugin bug') },
|
||||
onClose: () => { throw new Error('plugin bug') },
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
expect(() => service.openTab({ type: 'boom', title: 'Boom' })).not.toThrow()
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'boom')!
|
||||
expect(() => service.closeTab(tab.id)).not.toThrow()
|
||||
})
|
||||
|
||||
it('a disabled tab type still refuses to open and fires no callbacks', () => {
|
||||
const { store, service, events } = setup()
|
||||
store.setSession('s1')
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { life: false } })
|
||||
service.openTab({ type: 'life', title: 'Life' })
|
||||
expect(events).toEqual([])
|
||||
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'life')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab meta (v0.12.0)', () => {
|
||||
it('a seed meta rides onto the minted tab and survives a reload round-trip', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'db', title: 'DB', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'db', title: 'DB', id: 'db:1', meta: { table: 'users', page: 3 } })
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
|
||||
expect(tab.meta).toEqual({ table: 'users', page: 3 })
|
||||
// Reload round-trip: the persisted shape sanitizes back with meta intact.
|
||||
const sanitized = sanitizeState(JSON.parse(JSON.stringify(store.getSnapshot().state!)))
|
||||
const restored = allLeaves(sanitized!.splits).flatMap(l => l.tabs).find(t => t.id === 'db:1')!
|
||||
expect(restored.meta).toEqual({ table: 'users', page: 3 })
|
||||
})
|
||||
|
||||
it('older persisted tabs (no meta) sanitize unchanged', () => {
|
||||
const state = makeDefaultState(400, true, true)
|
||||
const sanitized = sanitizeState(JSON.parse(JSON.stringify(state)))
|
||||
const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs)
|
||||
expect(tabs[0]?.meta).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle classification vs dedupe (codex review fixes)', () => {
|
||||
it('a key-dedupe focus with a NEW id fires onActivate with the REAL tab (no phantom onOpen)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const events: Array<{ kind: string; tabId?: string }> = []
|
||||
service.registerTab({
|
||||
id: 'doc',
|
||||
title: 'Doc',
|
||||
// Dedupe by PATH like the editor builtin — the focused tab's id
|
||||
// differs from the newly requested id.
|
||||
dedupeKey: (tab) => tab.path ?? '',
|
||||
onOpen: (tab) => { events.push({ kind: 'open', tabId: tab.id }) },
|
||||
onActivate: (tab) => { events.push({ kind: 'activate', tabId: tab.id }) },
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:1', path: '/a.md' })
|
||||
expect(events).toEqual([{ kind: 'open', tabId: 'doc:1' }])
|
||||
// Same path, NEW id: the existing tab is focused — onActivate must
|
||||
// carry the EXISTING tab, and onOpen must NOT fire with doc:2.
|
||||
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:2', path: '/a.md' })
|
||||
expect(events).toEqual([
|
||||
{ kind: 'open', tabId: 'doc:1' },
|
||||
{ kind: 'activate', tabId: 'doc:1' },
|
||||
])
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'doc')
|
||||
expect(tabs.map(t => t.id)).toEqual(['doc:1'])
|
||||
})
|
||||
|
||||
it('an id safety-net focus (same id) fires onActivate, not onOpen', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const events: string[] = []
|
||||
service.registerTab({
|
||||
id: 'multi',
|
||||
title: 'Multi',
|
||||
onOpen: () => { events.push('open') },
|
||||
onActivate: () => { events.push('activate') },
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'm:1' })
|
||||
expect(events).toEqual(['open'])
|
||||
service.openTab({ type: 'multi', title: 'Multi', id: 'm:1' })
|
||||
expect(events).toEqual(['open', 'activate'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('independent CR follow-up fixes', () => {
|
||||
it('onOpen for a url-created tab receives the LANDED tab (path = url)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
let seenPath: string | undefined
|
||||
service.registerTab({
|
||||
id: 'web',
|
||||
title: 'Web',
|
||||
onOpen: (tab) => { seenPath = tab.path },
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'web', title: 'example.com', url: 'https://example.com/x' })
|
||||
expect(seenPath).toBe('https://example.com/x')
|
||||
})
|
||||
|
||||
it('a url seed NEVER overwrites the path of a dedupe-focused tab', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'web',
|
||||
title: 'Web',
|
||||
dedupeKey: () => 'web',
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'web', title: 'first', url: 'https://a.example' })
|
||||
service.openTab({ type: 'web', title: 'second', url: 'https://b.example' })
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'web')
|
||||
expect(tabs).toHaveLength(1)
|
||||
// The focused tab keeps its ORIGINAL url — the second open must not
|
||||
// repoint it.
|
||||
expect(tabs[0]!.path).toBe('https://a.example')
|
||||
})
|
||||
|
||||
it('closing / activating an unknown tab id is a strict no-op (no notify)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'x', title: 'X', component: () => null })
|
||||
store.setSession('s1')
|
||||
let calls = 0
|
||||
store.subscribe(() => { calls++ })
|
||||
service.closeTab('does-not-exist')
|
||||
service.activateTab('does-not-exist')
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('a targeted open into an INACTIVE session never auto-expands its panels', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
// The target session starts collapsed.
|
||||
store.reduceFor('s2', s => ({ ...s, panelOpen: false, bottomOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }, { sessionId: 's2' })
|
||||
// Nothing is in sight for the user — the open must not expand s2.
|
||||
store.setSession('s2')
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
expect(store.getSnapshot().state?.bottomOpen).toBe(false)
|
||||
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
})
|
||||
|
||||
it('closeTab/activateTab accept an optional scope that rides to the callback', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const seen: Array<{ kind: string; cwd?: string }> = []
|
||||
service.registerTab({
|
||||
id: 'life',
|
||||
title: 'Life',
|
||||
single: true,
|
||||
onActivate: (_tab, scope) => { seen.push({ kind: 'activate', cwd: scope.cwd }) },
|
||||
onClose: (_tab, scope) => { seen.push({ kind: 'close', cwd: scope.cwd }) },
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'life', title: 'Life' })
|
||||
const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')!
|
||||
service.activateTab(tab.id, { sessionId: 's1', cwd: '/work' })
|
||||
service.closeTab(tab.id, { sessionId: 's1', cwd: '/work' })
|
||||
expect(seen).toEqual([
|
||||
{ kind: 'activate', cwd: '/work' },
|
||||
{ kind: 'close', cwd: '/work' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Interactive tests for the settings popup's text/number rows: the draft is
|
||||
* local state committed on blur through the parent's onCommit; the parent's
|
||||
* canonical return is adopted (clamped numbers, stored value for invalid
|
||||
* input), and the row remounts when the committed pref value changes (a
|
||||
* failed commit reverts prefs → the stored value reappears).
|
||||
*
|
||||
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
|
||||
* side-card-section.spec.tsx; this file exercises the event paths).
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
import type { SidebarSettingToggle } from '../src/client/service.ts'
|
||||
import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
|
||||
/** Render the rows into a detached container under React's act(). */
|
||||
function mount(node: ReactNode): { container: HTMLDivElement; rerender: (node: ReactNode) => void; unmount: () => void } {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
act(() => { root.render(node) })
|
||||
return {
|
||||
container,
|
||||
rerender: (next) => { act(() => { root.render(next) }) },
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Type into an input and commit it via blur (React 18: input event +
|
||||
* focusout). The native setter bypasses React's value tracker so the
|
||||
* change is actually seen. */
|
||||
function typeAndBlur(input: HTMLInputElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
||||
act(() => {
|
||||
setter.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
const prefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
describe('FeatureSettingsRows typed rows (interactive)', () => {
|
||||
it('commits the raw text on blur and adopts the canonical return', () => {
|
||||
const commits: Array<[string, string]> = []
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
title: () => 'Font family',
|
||||
}
|
||||
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
onCommit: (t, raw) => {
|
||||
commits.push([t.key, raw])
|
||||
return raw
|
||||
},
|
||||
}))
|
||||
const input = container.querySelector('input')!
|
||||
typeAndBlur(input, 'Monaco')
|
||||
expect(commits).toEqual([['terminalFontFamily', 'Monaco']])
|
||||
// The canonical return is adopted into the draft.
|
||||
expect(input.value).toBe('Monaco')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('clamps numbers into the declared bounds on commit', () => {
|
||||
const commits: Array<[string, number]> = []
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => 'Font size',
|
||||
min: 9,
|
||||
max: 32,
|
||||
}
|
||||
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: { ...prefs, terminalFontSize: 13 },
|
||||
onToggle: () => {},
|
||||
onCommit: (t, raw) => {
|
||||
const parsed = Number(raw)
|
||||
const clamped = Math.min(32, Math.max(9, Math.round(parsed)))
|
||||
commits.push([t.key, clamped])
|
||||
return String(clamped)
|
||||
},
|
||||
}))
|
||||
const input = container.querySelector('input')!
|
||||
typeAndBlur(input, '40')
|
||||
expect(commits).toEqual([['terminalFontSize', 32]])
|
||||
expect(input.value).toBe('32')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => {
|
||||
const commits: Array<[string, number]> = []
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => 'Font size',
|
||||
min: 9,
|
||||
max: 32,
|
||||
}
|
||||
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: { ...prefs, terminalFontSize: 13 },
|
||||
onToggle: () => {},
|
||||
// The parent mirrors the real handler: an emptied number parses to 0
|
||||
// and clamps into the bounds (a browser number input never holds a
|
||||
// non-numeric string — the draft can only be empty or numeric).
|
||||
onCommit: (t, raw) => {
|
||||
const clamped = Math.min(32, Math.max(9, Math.round(Number(raw))))
|
||||
commits.push([t.key, clamped])
|
||||
return String(clamped)
|
||||
},
|
||||
}))
|
||||
const input = container.querySelector('input')!
|
||||
typeAndBlur(input, '')
|
||||
expect(commits).toEqual([['terminalFontSize', 9]])
|
||||
expect(input.value).toBe('9')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('reverts the draft to the committed value after a failed commit reverts prefs', () => {
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
title: () => 'Font family',
|
||||
}
|
||||
// The parent mirrors the real handler: the optimistic commit adopts the
|
||||
// typed value, and a FAILED write reverts prefs to the stored one.
|
||||
let prefsNow = { ...prefs, terminalFontFamily: 'Old' }
|
||||
const { container, rerender, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: prefsNow,
|
||||
onToggle: () => {},
|
||||
onCommit: (_t, raw) => raw,
|
||||
}))
|
||||
const input = container.querySelector('input')!
|
||||
typeAndBlur(input, 'New')
|
||||
// Optimistic commit: the draft adopts the typed value.
|
||||
expect(input.value).toBe('New')
|
||||
// The optimistic pref lands (parent state): the key changes, the row
|
||||
// remounts with the same value — no visible reset while editing.
|
||||
prefsNow = { ...prefsNow, terminalFontFamily: 'New' }
|
||||
rerender(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: prefsNow,
|
||||
onToggle: () => {},
|
||||
onCommit: (_t, raw) => raw,
|
||||
}))
|
||||
expect(container.querySelector('input')!.value).toBe('New')
|
||||
// The write fails: prefs revert to the stored value and the row
|
||||
// remounts with it (the stale draft is gone).
|
||||
prefsNow = { ...prefsNow, terminalFontFamily: 'Old' }
|
||||
rerender(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: prefsNow,
|
||||
onToggle: () => {},
|
||||
onCommit: (_t, raw) => raw,
|
||||
}))
|
||||
expect(container.querySelector('input')!.value).toBe('Old')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,11 @@
|
||||
* copy. The toggles are CARDS in a responsive grid: the card's main area is
|
||||
* the switch, the visual state IS the state (highlighted = enabled),
|
||||
* announced via `aria-pressed`, and the check badge sits at the far right.
|
||||
* Features that declare related settings carry a gear corner button whose
|
||||
* popup rows (native checkboxes) are tested through the extracted
|
||||
* FeatureSettingsRows component (the Modal portal renders only while open).
|
||||
* The general rows follow the DSH settings-row recipe with custom SWITCHES
|
||||
* (real checkboxes driving a styled track). Features that declare related
|
||||
* settings carry a gear corner button whose popup rows (switch controls)
|
||||
* are tested through the extracted FeatureSettingsRows component (the Modal
|
||||
* portal renders only while open).
|
||||
*
|
||||
* Rendered with renderToString (mount effects — the settings RPC sync — do
|
||||
* not run in SSR; the initial store prefs are the render input).
|
||||
@@ -18,7 +20,7 @@ import { createElement } from 'react'
|
||||
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { FeatureSettingsRows, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
import { FeatureSettingsRows, mergePluginSetting, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
|
||||
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
|
||||
function mount(): { store: SidebarStore; service: BetterSidebarService } {
|
||||
@@ -78,14 +80,27 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>explorer<')
|
||||
expect(html).toContain('data-icon="subagent"')
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Default prefs: openByDefault + interceptOpenPath + both tabs + the
|
||||
// image viewer are all enabled → 5 cards pressed, none pressed=false.
|
||||
// Default prefs: openByDefault + interceptOpenPath switches checked, and
|
||||
// both tabs + the image viewer cards pressed (3 aria-pressed cards).
|
||||
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
|
||||
expect(pressedCount(html, 'true')).toBe(5)
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
expect(pressedCount(html, 'false')).toBe(0)
|
||||
// The general toggles are custom switches (real checkboxes, checked).
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html).not.toContain('Auto-open Subagents')
|
||||
})
|
||||
|
||||
it('renders the section intro and group headings with inventory counts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('Manage what the side card shows and how it behaves')
|
||||
// Group headings carry the inventory count badge (2 tabs, 1 viewer).
|
||||
expect(html).toContain('>Sidebar content</span><span')
|
||||
expect(html).toContain('>File viewers</span><span')
|
||||
expect(html).toContain('>2</span>')
|
||||
expect(html).toContain('>1</span>')
|
||||
})
|
||||
|
||||
it('renders one small card per registered viewer: icon + title + exts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
@@ -104,6 +119,20 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('renders the two dashed "add plugin" cards (tab grid + viewer grid)', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
// The tab grid's dashed card (tab registration).
|
||||
expect(html).toContain('Add tab plugins')
|
||||
expect(html).toContain('Register a new sidebar page')
|
||||
// The viewer grid's dashed card (file-previewer registration).
|
||||
expect(html).toContain('Add preview plugins')
|
||||
expect(html).toContain('Register a file-type preview')
|
||||
// The add cards are plain buttons (open the modals), never switches:
|
||||
// they carry no aria-pressed, so the pressed-card counts stay untouched.
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
})
|
||||
|
||||
it('a disabled feature renders pressed=false', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
|
||||
@@ -111,8 +140,9 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>Subagents<')
|
||||
expect(html).toContain('>Image<')
|
||||
expect(pressedCount(html, 'false')).toBe(2)
|
||||
// openByDefault + interceptOpenPath + the explorer stay pressed.
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
// The explorer card stays pressed; the general switches stay checked.
|
||||
expect(pressedCount(html, 'true')).toBe(1)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
|
||||
@@ -134,7 +164,7 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
desc: () => 'Expand on new subagent',
|
||||
}]
|
||||
|
||||
it('renders one native checkbox row per declared toggle with its current value', () => {
|
||||
it('renders one switch row per declared toggle with its current value', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles,
|
||||
prefs,
|
||||
@@ -142,7 +172,9 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
}))
|
||||
expect(html).toContain('Auto-open Subagents')
|
||||
expect(html).toContain('Expand on new subagent')
|
||||
// The checkbox reflects the prefs value (false here → unchecked).
|
||||
// The row's switch is a real checkbox (aria-label = the toggle title)
|
||||
// reflecting the prefs value (false here → unchecked).
|
||||
expect(html).toContain('aria-label="Auto-open Subagents"')
|
||||
expect(html).not.toContain('checked=""')
|
||||
})
|
||||
|
||||
@@ -154,4 +186,98 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
}))
|
||||
expect(html).toContain('checked=""')
|
||||
})
|
||||
|
||||
it('renders a text row as an input seeded with the pref value (empty = theme default)', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [{
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
title: () => 'Font family',
|
||||
desc: () => 'CSS stack',
|
||||
placeholder: '"JetBrains Mono", monospace',
|
||||
}],
|
||||
prefs: { ...prefs, terminalFontFamily: '"JetBrains Mono", monospace' },
|
||||
onToggle: () => {},
|
||||
onCommit: () => '',
|
||||
}))
|
||||
expect(html).toContain('Font family')
|
||||
expect(html).toContain('placeholder=""JetBrains Mono", monospace"')
|
||||
// The input carries the pref value (no switch for text rows).
|
||||
expect(html).toContain('value=""JetBrains Mono", monospace"')
|
||||
expect(html).not.toContain('type="checkbox"')
|
||||
})
|
||||
|
||||
it('renders a number row with the pref value, the declared bounds and a unit suffix', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [{
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => 'Font size',
|
||||
min: 9,
|
||||
max: 32,
|
||||
unit: 'px',
|
||||
}],
|
||||
prefs: { ...prefs, terminalFontSize: 18 },
|
||||
onToggle: () => {},
|
||||
onCommit: () => '18',
|
||||
}))
|
||||
expect(html).toContain('Font size')
|
||||
expect(html).toContain('type="number"')
|
||||
expect(html).toContain('value="18"')
|
||||
expect(html).toContain('min="9"')
|
||||
expect(html).toContain('max="32"')
|
||||
expect(html).toContain('px')
|
||||
expect(html).not.toContain('type="checkbox"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergePluginSetting (v0.12.0, codex review fix)', () => {
|
||||
it('sequential merges are additive — a later write never drops an earlier key', () => {
|
||||
// Simulates two same-tick updatePluginSetting calls: each merge spreads
|
||||
// the map it was GIVEN, so building from the latest optimistic map
|
||||
// preserves both keys (the pre-fix code spread the stale render-time
|
||||
// prefs twice and the second write dropped the first key).
|
||||
let map: Record<string, Record<string, unknown>> = {}
|
||||
map = mergePluginSetting(map, 'my-plugin:db', 'pageSize', 25)
|
||||
map = mergePluginSetting(map, 'my-plugin:db', 'theme', 'dark')
|
||||
expect(map['my-plugin:db']).toEqual({ pageSize: 25, theme: 'dark' })
|
||||
// A second descriptor's blob stays independent.
|
||||
map = mergePluginSetting(map, 'other:view', 'refresh', true)
|
||||
expect(map['my-plugin:db']).toEqual({ pageSize: 25, theme: 'dark' })
|
||||
expect(map['other:view']).toEqual({ refresh: true })
|
||||
// Overwriting one key keeps the sibling keys.
|
||||
map = mergePluginSetting(map, 'my-plugin:db', 'pageSize', 50)
|
||||
expect(map['my-plugin:db']).toEqual({ pageSize: 50, theme: 'dark' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () => {
|
||||
it('plugin rows read from their OWN value source — a plugin key colliding with a host pref never reads the host value', () => {
|
||||
const prefs = { ...SIDEBAR_PREFS_DEFAULTS, openByDefault: true }
|
||||
const toggle = { key: 'openByDefault', title: 'My flag' }
|
||||
// valueOf returns undefined (the plugin never wrote this key): the row
|
||||
// must render UNCHECKED even though the host pref openByDefault is true.
|
||||
let html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
valueSource: () => undefined,
|
||||
}))
|
||||
expect(html).not.toContain('checked=""')
|
||||
// The plugin wrote `true` into its own blob: the row is checked.
|
||||
html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
valueSource: () => true,
|
||||
}))
|
||||
expect(html).toContain('checked=""')
|
||||
// Without valueOf the row falls back to the prefs face (host semantics).
|
||||
html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
}))
|
||||
expect(html).toContain('checked=""')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Sidebar crash tests — the two failure modes behind issue #31.
|
||||
*
|
||||
* 1. Layout-push leak: the layout-push effect writes
|
||||
* `--dsh-sidebar-width/--dsh-sidebar-height` on document.documentElement.
|
||||
* Unmounting the Sidebar for ANY reason (error-boundary swap, plugin
|
||||
* disable, HMR) must clear them — otherwise layout.css keeps squeezing
|
||||
* `#root` with a stale margin and "the sidebar cannot be hidden" until a
|
||||
* full page reload.
|
||||
*
|
||||
* 2. Tab crash containment: a render error inside ONE tab's content must not
|
||||
* take down the whole sidebar. The per-tab boundary shows a strip inside
|
||||
* that tab's pane while the toggle cluster, the other tabs, and the panel
|
||||
* itself stay alive; the retry button recovers a transient crash.
|
||||
*
|
||||
* Rendered with the REAL Sidebar shell + real store/service against a minimal
|
||||
* fake context (createRoot + act(), the repo's jsdom pattern).
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
import { Sidebar } from '../src/client/Sidebar.tsx'
|
||||
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import { t } from '../src/client/locales.ts'
|
||||
|
||||
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
|
||||
class FakeWebSocket {
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
close = (): void => {}
|
||||
constructor(_url: string) {}
|
||||
}
|
||||
|
||||
interface MountedSidebar {
|
||||
container: HTMLDivElement
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
/** Mount the real Sidebar shell against a minimal context (real store + service). */
|
||||
function mountSidebar(): MountedSidebar {
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
// Fresh-session seed: the panel starts OPEN (openByDefault default true).
|
||||
store.setSession('s1')
|
||||
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
|
||||
// services return stable objects) — a fresh object per call loops forever.
|
||||
const localeSnapshot = { active: 'en' }
|
||||
const sessionsSnapshot = {
|
||||
current: 's1',
|
||||
// cwd present → api.sessionCwd is never called in these tests.
|
||||
byId: { s1: { cwd: '/tmp' } },
|
||||
}
|
||||
const ctx = {
|
||||
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
||||
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
|
||||
betterSidebar: service,
|
||||
}
|
||||
const root: Root = createRoot(container)
|
||||
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
|
||||
return {
|
||||
container,
|
||||
store,
|
||||
service,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('layout-push variable cleanup', () => {
|
||||
it('clears --dsh-sidebar-width/--dsh-sidebar-height when the sidebar unmounts', () => {
|
||||
const { store, unmount } = mountSidebar()
|
||||
const htmlStyle = document.documentElement.style
|
||||
// The seeded session is open: the layout push is applied on mount.
|
||||
const width = store.getSnapshot().state!.width
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe(`${width}px`)
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('0px')
|
||||
// Any unmount (boundary swap, plugin disable, HMR) must release the push.
|
||||
unmount()
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe('')
|
||||
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab crash containment', () => {
|
||||
it('a crashing tab shows an in-pane strip while the cluster and panel survive', () => {
|
||||
const { container, service, store } = mountSidebar()
|
||||
service.registerTab({
|
||||
id: 'crash',
|
||||
title: 'Crash',
|
||||
component: () => { throw new Error('boom') },
|
||||
})
|
||||
act(() => { service.openTab({ type: 'crash', title: 'Crash' }) })
|
||||
// The strip lives inside the tab's pane — the crash is contained.
|
||||
expect(container.textContent).toContain('boom')
|
||||
expect(container.textContent).toContain(t('terminalRetry'))
|
||||
// The toggle cluster and the panel itself survived (no full-tree swap):
|
||||
// the collapse button is still there and the layout push is still live.
|
||||
expect(container.querySelector(`[aria-label="${t('collapse')}"]`)).not.toBeNull()
|
||||
expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')).toBe(
|
||||
`${store.getSnapshot().state!.width}px`,
|
||||
)
|
||||
})
|
||||
|
||||
it('the retry button recovers a tab whose crash has since been fixed', () => {
|
||||
// A PERSISTENT render error reaches the boundary strip (React 18.2
|
||||
// auto-recovers transient throws — one bad render followed by a good
|
||||
// retry is swallowed as a recoverable error and never shows a strip).
|
||||
let shouldThrow = true
|
||||
const { container, service } = mountSidebar()
|
||||
service.registerTab({
|
||||
id: 'crash-until-fixed',
|
||||
title: 'Crash until fixed',
|
||||
component: () => {
|
||||
if (shouldThrow) throw new Error('transient')
|
||||
return createElement('div', null, 'recovered')
|
||||
},
|
||||
})
|
||||
act(() => { service.openTab({ type: 'crash-until-fixed', title: 'Crash until fixed' }) })
|
||||
expect(container.textContent).toContain('transient')
|
||||
const retry = [...container.querySelectorAll('button')]
|
||||
.find(button => button.textContent === t('terminalRetry'))
|
||||
expect(retry).toBeDefined()
|
||||
// The crash condition is gone (e.g. the data arrived): the retry button
|
||||
// remounts the tab's content and the strip clears.
|
||||
shouldThrow = false
|
||||
act(() => { retry!.click() })
|
||||
expect(container.textContent).toContain('recovered')
|
||||
expect(container.textContent).not.toContain('transient')
|
||||
})
|
||||
})
|
||||
+20
-16
@@ -16,8 +16,8 @@ import { defaultShell, PtyManager } from '../src/pty-manager.ts'
|
||||
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
|
||||
|
||||
interface FakeContext {
|
||||
loader: { entries: () => never[] }
|
||||
httpServer: {
|
||||
webRuntime: { trustedHosts: readonly string[] }
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => () => void
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
|
||||
}
|
||||
@@ -27,7 +27,7 @@ interface FakeContext {
|
||||
/** The settings service never appears in the smoke context: the inject
|
||||
* callback must never run (mirror of cordis' service-less inject). */
|
||||
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
|
||||
/** Optional services (tasks/agents) are read lazily; absent → undefined. */
|
||||
/** Optional services (jobs/agents) are read lazily; absent → undefined. */
|
||||
get: (key: string) => undefined
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('host plugin smoke', () => {
|
||||
const upgrades: SidebarWebUpgradeRoute[] = []
|
||||
const effects: Array<() => void | (() => void)> = []
|
||||
const ctx: FakeContext = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
webRuntime: { trustedHosts: [] },
|
||||
webServer: {
|
||||
register: (route) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
|
||||
},
|
||||
@@ -58,7 +58,7 @@ describe('host plugin smoke', () => {
|
||||
// No settings service in the smoke context: the registration callback
|
||||
// never runs (cordis' service-less inject behaves the same).
|
||||
inject: () => () => {},
|
||||
// No tasks/agents services: the tasks routes degrade to a 503.
|
||||
// No jobs/agents services: the jobs routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
@@ -308,8 +308,8 @@ describe('session cwd resolution over the API route', () => {
|
||||
const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
webRuntime: { trustedHosts: [] },
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
@@ -319,7 +319,7 @@ describe('session cwd resolution over the API route', () => {
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
// No settings service: the namespace registration never runs.
|
||||
inject: () => () => {},
|
||||
// No tasks/agents services in the smoke context: the routes degrade.
|
||||
// No jobs/agents services in the smoke context: the routes degrade.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
@@ -458,8 +458,8 @@ describe('side card settings routes', () => {
|
||||
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
webRuntime: { trustedHosts: [] },
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
@@ -470,7 +470,7 @@ describe('side card settings routes', () => {
|
||||
if (deps.includes('settings') && settings !== undefined) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
// No tasks/agents services: the tasks routes degrade to a 503.
|
||||
// No jobs/agents services: the jobs routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
@@ -514,9 +514,11 @@ describe('side card settings routes', () => {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 30,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenTasks: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
@@ -525,6 +527,8 @@ describe('side card settings routes', () => {
|
||||
// The enable-switch maps default to {} (everything on).
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
// The plugin-owned settings map defaults to {} too.
|
||||
pluginSettings: {},
|
||||
},
|
||||
revision: 0,
|
||||
})
|
||||
@@ -644,8 +648,8 @@ describe('agent terminal tool gating', () => {
|
||||
async update() {},
|
||||
}
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
webRuntime: { trustedHosts: [] },
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => { void route; return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
@@ -656,7 +660,7 @@ describe('agent terminal tool gating', () => {
|
||||
if (deps.includes('settings')) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
// No tasks/agents services: the tasks routes degrade to a 503.
|
||||
// No jobs/agents services: the jobs routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Subagent page tests for the background-task section: rows render from the
|
||||
* `tasksBySession` mirror, clicking a row peeks its output through
|
||||
* `tasks.output` with the OWNER session scope, the kill button needs a
|
||||
* Subagent page tests for the background-job section: rows render from the
|
||||
* `jobsBySession` mirror, clicking a row peeks its output through
|
||||
* `jobs.output` with the OWNER session scope, the kill button needs a
|
||||
* two-click confirm, settled rows offer no kill, and the output panel never
|
||||
* polls while the page is hidden.
|
||||
*/
|
||||
@@ -80,7 +80,7 @@ function baseSnapshot(): SidebarSessionList {
|
||||
child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true },
|
||||
},
|
||||
subagentsByParent: {},
|
||||
tasksBySession: {
|
||||
jobsBySession: {
|
||||
root: [
|
||||
{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
|
||||
],
|
||||
@@ -97,9 +97,9 @@ beforeEach(() => {
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string }
|
||||
if (method === 'tasks.output') {
|
||||
if (method === 'jobs.output') {
|
||||
outputCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
// bash-9 stands for a task the model never read (read:false).
|
||||
// bash-9 stands for a job the model never read (read:false).
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -109,7 +109,7 @@ beforeEach(() => {
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'tasks.kill') {
|
||||
if (method === 'jobs.kill') {
|
||||
killCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
|
||||
}
|
||||
@@ -123,15 +123,15 @@ afterEach(() => {
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
describe('SubagentView background tasks', () => {
|
||||
it('renders the tree tasks with status, durations, and owner labels', () => {
|
||||
describe('SubagentView background jobs', () => {
|
||||
it('renders the tree jobs with status, durations, and owner labels', () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
const text = container.textContent ?? ''
|
||||
expect(text).toContain('后台命令')
|
||||
expect(text).toContain('2 个后台命令 · 1 运行中')
|
||||
expect(text).toContain('后台任务')
|
||||
expect(text).toContain('2 个后台任务 · 1 运行中')
|
||||
// Both rows of the whole tree, owner-labeled (the tree spans two sessions).
|
||||
expect(text).toContain('sleep 300')
|
||||
expect(text).toContain('echo hi')
|
||||
@@ -142,12 +142,12 @@ describe('SubagentView background tasks', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('renders nothing task-related when the mirror is empty', () => {
|
||||
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, tasksBySession: {} })
|
||||
it('renders nothing job-related when the mirror is empty', () => {
|
||||
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} })
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
expect(container.textContent).not.toContain('后台命令')
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
unmount()
|
||||
})
|
||||
|
||||
@@ -157,19 +157,19 @@ describe('SubagentView background tasks', () => {
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
// The mirror empties (all tasks settled and dropped): the section must
|
||||
// The mirror empties (all jobs settled and dropped): the section must
|
||||
// vanish WITHOUT reordering hooks — a hook below the empty-state return
|
||||
// would crash React with "Rendered fewer hooks than expected" (the
|
||||
// minified #300 the sidebar boundary surfaces with a retry button).
|
||||
await act(async () => { store.set({ ...baseSnapshot(), tasksBySession: {} }) })
|
||||
expect(container.textContent).not.toContain('后台命令')
|
||||
// And returning tasks must work too, with the same hook order.
|
||||
await act(async () => { store.set({ ...baseSnapshot(), jobsBySession: {} }) })
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
// And returning jobs must work too, with the same hook order.
|
||||
await act(async () => { store.set(baseSnapshot()) })
|
||||
expect(container.textContent).toContain('sleep 300')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('shows the selected task output in the bottom dock, closeable', async () => {
|
||||
it('shows the selected job output in the bottom dock, closeable', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
@@ -200,7 +200,7 @@ describe('SubagentView background tasks', () => {
|
||||
expect(container.textContent).toContain('output-of-bash-1')
|
||||
const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement
|
||||
await act(async () => { second.click() })
|
||||
// One dock, now fed by the second task (its owner session scopes the replay).
|
||||
// One dock, now fed by the second job (its owner session scopes the replay).
|
||||
expect(outputCalls).toEqual([
|
||||
{ sessionId: 'root', id: 'bash-1' },
|
||||
{ sessionId: 'child', id: 'bash-2' },
|
||||
@@ -211,11 +211,11 @@ describe('SubagentView background tasks', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('explains when the model has not read the task yet', async () => {
|
||||
it('explains when the model has not read the job yet', async () => {
|
||||
const snapshot = baseSnapshot()
|
||||
snapshot.tasksBySession = {
|
||||
snapshot.jobsBySession = {
|
||||
root: [
|
||||
...(snapshot.tasksBySession?.root ?? []),
|
||||
...(snapshot.jobsBySession?.root ?? []),
|
||||
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
|
||||
],
|
||||
}
|
||||
@@ -225,13 +225,13 @@ describe('SubagentView background tasks', () => {
|
||||
)
|
||||
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
// read:false → the pane explains the output awaits the model's task_output
|
||||
// read:false → the pane explains the output awaits the model's job_output
|
||||
// (never the model's cursor, so there is nothing to steal yet).
|
||||
expect(container.textContent).toContain('等待模型读取该任务的输出')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('stays compact and functional with many tasks', async () => {
|
||||
it('stays compact and functional with many jobs', async () => {
|
||||
const many = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: `bash-${index + 10}`,
|
||||
kind: 'bash' as const,
|
||||
@@ -244,12 +244,12 @@ describe('SubagentView background tasks', () => {
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: '主会话' } },
|
||||
subagentsByParent: {},
|
||||
tasksBySession: { root: many },
|
||||
jobsBySession: { root: many },
|
||||
})
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
expect(container.textContent).toContain('60 个后台命令 · 30 运行中')
|
||||
expect(container.textContent).toContain('60 个后台任务 · 30 运行中')
|
||||
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
|
||||
// Clicking a row anywhere in the long list still feeds the single dock.
|
||||
const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
|
||||
@@ -259,7 +259,7 @@ describe('SubagentView background tasks', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('kills a live task only after the two-click confirm', async () => {
|
||||
it('kills a live job only after the two-click confirm', async () => {
|
||||
const store = makeStore(baseSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Pure-helper tests for the Subagent page's background-job section:
|
||||
* tree-membership collection, ordering, and status presentation mapping.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectTreeJobs,
|
||||
detectNewJob,
|
||||
formatJobDuration,
|
||||
isJobLive,
|
||||
orderJobs,
|
||||
jobDotState,
|
||||
jobStatusLabel,
|
||||
treeSessionIds,
|
||||
} from '../src/client/subagent-jobs.ts'
|
||||
import type { SidebarSessionList, SidebarSessionSummary, SidebarJobStatus, SidebarJobView } from '../src/context-types.ts'
|
||||
|
||||
/** The translator stub: renders duration templates like the real locale copy. */
|
||||
const templates: Record<string, string> = {
|
||||
jobDurationSeconds: '{seconds}秒',
|
||||
jobDurationMinutes: '{minutes}分{seconds}秒',
|
||||
jobDurationHours: '{hours}小时{minutes}分',
|
||||
}
|
||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||
let text = templates[key] ?? key
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
|
||||
return { id, displayTitle: `title-${id}`, ...over }
|
||||
}
|
||||
|
||||
function job(id: string, over: Partial<SidebarJobView> = {}): SidebarJobView {
|
||||
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
|
||||
}
|
||||
|
||||
describe('treeSessionIds', () => {
|
||||
it('includes the root and every subagent-origin session whose chain reaches it', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
|
||||
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
|
||||
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
|
||||
}
|
||||
const ids = treeSessionIds(byId, 'root')
|
||||
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
|
||||
})
|
||||
|
||||
it('fails soft on parent cycles and yields nothing without a root', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
a: summary('a', { origin: 'subagent', parentId: 'b' }),
|
||||
b: summary('b', { origin: 'subagent', parentId: 'a' }),
|
||||
}
|
||||
expect(treeSessionIds(byId, 'root').size).toBe(1)
|
||||
expect(treeSessionIds(byId, undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectTreeJobs', () => {
|
||||
it('collects jobs of the whole tree with owner titles, ignoring outside sessions', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
}
|
||||
const jobsBySession = {
|
||||
root: [job('bash-1')],
|
||||
child: [job('bash-2', { status: 'completed', finishedAt: 2_000 })],
|
||||
stranger: [job('bash-9')],
|
||||
}
|
||||
const rows = collectTreeJobs(byId, jobsBySession, 'root')
|
||||
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.job.id]))
|
||||
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
|
||||
})
|
||||
|
||||
it('returns an empty list for an absent mirror or empty sets', () => {
|
||||
const byId = { root: summary('root') }
|
||||
expect(collectTreeJobs(byId, undefined, 'root')).toEqual([])
|
||||
expect(collectTreeJobs(byId, {}, 'root')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderJobs', () => {
|
||||
it('puts live rows first in start order, then settled rows newest-first', () => {
|
||||
const row = (id: string, status: SidebarJobStatus, startedAt: number, finishedAt?: number) => ({
|
||||
ownerSessionId: 'root',
|
||||
ownerTitle: 'root',
|
||||
job: job(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
|
||||
})
|
||||
const rows = [
|
||||
row('old-settled', 'completed', 1_000, 2_000),
|
||||
row('live-2', 'running', 4_000),
|
||||
row('new-settled', 'killed', 1_500, 1_800),
|
||||
row('live-1', 'stopping', 3_000),
|
||||
]
|
||||
expect(orderJobs(rows).map(r => r.job.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status presentation helpers', () => {
|
||||
it('treats running and stopping as live', () => {
|
||||
expect(isJobLive(job('a', { status: 'running' }))).toBe(true)
|
||||
expect(isJobLive(job('b', { status: 'stopping' }))).toBe(true)
|
||||
expect(isJobLive(job('c', { status: 'completed' }))).toBe(false)
|
||||
expect(isJobLive(job('d', { status: 'killed' }))).toBe(false)
|
||||
expect(isJobLive(job('e', { status: 'failed' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('maps the five wire statuses to dot states and localized labels', () => {
|
||||
expect(jobDotState('running')).toBe('ongoing')
|
||||
expect(jobDotState('stopping')).toBe('warning')
|
||||
expect(jobDotState('completed')).toBe('done')
|
||||
expect(jobDotState('killed')).toBe('warning')
|
||||
expect(jobDotState('failed')).toBe('error')
|
||||
expect(jobStatusLabel('running', t)).toBe('jobStatusRunning')
|
||||
expect(jobStatusLabel('stopping', t)).toBe('jobStatusStopping')
|
||||
expect(jobStatusLabel('completed', t)).toBe('jobStatusCompleted')
|
||||
expect(jobStatusLabel('killed', t)).toBe('jobStatusKilled')
|
||||
expect(jobStatusLabel('failed', t)).toBe('jobStatusFailed')
|
||||
})
|
||||
|
||||
it('formats durations in at most two adjacent units', () => {
|
||||
expect(formatJobDuration(0, t)).toBe('0秒')
|
||||
expect(formatJobDuration(45_000, t)).toBe('45秒')
|
||||
expect(formatJobDuration(90_000, t)).toBe('1分30秒')
|
||||
expect(formatJobDuration(3_661_000, t)).toBe('1小时1分')
|
||||
// Negative or fractional input clamps to zero seconds.
|
||||
expect(formatJobDuration(-5, t)).toBe('0秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectNewJob', () => {
|
||||
const list = (jobsBySession: Record<string, SidebarJobView[]>): SidebarSessionList => ({
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: 'root' } },
|
||||
subagentsByParent: {},
|
||||
jobsBySession: jobsBySession,
|
||||
})
|
||||
|
||||
it('fires on EVERY new job id for the session (not just the first)', () => {
|
||||
expect(detectNewJob(list({}), list({ root: [job('bash-1')] }), 'root')).toBe(true)
|
||||
expect(detectNewJob(
|
||||
list({ root: [job('bash-1')] }),
|
||||
list({ root: [job('bash-1'), job('bash-2')] }),
|
||||
'root',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
|
||||
// Settling only mutates status, never adds ids.
|
||||
expect(detectNewJob(
|
||||
list({ root: [job('bash-1')] }),
|
||||
list({ root: [job('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
|
||||
'root',
|
||||
)).toBe(false)
|
||||
expect(detectNewJob(list({ root: [job('bash-1')] }), list({ root: [job('bash-1')] }), 'root')).toBe(false)
|
||||
// Jobs owned by another session do not trigger the current one.
|
||||
expect(detectNewJob(list({}), list({ child: [job('bash-1')] }), 'root')).toBe(false)
|
||||
expect(detectNewJob(list({}), list({}), 'root')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Pure-helper tests for the Subagent page's background-task section:
|
||||
* tree-membership collection, ordering, and status presentation mapping.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectTreeTasks,
|
||||
detectNewTask,
|
||||
formatTaskDuration,
|
||||
isTaskLive,
|
||||
orderTasks,
|
||||
taskDotState,
|
||||
taskStatusLabel,
|
||||
treeSessionIds,
|
||||
} from '../src/client/subagent-tasks.ts'
|
||||
import type { SidebarSessionList, SidebarSessionSummary, SidebarTaskStatus, SidebarTaskView } from '../src/context-types.ts'
|
||||
|
||||
/** The translator stub: renders duration templates like the real locale copy. */
|
||||
const templates: Record<string, string> = {
|
||||
taskDurationSeconds: '{seconds}秒',
|
||||
taskDurationMinutes: '{minutes}分{seconds}秒',
|
||||
taskDurationHours: '{hours}小时{minutes}分',
|
||||
}
|
||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||
let text = templates[key] ?? key
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
|
||||
return { id, displayTitle: `title-${id}`, ...over }
|
||||
}
|
||||
|
||||
function task(id: string, over: Partial<SidebarTaskView> = {}): SidebarTaskView {
|
||||
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
|
||||
}
|
||||
|
||||
describe('treeSessionIds', () => {
|
||||
it('includes the root and every subagent-origin session whose chain reaches it', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
|
||||
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
|
||||
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
|
||||
}
|
||||
const ids = treeSessionIds(byId, 'root')
|
||||
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
|
||||
})
|
||||
|
||||
it('fails soft on parent cycles and yields nothing without a root', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
a: summary('a', { origin: 'subagent', parentId: 'b' }),
|
||||
b: summary('b', { origin: 'subagent', parentId: 'a' }),
|
||||
}
|
||||
expect(treeSessionIds(byId, 'root').size).toBe(1)
|
||||
expect(treeSessionIds(byId, undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectTreeTasks', () => {
|
||||
it('collects tasks of the whole tree with owner titles, ignoring outside sessions', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
}
|
||||
const tasksBySession = {
|
||||
root: [task('bash-1')],
|
||||
child: [task('bash-2', { status: 'completed', finishedAt: 2_000 })],
|
||||
stranger: [task('bash-9')],
|
||||
}
|
||||
const rows = collectTreeTasks(byId, tasksBySession, 'root')
|
||||
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.task.id]))
|
||||
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
|
||||
})
|
||||
|
||||
it('returns an empty list for an absent mirror or empty sets', () => {
|
||||
const byId = { root: summary('root') }
|
||||
expect(collectTreeTasks(byId, undefined, 'root')).toEqual([])
|
||||
expect(collectTreeTasks(byId, {}, 'root')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderTasks', () => {
|
||||
it('puts live rows first in start order, then settled rows newest-first', () => {
|
||||
const row = (id: string, status: SidebarTaskStatus, startedAt: number, finishedAt?: number) => ({
|
||||
ownerSessionId: 'root',
|
||||
ownerTitle: 'root',
|
||||
task: task(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
|
||||
})
|
||||
const rows = [
|
||||
row('old-settled', 'completed', 1_000, 2_000),
|
||||
row('live-2', 'running', 4_000),
|
||||
row('new-settled', 'killed', 1_500, 1_800),
|
||||
row('live-1', 'stopping', 3_000),
|
||||
]
|
||||
expect(orderTasks(rows).map(r => r.task.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status presentation helpers', () => {
|
||||
it('treats running and stopping as live', () => {
|
||||
expect(isTaskLive(task('a', { status: 'running' }))).toBe(true)
|
||||
expect(isTaskLive(task('b', { status: 'stopping' }))).toBe(true)
|
||||
expect(isTaskLive(task('c', { status: 'completed' }))).toBe(false)
|
||||
expect(isTaskLive(task('d', { status: 'killed' }))).toBe(false)
|
||||
expect(isTaskLive(task('e', { status: 'failed' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('maps the five wire statuses to dot states and localized labels', () => {
|
||||
expect(taskDotState('running')).toBe('ongoing')
|
||||
expect(taskDotState('stopping')).toBe('warning')
|
||||
expect(taskDotState('completed')).toBe('done')
|
||||
expect(taskDotState('killed')).toBe('warning')
|
||||
expect(taskDotState('failed')).toBe('error')
|
||||
expect(taskStatusLabel('running', t)).toBe('taskStatusRunning')
|
||||
expect(taskStatusLabel('stopping', t)).toBe('taskStatusStopping')
|
||||
expect(taskStatusLabel('completed', t)).toBe('taskStatusCompleted')
|
||||
expect(taskStatusLabel('killed', t)).toBe('taskStatusKilled')
|
||||
expect(taskStatusLabel('failed', t)).toBe('taskStatusFailed')
|
||||
})
|
||||
|
||||
it('formats durations in at most two adjacent units', () => {
|
||||
expect(formatTaskDuration(0, t)).toBe('0秒')
|
||||
expect(formatTaskDuration(45_000, t)).toBe('45秒')
|
||||
expect(formatTaskDuration(90_000, t)).toBe('1分30秒')
|
||||
expect(formatTaskDuration(3_661_000, t)).toBe('1小时1分')
|
||||
// Negative or fractional input clamps to zero seconds.
|
||||
expect(formatTaskDuration(-5, t)).toBe('0秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectNewTask', () => {
|
||||
const list = (tasksBySession: Record<string, SidebarTaskView[]>): SidebarSessionList => ({
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: 'root' } },
|
||||
subagentsByParent: {},
|
||||
tasksBySession: tasksBySession,
|
||||
})
|
||||
|
||||
it('fires on EVERY new task id for the session (not just the first)', () => {
|
||||
expect(detectNewTask(list({}), list({ root: [task('bash-1')] }), 'root')).toBe(true)
|
||||
expect(detectNewTask(
|
||||
list({ root: [task('bash-1')] }),
|
||||
list({ root: [task('bash-1'), task('bash-2')] }),
|
||||
'root',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
|
||||
// Settling only mutates status, never adds ids.
|
||||
expect(detectNewTask(
|
||||
list({ root: [task('bash-1')] }),
|
||||
list({ root: [task('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
|
||||
'root',
|
||||
)).toBe(false)
|
||||
expect(detectNewTask(list({ root: [task('bash-1')] }), list({ root: [task('bash-1')] }), 'root')).toBe(false)
|
||||
// Tasks owned by another session do not trigger the current one.
|
||||
expect(detectNewTask(list({}), list({ child: [task('bash-1')] }), 'root')).toBe(false)
|
||||
expect(detectNewTask(list({}), list({}), 'root')).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user