Compare commits
10
Commits
v0.9.0
..
0b79269b2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b79269b2c | ||
|
|
cfc9d3c884 | ||
|
|
4e60dde18d | ||
|
|
a94c43343d | ||
|
|
48e16c73c7 | ||
|
|
856d744139 | ||
|
|
e4dcd1118e | ||
|
|
f86c1d82e6 | ||
|
|
0e91e99f49 | ||
|
|
c319fb106e |
+22
-139
@@ -1,148 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
# Build output
|
||||
lib/
|
||||
dist/
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
# Dependencies
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
# Logs & caches
|
||||
*.log
|
||||
.vite/
|
||||
.vitest/
|
||||
coverage/
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# vuepress v2.x temp directory
|
||||
.temp
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
# Local env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
pnpm-debug.log*
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
lib/
|
||||
|
||||
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
|
||||
registry/
|
||||
.npmrc
|
||||
# Unrelated work that landed in this directory (not part of apes-sidebar)
|
||||
maxkb-integration/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# apes-sidebar build config
|
||||
# Allow node-pty (a transitive runtime dep of dsh-better-sidebar's terminal
|
||||
# panel) to run its native build script. Phase 0 never exercises the terminal
|
||||
# panel, but pnpm's ignored-builds gate hard-fails the preinstall dep check
|
||||
# otherwise, which blocks `pnpm build`.
|
||||
onlyBuiltDependencies=node-pty
|
||||
@@ -1,502 +1,157 @@
|
||||
# dsh-better-sidebar 插件接入文档
|
||||
# apes-sidebar — engineering conventions
|
||||
|
||||
> 面向**消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
A DSH (DeepSeek Harness) **sidebar plugin** that registers five information-layer
|
||||
tabs into the `dsh-better-sidebar` host: `apes:tasks`, `apes:agents`,
|
||||
`apes:apps`, `apes:messages`, `apes:profile`.
|
||||
|
||||
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件通过 `registerTab` / `registerFileViewer` 注册扩展点,返回 disposer 由 Cordis fiber 自动管理生命周期(HMR-safe)。
|
||||
This repo is a **from-scratch v2.0.0 rewrite** of the legacy JS-bundle sidebar.
|
||||
The engineering template is `dsh-better-sidebar` (TS + tsdown + host/client
|
||||
split). If a convention here conflicts with `dsh-better-sidebar`, follow
|
||||
better-sidebar and file a note in `docs/`.
|
||||
|
||||
---
|
||||
## Stack
|
||||
|
||||
## 0. 仓库硬约束(所有本仓库插件必须遵守)
|
||||
- **Language**: TypeScript (strict, `noUncheckedIndexedAccess`, ESM, `.ts` extension imports)
|
||||
- **Build**: `tsdown` (rolldown). Dual output — `lib/index.js` + `lib/invariant.js`
|
||||
(host, ESM) and `lib/client.js` (client, self-contained bundle with a named
|
||||
`apply` export)
|
||||
- **Type check**: `tsc --noEmit -p tsconfig.json`
|
||||
- **Unit tests**: `vitest` (node environment, no DOM)
|
||||
- **Package manager**: `pnpm` (Node ≥ 22.13 required — `node:sqlite` + pnpm 11)
|
||||
- **Peer**: `dsh-better-sidebar` ≥ 0.18.0 (host-provided, never bundled)
|
||||
- **Dev-only types**: `@deepseek-ai/cordis` (types; host provides at runtime)
|
||||
|
||||
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
|
||||
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
|
||||
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
## Commands
|
||||
|
||||
---
|
||||
|
||||
## 1. 服务定位
|
||||
|
||||
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
|
||||
- **发布侧**:better-sidebar 的 client half(`src/client/index.tsx`,通过 `ctx.provide('betterSidebar', service)` 发布)
|
||||
- **消费侧**:你的插件的 client half(`inject = ['betterSidebar', ...]`,然后 `ctx.betterSidebar.registerTab(...)`)
|
||||
- **类型合并**:`declare module 'cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并
|
||||
|
||||
> ⚠️ **host 半不发布此服务**:`ctx.betterSidebar` 只在 client 侧存在。如果你的插件 host 半需要读 better-sidebar 状态,走 better-sidebar 自己的 HTTP/WS 路由(`/sidebar/api/*`),不走服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 消费插件的最小骨架
|
||||
|
||||
### 2.1 `package.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```bash
|
||||
pnpm install # install deps (preinstall re-checks on build)
|
||||
pnpm typecheck # tsc --noEmit (strict)
|
||||
pnpm build # rm -rf lib && tsc (d.ts) && tsdown (bundles)
|
||||
pnpm test # vitest run (unit only; no e2e in Phase 0)
|
||||
pnpm build && pnpm test && pnpm typecheck # the full gate
|
||||
```
|
||||
|
||||
- `dsh-better-sidebar` 必须声明为 **peerDependency**(不是 dependency,避免重复实例化)
|
||||
- 标记 `optional: true` 让你的插件在 better-sidebar 未安装时也能加载(注册代码会因为 `ctx.betterSidebar` 为 undefined 而跳过)
|
||||
Run the **full gate** before pushing. All three must be green.
|
||||
|
||||
### 2.2 client half 入口
|
||||
## Layout
|
||||
|
||||
```ts
|
||||
// my-plugin/src/client/index.ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
|
||||
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// 注册一个 sidebar tab
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: <DbIcon />,
|
||||
order: 50,
|
||||
component: ({ ctx, scope, tab }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
|
||||
// 注册一个文件预览器
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)),
|
||||
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
src/
|
||||
index.ts # HOST entry: export name/inject/apply (namespace plugin shape)
|
||||
invariant.ts # invariant surface (assert, semver, peer guard) — dep-free
|
||||
context-types.ts # service face contracts (ApesContext = CordisContext & {...})
|
||||
client/
|
||||
index.tsx # CLIENT entry: named `apply(ctx)` registering apes:* tabs
|
||||
locales.ts # i18n (zh default, en fallback) + useApesLocale hook
|
||||
tests/
|
||||
smoke.spec.ts # host plugin shape + apply + invariant surface
|
||||
locales.spec.ts # i18n contract against a mock ApesLocaleService
|
||||
tsdown.config.ts # dual build config (host ESM + client CJS)
|
||||
vitest.config.ts
|
||||
cordis.patch.yml # plugin registration into the DSH profile
|
||||
```
|
||||
|
||||
> ⚠️ **构建期纯度门**:client bundle 禁止 value-import 别的插件代码(`tsdown.config.ts` 的纯度门会挡)。`import type {}` 会被擦除,**不触发门禁**——所以类型可以自由共享,运行时符号不行。所有运行时交互必须走 `ctx.betterSidebar` 的方法调用。
|
||||
## Hard rules
|
||||
|
||||
### 2.3 类型导入
|
||||
1. **Namespace plugin export shape.** `src/index.ts` MUST export
|
||||
`{ name, inject, apply }` as named exports — **never** a `default` export.
|
||||
A stray `default` makes the cordis `Loader.unwrapExports` discard
|
||||
`name`/`inject`/`apply` and the plugin silently fails to mount.
|
||||
`tests/smoke.spec.ts` pins this.
|
||||
|
||||
```ts
|
||||
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
|
||||
```
|
||||
2. **Host / client split is load-bearing.**
|
||||
- The **host** half (`lib/index.js`) runs in Node. It may use Node builtins.
|
||||
- The **client** half (`lib/client.js`) runs in the DSH browser shell. Its
|
||||
entry is a **named `export function apply(ctx: ApesClientContext)`** (not a
|
||||
default export); tsdown bundles it to a self-contained module whose export
|
||||
face is `{ apply, ... }`. The host wires it via `ctx.betterSidebar` /
|
||||
`ctx.effect` (read from `ctx`, not a separately-injected `hostApi`).
|
||||
React is external (host-provided). Never `import` a Node builtin into
|
||||
`src/client/**`.
|
||||
- `src/invariant.ts` is reachable from BOTH halves — it must stay
|
||||
dependency-free (no Node builtins, no @deepseek-ai imports).
|
||||
|
||||
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
|
||||
3. **`dsh-better-sidebar` is a peerDependency, not a bundled dep.** The tsdown
|
||||
config lists it in `deps.neverBundle`. If a build inlines it, the client
|
||||
bundle ships a second copy of the sidebar registry — tabs register into a
|
||||
dead instance and never appear.
|
||||
|
||||
---
|
||||
4. **`@deepseek-ai/cordis` is devDependency (types only).** The host provides
|
||||
the real cordis context at runtime. Do NOT add cordis to `dependencies` or
|
||||
to `deps.neverBundle` — that would force the client to resolve it.
|
||||
|
||||
## 3. Tab 注册 API
|
||||
5. **Tab IDs are `apes:*`.** Never `wb:*` (legacy prefix) or any better-sidebar
|
||||
internal id. The five Phase 1 tabs: `apes:tasks`, `apes:agents`,
|
||||
`apes:apps`, `apes:messages`, `apes:profile`.
|
||||
|
||||
### 3.1 `TabDescriptor` 完整字段
|
||||
6. **Scope lock.** This plugin is the **information layer only**:
|
||||
tasks / agents / apps / messages / profile. Do NOT add file/terminal/browser
|
||||
panels — those belong to `dsh-better-sidebar` itself. Do NOT add team/room
|
||||
features — those belong to `dsh-background-agents`.
|
||||
|
||||
```ts
|
||||
interface TabDescriptor {
|
||||
/** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */
|
||||
id: string
|
||||
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
|
||||
title: string | (() => string)
|
||||
/** 图标:ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */
|
||||
order?: number
|
||||
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
|
||||
hidden?: boolean
|
||||
/** + 菜单禁用判定(如 terminal 配额满)。三参:ctx、会话 scope、当前状态 */
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
|
||||
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
|
||||
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
||||
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
|
||||
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
```
|
||||
7. **Bilingual i18n.** Every user-facing string goes through `t(key)` in
|
||||
`src/client/locales.ts` with BOTH a `zh` and an `en` entry. No raw strings in
|
||||
JSX.
|
||||
|
||||
### 3.2 `TabComponentProps`
|
||||
8. **pnpm build-script approval.** `node-pty` (a transitive runtime dep of
|
||||
better-sidebar's terminal panel) needs its native build approved once:
|
||||
`pnpm approve-builds` (pick `node-pty`, `y`). Without this, `pnpm build`'s
|
||||
preinstall dep-check hard-fails. It is approved in the committed
|
||||
`pnpm-lock.yaml` state — don't reset it.
|
||||
|
||||
```ts
|
||||
interface TabComponentProps {
|
||||
ctx: Context // client cordis context
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
|
||||
scope: SessionScope // { sessionId, cwd? }
|
||||
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
|
||||
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
|
||||
// 以下由内置 tab 使用,外部 tab 可忽略:
|
||||
expanded?: string[] // explorer 的展开目录集
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
onOpenDiff?: (tab: SidebarTab) => void
|
||||
onSubagentJump?: (childSessionId: string) => void
|
||||
}
|
||||
```
|
||||
## Testing conventions
|
||||
|
||||
### 3.3 注册示例
|
||||
- Unit tests run in a **node** vitest environment. No DOM, no jsdom (the
|
||||
client React components are not rendered in Phase 0 — a full cordis scratch
|
||||
mount smoke lands in Phase 1).
|
||||
- The host `apply()` is tested against a **minimal fake context** (see
|
||||
`tests/smoke.spec.ts`) — never against a live DSH shell in CI.
|
||||
- The locale contract is tested against a **mock `ApesLocaleService`** (see
|
||||
`tests/locales.spec.ts`).
|
||||
- A test that needs the real DSH host is a **Phase 1+** concern (cordis
|
||||
scratch profile); do not add such tests to Phase 0.
|
||||
|
||||
**最简 tab**(单实例、+ 菜单可见):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:notes',
|
||||
title: 'Notes',
|
||||
icon: <NoteIcon />,
|
||||
order: 50,
|
||||
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
|
||||
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
## Gotchas (learned, don't rediscover)
|
||||
|
||||
**多实例 tab**(每次新开、带自定义 id):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:doc',
|
||||
title: 'Doc',
|
||||
icon: <DocIcon />,
|
||||
order: 60,
|
||||
// 不设 dedupeKey:每次 openTab 都新开
|
||||
component: ({ tab, scope }) => <DocView docId={tab.id} sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
// 外部触发打开:
|
||||
ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' })
|
||||
```
|
||||
- `tsdown` 0.22 deprecated `external`/`noExternal` in favour of
|
||||
`deps.neverBundle`/`deps.alwaysBundle`. **`external` and `deps.neverBundle`
|
||||
cannot coexist** — using both throws at build. Use only the `deps` API.
|
||||
- `allowImportingTsExtensions` (for `.ts`-extension imports) is set in
|
||||
`tsconfig.json`. `tsconfig.build.json` inherits it but emits `d.ts`, which is
|
||||
compatible (tsdown handles the strip). If `tsc` build complains, the
|
||||
declaration emit needs `emitDeclarationOnly` + `noEmit: false` — that is the
|
||||
current `tsconfig.build.json` setup.
|
||||
- `node-pty` build approval is **per-machine state** in `pnpm-lock.yaml` + the
|
||||
pnpm store. A fresh `pnpm install` on a clean checkout will re-prompt.
|
||||
- The workspace path contains spaces and Chinese characters
|
||||
(`文稿 - figmarLite的MacBook Pro/...`) — always quote it in shell commands.
|
||||
- **`lib/client.js` is a DSH browser `__ModuleLoader__` bundle, NOT plain
|
||||
CJS/ESM.** Line 1 is `window.__ModuleLoader__.load({...})` — it is consumed
|
||||
by the DSH browser shell's module loader and **cannot be `require()`d in
|
||||
Node** (top-level `window` reference throws `window is not defined`). This is
|
||||
why the cordis scratch mount smoke cannot run in the unit gate: the client
|
||||
artifact is browser-only by construction, not just because the `dsh` CLI is
|
||||
absent. Unit specs test the **source** `src/client/index.tsx` (`apply`
|
||||
contract), never the built bundle.
|
||||
|
||||
**条件可见**(仅 git 仓库时显示):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:commits',
|
||||
title: 'Commits',
|
||||
icon: <CommitIcon />,
|
||||
order: 70,
|
||||
available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled
|
||||
dedupeKey: () => 'my-plugin:commits',
|
||||
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
## Phase status
|
||||
|
||||
### 3.4 内置 tab(不可重复注册)
|
||||
|
||||
| id | order | single | hidden | 用途 |
|
||||
|---|---|---|---|---|
|
||||
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
|
||||
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
|
||||
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
|
||||
|
||||
---
|
||||
|
||||
## 4. FileViewer 注册 API
|
||||
|
||||
### 4.1 `FileViewerDescriptor` 完整字段
|
||||
|
||||
```ts
|
||||
interface FileViewerDescriptor {
|
||||
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
|
||||
id: string
|
||||
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
|
||||
title?: string | (() => string)
|
||||
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
|
||||
exts: readonly string[]
|
||||
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
|
||||
priority?: number
|
||||
/** 字节获取策略 */
|
||||
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) }[] }
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `FileViewerProps`
|
||||
|
||||
```ts
|
||||
interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean // fetchStrategy='fsRead' 时
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `fetchStrategy` 对照
|
||||
|
||||
| 策略 | 字节来源 | 传给 component 的字段 | 适用 |
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
### 4.4 匹配算法
|
||||
|
||||
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
|
||||
|
||||
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领)
|
||||
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)
|
||||
|
||||
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
|
||||
|
||||
> **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)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
|
||||
**CSV 预览器**(自定义加载 + 渲染):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => {
|
||||
const text = await fetchText(scope, path)
|
||||
return parseCsv(text)
|
||||
},
|
||||
component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**覆盖内置 image viewer**(如想用自定义的 SVG 优化渲染):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:svg-pro',
|
||||
exts: ['svg'],
|
||||
priority: 10, // 高于内置 image 的 0
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl }) => <OptimizedSvg src={mediaUrl} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**内容嗅探**(按 magic bytes 路由,忽略扩展名):
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:magic-parquet',
|
||||
exts: [], // catch-all,但 priority 高 + detect 精确命中
|
||||
priority: 100,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head) => head.length >= 4
|
||||
&& head[0] === 0x50 && head[1] === 0x41
|
||||
&& head[2] === 0x52 && head[3] === 0x31, // 'PAR1'
|
||||
load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
|
||||
component: ({ customData }) => <ParquetTable data={customData} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 服务方法完整清单
|
||||
|
||||
```ts
|
||||
interface BetterSidebarService {
|
||||
/** 注册 tab 类型;返回 disposer */
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isTabEnabled(id: string): boolean
|
||||
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
||||
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
* 内容型打开(带 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
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(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 丢弃。
|
||||
|
||||
---
|
||||
|
||||
## 6. 生命周期与 HMR
|
||||
|
||||
- **disposer 必须返回**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
|
||||
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,所以你的插件 `inject = ['betterSidebar']` 时,better-sidebar 已经就绪。
|
||||
- **顺序无关**:Cordis 的 `inject` 保证服务就绪后才激活你的插件;你的插件可在 `apply` 内任意时刻注册。
|
||||
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡(显示 "插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复。
|
||||
|
||||
---
|
||||
|
||||
## 7. 平台约束与陷阱
|
||||
|
||||
| 陷阱 | 说明 |
|
||||
|---|---|
|
||||
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`;类型 `import type {}` 会被擦除,不触发门禁 |
|
||||
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`,你 `import type {}` 即拿到类型 |
|
||||
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **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` 纯渲染函数)不变 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 完整最小示例
|
||||
|
||||
> 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。
|
||||
|
||||
**`my-plugin/package.json`**:
|
||||
```jsonc
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`my-plugin/src/client/index.tsx`**:
|
||||
```tsx
|
||||
import { createElement } from 'react'
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const inject = ['betterSidebar']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// Database tab
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
order: 50,
|
||||
dedupeKey: () => 'my-plugin:db',
|
||||
component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }),
|
||||
})
|
||||
)
|
||||
|
||||
// CSV viewer
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => {
|
||||
const res = await fetch('/sidebar/api/fs.read', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: scope.sessionId, path }),
|
||||
})
|
||||
const { value } = await res.json()
|
||||
return parseCsv(value.content)
|
||||
},
|
||||
component: ({ customData, path }) =>
|
||||
createElement(CsvGrid, { rows: customData as string[][], path }),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ }
|
||||
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` + 浏览器硬刷新。
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考实现
|
||||
|
||||
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/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`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`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
- **Phase 0 (done)**: engineering skeleton, host/client split, i18n, invariant
|
||||
surface, build gate green, unit smoke green.
|
||||
- **Phase 1 (done)**: real tab registration for the five `apes:*` panels
|
||||
against a mock host registry (type-only real `TabDescriptor` /
|
||||
`TabComponentProps` / `Context` from `dsh-better-sidebar`), i18n for all
|
||||
five views, `ctx.effect`-wrapped registration with HMR-safe disposer, and
|
||||
graceful degrade when `betterSidebar` is absent. Unit smoke: `tests/tabs.spec.ts`
|
||||
(6 tests). The cordis scratch mount smoke (real DSH host process) is deferred
|
||||
to Phase 2 — it needs a live host and is outside the unit gate.
|
||||
- **Phase 2 (partial)**: badge counts + auto-collapse (visible-gated
|
||||
subscription pause) + per-tab state landed for the information panels.
|
||||
`apes:tasks` / `apes:agents` show live count pills (running / subagent);
|
||||
`apes:tasks` persists a `showInactive` preference through the settings
|
||||
namespace (`apesSidebar.tasks`, `applies: 'live'`). The cordis scratch mount
|
||||
smoke and wiring `apps` / `messages` / `profile` to real feeds are **deferred**
|
||||
— better-sidebar 0.18.0 exposes no workspace-apps / message-stream /
|
||||
model-provider host feeds for those three panels, and a live scratch mount
|
||||
needs a `dsh` CLI (absent locally) plus a DOM harness the Phase 0 convention
|
||||
deliberately avoids. See docs/ for the deferral rationale.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 dsh-external
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,133 +0,0 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
> 一个插件,一套完整工作台:
|
||||
>
|
||||
> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、后台任务页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
|
||||
>
|
||||
> 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览
|
||||
|
||||
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" />
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 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,不干扰模型的 `job_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 命名空间;切换语言无需刷新
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 操作 | 按键 |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
前置:已安装 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 也走同一服务,吃自己的狗粮):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
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} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。
|
||||
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
|
||||
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
|
||||
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
|
||||
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)
|
||||
pnpm watch # tsdown --watch
|
||||
```
|
||||
|
||||
**架构**:单 npm 包、host/client 双半结构——host(`src/index.ts`):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/html` 预览路由、`/sidebar/ws/terminal` WebSocket(fs / git / pty / 预览,全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + 拦截;状态按会话持久化 localStorage。插件按 DSH 官方规范组织(无 default 导出、双 client bundle),运行期不依赖 npm / checkout(`@deepseek-ai/*` 由 web profile 提供)。
|
||||
|
||||
## 🔐 安全
|
||||
|
||||
- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;媒体/预览路由仅限会话 cwd 内文件;git 只调 CLI、绝不设置身份
|
||||
- HTML 预览与浏览器 tab 的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界;地址栏拒绝 `javascript:`/`data:`/`file:` 与 localhost 等本机地址
|
||||
- 界面实时显示沙箱状态(关闭时红色警示),可临时解锁当前页面;设置页可按功能关闭沙箱(默认关闭该设置,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用
|
||||
|
||||
## ⚠️ 已知限制
|
||||
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
|
||||
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
|
||||
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
|
||||
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
|
||||
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
|
||||
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
|
||||
|
||||
## 🖥️ 平台支持
|
||||
|
||||
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
|
||||
@@ -0,0 +1,13 @@
|
||||
# apes-sidebar bundle patch: inserts the host plugin row into the profile
|
||||
# roster. The client half (sidebar tabs: Tasks / Agents / Messages / Apps /
|
||||
# Profile) loads through the dsh.client.inject declaration in package.json;
|
||||
# this patch mounts the Node-side host entry only.
|
||||
#
|
||||
# Double-mount guard: if another *enabled* entry already mounts the same
|
||||
# package (e.g. via an aggregate bundle), this row backs off.
|
||||
- insert:
|
||||
- id: apes-sidebar
|
||||
name: apes-sidebar
|
||||
disabled: !!js >
|
||||
[...ctx.loader.entries()].some((e) =>
|
||||
e.options.name === 'apes-sidebar' && e.options.id !== 'apes-sidebar' && !e.disabled)
|
||||
@@ -0,0 +1,362 @@
|
||||
# apes-sidebar 重写方案(v2)— 冲突检查通过,从零开始
|
||||
|
||||
> 目标:重写现有 `apes-sidebar`(v0.4.0,单文件手写 JS client bundle),
|
||||
> **代码规范/工程方案学习 [DSH-better-sidebar v0.18.0](https://github.com/omdsh-dev/DSH-better-sidebar)**,
|
||||
> **需求功能/交互能力学习 [tutti-os/tutti](https://github.com/tutti-os/tutti)**。
|
||||
>
|
||||
> 分析基线:DSH-better-sidebar `v0.18.0`(浅克隆 /tmp/DSH-better-sidebar)· Tutti(本地 /Users/figmarlite/Desktop/…/APES/tutti)
|
||||
> 生成时间:2026-09-03 · 用途:重构开工前对齐,供评审与拆单。
|
||||
|
||||
## 0. 冲突检查结论(2026-09-03)
|
||||
|
||||
**目标自洽,不冲突,无需推翻重规划。** 两个参考源互补:
|
||||
- better-sidebar v0.18.0 = 「怎么建」(工程规范/构建/测试/i18n/token/服务契约)
|
||||
- tutti-os/tutti = 「做什么」(面板模型/角标/agent 可编程驱动)
|
||||
|
||||
三处主动调整(借"从零开始"授权做干净化):
|
||||
1. **Tab id 换前缀**:旧 `wb:*` → 新 `apes:*`(apes:tasks / apes:agents / apes:apps / apes:messages / apes:profile),不留 `wb` 历史包袱。
|
||||
2. **定位收敛**:只做 **tasks/agents/apps/messages/profile** 五面板信息层;files/terminal/browser/editor 由 better-sidebar 宿主提供,**不重复造轮子**(复用 `openTab/openFile` + 宿主内置 tab 即可)。
|
||||
3. **放弃团队房间**:tutti 的 rooms/team 依赖 `dsh-background-agents` 0.7.x,与 DSH 基座 loader id 冲突会崩(历史教训),v2 不做。
|
||||
|
||||
唯一硬约束:peer 钉 `@deepseek-ai/cordis ^4.0.2` + `dsh-better-sidebar ^0.18.0`(better-sidebar 发 npm `next` dist-tag,随 DSH rc.x 漂移;升级前必须跑挂载冒烟)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状诊断(旧版 v0.4.0 的痛点)
|
||||
|
||||
### 1.1 现状结构
|
||||
|
||||
```
|
||||
figmar/apes-sidebar (v0.4.0)
|
||||
├── package.json # peer: cordis ^4.0.1 / better-sidebar ^0.16.0 / react 18
|
||||
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫)
|
||||
├── lib/index.js # host 最小入口(~1KB,只打日志)
|
||||
└── lib/client.js # 45KB 单文件,window.__ModuleLoader__.load 手写 bundle
|
||||
# 纯 JS + React.createElement,无 JSX/TS/构建/测试
|
||||
```
|
||||
|
||||
注册 4 个 Tab(全部基于 better-sidebar `ctx.betterSidebar.registerTab`):
|
||||
|
||||
| Tab id | 作用 | 参照 |
|
||||
|---|---|---|
|
||||
| `wb:tasks` | 工作区+时间分组会话列表、搜索、运行中徽标、运行中任务区 | WorkBuddy |
|
||||
| `wb:agents` | 子代理/后台 agent 列表、状态、一键跳转 | Tutti multi-agent |
|
||||
| `wb:apps` | 应用中心(Design/PPT/Video 网格) | Tutti App Center |
|
||||
| `wb:profile` | Provider/模型信息 + 设置/帮助入口 | WorkBuddy 底部 |
|
||||
|
||||
配置:better-sidebar 设置页 `pluginSettings['apes-sidebar']`(`enableTasks`/`enableAgents`/`enableApps`/`enableProfile`/`displayName`/`showTime`/`showUngrouped`/`appsList`)。
|
||||
|
||||
### 1.2 主要痛点
|
||||
|
||||
1. **单文件 45KB 手写 JS bundle**:无类型、无模块拆分、无构建产物管理;
|
||||
CSS 以字符串内联注入(`APPS_CSS` 拼接),每次改样式要手工编辑字符串。
|
||||
2. **零测试**:无 spec,无 CI 挂载冒烟——任何 API 漂移(如 DSH rc 升级)静默崩。
|
||||
3. **i18n 手写**:本地 zh/en 两字典,不跟随宿主 `ctx.locale`,第三方语言直接缺。
|
||||
4. **皮肤脆弱**:CSS 硬编码 `#fff` 渐变等(`card-icon` 用 `#3370ff→#7c3aed`),不消费
|
||||
`--dsw-alias-*` token 链,深色/浅色/品牌换肤会破。
|
||||
5. **状态模型简单**:轮询/快照读取 sessions/workspaces,无订阅模型、无 memo、无懒加载。
|
||||
6. **无 host 能力**:只有日志入口。需要 host 路由(读会话日志、子代理拓扑、job 回放)
|
||||
时只能绕 DSH 网关或放弃。
|
||||
7. **版本基线落后**:peer 只到 better-sidebar ^0.16.0;而 better-sidebar v0.18.0
|
||||
已毕业正式版、只支持 DSH 0.1.2-rc.1+(协议、RPC 方言、client store 均大变)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 参考源 A:DSH-better-sidebar v0.18.0 工程规范(「怎么建」)
|
||||
|
||||
### 2.1 包结构(published npm package)
|
||||
|
||||
- `name: dsh-better-sidebar` / `version: 0.18.0` / `type: module` / `pnpm@11.8.0`
|
||||
- `main: lib/index.js`;exports 子路径:`.` / `./invariant` / `./client` / `./client/service` / `./client/api` / `./src/*` / `./package.json`
|
||||
- `dsh.bundle.patch: ./cordis.patch.yml`(bundle 激活的**硬性资格**)
|
||||
- `dsh.client.inject`: `[locale, ui-slots, ui-conversation, modules]`,`platform: web`
|
||||
- `dsh.plugin.json`:市场 manifest 形态(id/version/main/engines.dsh/contributes/client.main)
|
||||
- `files`: lib + src + cordis.patch.yml + scripts + README
|
||||
|
||||
### 2.2 构建与质量
|
||||
|
||||
- `build`: `tsc -p tsconfig.build.json && tsdown`(类型声明 + JS 产物)
|
||||
- `typecheck` / `test`(vitest) / `lint:ts`(oxlint) / `playwright`(e2e)
|
||||
- **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)独立 bundle `lib/client-<name>.js`,
|
||||
经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`);
|
||||
核心 bundle 禁止静态 import chunks。
|
||||
- **CI 挂载冒烟**(`plugin-mount` job):`pnpm pack` → scratch profile 真实安装 →
|
||||
`dsh web`(keyless)→ Playwright 断言 `[data-dsh-better-sidebar]` 挂载、无 pageerror、
|
||||
+ 菜单逐 tab 打开、editor chunk 强制加载。本地:`pnpm build && pnpm pack && pnpm test:mount`。
|
||||
|
||||
### 2.3 仓库硬约束(AGENTS.md)
|
||||
|
||||
- 禁止修改 DSH 源码(零写入 `~/.dsh/source/current`);改动必须 PR(`feat/*`/`fix/*`)。
|
||||
- 挂载只走 `cordis.patch.yml` + profile;**不反向侵入 DSH**。
|
||||
- 市场约束:`dependencies/peerDependencies/optionalDependencies` **一律不得出现 `cordis`**;
|
||||
`scripts` 不得含 `preinstall/install/postinstall/prepare`(`tests/market-manifest.spec.ts` 守护)。
|
||||
- 缺能力时用 DSH 现成只读/公开 API 或插件自有路由(如 jobs.output 事件回放),不改 DSH。
|
||||
|
||||
### 2.4 架构:host half + client half
|
||||
|
||||
**host half(Node 侧,`src/index.ts` ~1300 行)**
|
||||
- 导出 `export const name = 'dsh-better-sidebar'`、`inject = ['webServer','sessions','webRuntime','tools']`、`export function apply(ctx)`。
|
||||
- 插件自有路由全走同源 fence:`/sidebar/api/*`(JSON:fs.tree/search/read/write、git.*、session.cwd、
|
||||
changes.ops…)、`/sidebar/file`(媒体)、`/sidebar/html`(预览)、`/sidebar/bundle`(懒加载)、
|
||||
`/sidebar/ws/*`(终端 WebSocket upgrade)。
|
||||
- **信任围栏** `trust-fence.ts`:与 /api 网关同源(Host-header loopback / webRuntime trustedHosts),
|
||||
逐请求读取;**路径安全** `path-security.ts`:会话 cwd 内解析、workspaceFence 守卫、写路径原子写(tmp+rename)。
|
||||
- 会话隔离:所有请求带 sessionId,权威 cwd 来自 session store(header → client cwd → persistence → process.cwd 四级回退)。
|
||||
- PTY 管理:`pty-manager.ts` + `agent-pty.ts`(agent 终端注册表、resize gate、按会话配额)。
|
||||
- 工具注册:`registerTools`(agent 可调用)、`agent-opens.ts`(agent 主动开 tab 的 registry + open tool)。
|
||||
|
||||
**client half(浏览器侧,`src/client/index.tsx` ~446 行)**
|
||||
- `inject = ['slots','sessions','locale','modules','connection']`
|
||||
- `attachLocale(ctx.locale)` + `ctx.effect` 注册词典(zh/en 为主,第三语言走 better-locale 可选覆盖)。
|
||||
- `createSidebarStore()` 单例 per activation → `ctx.provide('betterSidebar', service)`。
|
||||
- `ctx.effect(() => registerBuiltins(ctx, service))`:内置 7 tab + 6 viewer **自己吃自己的狗粮**。
|
||||
- 挂载:`document.body` 追加 host div → `createRoot` 渲染 `RenderBoundary > Sidebar`;
|
||||
bodyObserver 防宿主移除;几何自检防页面 transform 漂移(degraded viewport sync)。
|
||||
- 拦截层:turn-tail 拦截、open-path 拦截(`remote.session.openWorkspacePath` 遮蔽)、外链拦截
|
||||
(urlTarget 认领,默认 browser tab)、**IME 守卫**(composition 键防第三方 UI 抢键)。
|
||||
- 设置面:`ctx.slots.inject('settings.section', …)` 声明「Side card」设置分区。
|
||||
|
||||
### 2.5 服务契约(消费插件 API,也是 apes-sidebar 重写后要面向的基座)
|
||||
|
||||
`ctx.betterSidebar`(client-only):
|
||||
|
||||
```ts
|
||||
registerTab(descriptor: TabDescriptor): () => void // 返回 disposer,HMR-safe
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
getTabs() / getFileViewers() / getTab(id)
|
||||
isTabEnabled(id) / isViewerEnabled(id)
|
||||
matchFileViewer(path, head?)
|
||||
openTab(seed: OpenTabSeed, scope?) / closeTab(tabId, scope?)
|
||||
openFile(scope, path, title?)
|
||||
updateTab / activateTab
|
||||
subscribe(listener) / getSnapshot() / subscribeState(listener)
|
||||
```
|
||||
|
||||
`TabDescriptor` 关键字段(v0.18.0 全量):
|
||||
|
||||
```ts
|
||||
interface TabDescriptor {
|
||||
id: string
|
||||
title: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
order?: number // + 菜单排序,默认 100
|
||||
hidden?: boolean
|
||||
available?: (ctx, scope, state) => boolean // + 菜单禁用谓词
|
||||
single?: boolean // 单实例(dedupeKey: () => id 语法糖)
|
||||
dedupeKey?: (tab) => string | undefined
|
||||
createTab?: (state) => { tab, patch? } | null // 自定义 mint tab id
|
||||
urlTarget?: (url: URL) => boolean // 外链认领
|
||||
settings?: SidebarSettingsDeclaration // toggles / pluginToggles / render
|
||||
badge?: (ctx, scope, state) => string | number | null | undefined // Tab 角标
|
||||
onOpen? / onActivate? / onClose? // 生命周期(仅 service 路径触发)
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
声明式设置:`'switch' | 'text' | 'number' | 'select'`;select 支持 `multi` 与图标选项卡。
|
||||
`TabComponentProps = { ctx, store, scope, tab, visible, expanded?, revealed?, onToggleDir?/onOpenFile?/… }`。
|
||||
|
||||
**接入规范(外部插件视角)**:
|
||||
- peerDependencies: `@deepseek-ai/cordis` + `dsh-better-sidebar`(optional);
|
||||
type-only import 触发 `declare module '@deepseek-ai/cordis'` 类型合并;
|
||||
- 纯浏览器插件走 `import type {} from 'dsh-better-sidebar/client/service'`(零 Node 依赖图);
|
||||
- client half `inject: ['betterSidebar', …]`,`ctx.effect(() => ctx.betterSidebar.registerTab(...))`;
|
||||
- 宿主 half 无此服务——需要读状态走 better-sidebar 自己的 HTTP/WS 路由。
|
||||
|
||||
### 2.6 i18n / 皮肤 / 无障碍规范
|
||||
|
||||
- i18n:词典注册到 DSH `ctx.locale`(LOCALE_NS);新增 zh key 必须同步 ja(否则 ja 回退 en);
|
||||
渲染 MarkdownText 必须经 `markdownTextProps()`(labels 嵌套契约)。
|
||||
- 皮肤:**视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌**,无硬编码颜色;
|
||||
硬编码 hex 只能做 token 链末端 fallback(`color:var(--x,#fff)`),并有 `tests/theme.spec.ts` 守护。
|
||||
- a11y:role=tablist/tab/separator、aria-selected/valuenow、focus-visible ring、IME guard。
|
||||
|
||||
---
|
||||
|
||||
## 3. 参考源 B:Tutti 侧边栏能力(「做什么」)
|
||||
|
||||
### 3.1 面板模型(packages/agent/gui/workbench/tool-sidebar/model.ts)
|
||||
|
||||
```ts
|
||||
export const agentToolPanelIds = ['files','terminal','browser','tasks','apps','messages','side'] as const
|
||||
```
|
||||
|
||||
- 每面板有 label、可开多实例(resourceId 区分,如 browser tab 带 agent session id)。
|
||||
- 每面板默认/最小/最大宽度(files 最大不限、side 默认 440 最大 600、其余默认 720/最小 420)。
|
||||
- `mainContentMinWidth = 280`:主内容区最小宽度保护。
|
||||
|
||||
### 3.2 行为层(AgentToolSidebar.tsx + useAgentToolSidebarController)
|
||||
|
||||
- **多标签**:TabBar(role=tablist),每个 tab = panel + resourceId;关闭/激活/拖拽。
|
||||
- **可编程 handle**(AgentToolSidebarHandle,agent/宿主可驱动):
|
||||
`addPanel / ensurePanel / openPanel / closeTab / close / collapseForContainerConstraint`
|
||||
- **容器约束自动折叠**:`shouldAutoCollapseAgentToolSidebar({containerWidth, mainContentMinWidth, sidebarWidth})`
|
||||
→ 窗口变窄时 `collapseForContainerConstraint()`(不关 tab,只收起到 Dock)。
|
||||
- **宽度记忆 + 动画**:`onLayoutWidthChange`、resize handle(键盘可操作 role=separator)、
|
||||
width transition 260ms。
|
||||
- **懒挂载渲染**:`contentReadyTabIds` + `renderLoading`(未就绪 tab 显示 loading,就绪才挂)。
|
||||
- **空态 Picker**:`isEmptySidebar` → AgentToolSidebarPicker(从 + 菜单选面板)。
|
||||
- **展开态**:expand 按钮让活动面板撑满(expanded panel 概念)。
|
||||
|
||||
### 3.3 信息架构与角标(StandaloneAgentToolSidebar.tsx)
|
||||
|
||||
- `panels` 条件组装:`side` 面板仅在有 side conversation identity 时出现(`canAdd: false`)。
|
||||
- `quickActionPanels`: 有自动化 browser 时把 browser 提到 quick action;常驻 tasks/apps/messages。
|
||||
- `reminders`(角标计数):
|
||||
- `browser: automationBrowserCount`(运行中 automation browser tab 数)
|
||||
- `messages: messageCenterWorkingCount`(工作会话中 working agent 消费者计数,订阅 sessionEngine)
|
||||
- 对应旧版 apes-sidebar 的 badge 概念:tasks 徽标 = 运行中会话数;agents 徽标 = 运行子代理数。
|
||||
|
||||
### 3.4 各面板承载(渲染层职责划分)
|
||||
|
||||
| 面板 | 承载 | apes-sidebar 对应/借鉴 |
|
||||
|---|---|---|
|
||||
| files | 文件树/编辑器 | (better-sidebar 已有 editor)不重复 |
|
||||
| terminal | 终端 | (better-sidebar 已有)不重复 |
|
||||
| browser | 内嵌浏览器 | (better-sidebar 已有)不重复 |
|
||||
| tasks | Issue Manager/任务编排 | **apes:wb:tasks 升级对象**:会话/任务拓扑 |
|
||||
| apps | App Center | **apes:wb:apps 对齐对象**:应用网格+调起 |
|
||||
| messages | 消息中心(审批/通知/后台工作汇总) | **apes 可新增 wb:messages** |
|
||||
| side | Side Conversation(子会话并排) | 子代理会话展示可借鉴 |
|
||||
|
||||
Tutti 其余值得迁移的交互:控制中心(一个视图看所有 agent 对话+待审批+运行任务)、
|
||||
goal→tasks 自动拆分、@ 深度引用、task 分配给人/agent。
|
||||
|
||||
### 3.5 进程/事件模型
|
||||
|
||||
- 会话与活动数据经 `sessionEngine`/activity 服务订阅(`subscribe + getSnapshot` 模式,
|
||||
即 React 19 `useSyncExternalStore` 形状)——ape-sidebar 应把「轮询」升级为订阅 + memo。
|
||||
- 子代理/后台工作 → `subagent-live-route` / jobs 回放(better-sidebar 已实现:
|
||||
`jobs-routes.ts` 从会话事件日志回放 job 输出、`subagent-live-route.ts` 批量子代理状态)。
|
||||
|
||||
---
|
||||
|
||||
## 4. apes-sidebar v2 目标架构
|
||||
|
||||
### 4.1 定位
|
||||
|
||||
> **apes-sidebar v2 = 构建在 better-sidebar v0.18.0 之上的「工作空间指挥层」**,
|
||||
> 不重复 files/terminal/browser/editor(better-sidebar 已有),聚焦 Tutti 风格
|
||||
> **任务 / Agent / 消息 / 应用**四类信息面板 + host 侧路由与 agent 可编程驱动。
|
||||
|
||||
仍是一个 **client-half 注册型插件**(注册到 `ctx.betterSidebar`),但工程规范全面对齐 better-sidebar。
|
||||
|
||||
### 4.2 目标仓库布局
|
||||
|
||||
```
|
||||
apes-sidebar/
|
||||
├── package.json # peer: @deepseek-ai/cordis ^4.0.2 / dsh-better-sidebar ^0.18.0 / react ^18
|
||||
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫,抄 v0.18.0 写法)
|
||||
├── tsconfig.json / tsconfig.build.json
|
||||
├── tsdown.config.ts # 多产物:lib/index.js + lib/client.js + lib/client-<chunk>.js
|
||||
├── vitest.config.ts / playwright.config.ts
|
||||
├── scripts/e2e-mount.sh # scratch profile 真实挂载冒烟
|
||||
├── AGENTS.md # 仓库硬约束(参照 better-sidebar)
|
||||
├── src/
|
||||
│ ├── index.ts # host half:路由 + 工具(若需要)
|
||||
│ ├── context-types.ts # declare module '@deepseek-ai/cordis' 类型合并
|
||||
│ ├── client/
|
||||
│ │ ├── index.tsx # apply():attachLocale → 注册词典 → registerTabs
|
||||
│ │ ├── locales.ts # zh/en(跟随 ctx.locale)
|
||||
│ │ ├── api.ts # 插件自有路由 client
|
||||
│ │ ├── tabs/
|
||||
│ │ │ ├── TasksTab.tsx # 会话/任务导航(保留 wb:tasks id 兼容持久化)
|
||||
│ │ │ ├── AgentsTab.tsx # Agent/子代理拓扑(wb:agents)
|
||||
│ │ │ ├── AppsTab.tsx # 应用中心(wb:apps)
|
||||
│ │ │ ├── MessagesTab.tsx # (新)消息中心/审批汇总
|
||||
│ │ │ └── ProfileTab.tsx # 底部用户区(wb:profile)
|
||||
│ │ └── builtins.ts # 统一注册(吃自己的狗粮)
|
||||
│ └── (host routes 需要时) sidechat-routes.ts / subagent-routes.ts
|
||||
└── tests/
|
||||
├── tabs.spec.tsx / theme.spec.ts / market-manifest.spec.ts
|
||||
└── e2e/mount.e2e.ts
|
||||
```
|
||||
|
||||
### 4.3 关键工程决策
|
||||
|
||||
1. **TS + tsdown 双产物**:lib/index.js(host)、lib/client.js(client 入口)、
|
||||
chunks 按需(若引入 mermaid/图表等重依赖);`files` 白名单发布。
|
||||
2. **client inject 精简**:只声明实际 require 的宿主包
|
||||
(`@deepseek-ai/dsh-client-ui-primitives` + 服务注入 betterSidebar/sessions/workspaces/locale 经 cordis inject,不塞 dsh.client.inject)。
|
||||
3. **host half 保持最小**:纯展示可无 host;需要会话日志回放/子代理状态时新增
|
||||
better-sidebar 风格的自有路由(同源 fence + 会话隔离),不碰 DSH 源码。
|
||||
4. **Tab id 向后兼容**:`wb:tasks` / `wb:agents` / `wb:apps` / `wb:profile` 保持不变
|
||||
(避免破坏已持久化 pluginToggles / 用户布局);样式 data 属性带 `apes-sidebar-` 前缀。
|
||||
5. **配置迁移到声明式 settings**:沿用 `pluginToggles`(switch/select)机制,
|
||||
在 better-sidebar 设置页渲染;新增 `displayName` select(apes-sidebar/feyOS/自定义)。
|
||||
6. **i18n 对齐**:词典注册 `ctx.locale`(zh/en);新增 key 同步第三方语言或做 en 回退。
|
||||
7. **皮肤对齐**:全部视觉消费 `--dsw-alias-*`/`--ds-*` token,硬编码 hex 仅作链尾 fallback。
|
||||
8. **badge/reminders 对齐 Tutti**:TasksTab 徽标 = 运行中会话/任务数;AgentsTab 徽标 =
|
||||
运行子代理数(订阅 sessionEngine 而非轮询)。若可经 host 路由读 agent consumer counts,
|
||||
则消息中心徽标 = working count(对应 Tutti messageCenterWorkingCount)。
|
||||
9. **懒挂载**:未激活 tab 不渲染内容;首次打开再挂(contentReady + loading),
|
||||
与 better-sidebar TabContent 行为一致。
|
||||
10. **测试门禁**:至少 tabs 注册/开关/渲染 smoke + 挂载 e2e + 皮肤 token 守护 +
|
||||
market manifest 守护(cordis 不得出现在 dependencies)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能清单(Tutti → apes-sidebar 映射)
|
||||
|
||||
| # | 能力 | Tutti 参照 | apes-sidebar v2 落地 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| F1 | 会话/任务导航(工作区+时间分组、搜索、运行徽标) | Tasks | `wb:tasks` 升级:订阅式 + 运行中任务区 + 拓扑 | P0 |
|
||||
| F2 | Agent 总览(子代理/后台 agent、状态、跳转) | multi-agent | `wb:agents` 升级:订阅式、subagent lineage | P0 |
|
||||
| F3 | 应用中心(已装应用网格 + 调起) | App Center | `wb:apps`:appsList 可配置、viewId 调起多级降级 | P0 |
|
||||
| F4 | 消息中心/审批/工作汇总(角标) | messages + messageCenterWorkingCount | `wb:messages`(新) | P1 |
|
||||
| F5 | 任务自动拆分(goal→tasks) | Tutti goal 编排 | 规划:输入目标→子任务草稿→一键建会话(DSH 能力内简化版) | P2 |
|
||||
| F6 | 显示名/开关/子设置 | — | 声明式 settings(pluginToggles) | P0 |
|
||||
| F7 | agent 可编程开 tab | AgentToolSidebarHandle | host route + better-sidebar openTab/openFile 组合 | P1 |
|
||||
| F8 | 自动折叠/宽度记忆 | shouldAutoCollapseAgentToolSidebar | 交 better-sidebar 容器(若支持)或本地监听 | P2 |
|
||||
| F9 | Profile 用户区 | — | `wb:profile`:模型/provider/连接状态 | P0 |
|
||||
| F10 | 深色/浅色/换肤 | skin tokens | 全 token 化 | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施
|
||||
|
||||
### Phase 0:工程骨架(0.5d)
|
||||
- 建仓、tsconfig/tsdown/vitest/playwright、package.json(peer 钉 cordis ^4.0.2 + better-sidebar ^0.18.0)
|
||||
- cordis.patch.yml(v0.18.0 守卫写法)、AGENTS.md、最小 host+client 空注册冒烟
|
||||
- 通过 `pnpm test:mount` 在 scratch profile 验证挂载
|
||||
|
||||
### Phase 1:4 Tab 平移 + 质量化(1-1.5d)
|
||||
- 把旧 client.js 的 4 视图用 TSX 重写,行为不变(保留 wb:* id)
|
||||
- i18n → ctx.locale、皮肤 → token、样式 → CSS modules
|
||||
- 配置 → pluginToggles 声明式
|
||||
- 单测:注册数/开关/渲染/badge
|
||||
|
||||
### Phase 2:Tutti 能力增强(2-3d)
|
||||
- `wb:tasks`:订阅式 sessionEngine 快照 + 运行中任务区(子代理拓扑优先排序)
|
||||
- `wb:agents`:subagent lineage + 状态订阅
|
||||
- 新增 `wb:messages`(若 host 有 working count 数据源)
|
||||
- agent 可编程开 tab(F7)最小实现
|
||||
|
||||
### Phase 3:打磨与发布(1d)
|
||||
- 皮肤 token 全检、a11y、IME guard、错误边界
|
||||
- e2e 挂载冒烟入 CI、Gitea 发布 + npm 发版脚本
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证清单(每个 Phase 完成时)
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test # vitest 单测
|
||||
pnpm lint:ts
|
||||
pnpm build && pnpm pack
|
||||
pnpm test:mount # scratch profile 真实挂载(需 dsh + playwright chromium)
|
||||
# 真机:dsh plugin --profile desktop add file:<tarball> → 硬刷新 → + 菜单见新 tab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与注意
|
||||
|
||||
- **DSH rc 漂移**:better-sidebar 是 0.1.2-rc.1 线,DSH 官方 rc.x 发 next dist-tag;
|
||||
重写期间锁定 devDeps peer,升级前跑 `pnpm peers check`。
|
||||
- **不要装 dsh-background-agents 0.7.x**:与 DSH 基座 loader 条目 id 冲突 → 启动崩溃
|
||||
(历史教训),团队房间类能力暂缓。
|
||||
- **apps 调起无公开一键 API**:维持多级防御(uiConversation.activateView → slots.set → 提示)。
|
||||
- **client 纯模块表**:运行时只能 require react/react-dom/primitives 等宿主提供模块;
|
||||
其余依赖必须内联或走懒加载 chunk,否则构建纯度门会拦。
|
||||
@@ -1,618 +0,0 @@
|
||||
# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面
|
||||
|
||||
> 面向 **消费插件开发者**:如何让你的插件向 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`(类型声明)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览:你能扩展什么
|
||||
|
||||
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 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
|
||||
|
||||
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
|
||||
|
||||
> ⚠️ **服务只在 client half**:`ctx.betterSidebar` 只存在于浏览器侧。你的插件 **host 半没有这个服务**;host 半需要读 better-sidebar 状态时,走它自己的 HTTP/WS 路由(`/sidebar/api/*`、`/sidebar/file`、`/sidebar/ws/*`),不走服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置:类型合并与依赖声明
|
||||
|
||||
### 2.1 双 cordis 实例问题
|
||||
|
||||
外部插件在 DSH monorepo 之外解析,拿不到官方 cordis 的 augmentation,因此 `ctx.betterSidebar` 不会自动出现在你的 `Context` 类型上。解法由 better-sidebar 自己提供:
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类型合并
|
||||
```
|
||||
|
||||
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §9)。
|
||||
|
||||
### 2.2 package.json 声明
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `dsh-better-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例;
|
||||
- `optional: true`:better-sidebar 未安装时你的插件照常加载,注册代码因为 `ctx.betterSidebar` 为 undefined 而安全跳过。
|
||||
|
||||
### 2.3 类型导入路径
|
||||
|
||||
```ts
|
||||
// 方式一:主入口(推荐,src/index.ts 已 re-export 全部描述符类型)
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
TabDescriptor,
|
||||
TabComponentProps,
|
||||
FileViewerDescriptor,
|
||||
FileViewerProps,
|
||||
FileFetchStrategy,
|
||||
} from 'dsh-better-sidebar'
|
||||
|
||||
// 方式二:子路径(与主入口等价)
|
||||
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 最小骨架(client half)
|
||||
|
||||
```ts
|
||||
// my-plugin/src/client/index.ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需)
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// 注册一个 sidebar tab:ctx.effect 包裹 → 卸载时自动撤销注册(HMR-safe)
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: <DbIcon />,
|
||||
order: 50,
|
||||
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
|
||||
// 注册一个文件预览器
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)),
|
||||
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **注册必须包在 `ctx.effect(...)` 里**。`registerTab` / `registerFileViewer` 返回 `() => void` disposer,Cordis fiber 卸载时自动调用;不包 effect,HMR / 插件禁用后注册残留,下次激活会抛 `"already registered"`。
|
||||
- `inject = ['betterSidebar']` 让 Cordis 在 better-sidebar 激活后才激活你的插件,注册时机无忧(顺序无关)。
|
||||
- 注册在 `apply` 内任意时刻都行;服务在 better-sidebar 的 `apply()` 开头就绪。
|
||||
|
||||
---
|
||||
|
||||
## 4. 新页面(Tab)注册 API
|
||||
|
||||
### 4.1 `TabDescriptor` 完整字段
|
||||
|
||||
```ts
|
||||
interface TabDescriptor {
|
||||
/** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */
|
||||
id: string
|
||||
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
|
||||
title: string | (() => string)
|
||||
/** 图标:ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */
|
||||
order?: number
|
||||
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
|
||||
hidden?: boolean
|
||||
/** + 菜单禁用判定(如 terminal 配额满)。三参:ctx、会话 scope、当前状态 */
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
|
||||
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
|
||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
|
||||
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
||||
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):见 §7。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
/** 渲染函数 */
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `TabComponentProps`(你的页面组件收到的 props)
|
||||
|
||||
```ts
|
||||
interface TabComponentProps {
|
||||
ctx: Context // client cordis context
|
||||
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
|
||||
scope: SessionScope // { sessionId, cwd? } —— 会话标识,调用 /sidebar API 必带
|
||||
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
|
||||
visible: boolean // 是否当前激活 tab 且面板打开(不可见时暂停轮询等)
|
||||
// 以下由内置 tab 使用,外部 tab 可忽略:
|
||||
expanded?: string[] // explorer 的展开目录集
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
onOpenDiff?: (tab: SidebarTab) => void
|
||||
onSubagentJump?: (childSessionId: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
实践建议:
|
||||
|
||||
- **用 `visible` 做性能门**:subagent 内置页在 `visible === false` 时暂停轮询;你的页面若有轮询/订阅,同样处理。
|
||||
- **用 `scope.sessionId`(+ `scope.cwd`)访问会话数据**:所有 `/sidebar/api/*` 请求都要带这两个字段(见 §6)。
|
||||
|
||||
### 4.3 注册示例
|
||||
|
||||
**最简单实例 tab**(+ 菜单可见):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:notes',
|
||||
title: 'Notes',
|
||||
icon: <NoteIcon />,
|
||||
order: 50,
|
||||
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
|
||||
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**多实例 tab + 外部触发打开**(每次新开,带自定义 id):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:doc',
|
||||
title: 'Doc',
|
||||
icon: <DocIcon />,
|
||||
order: 60,
|
||||
// 不设 dedupeKey:每次 openTab 都新开
|
||||
component: ({ tab, scope }) => <DocView docId={tab.id} sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
// 外部触发打开(你的插件其他流程、甚至用户操作):
|
||||
ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' })
|
||||
```
|
||||
|
||||
**条件可见**(仅满足条件时 + 菜单可用;返回 false 显示为 disabled 行而非隐藏):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:commits',
|
||||
title: 'Commits',
|
||||
icon: <CommitIcon />,
|
||||
order: 70,
|
||||
available: (ctx, scope, state) => hasGitRepo(state),
|
||||
dedupeKey: () => 'my-plugin:commits',
|
||||
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**自定义创建**(mint 自增 id,terminal 内置页同款):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:console',
|
||||
title: 'Console',
|
||||
order: 80,
|
||||
createTab: (state) => ({
|
||||
tab: { id: `console:${state.nextTerminal}`, type: 'my-plugin:console', title: `Console ${state.nextTerminal}` },
|
||||
patch: { nextTerminal: state.nextTerminal + 1 }, // 借内置计数器;也可自建 state 字段
|
||||
}),
|
||||
component: ({ tab, scope }) => <ConsoleView tabId={tab.id} sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
### 4.4 内置 tab 清单(不可重复注册)
|
||||
|
||||
| id | order | single | hidden | 用途 |
|
||||
|---|---|---|---|---|
|
||||
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
|
||||
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件预览器(FileViewer)注册 API
|
||||
|
||||
### 5.1 `FileViewerDescriptor` 完整字段
|
||||
|
||||
```ts
|
||||
interface FileViewerDescriptor {
|
||||
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
|
||||
id: string
|
||||
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
|
||||
title?: string | (() => string)
|
||||
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
|
||||
exts: readonly string[]
|
||||
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
|
||||
priority?: number
|
||||
/** 字节获取策略 */
|
||||
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) }[] }
|
||||
/** 渲染函数 */
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 `FileViewerProps`
|
||||
|
||||
```ts
|
||||
interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean // fetchStrategy='fsRead' 时
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 `fetchStrategy` 对照
|
||||
|
||||
| 策略 | 字节来源 | 传给 component 的字段 | 适用 |
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
### 5.4 匹配算法(`matchFileViewer`)
|
||||
|
||||
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
|
||||
|
||||
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领);
|
||||
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)。
|
||||
|
||||
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
|
||||
|
||||
> **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)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 5.5 注册示例
|
||||
|
||||
**CSV 预览器**(自定义加载 + 渲染):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => {
|
||||
const text = await fetchText(scope, path)
|
||||
return parseCsv(text)
|
||||
},
|
||||
component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**覆盖内置 image viewer**(如自定义 SVG 优化渲染):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:svg-pro',
|
||||
exts: ['svg'],
|
||||
priority: 10, // 高于内置 image 的 0
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl }) => <OptimizedSvg src={mediaUrl} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**内容嗅探**(按 magic bytes 路由,忽略扩展名):
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:magic-parquet',
|
||||
exts: [], // catch-all,但 priority 高 + detect 精确命中
|
||||
priority: 100,
|
||||
fetchStrategy: 'custom',
|
||||
detect: (_path, head) => head.length >= 4
|
||||
&& head[0] === 0x50 && head[1] === 0x41
|
||||
&& head[2] === 0x52 && head[3] === 0x31, // 'PAR1'
|
||||
load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
|
||||
component: ({ customData }) => <ParquetTable data={customData} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 页面内如何访问数据(/sidebar API)
|
||||
|
||||
你的 tab / viewer 组件运行在浏览器里,与内置视图同源同权。访问文件/会话数据直接 `fetch` better-sidebar 的 JSON API(内置 `src/client/api.ts` 的封装就是干这个的,你可以在自己插件里复制这个 fetch 模式):
|
||||
|
||||
```ts
|
||||
// POST /sidebar/api/<method>,body 带 sessionId + cwd(可选)
|
||||
const res = await fetch('/sidebar/api/fs.read', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: scope.sessionId, path }),
|
||||
})
|
||||
const { value } = await res.json() // 错误时 { ok: false, error: { code, message } }
|
||||
```
|
||||
|
||||
常用方法(完整清单见 `src/client/api.ts`):
|
||||
|
||||
| 方法 | 说明 |
|
||||
|---|---|
|
||||
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
|
||||
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`) |
|
||||
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
|
||||
| `fs.write` | 原子写文件 |
|
||||
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
|
||||
| `pty.close` / `agent-pty.close` | 释放终端(外部 tab 一般用不到) |
|
||||
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫) |
|
||||
|
||||
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
|
||||
|
||||
```ts
|
||||
// 媒体 URL(图片等直接 <img src>):/sidebar/file?sessionId=...&path=...
|
||||
const url = `/sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}`
|
||||
```
|
||||
|
||||
> 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可。所有路由带与 `/api` 相同的 Host 头信任围栏,浏览器同源访问天然通过。
|
||||
|
||||
---
|
||||
|
||||
## 7. 服务方法完整清单
|
||||
|
||||
```ts
|
||||
interface BetterSidebarService {
|
||||
/** 注册 tab 类型;返回 disposer */
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 按 id 查 tab 描述符 */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isTabEnabled(id: string): boolean
|
||||
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅注册表变化(register/dispose 时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 声明式设置(v0.4.1+)
|
||||
|
||||
每个注册的 tab / viewer **自动**出现在 DSH 设置页「侧边卡片」分区(`SideCardSection` 按注册表驱动渲染,无硬编码):
|
||||
|
||||
- 展示:小卡片网格(图标 + 标题 + 类型 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`。
|
||||
|
||||
```ts
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: 'Database',
|
||||
order: 50,
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'myPluginAutoConnect', // ⚠️ 必须是宿主 PrefsSchema 的字段,见下
|
||||
title: 'Auto connect',
|
||||
desc: 'Connect on open',
|
||||
}],
|
||||
},
|
||||
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
> ⚠️ **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`)。外部插件的自定义键会被 settings seam 丢弃——当前版本嵌套开关只支持宿主预置的布尔偏好;想给用户暴露你自己的设置,走 DSH 自己的 settings 服务(`ctx.settings`),不要依赖这里的 `toggles`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 生命周期与 HMR
|
||||
|
||||
- **disposer 必须返回并被 fiber 持有**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
|
||||
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,你的 `inject = ['betterSidebar']` 保证你激活时服务已就绪;注册顺序无关紧要。
|
||||
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡("插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复(`sanitizeNode` 保留未注册类型而非丢弃)。
|
||||
- **`visible` 语义**:面板折叠或非激活 tab 的 `visible` 为 false;你的页面应借此暂停轮询/订阅,激活时恢复。
|
||||
|
||||
---
|
||||
|
||||
## 10. 平台约束与陷阱
|
||||
|
||||
| 陷阱 | 说明 |
|
||||
|---|---|
|
||||
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`(`src/context-types.ts`),你 `import type {} from 'dsh-better-sidebar'` 即拿到类型 |
|
||||
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
|
||||
| **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`) |
|
||||
| **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
|
||||
|
||||
---
|
||||
|
||||
## 11. 完整最小示例
|
||||
|
||||
假设插件 `my-plugin` 要加一个 "Database 浏览器" tab + `.csv` 文件预览器。
|
||||
|
||||
**`my-plugin/package.json`**:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`my-plugin/src/client/index.tsx`**:
|
||||
|
||||
```tsx
|
||||
import { createElement } from 'react'
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const inject = ['betterSidebar']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// Database tab(单实例,+ 菜单可见)
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
order: 50,
|
||||
dedupeKey: () => 'my-plugin:db',
|
||||
component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }),
|
||||
})
|
||||
)
|
||||
|
||||
// CSV viewer(custom 策略:自己拉字节 + 解析)
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => {
|
||||
const res = await fetch('/sidebar/api/fs.read', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: scope.sessionId, path }),
|
||||
})
|
||||
const { value } = await res.json()
|
||||
return parseCsv(value.content)
|
||||
},
|
||||
component: ({ customData, path }) =>
|
||||
createElement(CsvGrid, { rows: customData as string[][], path }),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ }
|
||||
function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /* ... */ }
|
||||
function parseCsv(text: string): string[][] { /* ... */ }
|
||||
```
|
||||
|
||||
**注册到 profile**:
|
||||
|
||||
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)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 参考实现与调试
|
||||
|
||||
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:6 个内置 tab(tabs.tsx)+ 8 个内置 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 + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
@@ -1,19 +0,0 @@
|
||||
# Word 缩放与 PDF 拖拽修复设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
|
||||
## Word 缩放
|
||||
|
||||
- 默认 100%,范围 50%–200%,步进 10%。
|
||||
- `Alt + 滚轮` 调整缩放并阻止 viewport 默认滚动。
|
||||
- 底部固定 range slider,同步显示百分比。
|
||||
- 使用 CSS `zoom` 缩放 docx-preview 专属容器,使滚动尺寸同步变化。
|
||||
- 切换 Word 文件时恢复 100%。
|
||||
|
||||
## PDF 拖拽
|
||||
|
||||
- iframe 放入独立 stage。
|
||||
- stage 内常驻透明 drag shield。
|
||||
- Tab 拖拽或面板/分栏调整期间立即启用 shield;结束、取消或窗口失焦时关闭。
|
||||
- shield 捕获 Chromium 内置 PDF viewer 吞掉的 dragover/pointer 流,正常状态不影响 PDF 交互。
|
||||
@@ -1,364 +0,0 @@
|
||||
# Office 三件套预览 — 设计文档
|
||||
|
||||
> 日期:2026-08-10
|
||||
> 状态:已批准(待实现)
|
||||
> 范围:`dsh-better-sidebar` 插件,`EditorView` 文件类型扩展
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 `EditorView.tsx` 对 `.docx/.xlsx/.pptx` 及旧格式 `.doc/.xls/.ppt` 一律显示 binary 占位(`EditorView.tsx:210`),因为 host 的 `readText` 检测到 NUL 字节就标记 binary(`src/index.ts:108`),而 Office 文件本质是 zip。
|
||||
|
||||
**目标**:为 Office 三件套新增有意义的预览能力,保持插件现有架构纪律(host 零改动、bundle 纯度门、跨平台、失败模式全覆盖)。
|
||||
|
||||
**非目标**:
|
||||
- 不做 Office 文件编辑(只预览)
|
||||
- 不做 `push/pull/fetch` 之类网络 Git 操作(与本次无关,沿用 v1 限制)
|
||||
- 不做文件 watcher
|
||||
|
||||
## 2. 范围与选型
|
||||
|
||||
| 类型 | 方案 | 库 | 加载策略 | host 改动 |
|
||||
|---|---|---|---|---|
|
||||
| `.docx` | 保真渲染(保留样式/页眉页脚/图片/表格) | `docx-preview` | 静态 import(~500KB,小) | 零 |
|
||||
| `.xlsx` | 完整电子表格(公式/图表/条件格式/多 sheet) | `@univerjs/*` 全家桶 + `xlsx`(SheetJS,导入) | `dynamic import()` 懒加载(~5MB,首次打开 .xlsx 才加载) | 零 |
|
||||
| `.pptx` | 降级下载按钮 | — | — | — |
|
||||
| `.doc/.xls/.ppt`(OLE 二进制) | 降级下载按钮 | 纯前端无成熟库 | — | — |
|
||||
|
||||
**决策依据**:
|
||||
- 纯前端路线符合现有 bundle 纯度门(`tsdown.config.ts:128` 的 purity gate 只禁 `@deepseek-ai/*` value import,普通 npm 包内联不受限)与跨平台原则(无需 host 装重依赖)。
|
||||
- `pptx` 纯前端无成熟渲染库( OOXML slides + layouts + masters + shapes 解析极复杂),YAGNI 降级。
|
||||
- 旧 OLE 二进制格式(`.doc/.xls/.ppt`)纯前端基本无解,降级。
|
||||
- Univer 选 dynamic import 懒加载:静态内联会让 client bundle 从 2MB→7MB(gzip 1.5MB→3MB),首屏负担过重。
|
||||
|
||||
## 3. 架构
|
||||
|
||||
### 3.1 host 半:零改动
|
||||
|
||||
复用现有 `/sidebar/file` media route(`src/index.ts:349-392`):
|
||||
- 该路由已支持任意文件字节(不限扩展名,只检查 `isWithin(cwd, path)` + `isFile` + `size <= mediaLimit`)
|
||||
- `MEDIA_TYPES`(`index.ts:44`)对 Office 扩展名 fallback 到 `application/octet-stream`,client 用 `fetch` 拿 ArrayBuffer,不依赖 content-type
|
||||
- `mediaLimit` 默认 20MB,对 Office 文件足够(超限降级下载)
|
||||
|
||||
**不改 `fs.read`**:Office 文件不走文本读取路径(会被 NUL 探测当 binary 拒绝),直接走 media route。
|
||||
|
||||
### 3.2 client 半:EditorView 类型扩展
|
||||
|
||||
`EditorLoad` 类型(`EditorView.tsx:26`)扩展:
|
||||
|
||||
```ts
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx'; content: string; truncated: boolean }
|
||||
| { status: 'binary' } // 现保留:未知二进制 / pptx / OLE 旧格式 → 下载按钮
|
||||
```
|
||||
|
||||
加载流程(`EditorView.tsx:60` 的 `useEffect`):
|
||||
1. `api.fsRead(scope, path)` 仍调(用于探测 binary/text)— 对 Office 文件会返回 `{kind:'binary'}`,据此分流
|
||||
2. 识别扩展名 ∈ `{.docx, .xlsx}` → `setLoad({ status:'ready', kind:'docx'|'xlsx', content:'', truncated:false })`
|
||||
3. 识别 `.pptx` / `.doc` / `.xls` / `.ppt` / 其他未知 binary → 保持 `{status:'binary'}`,但 binary 占位改为"下载查看"按钮(见 §3.5)
|
||||
4. `IMAGE_EXT` / `MD_EXT` 分支不变
|
||||
|
||||
> 备注:step 1 的 `fsRead` 对 Office 文件是冗余往返(必定返回 binary)。可选优化:client 侧先按扩展名短路,Office 扩展名直接走 media route 不调 `fsRead`。实现时取短路方案,省一次请求。
|
||||
|
||||
### 3.3 文件类型分发(纯函数,易测)
|
||||
|
||||
新增 `src/client/office-types.ts`:
|
||||
|
||||
```ts
|
||||
export const OFFICE_PREVIEWABLE = ['.docx', '.xlsx'] as const
|
||||
export const OFFICE_DOWNLOAD_ONLY = ['.pptx', '.doc', '.xls', '.ppt'] as const
|
||||
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'download-only' | null
|
||||
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === '.docx') return 'docx'
|
||||
if (ext === '.xlsx') return 'xlsx'
|
||||
if (['.pptx', '.doc', '.xls', '.ppt'].includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
`EditorView` 的 `extOfPath` 已有(`EditorView.tsx:235`),复用。
|
||||
|
||||
### 3.4 DocxView / XlsxView 组件
|
||||
|
||||
新增 `src/client/office-view.tsx`,导出 `DocxView` 与 `XlsxView`,各自管理库生命周期。
|
||||
|
||||
#### DocxView
|
||||
|
||||
```tsx
|
||||
export function DocxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = hostRef.current
|
||||
if (container === null) return
|
||||
;(async () => {
|
||||
try {
|
||||
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
|
||||
if (cancelled) return
|
||||
const { renderAsync } = await import('docx-preview') // 静态 import 亦可,此处用动态保持一致
|
||||
await renderAsync(buf, container, null, { className: 'docx', inWrapper: true })
|
||||
if (!cancelled) setLoad({status:'ready'})
|
||||
} catch (error) {
|
||||
if (!cancelled) setLoad({status:'error', message: error instanceof Error ? error.message : String(error)})
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true; container.innerHTML = '' }
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
// 渲染:host div + loading/error 占位
|
||||
}
|
||||
```
|
||||
|
||||
- `docx-preview` 的 `renderAsync(arrayBuffer, container, null, options)` 把 docx 渲染到 container,保留样式/页眉页脚/图片(图片库内部 base64 内联处理)
|
||||
- 卸载清空 `container.innerHTML`(docx-preview 无 dispose API,DOM 清空即可)
|
||||
|
||||
#### XlsxView
|
||||
|
||||
```tsx
|
||||
export function XlsxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<Univer | null>(null)
|
||||
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = hostRef.current
|
||||
if (container === null) return
|
||||
;(async () => {
|
||||
try {
|
||||
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
|
||||
if (cancelled) return
|
||||
// 懒加载 Univer 全家桶(首次打开 .xlsx 才下载 ~5MB chunk)
|
||||
const { createUniver, defaultLocale } = await import('@univerjs/core')
|
||||
const { UniverSheetsCorePlugin } = await import('@univerjs/sheets-ui')
|
||||
const { UniverSheetsFormulaPlugin } = await import('@univerjs/sheets-formula')
|
||||
const { UniverSheetsConditionalFormattingPlugin } = await import('@univerjs/sheets-conditional-formatting')
|
||||
const { UniverSheetsChartPlugin } = await import('@univerjs/sheets-chart')
|
||||
const { UniverDocsPlugin } = await import('@univerjs/docs-ui')
|
||||
// xlsx 导入:SheetJS 解析后转 Univer workbook data
|
||||
const XLSX = await import('xlsx')
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const workbookData = xlsxWorkbookToUniver(wb) // 转换函数,见 §3.6
|
||||
|
||||
const univer = createUniver({
|
||||
locale: defaultLocale,
|
||||
plugins: [
|
||||
UniverDocsPlugin,
|
||||
UniverSheetsCorePlugin,
|
||||
UniverSheetsFormulaPlugin,
|
||||
UniverSheetsConditionalFormattingPlugin,
|
||||
UniverSheetsChartPlugin,
|
||||
],
|
||||
})
|
||||
univer.createUniverSheet(workbookData)
|
||||
univerRef.current = univer
|
||||
if (!cancelled) setLoad({status:'ready'})
|
||||
} catch (error) {
|
||||
if (!cancelled) setLoad({status:'error', message: ...})
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
univerRef.current?.dispose() // 关键:防 canvas + worker 泄漏
|
||||
univerRef.current = null
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
// 渲染:host div + loading/error 占位
|
||||
}
|
||||
```
|
||||
|
||||
- **dispose 纪律**:卸载必须 `univer.dispose()`,否则 canvas + worker 泄漏(类比 `TerminalView` 的 `term.dispose()` + `observer.disconnect()`)
|
||||
- Univer 的 UI 需要容器有尺寸(类似 xterm),复用 `ResizeObserver` 模式
|
||||
|
||||
### 3.5 binary 占位升级为下载按钮
|
||||
|
||||
`EditorView.tsx:210` 现状:`{t('binary')}` 纯文本占位。
|
||||
|
||||
改为:
|
||||
|
||||
```tsx
|
||||
{load.status === 'binary' && (
|
||||
<div className={css.editorBinary}>
|
||||
<span>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- 复用现有 `downloadUrl(scope, path)`(`api.ts:140`),走 `/sidebar/file?download=1`
|
||||
- 对所有 binary 统一(不只 Office)— PDF、zip、exe 等都受益
|
||||
|
||||
### 3.6 xlsx → Univer workbook data 转换
|
||||
|
||||
SheetJS 解析的 workbook 结构与 Univer 的 `IWorkbookData` 不同,需转换层 `src/client/xlsx-to-univer.ts`:
|
||||
|
||||
- SheetJS `wb.SheetNames[]` + `wb.Sheets[name]` → Univer `sheets: Record<sheetId, IWorksheetData>`
|
||||
- 单元格:`ws[addr]` → `cellData[row][col]`,映射类型(s/n/b/e)、值、样式(对齐/字体/背景/边框)
|
||||
- 合并单元格:`ws['!merges']` → `mergeData`
|
||||
- 列宽/行高:`ws['!cols']` / `ws['!rows']` → `columnData` / `rowData`
|
||||
- 公式:SheetJS `cell.f` → Univer 公式格式
|
||||
- 图表/条件格式:SheetJS 不解析这些,需 Univer importer 或跳过(首版可只迁数据+样式+合并+公式)
|
||||
|
||||
**首版范围**:数据 + 基本样式(字体/颜色/对齐/边框/合并)+ 公式(文本形式即可,Univer 引擎计算)。图表/条件格式若 Univer importer 直接支持则用,否则跳过。
|
||||
|
||||
> 备选:用 Univer 官方的 `@univerjs/sheets-import-export`(基于 SheetJS 封装,直接产出 `IWorkbookData`),省去自写转换层。实现时优先评估此包,若版本耦合/体积可接受则用,否则自写。
|
||||
|
||||
### 3.7 EditorView 集成
|
||||
|
||||
`EditorView.tsx` 的 `renderTab` / `TabContent` 分发(`Sidebar.tsx:49` 的 switch)无需改 — editor tab 仍走 `EditorView`。
|
||||
|
||||
`EditorView` 内部 `useEffect`(`EditorView.tsx:60`)加 Office 分支:
|
||||
|
||||
```ts
|
||||
const ext = extOfPath(path)
|
||||
const officeKind = officeKindForExt(ext)
|
||||
if (officeKind === 'docx' || officeKind === 'xlsx') {
|
||||
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
|
||||
return // 不再走 fsRead(短路,省一次 binary 往返)
|
||||
}
|
||||
// 原有 IMAGE / MD / text / binary 逻辑不变
|
||||
```
|
||||
|
||||
渲染区(`EditorView.tsx:220` 附近)加:
|
||||
|
||||
```tsx
|
||||
{load.status === 'ready' && load.kind === 'docx' && (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'xlsx' && (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
```
|
||||
|
||||
## 4. 错误处理
|
||||
|
||||
| 场景 | 检测 | 响应 |
|
||||
|---|---|---|
|
||||
| 文件损坏(非有效 zip) | 库抛错 | 显示错误信息 + 下载按钮 |
|
||||
| `>20MB`(超 `mediaLimit`) | fetch 响应 / host 返回 400 | 提示"文件过大" + 下载按钮 |
|
||||
| 加密文件(密码保护) | SheetJS/docx-preview 抛特定错 | "不支持加密文件" + 下载按钮 |
|
||||
| Univer chunk 加载失败 | dynamic import reject | 重试按钮(类比 `TerminalView` 的 `FAILURE_LIMIT`) |
|
||||
| 网络失败(媒体路由 403) | fetch 非 200 | 错误信息(信任围栏拒绝) |
|
||||
| Univer dispose 后异步回调 | cancelled flag | 静默(类比现有 `TerminalView` 模式) |
|
||||
|
||||
**降级统一出口**:所有 Office 错误分支都附带"下载查看"链接,确保用户始终能拿到文件。
|
||||
|
||||
## 5. bundle 与构建
|
||||
|
||||
### 5.1 依赖新增(`package.json`)
|
||||
|
||||
`dependencies`(运行时,内联进 client bundle):
|
||||
- `docx-preview`(~500KB)
|
||||
- `xlsx`(SheetJS,~400KB,xlsx 导入)
|
||||
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
|
||||
|
||||
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`xterm`/`CodeMirror` 也在 `dependencies`)。
|
||||
|
||||
### 5.2 tsdown.config.ts 调整
|
||||
|
||||
现有 client bundle 配置(`tsdown.config.ts:103` 的 `clientBundle()`):
|
||||
- `format: 'cjs'` + `banner`/`footer` 包成 `__ModuleLoader__.load` 闭包工厂
|
||||
- `noExternal: (id) => (CLIENT_EXTERNALS.includes(id) ? undefined : true)` — 非 externals 全内联
|
||||
|
||||
**dynamic import 兼容性(最大实现风险)**:
|
||||
|
||||
Rollup 在 `format: 'cjs'` + `inlineDynamicImports: false` 时会拆 chunk,但 CJS chunk 在浏览器端无 `require`,动态导入链路可能断裂。需 spike 验证:
|
||||
|
||||
1. **首选**:保持 `format: 'cjs'`,让 rollup 拆 chunk,验证浏览器 `import()` 是否能加载子 chunk(rollup 会输出 `import()` 调用,浏览器原生支持 ESM dynamic import,但 CJS chunk 的 `module.exports` 形态需 wrapper)
|
||||
2. **fallback A**:退回静态内联(放弃懒加载,接受 7MB bundle)
|
||||
3. **fallback B**:Univer 单独打 IIFE bundle `lib/univer-bundle.js`,host 新增静态路由 `/sidebar/univer-bundle.js` serve,client 用 `<script>` 动态注入(破坏 host 零改动,最后手段)
|
||||
|
||||
**spike 必须是实现第一步**,结果决定后续构建配置。
|
||||
|
||||
### 5.3 CSS
|
||||
|
||||
Univer 自带样式,需确保:
|
||||
- Univer 的 CSS 被 `dsh-css-inline` plugin 正确内联(走 `import '@univerjs/.../style.css'`)
|
||||
- 或 Univer 的样式注入机制不与现有 `data-plugin-css` tag 冲突
|
||||
|
||||
docx-preview 的样式由 `className: 'docx'` 选项控制,可能需补 `src/client/office.module.css` 微调。
|
||||
|
||||
## 6. 测试
|
||||
|
||||
### 6.1 Unit(纯函数,加进 `tests/unit.spec.ts`)
|
||||
|
||||
- `officeKindForExt('.docx')` → `'docx'`
|
||||
- `officeKindForExt('.xlsx')` → `'xlsx'`
|
||||
- `officeKindForExt('.pptx')` → `'download-only'`
|
||||
- `officeKindForExt('.doc')` → `'download-only'`
|
||||
- `officeKindForExt('.txt')` → `null`
|
||||
- `officeKindForExt('')` → `null`
|
||||
|
||||
### 6.2 xlsx → Univer 转换(若自写转换层)
|
||||
|
||||
- fixture xlsx → 解析 → 转换 → 断言 `IWorkbookData` 结构(sheet 名/单元格值/合并/公式)
|
||||
- 用 `tests/fixtures/sample.xlsx` 作为输入
|
||||
|
||||
### 6.3 集成测试限制
|
||||
|
||||
- Univer 依赖 canvas API,jsdom 环境无法渲染 → 不测 Univer 渲染产物
|
||||
- 只测加载逻辑:mock `fetch` 返回 ArrayBuffer,验证 loading → ready/error 状态转换
|
||||
- docx-preview 同样依赖 DOM,jsdom 可跑 `renderAsync`(无 canvas 依赖)但渲染产物脆弱 → 只测"不抛错"
|
||||
|
||||
### 6.4 手动冒烟
|
||||
|
||||
- 真实 .docx(含图片/表格/页眉)→ 渲染保真度
|
||||
- 真实 .xlsx(多 sheet/公式/条件格式)→ 渲染 + 公式计算
|
||||
- .pptx → 下载按钮
|
||||
- 加密 docx/xlsx → 错误提示 + 下载
|
||||
- >20MB → 提示 + 下载
|
||||
|
||||
## 7. 国际化
|
||||
|
||||
`src/client/locales.ts` 新增 key:
|
||||
|
||||
- `downloadToView` — "下载查看" / "Download to view"
|
||||
- `binaryNoPreview` — "此文件类型不支持预览" / "This file type cannot be previewed"
|
||||
- `officeTooLarge` — "文件过大,无法预览" / "File too large to preview"
|
||||
- `officeCorrupt` — "文件损坏或格式无效" / "File is corrupt or in an invalid format"
|
||||
- `officeEncrypted` — "不支持加密文件" / "Encrypted files are not supported"
|
||||
- `officeLoadFailed` — "Office 预览组件加载失败" / "Office preview component failed to load"
|
||||
- `retry` — 复用现有 `terminalRetry` 或新增 `retry`
|
||||
|
||||
## 8. 文件改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/client/EditorView.tsx` | 加 docx/xlsx kind 分支 + 下载按钮 binary 占位 |
|
||||
| `src/client/office-view.tsx`(新) | `DocxView` / `XlsxView` 组件 |
|
||||
| `src/client/office-types.ts`(新) | 扩展名 → kind 纯函数 |
|
||||
| `src/client/xlsx-to-univer.ts`(新,若自写转换) | SheetJS workbook → Univer IWorkbookData |
|
||||
| `src/client/locales.ts` | 新文案 key |
|
||||
| `src/client/sidebar.module.css` | `.editorBinary` / `.editorDownloadLink` 样式 |
|
||||
| `package.json` | 加 docx-preview / @univerjs/* / xlsx 依赖 |
|
||||
| `tsdown.config.ts` | dynamic import 配置(spike 后定) |
|
||||
| `tests/unit.spec.ts` | officeKindForExt 单测 |
|
||||
| `tests/fixtures/`(新) | sample.docx / sample.xlsx 测试夹具 |
|
||||
|
||||
## 9. 风险登记
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解 |
|
||||
|---|---|---|---|
|
||||
| tsdown CJS 闭包不兼容 dynamic import | 中 | 高(阻塞懒加载) | spike 优先;fallback A 静态内联 / fallback B 独立 bundle |
|
||||
| Univer 体积超预期(>5MB) | 低 | 中 | 评估 tree-shaking;裁剪非必要插件(如 chart) |
|
||||
| xlsx → Univer 转换层复杂度爆表 | 中 | 中 | 优先用 `@univerjs/sheets-import-export` 官方包 |
|
||||
| Univer canvas 在某些浏览器渲染异常 | 低 | 中 | 错误边界捕获 + 下载降级 |
|
||||
| docx-preview 图片 base64 内存爆炸(大 docx) | 低 | 低 | 文件 size cap 已 20MB,可接受 |
|
||||
| Univer dispose 不彻底导致内存泄漏 | 中 | 中 | 严格 cleanup + 手动验证 Tab 切换/卸载 |
|
||||
|
||||
## 10. 实现里程碑(粗略,待 writing-plans 细化)
|
||||
|
||||
1. **Spike**:验证 tsdown CJS + dynamic import 兼容性(决定构建策略)
|
||||
2. **基础**:office-types.ts + EditorView 分发 + binary 下载按钮(最小可用,pptx/旧格式先受益)
|
||||
3. **docx**:DocxView + docx-preview 集成 + 错误处理
|
||||
4. **xlsx**:XlsxView + Univer 集成 + xlsx 导入(优先官方 importer)
|
||||
5. **测试**:unit + fixture + 手动冒烟
|
||||
6. **收尾**:CSS 打磨 + 国际化 + 文档更新(README 功能一览)
|
||||
@@ -1,31 +0,0 @@
|
||||
# PDF 原生预览设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
> 范围: `dsh-better-sidebar` 的 `EditorView` 文件预览扩展
|
||||
|
||||
## 目标
|
||||
|
||||
为 `.pdf` 增加侧边栏内预览,同时保持零新增前端依赖和现有下载降级出口。
|
||||
|
||||
## 方案
|
||||
|
||||
- Client 按扩展名识别 `.pdf`,短路 `fsRead` 的二进制探测。
|
||||
- `PdfView` 通过 `fetch(mediaUrl(scope, path))` 读取字节,创建显式
|
||||
`application/pdf` Blob URL 后交给浏览器原生 `<iframe>`,避免旧 host
|
||||
或代理缓存 `application/octet-stream` 时触发直接下载。
|
||||
- Host media route 为 `.pdf` 返回 `application/pdf`。
|
||||
- PDF 预览工具条始终提供“下载查看”链接。
|
||||
- 不引入 PDF.js,不实现缩略图、文本搜索或自定义翻页工具栏。
|
||||
|
||||
## 错误与限制
|
||||
|
||||
- 是否可渲染取决于浏览器内置 PDF 查看器。
|
||||
- 文件仍受现有 `mediaLimit` 限制。
|
||||
- 浏览器原生 PDF iframe 对 HTTP 错误没有统一可观测事件;下载链接作为稳定降级出口。
|
||||
|
||||
## 测试
|
||||
|
||||
- `.pdf` 扩展名识别为 PDF 预览。
|
||||
- media type helper 对 `.pdf` 返回 `application/pdf`。
|
||||
- Typecheck、bundle purity、manifest/plugin-shape 测试通过。
|
||||
@@ -1,37 +0,0 @@
|
||||
# PPTX 高保真预览设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
> 范围: `dsh-better-sidebar` 的 `.pptx` 浏览器预览
|
||||
|
||||
## 目标
|
||||
|
||||
使用 `@aiden0z/pptx-renderer` 在侧边栏内预览 `.pptx`,保留图片、表格、
|
||||
图表、SmartArt 与常见 OOXML 样式,同时保持下载降级出口。
|
||||
|
||||
## 架构
|
||||
|
||||
- `.pptx` 从 `download-only` 调整为独立 `pptx` 预览类型。
|
||||
- `PptxView` 从现有 media route 获取 `ArrayBuffer`。
|
||||
- 使用 `PptxViewer.open()` 渲染到库专属 DOM 容器。
|
||||
- 开启 `lazyMedia`、`lazySlides` 与推荐 ZIP 安全限制。
|
||||
- UI 提供上一页、下一页、页码和下载按钮。
|
||||
- 卸载时销毁 viewer 并清空库专属容器。
|
||||
- 旧 `.ppt/.doc/.xls` 继续仅下载。
|
||||
|
||||
## Bundle
|
||||
|
||||
- 先验证普通 bundler 入口不会遗留 Node builtin `require()`。
|
||||
- 若普通入口不纯,切换包提供的 standalone browser ESM 入口。
|
||||
- 不启用可选 PDF.js EMF fallback,避免额外体积。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 损坏、加密或超限文件显示错误信息和下载链接。
|
||||
- viewer 初始化失败时清理部分创建的实例与 DOM。
|
||||
|
||||
## 测试
|
||||
|
||||
- `.pptx` 类型分发。
|
||||
- bundle 模块表 require guard。
|
||||
- Typecheck、manifest/plugin-shape 与构建通过。
|
||||
@@ -1,137 +0,0 @@
|
||||
# 侧边栏设置声明式化 + 内置支持"插头化"设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.4.1
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 把 DSH 设置页的「侧边卡片」分区从硬编码 4 行改为**注册表驱动的声明式清单**:
|
||||
- 展示当前注册的所有侧边栏内容(tab 类型)与文件查看器(viewer),每项可**直接打开/关闭**;
|
||||
- 每项声明式元数据:**类型(id)、图标(SVG)、相关设置**(如子代理的"检测到子代理就自动打开")。
|
||||
2. 内置支持(资源管理 / Git / 终端 / 子代理 / 编辑器 / 差异)与 8 个文件预览器继续以**插头(descriptor)形式**注册,并在描述符上携带上述声明。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不改宿主路由 / pty / tools 的 gating 语义:`agentTerminalTools` 仍是独立布尔偏好(宿主 `syncToolsGate` 零改动),只是它的**设置行渲染位置**改由终端 tab 描述符声明。
|
||||
- 不做"已打开 tab 随禁用即时关闭"(保留式语义:禁用只影响新开,已打开 tab 继续渲染)。
|
||||
- 不实现外部插件自定义 settings 键的动态 schema(见 §6 限制)。
|
||||
- 不迁移 bundle 形态、不加依赖、不动 peerDependencies。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `SideCardSection.tsx` 硬编码 4 行:`openByDefault` / `defaultWidthPercent` / `autoOpenSubagent` / `agentTerminalTools`。
|
||||
- `PrefsSchema`(`config.ts`)为扁平对象,`parsePrefs`(`client/prefs.ts`)逐字段校验。
|
||||
- 内置 tab/viewer 已通过 `registerBuiltins` 走同一注册表(v0.4.0),但描述符缺少设置面元数据:viewer 无 `title`/`icon`,tab 无相关设置声明。
|
||||
- 服务无启用态概念:`getTabs`/`matchFileViewer`/`openTab` 不过滤。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
openByDefault: boolean
|
||||
defaultWidthPercent: number
|
||||
autoOpenSubagent: boolean
|
||||
agentTerminalTools: boolean
|
||||
tabsEnabled: Record<string, boolean> // 按 tab descriptor id;缺省 = 启用
|
||||
viewersEnabled: Record<string, boolean> // 按 viewer descriptor id;缺省 = 启用
|
||||
}
|
||||
```
|
||||
|
||||
- schema:`z.dict(z.boolean()).default({})`(schemastery `dict`);旧文档自动解析为 `{}`(全开),零迁移。
|
||||
- `parsePrefs`:record-of-boolean 校验(非对象/非布尔值回退 `{}` / 丢弃该键)。
|
||||
- 设置页开关写入 `tabsEnabled: { ...prefs.tabsEnabled, [id]: next }`(viewers 同理)。
|
||||
|
||||
### 4.2 声明式描述符(`client/service.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarSettingToggle {
|
||||
key: string // SidebarPrefs 字段名('autoOpenSubagent')
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
interface SidebarSettingsDeclaration {
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
// TabDescriptor 增加:
|
||||
settings?: SidebarSettingsDeclaration
|
||||
|
||||
// FileViewerDescriptor 增加(设置清单展示用):
|
||||
title?: string | (() => string) // 缺省回退 id
|
||||
icon?: ReactNode | ((size) => ReactNode)
|
||||
settings?: SidebarSettingsDeclaration
|
||||
```
|
||||
|
||||
服务新增:
|
||||
```ts
|
||||
isTabEnabled(id: string): boolean // store.getPrefs().tabsEnabled[id] !== false
|
||||
isViewerEnabled(id: string): boolean
|
||||
```
|
||||
|
||||
行为变化:
|
||||
- `openTab`:类型被禁用 → no-op(`console.warn` 提示);`matchFileViewer`:跳过禁用 viewer(文件落到下一个匹配或下载按钮)。
|
||||
- `getTabs()/getFileViewers()` 仍返回**全部**注册项(设置页清单必须看到已禁用的项)。
|
||||
|
||||
### 4.3 内置声明(`builtins/tabs.tsx` / `builtins/viewers.tsx`)
|
||||
|
||||
| tab | 相关设置声明 |
|
||||
|---|---|
|
||||
| subagent | `toggles: [{ key: 'autoOpenSubagent', ... }]` |
|
||||
| terminal | `toggles: [{ key: 'agentTerminalTools', ... }]` |
|
||||
| explorer/git/editor/diff | 无(仅开关) |
|
||||
|
||||
8 个 viewer 全部补 `title`(locales 新增 `viewerImage`…`viewerBinary`)+ `icon`(`icons.tsx` 新增 6 个手绘 outline SVG:image/pdf/docx/xlsx/pptx/markdown;code/binary-download 复用 `IconCodeOutline16` / `IconDownloadOutline16`)。
|
||||
|
||||
### 4.4 Store 快照携带 prefs(`client/state.ts`)
|
||||
|
||||
`SidebarSnapshot` 增加 `prefs` 字段;`setPrefs` 更新快照并 `notify()`(`setSession`/`update`/`reduce` 构造快照时带上当前 prefs)。效果:+ 菜单等 `useSyncExternalStore` 消费者在设置变更后**立即**重渲染(禁用 git → + 菜单马上消失)。
|
||||
|
||||
`makeDefaultState(width, panelOpen, seedExplorer = true)` 增加第三参;`loadState` 在 `tabsEnabled['explorer'] === false` 时传 `false`——新会话不再预置 explorer tab(与"已存在会话保持布局"语义并列:禁用只影响新布局)。
|
||||
|
||||
### 4.5 消费点 gating
|
||||
|
||||
| 消费点 | 行为 |
|
||||
|---|---|
|
||||
| + 菜单(`buildNewTabOptions`) | `filter(!d.hidden && service.isTabEnabled(d.id))` |
|
||||
| 子代理自动展开 effect | 追加 `isTabEnabled('subagent') === false` 早退(禁用时不展开面板) |
|
||||
| agent 终端 reconcile(WS 推送) | `isTabEnabled('terminal') === false` 时忽略推送(不自动补 tab;重新启用后下一次推送自愈) |
|
||||
| 产出文件拦截(`intercept.tsx` select) | `tabsEnabled['editor'] === false` 时 select 返回 null(回落默认交付行) |
|
||||
| `openTab` / `matchFileViewer` | 见 §4.2 |
|
||||
| 新会话默认布局 | explorer 禁用 → 空 pane |
|
||||
|
||||
### 4.6 设置页声明式渲染(`SideCardSection.tsx`)
|
||||
|
||||
- `inject: () => ({ store, service })`(`index.tsx` 闭包直接捕获 service)。
|
||||
- 注册表清单:本地 state + `service.subscribe` 同步(注册变化罕见,无需 external-store)。
|
||||
- 行结构:
|
||||
- **常规**:`openByDefault`、宽度(既有逻辑)。
|
||||
- **侧边栏内容**:按 `order` 升序、`hidden` 置后遍历 tabs——图标(16) + 标题 + desc=`tab.id`(类型)+ 开关;`settings.toggles` 渲染为缩进子行(仅父级启用时显示,绑定 `prefs[toggle.key]`)。
|
||||
- **文件预览**:按 priority 降序遍历 viewers——图标 + `title ?? id` + desc=`exts.join(' · ') || t('settingsViewerCatchAll')` + 开关。
|
||||
- 提交机制不变:串行化 + revision 守卫 + 失败回滚 + `store.setPrefs` 同步。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- `service.spec.ts`:启用态缺省 true;`openTab` 拒绝禁用类型;`matchFileViewer` 跳过禁用 viewer(含兜底 code 被关 → undefined)。
|
||||
- `builtins.spec.ts`:subagent/terminal 的 toggles 声明;全部 tab 有 icon;全部 viewer 有 title+icon。
|
||||
- `unit.spec.ts`:`parsePrefs` 的 map 校验(非对象/非布尔回退)+ 新字段默认值;`loadState` explorer 禁用 → 空 pane。
|
||||
- `plugin-shape.spec.ts` / `smoke.spec.ts`:schema 解析与 settings 路由断言补新字段。
|
||||
- `side-card-section.spec.tsx`(新增):`renderToString` 断言声明式行(图标/标题/id/exts/开关/嵌套开关隐藏规则)。
|
||||
|
||||
## 6. 限制与失败模式
|
||||
|
||||
- **外部插件的自定义 settings 键**:`toggles[].key` 必须是宿主 `PrefsSchema` 的字段(内置:`autoOpenSubagent` / `agentTerminalTools`);未知键会被 settings seam 丢弃(schemastery object 非严格模式放行未知键但 schema 不含其校验)。文档注明。
|
||||
- 设置路由失败 / 文档缺失:沿用既有降级(schema 默认 + 本地 state,开关默认全开)。
|
||||
- 已打开 tab 的类型被禁用:tab 保留可继续使用;关闭后无法重开。
|
||||
- 父级(子代理/终端)关闭时嵌套开关隐藏,其值保留在 prefs 不销毁。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
1. **嵌套开关的"父级关闭即隐藏"**:设计初稿曾考虑始终显示嵌套开关;实施决定仅在父级启用时渲染(避免"autoOpenSubagent 已开但子代理页面被关"的无效组合),值保留。
|
||||
2. **宿主 `syncToolsGate` 不改**:`agentTerminalTools` 仅随自身开关走,不与 `tabsEnabled['terminal']` 联动(模型终端工具是模型面向能力,与侧边栏 UI 开关解耦)。
|
||||
3. **`renderRow` 统一行渲染**:tab/viewer/嵌套开关共用一行渲染器(`sub` 参数区分缩进),CSS 新增 `.sectionHeading` / `.rowIcon` / `.titleLine` / `.subRow`。
|
||||
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 直测。
|
||||
@@ -1,136 +0,0 @@
|
||||
# 内置 HTML 预览器 + 内嵌网页浏览器(沙箱化)设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已批准,已实施
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.4.3(main `35cde82`)
|
||||
**目标版本**:v0.5.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在 `dsh-better-sidebar` 内按既有 builtins 模式新增两个内置能力:
|
||||
|
||||
1. **`html` 文件预览器**:`.html`/`.htm` 文件在侧边栏编辑器中默认以真实渲染预览(相对资源可加载),可切换代码编辑(CodeMirror HTML 高亮 + 保存)。
|
||||
2. **`browser` 浏览器 tab**:`+` 菜单新增「浏览器」页面,地址栏输入 URL 在沙箱 iframe 中浏览,支持后退/前进/刷新,URL 与标题按 tab 持久化。
|
||||
|
||||
安全问题为第一优先级(用户显式要求"注意安全问题"):所有渲染走**不透明源沙箱 iframe**,host 侧新增 `/sidebar/html` 路由带 CSP + 信任围栏 + cwd 边界。另按用户追加要求提供**关闭沙箱的子设置**(默认关闭该设置;开启需明确勾选,设置项与界面都带安全警告文案)。
|
||||
|
||||
## 2. 复用门(dsh-reuse-first)
|
||||
|
||||
`NO EXISTING CAPABILITY`:已搜索 DSH checkout(docs/*-catalog、apps/web、packages 源码——sidecar/iframe/webview/html preview/CSP 均无等价物;`WorkspaceBrowser` 只是目录树)与 dsh-external org hub 目录(browser/html/webview/iframe/sidecar/viewer/preview 均 0 命中)。全新实现,落点为本仓库 builtins(用户已确认)。
|
||||
|
||||
## 3. 威胁模型与安全基线
|
||||
|
||||
| 威胁 | 对策 |
|
||||
|---|---|
|
||||
| 恶意 HTML/网页读取 GUI 数据(localStorage、`/sidebar/api`、同源 cookie) | iframe 一律 `sandbox` 且**不含 `allow-same-origin`** → 文档源为不透明源(null)。**禁止 srcdoc**(srcdoc 在无沙箱时继承父源),一律 route-src |
|
||||
| 预览/浏览内容劫持 GUI 页面(top 导航) | 不含 `allow-top-navigation` / `allow-top-navigation-by-user-activation` |
|
||||
| 表单 CSRF 打到本地服务 | html 预览器 iframe **不含 `allow-forms`**;浏览器 tab 含 `allow-forms`(登录流必需,残余风险见 §9) |
|
||||
| 浏览器 tab 访问内部地址(GUI 自身、localhost、127.0.0.0/8) | 地址栏导航前客户端阻断(`browser.ts` 纯函数);`javascript:`/`data:`/`file:` 等非 http(s) 一律拒绝 |
|
||||
| 预览页脚本请求 `/sidebar/api` | 三层:不透明源 fetch 被 CORS 拒读;信任围栏拒 `sec-fetch-site: cross-site`;`Origin: null` 无法伪造同源 |
|
||||
| 敏感信息泄露给被浏览站点 | iframe `referrerPolicy="no-referrer"`;权限策略 `allow=""`(拒绝 camera/mic/geolocation 等一切策略特性) |
|
||||
| 直接顶层打开 `/sidebar/html/*` URL(弹窗等) | 路由响应带 `Content-Security-Policy: sandbox ...; object-src 'none'`,顶层同样被沙箱化 |
|
||||
| 路径逃逸 | 路由复用 `requireAbsolute` + `isWithin(cwd, path)` + `mediaLimit` 上限 + 信任围栏 |
|
||||
| 关闭沙箱(用户主动选择) | 每功能独立子设置(`htmlViewerNoSandbox` / `browserNoSandbox`,默认 false);设置文案 + 渲染时持久警告条双警告 |
|
||||
|
||||
## 4. 变更清单
|
||||
|
||||
### 4.1 host:`/sidebar/html` 路由(src/html-route.ts 新 + src/index.ts)
|
||||
|
||||
URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相对引用会丢弃 base 的 query,query 式 URL 下 `./style.css` 会丢失 session 作用域:
|
||||
|
||||
```
|
||||
/sidebar/html/<sessionId>/<绝对路径逐段 encodeURIComponent>
|
||||
例:/sidebar/html/S/Users/me/proj/index.html
|
||||
./style.css → /sidebar/html/S/Users/me/proj/style.css ✓
|
||||
Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
|
||||
```
|
||||
|
||||
- `src/html-route.ts`(无依赖纯模块,client 可导入不触发纯度门):`encodeHtmlUrl(sessionId, path)`、`decodeHtmlUrl(pathname)`(404 错前缀;400 空路径/双斜杠/畸形编码/缺 sessionId 或文件路径/空段)。
|
||||
- `src/index.ts`:注册 `/sidebar/html` prefix 路由(fence → GET 校验 → decode → `requireAbsolute` → `isWithin(cwd)` → stat isFile + `mediaLimit` → 读文件),响应头:
|
||||
`content-type`(新增 `.html`/`.htm` → `text/html` 到 MEDIA_TYPES)、`cache-control: no-cache`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`Content-Security-Policy: sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'`。
|
||||
- cwd 语义与媒体路由一致:session store 权威;会话附着前的首请求回退 `process.cwd()`,通常被 `isWithin` 拒绝(可接受)。
|
||||
|
||||
### 4.2 client:`html` 预览器(TextEditor.tsx 扩展 + builtins/viewers.tsx)
|
||||
|
||||
- descriptor:`{ id:'html', exts:['html','htm'], priority:0, fetchStrategy:'fsRead', settings.toggles:[htmlViewerNoSandbox] }`。
|
||||
- TextEditor:`markdown || html` 都显示预览/编辑切换;html 预览分支渲染:
|
||||
`<iframe src={htmlUrl(scope,path)} sandbox="allow-scripts allow-popups allow-downloads allow-modals" referrerPolicy="no-referrer" allow="" />`
|
||||
- 导出 `HTML_IFRAME_SANDBOX` 常量(测试钉死);沙箱关闭时 `sandbox` 属性整体移除 + 渲染 `sandboxWarning` 警告条。
|
||||
- 预览显示**已保存文件**(route-src),不反映未保存草稿(有意取舍,文档化)。
|
||||
|
||||
### 4.3 client:`browser` tab(BrowserView.tsx 新 + browser.ts 新 + builtins/tabs.tsx + state.ts)
|
||||
|
||||
- state.ts:`SidebarState.nextBrowser`(默认 1;sanitize 对缺失/畸形值回退 1,旧持久化布局继续加载);新 reducer `patchTab(state, tabId, {title?, path?})`。
|
||||
- tabs.tsx:`{ id:'browser', order:50, settings.toggles:[browserNoSandbox], createTab: 铸造 browser:<n> + nextBrowser 递增 }`。
|
||||
- browser.ts(无依赖纯模块):`isLoopbackHostname`(localhost/::1/0.0.0.0/127/8);`normalizeBrowserUrl(input, selfOrigin)`:
|
||||
- 无 scheme → 补 `https://`;`://` 完整 URL 走解析后协议兜底;已知危险 scheme 前缀(javascript/data/file/about/...)直接拒绝;`host:port` 形态(如 `example.com:8080`)不被误判为 scheme。
|
||||
- 拒绝顺序:协议 → 自身 origin **放行**(GUI 自身可在侧边栏打开,沙箱不透明源兜底;须先于 loopback 判定,因其主机通常就是 loopback)→ loopback 阻止 → ok。
|
||||
- BrowserView:地址栏(后退/前进/刷新/输入/前往)+ iframe + 状态行;URL 经 `patchTab` 持久化到 `tab.path`/`tab.title`(hostname);历史栈仅记录地址栏导航(iframe 内部跳转跨域不可见,已知限制);无 URL 时显示起始页(不自动加载任何站点)。
|
||||
- iframe:`sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox"`(导出 `BROWSER_IFRAME_SANDBOX`;无 allow-same-origin / allow-top-navigation;allow-popups-to-escape-sandbox 供 OAuth 弹窗);沙箱关闭时移除属性 + 警告条。
|
||||
- 常驻底部小字沙箱说明(`browserHint`)。
|
||||
|
||||
### 4.4 设置(prefs-shared.ts + config.ts + SideCardSection 声明式渲染)
|
||||
|
||||
- `SidebarPrefs` 新增 `htmlViewerNoSandbox` / `browserNoSandbox`(boolean,默认 false);`SIDEBAR_PREFS_DEFAULTS` 与 `PrefsSchema` 同步;`parsePrefs` 按字段校验回退。
|
||||
- 声明式设置:html viewer 行与 browser tab 行各自 `settings.toggles`,设置页自动渲染(父级关闭时隐藏),文案含「(不安全)」+ 后果说明。
|
||||
|
||||
### 4.5 其他
|
||||
|
||||
- `api.ts`:`htmlUrl(scope, path)` = `encodeHtmlUrl(sessionId, path)`。
|
||||
- `icons.tsx`:`IconHtmlOutline16`(文件 + `‹/›`)、`IconGlobeOutline16`(地球)。
|
||||
- `locales.ts`:zh/en 双份新键(viewerHtml/browser/浏览器栏/阻止原因/沙箱提示/设置文案)。
|
||||
- `sidebar.module.css`:`sandboxWarning`、`editorHtml`、`.browser*` 系列。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
| 文件 | 覆盖 |
|
||||
|---|---|
|
||||
| tests/html-route.spec.ts(新) | 编码/解码往返(POSIX/Windows/特殊字符)、相对解析保持同路由同 session、404/400 拒绝、遍历段留给 isWithin |
|
||||
| tests/browser.spec.ts(新) | 补 https、host:port 不误判、危险 scheme/loopback 拒绝、自身 origin 放行、invalid |
|
||||
| tests/sandbox-views.spec.tsx(新) | iframe sandbox 常量钉死(无 allow-same-origin/top-navigation)、route-src 非 srcdoc、referrer/allow 属性、沙箱关闭 → 无 sandbox 属性 + 警告条、markdown 分支回归、浏览器起始页不自动加载 |
|
||||
| tests/builtins.spec.ts | 7 tab / 9 viewer 清单、html 扩展名匹配、browser createTab 铸造与 nextBrowser、两个 toggles 声明 |
|
||||
| tests/unit.spec.ts | nextBrowser sanitize(缺失/畸形回退 1)、patchTab 更新/无操作、parsePrefs 新字段默认与校验 |
|
||||
| tests/smoke.spec.ts | fenced routes 清单 + `/sidebar/html` |
|
||||
|
||||
## 6. 版本与文档
|
||||
|
||||
- 版本 0.4.3 → 0.5.0(package.json + dsh.plugin.json 同步,manifest-consistency 守卫)。
|
||||
- README/AGENTS 内置清单更新为 7 tab + 9 viewer。
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. `pnpm typecheck && pnpm build && pnpm test` 全绿(245 用例)。
|
||||
2. 手动:打开 `.html` 默认预览渲染(相对资源可加载),可切编辑/保存;`+` 菜单浏览器 tab 多开、地址栏浏览、后退/前进/刷新、刷新页面恢复 URL 与标题。
|
||||
3. 安全:预览/浏览 iframe 无 allow-same-origin/top-navigation;`javascript:`/`data:`/`file:`/localhost/127.x 均被阻止并提示(自身 origin 允许);`/sidebar/html` 越界 403、超限 400、跨站 403;预览页 fetch `/sidebar/api` 读不到(CORS),预览器表单被沙箱禁(无 allow-forms)。
|
||||
4. 关闭沙箱开关:设置页勾选后界面出现持久警告条,iframe 无 sandbox 属性。
|
||||
|
||||
## 8. 实施偏差记录
|
||||
|
||||
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
|
||||
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
|
||||
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
|
||||
|
||||
## 8.1 追加需求(2026-08-11,v0.5.0 内)
|
||||
|
||||
1. **设置页滚动**:`SideCardSection.module.css` 的 `.section` 填满设置壳 options 区并内部滚动(`height:100% + min-height:0 + overflow-y:auto`),7 tab + 9 viewer 卡片再多也不会被裁切。
|
||||
2. **沙箱状态实时显示 + 临时解锁**:新增 `SandboxStatusBar`(HTML 预览与浏览器 tab 共用)——沙箱开启显示绿色状态 + 「临时解锁(不安全)」按钮;关闭(全局设置或本页临时解锁)显示**红色警示**(`state-error-primary` 色系 + 危险文案)与「恢复沙箱」按钮(仅临时态)。临时解锁是组件内 state,不写回全局设置,随页面/标签卸载失效;iframe 随沙箱翻转重挂载。
|
||||
3. **聊天/界面外链在侧边栏打开**:新设置 `browserInterceptLinks`(默认开,受浏览器 tab 启用开关 gate)——`link-intercept.ts` 在 document capture 阶段拦截 http(s) **外部**链接点击(同源/非 http(s)/修饰键点击一律放行),经 `openTab({type:'browser', url, title: hostname})`(openTab seed 新增 `url` 字段:落地后 patchTab 预填 path)在侧边栏打开。浏览器 tab 地址栏新增「在浏览器中打开」按钮(`window.open(url,'_blank','noopener')`,用户手势触发,等价真实浏览器新标签)。
|
||||
|
||||
## 8.2 追加需求(2026-08-11 二轮,v0.5.0 内)
|
||||
|
||||
1. **HTML「默认非安全」二级设置**:`htmlViewerDefaultUnsafe`(默认 false)——打开 HTML 文件时预览**初始**处于非沙箱状态(localUnlock 初始值取自该 pref),状态行仍可一键「恢复沙箱」;与 `htmlViewerNoSandbox`(全局强制关闭,不可界面恢复)语义区分。
|
||||
2. **沙箱状态行单行化并取代底部说明**:浏览器 tab 底部常驻 `browserHint` 条移除,说明并入 `SandboxStatusBar` 开启态文案(「沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用」),状态行 `nowrap + ellipsis + title` 恒为一行;关闭态同样单行红色警示。
|
||||
3. **「在浏览器中打开」图标放大**:`IconRightUpOutline14` → `IconRightUpOutline16`(15px 渲染)。
|
||||
|
||||
## 8.3 追加需求(2026-08-11 三轮,v0.5.0 内)
|
||||
|
||||
1. **图标改 15px**:`<IconRightUpOutline16 size={15} />`。
|
||||
2. **嵌入拒绝面板**:站点通过 `X-Frame-Options` / CSP `frame-ancestors` 禁止嵌入时,浏览器 tab 不再显示空白拒绝框,而是显示 `BrowserEmbedBlocked` 面板——「{host} 拒绝了嵌入请求」+ 原因说明 + 「在浏览器中打开」(`window.open`)+ 「仍然加载」两个按钮。检测:新增 host 路由 `browser.probe`(HEAD→GET 兜底,8s 超时,仅 http(s) 非 loopback,返回目标响应头的 `x-frame-options`/`frame-ancestors`,受信任围栏保护)+ 客户端 `embeddabilityOf` 判定(XFO DENY/SAMEORIGIN 或 frame-ancestors 不含 `*` → blocked;探测失败 → unknown 保持普通 iframe)。探测在每次导航(含恢复的 path)时经 `useEffect(url)` 触发;「仍然加载」以 `forceEmbed` 保留普通 iframe。
|
||||
|
||||
## 9. 已知限制与残余风险
|
||||
|
||||
- 浏览器沙箱无登录态/第三方 Cookie 受限(SameSite=Lax 在跨站 iframe 不发送),部分站点登录需走弹窗(allow-popups-to-escape-sandbox 已开)。
|
||||
- iframe 内部链接跳转不进后退栈(跨域不可见);被 `X-Frame-Options`/`frame-ancestors` 拒绝的站点显示空白(站点策略,无法绕过)。
|
||||
- 用户主动浏览的恶意站点仍可在沙箱内发起盲 GET(无法读取响应)到本地服务;站点重定向链可把 iframe 带到 localhost(内容仍沙箱化、受围栏保护)。
|
||||
- 关闭沙箱后预览/浏览内容获得 GUI 同源权限(可读会话文件/本地存储/调内部接口)——仅建议对完全可信内容开启,设置与界面双重警告。
|
||||
@@ -1,416 +0,0 @@
|
||||
# DSH-better-sidebar 服务化改造设计
|
||||
|
||||
**日期**:2026-08-11
|
||||
**状态**:已批准,待实施
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.3.0(main `069c754`)
|
||||
**目标版本**:v0.4.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
将 `dsh-better-sidebar` 从一个"纯消费方"插件改造为"服务提供者":
|
||||
|
||||
1. 允许其他 DSH 插件向 better-sidebar **注册新的侧边栏页面(tab 类型)**
|
||||
2. 允许其他 DSH 插件向 better-sidebar **注册文件类型预览器**
|
||||
3. 把现有 5 个内置 tab 和 6 种内置文件预览器**全部迁移到新注册表**(吃自己的狗粮)
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- **不动 host 半**(`src/index.ts` 的 HTTP 路由 / pty / tools / settings)
|
||||
- **不迁移到标准 bundle 形态**(不加 `cordis.patch.yml`、不弃用 `dsh.plugin.json`、不放弃 `client-registry.js`)—— 作为独立工作
|
||||
- 不动 server 端 `ctx.provide`:本次纯 client 端服务
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
### 3.1 Tab 系统硬编码点(7 处)
|
||||
|
||||
| 文件:行 | 用途 |
|
||||
|---|---|
|
||||
| `src/client/state.ts:14` | `TabType` 联合类型(5 字面量) |
|
||||
| `src/client/state.ts:295-297` | `isSingle(type)` 单实例判定 |
|
||||
| `src/client/state.ts:555-561` | `sanitizeNode` 白名单校验 |
|
||||
| `src/client/Sidebar.tsx:56-83` | `TabContent` switch 渲染 |
|
||||
| `src/client/Sidebar.tsx:87-102` | `buildNewTabOptions` + 菜单项 |
|
||||
| `src/client/Sidebar.tsx:341-364` | `onNewTab` 创建分支 |
|
||||
| `src/client/icons.tsx:26-33` | `tabTypeIcon` 图标 switch |
|
||||
|
||||
### 3.2 文件预览硬编码点
|
||||
|
||||
- `src/client/EditorView.tsx:58-106`:顺序 if/else 选 `load.kind`
|
||||
- `src/client/EditorView.tsx:251-272`:if 链按 `load.kind` 渲染组件
|
||||
- 4 个预览组件(DocxView / XlsxView / PptxView / PdfView)静态 import 在 `EditorView.tsx:22-26`
|
||||
- 3 个纯函数模块(`office-types.ts` / `image-types.ts` / `pdf-types.ts`)+ `lang.ts` 已半抽象,可作为内置注册器的实现细节复用
|
||||
|
||||
### 3.3 服务暴露现状
|
||||
|
||||
- `src/index.ts` / `src/client/index.tsx` 全文搜 `ctx.provide` / `Context.service` / `extends Service` —— **零命中**
|
||||
- `package.json` 的 `exports` 没有暴露任何供外部 import 的注册 API
|
||||
- README:262 已承认 portal 挂载的限制(整面板 slot 不可用),但未提服务化方案
|
||||
|
||||
### 3.4 平台约束
|
||||
|
||||
- **构建期纯度门**(`tsdown.config.ts:177-180`):client bundle 禁止 value-import 别的插件代码;**type-only import 会被擦除,不触发门禁**
|
||||
- **双 cordis 实例**:外部插件解析不到 DSH monorepo 的 cordis augmentation,必须由 better-sidebar 自己导出 `declare module 'cordis'` augmentation
|
||||
- **ModuleLoader 运行时支持跨 bundle require,但被构建门挡** → 协作只能走 cordis 服务或 slot
|
||||
|
||||
## 4. 方案选型
|
||||
|
||||
| 方案 | 机制 | 评估 |
|
||||
|---|---|---|
|
||||
| **A. `ctx.provide` + 服务对象(采纳)** | client `apply()` 内 `ctx.provide('betterSidebar', service)`,service 暴露 `registerTab/registerFileViewer` 返回 disposer | registry 语义自然;disposer 生命周期贴合 cordis fiber;precedent:`dsh/packages/client/locale/src/client/index.ts:362` |
|
||||
| B. `ctx.slots.inject` 声明式 slot | 声明 `betterSidebar.tabType` 等 slot | 偏 UI 链替换,对带元数据 + disposer 的 registry 别扭 |
|
||||
| C. `extends Service` 类形式 | 完整 Service 类 + Config schema | client 半不支持 `extends Service`;本次纯 client,没必要 |
|
||||
|
||||
**采纳方案 A**。
|
||||
|
||||
## 5. 服务接口设计
|
||||
|
||||
### 5.1 主服务接口
|
||||
|
||||
```ts
|
||||
// 新文件 src/client/api.ts
|
||||
export interface BetterSidebarService {
|
||||
/** 注册一种 tab 类型;返回 disposer */
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
/** 注册一种文件预览器;返回 disposer */
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
/** 运行时快照(同步,供 React useSyncExternalStore 用) */
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** 匹配文件的预览器(按 priority 降序、exts/includes、detect() 三段匹配) */
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/** 工具方法:让外部 tab 也能开/关 tab */
|
||||
openTab(tab: { type: string; title: string; path?: string }): void
|
||||
closeTab(tabId: string): void
|
||||
/** 订阅 registry 变化(注册/卸载时触发) */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 TabDescriptor
|
||||
|
||||
```ts
|
||||
export interface TabDescriptor {
|
||||
id: string // 唯一,如 'explorer' / 'my-plugin:db'
|
||||
title: string | (() => string) // i18n 友好
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
order?: number // + 菜单排序,默认 100
|
||||
single?: boolean // 单实例(explorer/git/subagent)
|
||||
hidden?: boolean // 不出现在 + 菜单(editor 用)
|
||||
available?: (ctx: Context, scope: SessionScope) => boolean
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
export interface TabComponentProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 FileViewerDescriptor
|
||||
|
||||
```ts
|
||||
export interface FileViewerDescriptor {
|
||||
id: string // 'image' / 'pdf' / 'my-plugin:csv'
|
||||
exts: readonly string[] // 小写无点,如 ['png','jpg']
|
||||
priority?: number // 高优先;默认 0;内置默认 0;兜底 code 用 -100
|
||||
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
|
||||
detect?: (path: string, head: Uint8Array) => boolean // 内容嗅探(覆盖 exts)
|
||||
load?: (path: string, scope: SessionScope) => Promise<unknown> // fetchStrategy='custom' 时
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
export interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
content?: string // fetchStrategy='fsRead' 时
|
||||
truncated?: boolean
|
||||
mediaUrl?: string // fetchStrategy='mediaUrl' 时
|
||||
customData?: unknown // fetchStrategy='custom' 时 load() 的返回值
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Cordis 类型合并 outlet
|
||||
|
||||
```ts
|
||||
// 新文件 src/types.ts (纯类型)
|
||||
import type { BetterSidebarService } from './client/api'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
betterSidebar: BetterSidebarService
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
通过 `src/index.ts` 的 `export type * from './types.ts'` re-export 到主入口。Consumer 插件 `import type {} from 'dsh-better-sidebar'` 触发合并。
|
||||
|
||||
## 7. package.json exports 新增
|
||||
|
||||
```jsonc
|
||||
"exports": {
|
||||
".": { ... },
|
||||
"./invariant": { ... },
|
||||
"./client": { ... },
|
||||
"./client/api": { // 新增,纯类型导出
|
||||
"types": "./lib/types/client/api.d.ts",
|
||||
"default": "./lib/client.js" // 运行时落点回到 client bundle(不会被 value import)
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
}
|
||||
```
|
||||
|
||||
`files` 字段相应加 `lib/types/client/api.d.ts`。
|
||||
|
||||
## 8. 内置 tab 自我注册(吃狗粮)
|
||||
|
||||
`src/client/index.tsx` 的 `apply()` 内:
|
||||
|
||||
```ts
|
||||
const service = createBetterSidebarService(store)
|
||||
ctx.provide('betterSidebar', service)
|
||||
registerBuiltins(service) // 5 个内置 tab + 内置 viewer 全部用 service.registerTab/registerFileViewer
|
||||
```
|
||||
|
||||
### 8.1 5 个内置 tab
|
||||
|
||||
| id | order | single | hidden | available | 组件 |
|
||||
|---|---|---|---|---|---|
|
||||
| `editor` | -1 | false | true | always | `EditorView`(由 open-file 流程触发,不在 + 菜单) |
|
||||
| `explorer` | 10 | true | false | always | `ExplorerView` |
|
||||
| `git` | 20 | true | false | 检查 `.git` 存在 | `GitView` |
|
||||
| `subagent` | 30 | true | false | always | `SubagentView` |
|
||||
| `terminal` | 40 | false | false | 检查 terminal 配额 | `TerminalView` |
|
||||
|
||||
### 8.2 内置 file viewer
|
||||
|
||||
| id | exts | priority | fetchStrategy | 组件 |
|
||||
|---|---|---|---|---|
|
||||
| `image` | png/jpg/jpeg/gif/webp/svg/bmp/ico/avif | 0 | mediaUrl | `<img>` |
|
||||
| `pdf` | pdf | 0 | mediaUrl | `PdfView` |
|
||||
| `docx` | docx | 0 | mediaUrl | `DocxView` |
|
||||
| `xlsx` | xlsx | 0 | mediaUrl | `XlsxView` |
|
||||
| `pptx` | pptx | 0 | mediaUrl | `PptxView` |
|
||||
| `markdown` | md/markdown | 0 | fsRead | `<MarkdownText>` |
|
||||
| `code` | [](兜底) | -100 | fsRead | CodeMirror |
|
||||
| `binary-download` | doc/xls/ppt + NUL 探测为 binary | -50 | binary-download | 下载按钮 |
|
||||
|
||||
`matchFileViewer(path, head?)` 算法:
|
||||
1. 按 `priority` 降序遍历
|
||||
2. 若 `descriptor.detect` 提供且 `head` 可用 → 调 `detect(path, head)`,true 则命中
|
||||
3. 否则匹配 `exts`(小写无点)
|
||||
4. 全部 miss → 返回 `undefined`(caller 走兜底 `binary-download`)
|
||||
|
||||
## 9. 7 处硬编码 switch 改造点
|
||||
|
||||
| 文件:行 | 原状 | 改造后 |
|
||||
|---|---|---|
|
||||
| `src/client/state.ts:14` | `TabType` 联合 5 字面量 | `type TabType = string`;保留 `SIDEBAR_BUILTIN_TAB_TYPES` 常量数组供向后兼容 |
|
||||
| `src/client/state.ts:295-297` `isSingle` | switch 5 字面量 | `(type) => service.getTabs().find(t => t.id === type)?.single ?? false`;store 持有 service 引用 |
|
||||
| `src/client/state.ts:555-561` `sanitizeNode` | 白名单 5 type | 未注册 type 标记 `orphaned: true` 不丢弃,渲染占位卡 |
|
||||
| `src/client/Sidebar.tsx:56-83` `TabContent` | switch 5 分支 | 查 `service.getTabs().find(t => t.id === tab.type)`,调 `descriptor.component(props)`;未找到渲染 `<OrphanedTab/>` |
|
||||
| `src/client/Sidebar.tsx:87-102` `buildNewTabOptions` | 硬编码 4 项 | `service.getTabs().filter(t => !t.hidden && (t.available?.(ctx, scope) ?? true)).sort(byOrder).map(...)` |
|
||||
| `src/client/Sidebar.tsx:341-364` `onNewTab` | if optionId === 'explorer' 等 | `service.openTab({ type: optionId, title: descriptor.title() })` |
|
||||
| `src/client/icons.tsx:26-33` `tabTypeIcon` | switch 5 分支 | 废弃;调用方直接读 `descriptor.icon` |
|
||||
|
||||
## 10. 文件预览路由改造
|
||||
|
||||
### 10.1 `src/client/EditorView.tsx:58-106`(选预览器)
|
||||
|
||||
```ts
|
||||
const viewer = service.matchFileViewer(path, head?)
|
||||
if (!viewer || viewer.fetchStrategy === 'binary-download') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
}
|
||||
switch (viewer.fetchStrategy) {
|
||||
case 'mediaUrl': setLoad({ status: 'ready', kind: 'mediaUrl' }); break
|
||||
case 'fsRead':
|
||||
api.fsRead(scope, path).then(({ content, truncated }) =>
|
||||
setLoad({ status: 'ready', kind: 'fsRead', content, truncated })
|
||||
)
|
||||
break
|
||||
case 'custom':
|
||||
viewer.load(path, scope).then(data =>
|
||||
setLoad({ status: 'ready', kind: 'custom', customData: data })
|
||||
)
|
||||
break
|
||||
case 'none': setLoad({ status: 'ready', kind: 'none' }); break
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 `src/client/EditorView.tsx:251-272`(渲染)
|
||||
|
||||
```ts
|
||||
const viewer = service.matchFileViewer(path)
|
||||
if (!viewer) return <BinaryDownload ... />
|
||||
return <viewer.component {...props} />
|
||||
```
|
||||
|
||||
4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 `EditorView.tsx` 静态 import 移到 `registerBuiltins` 内部 import + 注册。
|
||||
|
||||
## 11. 持久化与降级
|
||||
|
||||
- localStorage 里 `orphaned` tab:渲染 `<OrphanedTab title="..." hint="Plugin not loaded: ${tab.type}"/>` + 关闭按钮
|
||||
- `sanitizeState` / `sanitizeNode` 不再丢弃未识别 type,改为标记 `orphaned`,保留 `title/path` 以备插件恢复时复用
|
||||
|
||||
## 12. Consumer 插件使用范例
|
||||
|
||||
### 12.1 Consumer `package.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"dsh-better-sidebar": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": { "optional": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 Consumer client half
|
||||
|
||||
```ts
|
||||
// my-plugin/src/client/index.tsx
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
|
||||
export const inject = ['betterSidebar', 'slots']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db',
|
||||
title: () => 'Database',
|
||||
icon: <DbIcon />,
|
||||
order: 50,
|
||||
component: ({ scope, store, tab }) => <DbView scope={scope} />,
|
||||
})
|
||||
)
|
||||
|
||||
ctx.effect(() =>
|
||||
ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv',
|
||||
exts: ['csv'],
|
||||
fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchCsv(scope, path)),
|
||||
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 13. 测试策略
|
||||
|
||||
### 13.1 单元测试
|
||||
|
||||
- `tests/service.spec.ts`:`createBetterSidebarService` 的 register/dispose/subscribe 生命周期
|
||||
- `tests/match-file-viewer.spec.ts`:`matchFileViewer` 的 priority + exts + detect 三段匹配算法
|
||||
- `tests/builtins.spec.ts`:5 个内置 tab + 8 个内置 viewer 都正确注册
|
||||
- 现有 `tests/state.spec.ts` / `tests/office-types.spec.ts` / `tests/image-types.spec.ts` 保持通过
|
||||
|
||||
### 13.2 集成测试
|
||||
|
||||
- `tests/orphaned-tab.spec.ts`:localStorage 里有未注册 type 的 tab → 渲染 `<OrphanedTab/>`,注册后恢复
|
||||
|
||||
### 13.3 现有 manifest 守卫
|
||||
|
||||
- `tests/manifest-consistency.spec.ts` 的 `contributes.tools/skills` 空数组守卫保持通过
|
||||
- `CLIENT_EXTERNALS` 列表守卫保持通过(不新增外部 value import)
|
||||
|
||||
## 14. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 双 cordis 实例导致 consumer 拿不到 `ctx.betterSidebar` 类型 | 由 dsh-better-sidebar 自己 `declare module 'cordis'`,consumer `import type {}` 触发合并 |
|
||||
| 注册顺序:consumer 在 better-sidebar 之前激活导致 inject 等待 | cordis `inject` 自带就绪等待,consumer 不会在服务就绪前激活 |
|
||||
| HMR 时 disposer 漏调导致注册项残留 | `ctx.effect(() => disposer)` 挂到 fiber,卸载时自动调 |
|
||||
| localStorage 里旧版本的 `TabType` 字面量 tab | `sanitizeNode` 改为标记 `orphaned` 不丢弃,向后兼容 |
|
||||
| `EditorView` 的 `kind` 字段类型从联合字面量变成 string | 内部 `load` 状态类型加 `kind: string` + `customData?: unknown`,TypeScript 收窄在渲染层做 |
|
||||
| 兜底 `code` viewer priority -100 与外部 `binary-download` -50 冲突 | `matchFileViewer` 先尝试 `detect`(NUL 探测为 binary),再走 exts;外部 `binary-download` 的 `exts: ['doc','xls','ppt']` 优先于兜底 `code` 的 `exts: []` |
|
||||
|
||||
## 15. 版本与发布
|
||||
|
||||
- 版本号:`v0.4.0`(破坏性:`TabType` 从联合字面量改 string)
|
||||
- peerDependencies 不变(不加新依赖)
|
||||
- `dsh.plugin.json` 的 `version` 同步更新到 `0.4.0`
|
||||
- 重建 `lib/` + 推送 main + profile 更新
|
||||
|
||||
## 16. 实施顺序(高层)
|
||||
|
||||
1. 拉取 main v0.3.0 到工作分支
|
||||
2. 新建 `src/client/api.ts`(类型 + `createBetterSidebarService` 工厂)
|
||||
3. 新建 `src/types.ts`(`declare module 'cordis'` augmentation)+ `src/index.ts` re-export
|
||||
4. `src/client/index.tsx` 改造:`ctx.provide('betterSidebar', service)` + `registerBuiltins(service)`
|
||||
5. 抽 `registerBuiltins.ts`:5 个内置 tab + 8 个内置 viewer 注册
|
||||
6. 改造 7 处硬编码 switch(state.ts / Sidebar.tsx / icons.tsx)
|
||||
7. 改造 `EditorView.tsx` 的选预览器 + 渲染两段
|
||||
8. `package.json` 加 `./client/api` exports + `files` 字段
|
||||
9. 加 `<OrphanedTab/>` 组件 + sanitize 降级
|
||||
10. 写测试(service / matchFileViewer / builtins / orphaned)
|
||||
11. typecheck + test + build 三件套
|
||||
12. 重建 lib/,提交,推送,更新 profile web
|
||||
|
||||
详细实施计划由 writing-plans skill 生成。
|
||||
|
||||
---
|
||||
|
||||
## 17. 实施偏差记录(2026-08-11,feat/modular-registry)
|
||||
|
||||
实现(PR #3 + 本分支)与上文设计有意或被迫不同的点,逐条记录。文档以本节为准。
|
||||
|
||||
### 17.1 去重机制:`dedupeKey` 为唯一机制,`single` 是其语法糖
|
||||
|
||||
§5.2 的 `single?: boolean` 与实现的 `dedupeKey` 合并为单一机制:
|
||||
- `dedupeKey?: (tab) => string | undefined` 是唯一去重规则(单实例 `() => id`、按 path、按 id 三种旧策略统一表达);
|
||||
- `single: true` ≡ `dedupeKey: () => id`(语法糖,显式 dedupeKey 优先)。
|
||||
|
||||
### 17.2 `available` 三参签名
|
||||
|
||||
§5.2 的 `available?: (ctx, scope) => boolean` 实现为 `(ctx, scope, state) => boolean`(state 用于 terminal 配额等 UI 状态判定,是设计签名的超集)。
|
||||
|
||||
### 17.3 匹配算法:单趟 per-descriptor,非三趟全局
|
||||
|
||||
§8.2 的算法(priority 降序逐 descriptor,detect 或 exts 二选一)为准;实现确认单趟,并补充一条规则:**`exts: []` + `detect` 是纯嗅探模式**——无 head 时不认领任何文件(避免 magic-sniffer 吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。盲 catch-all(无 detect)行为不变。
|
||||
|
||||
### 17.4 编辑器拆分:EditorHost + TextEditor(code/markdown 成为注册 viewer)
|
||||
|
||||
§10 的 EditorView 职责一分为二:
|
||||
- `EditorHost`('editor' tab 组件):标题栏 + `matchFileViewer` → 按 fetchStrategy 分发(纯逻辑在 `editor-load.ts` 的 `planFirstMatch`/`planFsReadOutcome`)→ 渲染 viewer.component;binary 无匹配渲染共享 `BinaryDownload`;
|
||||
- `TextEditor`(viewer 组件):code/markdown 的 CodeMirror 编辑 + md 预览/编辑切换 + 保存/脏点,内容由 host 经 fsRead 传入,按 `viewerId === 'markdown'` 分支。
|
||||
- 代码/保存按钮从标题行移到内容区第二行(VSCode 式工具栏,有意 UX 变更)。
|
||||
|
||||
### 17.5 `fs.read` 增加 `head` 字段(唯一 host 触碰)
|
||||
|
||||
§8.2/§14 的 detect 机制需要 head 字节:host `fs.read` 对二进制响应附加 `head`(base64,前 4KB,常量 `READ_HEAD_LIMIT`)。客户端在 fsRead 结果为二进制时用 head 重匹配一次(无额外 IO),NUL 探测因此成立。向后兼容(新字段)。纯文本文件不触发重匹配(文本嗅探请用 exts 或 custom 策略)。
|
||||
|
||||
### 17.6 导出路径:`./client/service` 为主,`./client/api` 为别名
|
||||
|
||||
§7 要求 `./client/api`,但 `api.ts` 已被既有的 fetch 助手占用;package.json exports 增加 `"./client/api"` 别名指向 `lib/types/client/service.d.ts`,公共导入路径满足设计,内部文件名不变。
|
||||
|
||||
### 17.7 + 菜单:disabled 行而非 filter
|
||||
|
||||
§9 的 `filter(!hidden && available)` 实现为"available 返回 false 显示 disabled 行"(与 v0.3.0 旧 UX 一致;终端配额满时用户能看到选项为何不可用)。行为有意保留,文档更新为现状。
|
||||
|
||||
### 17.8 保留的 store 级操作(非注册表职责)
|
||||
|
||||
- `openDiffTab`(state reducer):diff 的 split 树放置手术(粘性 diff pane、首次拆分下方),保留;其按 id 去重与 diff descriptor 的 `dedupeKey: (tab) => tab.id` 同规则(测试断言两者一致)。
|
||||
- `reconcileAgentTerminals`:agent 终端列表同步(host 推送驱动),直接落 state(`openTabInActivePane`),不经过 service(terminal 的 createTab 语义不适用)。
|
||||
- 命名:state.ts 的 `openTab` reducer 改名 `openTabInActivePane`(与 `service.openTab` 区分)。
|
||||
|
||||
### 17.9 内置清单
|
||||
|
||||
- tab 6 个不变(editor/explorer/git/subagent/terminal/diff),explorer/git/subagent 改用 `single: true`。
|
||||
- viewer 8 个:image/pdf/docx/xlsx/pptx(mediaUrl)+ markdown(fsRead)+ code(fsRead,catch-all -100)+ binary-download(-50,exts doc/xls/ppt + NUL detect)。
|
||||
- `SIDEBAR_BUILTIN_TAB_TYPES` 常量删除(零引用);`tabTypeIcon` 删除(图标归 descriptor.icon)。
|
||||
|
||||
### 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 语义重写。
|
||||
@@ -1,60 +0,0 @@
|
||||
# 跟随 DSH 的 i18n(ctx.locale)设计
|
||||
|
||||
> 2026-08-12 · 随 v0.8.2 发布
|
||||
|
||||
## 背景
|
||||
|
||||
better-sidebar 的界面文案(约 260 处 `t()` 调用、28 个文件)此前自带 zh/en 词典并直接读
|
||||
`navigator.language`(`src/client/locales.ts` 头注释曾明言"renders outside the slot
|
||||
system's locale seat, so it reads the browser language directly")。问题:
|
||||
|
||||
- DSH 的语言偏好是 Host-backed(`locale.preference`,存于 `$DSH_HOME/settings.yaml`),
|
||||
用户设置与浏览器语言不一致时侧边栏不跟随(如偏好 zh、浏览器 en 时侧边栏仍为英文)。
|
||||
- 词典游离在 DSH 的 i18n 注册表之外,无法参与公共命名空间回退等机制。
|
||||
|
||||
DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
|
||||
|
||||
- `ctx.locale` 服务(client-only):`getSnapshot()/subscribe()` 是 LocaleFace
|
||||
(uSES 安全,revision 递增),`locale/change` 事件在切换时触发;
|
||||
- `ctx.locale.register(ns, {zh, en})` 命名空间词典注册(typed 变体要求合并
|
||||
`LocaleNamespaceMap`);查询链 ns → common → zh → key;
|
||||
- 所有 DSH 客户端包以 `inject = [..., 'locale']` 消费。
|
||||
|
||||
## 设计决策
|
||||
|
||||
1. **结构镜像服务**(仓库惯例,`src/context-types.ts` 头注释):本插件解析在 DSH
|
||||
monorepo 单一 cordis 实例之外,上游 `declare module 'cordis'` augmentation 不达此
|
||||
Context。新增 `SidebarLocaleService` 结构脸(只镜像用到的切片:
|
||||
`getSnapshot` / `subscribe` / `register`),merge `Context.locale`。不引入
|
||||
`@deepseek-ai/dsh-client-locale` 依赖、不做 value import(client bundle 纯度门)。
|
||||
2. **保留模块级 `t()`**:`t()` 被 28 个文件使用,穿 props 不可行。改为模块级 holder:
|
||||
`apply()` 调 `attachLocale(ctx.locale)`,`t()`/`isZh()` 从 holder 读活跃 locale;
|
||||
无 holder 时回退既有 `navigator.language` 检测(行为与改动前完全一致,也是 jsdom
|
||||
测试与非标准组合的降级路径)。
|
||||
3. **根级重渲染触发**:侧边栏自行 `createRoot` 挂在 slot 系统之外,拿不到 `t` seat。
|
||||
在 `Sidebar.tsx` 根组件加 `useSyncExternalStore(ctx.locale.getSnapshot/subscribe)`
|
||||
(该文件已用此 API 订阅 sessions/store);树内无 `React.memo`,根重渲染即整树
|
||||
重渲染,所有渲染期 `t()` 求值读到新语言。事件期调用(intercept、api、subagent-tasks)
|
||||
在调用时刻读当前语言,天然正确。SideCardSection 由 settings shell 渲染,shell 自身
|
||||
订阅 locale revision(ui-settings-general 的 section rows 缓存含 locale revision),
|
||||
自动跟随。
|
||||
4. **命名空间 `'betterSidebar'`**:`'sidebar'` 已被 DSH 的 ui-sidebar 占用。注册走
|
||||
非类型化单语言重载 `register(ns, locale, dict)`(DSH 内 ui-permission 同款先例);
|
||||
键型安全由本地 `const en: Record<keyof typeof zh, string>` + 测试键集相等断言保证。
|
||||
5. **词典注册进 DSH 注册表**:`ctx.effect` 内注册 zh/en 并返回合并 disposer
|
||||
(fiber 卸载时撤销,HMR 重激活不抛 "already registered")。
|
||||
6. **xlsx 预览的 Univer locale pack**:`xlsx-view.tsx` 原内联 navigator 检测改走
|
||||
`isZh()`(跟随 DSH 语言;Univer 实例在挂载时创建,语言切换不重建实例——可接受)。
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- 无。计划与实施一致。
|
||||
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。
|
||||
|
||||
## 验收
|
||||
|
||||
- `pnpm typecheck` / `pnpm test` / `pnpm build`(纯度门)全绿;`tests/locales.spec.ts`
|
||||
覆盖:无服务回退、attach 后跟随活跃 locale 实时切换、detach 恢复回退、占位符插值、
|
||||
relativeTime 链路、命名空间名、zh/en 键集相等。
|
||||
- 手动:设置页切换语言 → 侧边栏全部文案实时切换;`locale.preference: zh` 且浏览器 en
|
||||
→ 侧边栏中文。
|
||||
@@ -1,88 +0,0 @@
|
||||
# 客户端懒加载分块(lazy chunks)设计
|
||||
|
||||
> 日期:2026-08-12
|
||||
> 状态:已实现
|
||||
> 范围:`dsh-better-sidebar` 插件,客户端 bundle 拆分
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
改造前客户端是单文件 bundle(`lib/client.js`,约 24.6MB),页面启动时全部**下载 + 解析 + 执行**。实测构成(按 sourcemap 源字节):
|
||||
|
||||
| 依赖 | 体积 | 用途 |
|
||||
|---|---|---|
|
||||
| `@univerjs/*` 全家桶 + echarts/zrender 等 | ~18MB | .xlsx 预览 |
|
||||
| `@codemirror/*` + `@lezer/*` | ~1.9MB | 文本编辑器高亮 |
|
||||
| `xlsx`(SheetJS) | ~0.6MB | .xlsx 解析 |
|
||||
| `@aiden0z/pptx-renderer` | ~0.5MB | .pptx 预览 |
|
||||
| `xterm` | ~0.3MB | 终端 |
|
||||
| `docx-preview` + `jszip` | ~0.2MB | .docx 预览 |
|
||||
| 插件自身代码 | ~0.35MB | — |
|
||||
|
||||
虽然 `office-view.tsx` 用了动态 `import()`,但 `codeSplitting: false` 只保证**延迟执行**(`Promise.resolve().then(() => (init_docx_preview(), ...))`),下载与解析成本仍在启动时;且 `xlsx-to-univer.ts` 对 `@univerjs/presets` 的静态 value-import 让 Univer 核心实际在启动时执行。
|
||||
|
||||
**目标**:重依赖拆成独立脚本,首次打开对应功能才下载/解析/执行;启动只拉核心(~325KB)。
|
||||
|
||||
## 2. 机制
|
||||
|
||||
chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import()` 的物化路径——模块系统的 import() 只解析 seed word / shell-own 模块 / 已注册 factory / boot graph row,chunk id 四者皆非,解析行为依赖 DSH 版本(实测某些版本在 factory 已注册时仍抛 "cannot resolve")。因此改为插件自有的全局 factory 注册表:
|
||||
|
||||
1. **构建期**:每个 chunk 是独立 tsdown 浏览器 bundle(`lib/client-<name>.js`),脚本首行为:
|
||||
```js
|
||||
globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
|
||||
globalThis.__dshChunks__["terminal"] = (require) => { /* CJS 闭包体 */ };
|
||||
```
|
||||
与主 bundle 相同的 externals(react 等走 module table)、纯度门、CSS 内联管线;`codeSplitting: false` 保持单文件。
|
||||
2. **运行期** `loadChunk(name)`:
|
||||
- 注入 `<script src="/sidebar/bundle/<name>.js">`(经典同源脚本;官方 `/plugins/<id>/client.js` 路由的路径白名单无法服务任意文件名,故走插件自有路由);
|
||||
- 从 `globalThis.__dshChunks__[name]` 读取 factory(脚本执行即赋值,幂等——重复执行覆盖槽位,无 "duplicate factory registration" 错误类);
|
||||
- 用**自定义 require** 物化:externals(CLIENT_EXTERNALS 清单)经 `__DSH_MODULES__.import(spec)` 的 **seed 分支**逐一解析(页面级记忆化;解析失败的 spec 标记未解析,仅当 chunk 实际 require 时才报错)——seed 分支是模块系统最稳定、跨版本一致的部分。
|
||||
|
||||
## 3. 分块清单
|
||||
|
||||
| chunk 名 | 入口 | 内容 | 构建产物 | 触发 |
|
||||
|---|---|---|---|---|
|
||||
| `xlsx` | `src/client/chunks/xlsx.tsx` | `XlsxView` + Univer 全家桶 + xlsx + Univer CSS | lib/client-xlsx.js(~20MB) | 打开 .xlsx |
|
||||
| `docx` | `src/client/chunks/docx.tsx` | `DocxView` + docx-preview + jszip | lib/client-docx.js(~330KB) | 打开 .docx |
|
||||
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
|
||||
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
|
||||
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
|
||||
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
|
||||
|
||||
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
|
||||
|
||||
## 4. 缓存契约(三层,各带失败路径)
|
||||
|
||||
| 层 | 机制 | 失败路径 |
|
||||
|---|---|---|
|
||||
| 内存 | `loadChunk` 每 chunk 一个 in-flight promise,并发去重,常驻到 `resetChunks` | 失败删除缓存项 → 下次调用重试 |
|
||||
| 执行 | 脚本执行即覆盖全局注册表槽位(赋值幂等) | 物化失败清缓存 → 重试重新注入 + 重新执行脚本 |
|
||||
| HTTP | `/sidebar/bundle` 路由 `cache-control: no-cache` + ETag(内容哈希,按 mtime/size 记忆化),`If-None-Match` 命中返回 304 | 页面刷新/HMR 重激活时 304 免重下载 17MB;文件变化 ETag 轮转返回 200 |
|
||||
|
||||
**HMR**:`apply()` 每次激活调用 `resetChunks()`(清内存缓存 + externals 记忆),热更新后重新拉取新代码。chunk 源码单独改动需手动刷新页面(HMR 轮询只盯 client.js)。
|
||||
|
||||
## 5. 对外 API 兼容性(硬约束)
|
||||
|
||||
- `ctx.betterSidebar` 服务方法签名与行为零变化;懒加载只作用于**内置** descriptor,外部插件注册的原样存储/渲染。
|
||||
- `component` 契约 `(props) => ReactNode` 保持:懒包装是**纯渲染函数**(函数体无 hooks,hooks 在内部 `LazyChunkView`),兼容 Sidebar 的函数式调用与 EditorHost 的 `createElement` 渲染两种风格。
|
||||
- 内置 viewer/tab 的 id、exts、priority、detect、icon、title、settings 全部不变 → `matchFileViewer` 语义、外部插件同扩展名高优先级覆盖、设置页清单不变。
|
||||
- 公开类型面(`./client/service`、`./client/api`)不变;无新增 peerDeps。
|
||||
- 契约测试:`tests/lazy-chunk.spec.tsx` 钉住"component 可直接函数调用不抛错"。
|
||||
|
||||
## 6. 边界与失败模式
|
||||
|
||||
- chunk 加载/执行/物化失败 → 视图级错误 + 重试 UI(`css.editorError` + `css.terminalRetry`),面板其余部分不受影响;
|
||||
- host 旧版本无 `/sidebar/bundle` 路由(404) → 同样的错误 + 重试,升级 host 后重试即可;
|
||||
- `window.__DSH_MODULES__` 缺失(异常环境) → 明确报错,不触网;
|
||||
- chunk 注册 id 恒定 `dsh-better-sidebar/<name>`,两渠道共用(每页只活一个渠道——双渠道并存本就双挂载,属既有破损组合);
|
||||
- 跨 chunk 复用:`t()`/`api.ts`/`SandboxStatusBar`/`clsx`/`sidebar.module.css` 等无状态小模块允许在各 chunk 内联(几十 KB 级重复),避免 chunk→主 bundle require 的脆弱耦合;
|
||||
- CSS:`sidebar.module.css` 的 tagId 与官方渠道核心一致 → 幂等跳过注入;registry 渠道会多注入一份同内容样式(数据标签差异,无功能影响);xterm.css/Univer.css 随各自 chunk 注入。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- 原设想 React.lazy + Suspense 被否决:错误处理需 error boundary,重试语义差;改用有状态包装组件(loading/error/retry 三态),与本库"每个视图自带 loading/error/retry"风格一致。
|
||||
- 原设想 chunk 注册 id 按渠道区分(`__DSH_PLUGIN_ID__` define)被否决:chunk 文件两渠道共用,恒定槽位即可;避免重复构建 6 份产物。
|
||||
- `resetChunks` 同时清空测试注册表(测试隔离需要)。
|
||||
- `lazy-chunk.tsx` 内部渲染用 `ComponentType<any>` 状态成员规避 React 18 createElement 泛型重载限制(仅内部一处显式 any)。
|
||||
- **v2(运行时修复)**:最初实现经 `window.__ModuleLoader__.load({id})` 注册 chunk factory、再 `__DSH_MODULES__.import(id)` 物化——真实环境报 `client-modules: cannot resolve "dsh-better-sidebar/terminal"`(某 DSH 版本的 import() 不解析已注册的非 boot 图 id)。改为全局 factory 注册表(`globalThis.__dshChunks__`) + 自定义 externals require(seed 分支),彻底不依赖模块系统的 factory 解析行为;`resetChunks` 不再需要 invalidate。
|
||||
- **v3(运行时修复)**:terminal 懒包装把整个 `TabComponentProps` 原样透传,但 `TerminalView` 的 props 是 `{ scope, tabId, store }` 而 `TabComponentProps` 只有 `tab` 对象、没有 `tabId` → `tabId` 为 undefined → `isAgentTabId` 里 `undefined.startsWith` 崩溃(`dsh-better-sidebar: Cannot read properties of undefined (reading 'startsWith')`)。修复:descriptor 显式映射 `tabId={tab.id}`,懒包装的 props 泛型 = TerminalView 真实签名(`{ scope, tabId, store }`);回归测试钉住映射(lazy-chunk.spec.tsx)。**教训:懒包装的 props 泛型必须等于目标组件真实 props,而非描述符 props 全集**。
|
||||
- **v2 附加**:bundled 产物经真实 `ClientModuleSystem`(带真实 react seed)端到端验证——5 个 chunk 均能物化出真实 React 组件;产物契约由 `tests/chunk-artifact.spec.ts` 长期钉住(脚本执行赋值槽位 + factory 可被 externals require 物化)。
|
||||
@@ -1,77 +0,0 @@
|
||||
# 移动端(窄视口 <768px)侧边栏布局设计
|
||||
|
||||
**日期**:2026-08-12
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.7.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
按**视口宽度**(<768px——"真正的移动端宽度",刻意不对齐宿主 1024 断点)切换移动端侧边栏体验:
|
||||
|
||||
1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。
|
||||
2. **合并显示 = 只显示右侧栏**:进入窄屏时底部面板的标签页**直接并入右侧栏**(一次性状态迁移),右侧栏成为全宽抽屉,底部面板浮层整体不存在。
|
||||
3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不改持久化 schema:`bottomOpen` / `bottomHeight` / `bottomSplits` 字段原样保留。
|
||||
- 不改桌面行为:≥768px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。
|
||||
- 不改公开服务 API、PrefsSchema、host 半。
|
||||
- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。
|
||||
- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`(独立 split 树,pane/tab id 全局唯一)。
|
||||
- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 断点模块(新 `src/client/breakpoints.ts`)
|
||||
|
||||
- `NARROW_MAX_WIDTH = 768`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。
|
||||
- CSS 侧配对 `@media (max-width: 767px)`(767 ≡ <768),两端注释互指。
|
||||
- 刻意**不对齐**宿主 `SIDEBAR_AUTO_COLLAPSE = 1024`:1024px 窗口(小笔记本、分屏)保留桌面双面板;只有手机/竖屏平板才进移动布局。
|
||||
|
||||
### 4.2 合并 = 底部标签并入右侧栏(状态迁移)
|
||||
|
||||
- **新 reducer `migrateBottomTabs(state)`**(`state.ts`):
|
||||
- 底部树所有标签按深度优先序**追加到右侧树第一个 leaf**(`firstLeaf(splits)`)的标签条;
|
||||
- 底部树清空(结构保留——桌面端回显欢迎卡片)、`bottomOpen=false`、`activePane` 指向右侧树第一个 leaf(保证迁移后新开的标签落在可见面板);
|
||||
- 幂等:底部无标签且面板已关且 activePane 不在底部树时返回同一引用。
|
||||
- **Sidebar 触发 effect**:`narrow && sessionId 已定义` 时执行(覆盖:窄屏挂载、会话切换、桌面→窄屏过渡);幂等性保证其自然收敛,不循环。
|
||||
- 迁移是**永久**的(符合"直接丢到右侧栏中"):回桌面后标签仍留在右侧树,底部面板为空(欢迎卡片);用户可在桌面重新往底部面板开标签,再进窄屏会再次迁移。
|
||||
- 迁移后右侧栏用**现有 `Workbench` 原样渲染**——零视图层特判、零 action 路由修补(标签真实住在右侧树,close/activate/drag 全部走原路径)。
|
||||
|
||||
### 4.3 窄屏渲染(`Sidebar.tsx`)
|
||||
|
||||
- 面板宽度 `100vw`(全宽抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。
|
||||
- `visible` 语义回到原始:底部树只在桌面底部面板场景(`bottomOpen && active`)——窄屏下底部树为空且不渲染。
|
||||
- 底部首展自动终端 effect 加 `narrow` 守卫。
|
||||
|
||||
### 4.4 行为修正
|
||||
|
||||
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
|
||||
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
|
||||
|
||||
### 4.5 样式(`sidebar.module.css`)
|
||||
|
||||
- `@media (max-width: 767px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。
|
||||
|
||||
### 4.6 测试
|
||||
|
||||
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
|
||||
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。
|
||||
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
|
||||
|
||||
## 5. 边界情况
|
||||
|
||||
- 迁移触发:窄屏挂载 / 会话切换 / 桌面→窄屏过渡,均由同一 effect 覆盖;幂等保证收敛。
|
||||
- 迁移后新开标签:`activePane` 已重指右侧树第一 leaf,`openTabInActivePane` 必落在可见面板(含"空底部树但 activePane 指向它"的陈旧指针场景)。
|
||||
- 跨断点往返:迁移永久;桌面底部面板空显欢迎卡片;`bottomHeight` 不受影响。
|
||||
- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。
|
||||
|
||||
## 6. 实施偏差记录
|
||||
|
||||
- **v1(已提交 b391ebf,用户反馈推翻)**:合并 = 单面板内上下堆叠两个工作台(`MobileWorkbench` + 可拖分隔条,高度复用 `bottomHeight`),断点 1024(对齐宿主)。用户明确:合并 = 只显示右侧栏、底部标签直接丢进右侧栏;窄屏 = 真窄屏、不对齐 1024。
|
||||
- **v2(本文档,最终形态)**:删除 `MobileWorkbench`(组件 + CSS + spec),改为 `migrateBottomTabs` 状态迁移 + 现有 `Workbench` 渲染;断点 768。`bottomHeight` 不再参与移动端布局(仅桌面底部面板使用)。
|
||||
@@ -1,43 +0,0 @@
|
||||
# 任务管理页后台命令区块(查看输出 + 强制终止)设计
|
||||
|
||||
> 2026-08-12 · v0.8.0
|
||||
|
||||
## 目标
|
||||
|
||||
任务管理页(当前树的主代理拓扑,原「子代理」页)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务:
|
||||
|
||||
- 点击任务行,输出显示在**侧边栏底部共享 Dock**(sticky 终端式,滚动时保持可见;运行中每 2s 轮询,页面不可见时暂停;自动跟随底部);
|
||||
- 运行中的任务行带两击确认的终止按钮;
|
||||
- **不破坏模型契约、不修改 DSH 源码**(本仓库规则:对官方源码 checkout 零写入,挂载只走 cordis.patch.yml + profile 机制):输出查看是**模型已读输出的回放**,绝不推进 `task_output` 的读取游标。
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
bash/tool run_in_background → ctx.tasks.start(owner = 执行 agent)
|
||||
api-proxy session/tasks 帧(TaskView[],无输出)→ 客户端 tasksBySession 镜像
|
||||
SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染(单 Dock,选中行)
|
||||
点行 → POST /sidebar/api/tasks.output(事件回放)→ 扫描属主会话 events 里
|
||||
task_output 的 tool/call + tool/result 对 → 模型已读文本
|
||||
终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill(DSH 现成 API)
|
||||
```
|
||||
|
||||
## 关键决策
|
||||
|
||||
1. **输出 = 会话事件回放,零 DSH 写入**:`TaskService.read()` 是单一消费游标——UI 直接读会偷走模型 `task_output` 的字节(或读到空)。而 DSH 源码禁止修改,所以 `tasks.output` **不碰注册表**:扫描属主会话的 append-only 事件日志,取 `task_output` 调用的 `tool/call`(`arguments.task_id` 配对)与其 `tool/result`(finalize 后的文本,即模型实际收到的内容)按序拼接;`"(no new output)"` 增量与错误结果跳过;`read: false` 表示模型尚未读过。模型游标天然不被触碰。
|
||||
- 语义代价(用户已确认接受):面板只在模型读过之后才有内容——没有实时流。
|
||||
2. **列表不新增路由**:任务清单由 harness 的 `session/tasks` 推送镜像提供;只加 `tasks.output`(回放)与 `tasks.kill`(注册表原生 `kill`)两个路由(信任围栏与其余路由一致)。注册表缺席时 kill 降级 503(output 不需要注册表)。
|
||||
3. **终止的访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`。
|
||||
4. **展示范围 = 整棵树**:与页面作用域一致(拓扑即当前树);树跨多会话时行尾显示属主标题。
|
||||
5. **单共享 Dock(大量任务的形态)**:行内展开会让列表随任务数膨胀、排序变化时面板跳动;改为一个底部 sticky Dock + 单一轮询实例,行保持紧凑、排序变动不惊吓,选中行高亮。
|
||||
6. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。
|
||||
7. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。
|
||||
|
||||
## 实施偏差
|
||||
|
||||
- 曾实现 harness 侧非消费 peek 缝隙(`BashProcess.peekOutput` / `TaskService.peek`),因「禁止修改 DSH 源码」规则**整体回退**(harness 提交已 reset,checkout 恢复纯净快照),改用事件回放。
|
||||
- 宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503);`ctx.sessions.get(id).events` 为只读访问。
|
||||
|
||||
## 验证
|
||||
|
||||
- 插件单测:tasks-routes(回放拼接/过滤/read 标志/截断/404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、单 Dock 切换、关闭、两击终止、read:false 提示、大量任务、隐藏不轮询、镜像清空 hook 顺序回归 #300)。
|
||||
- 集成(真实 dsh web):启动 `run_in_background` bash → 模型 `task_output` 读取 → `/sidebar/api/tasks.output` 返回模型已读内容 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`。
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.9.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
"dsh": ">=0.0.1"
|
||||
},
|
||||
"contributes": {
|
||||
"tools": [],
|
||||
"skills": []
|
||||
},
|
||||
"client": {
|
||||
"main": "./lib/client-registry.js"
|
||||
}
|
||||
}
|
||||
+51
-134
@@ -1,152 +1,69 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.9.0",
|
||||
"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,
|
||||
"name": "apes-sidebar",
|
||||
"version": "2.0.0",
|
||||
"description": "WorkBuddy/Tutti 风格右侧边栏增强(构建在 dsh-better-sidebar 之上:任务 / Agent / 消息 / 应用 / Profile 五面板信息层)",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/service": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./client/api": {
|
||||
"types": "./lib/types/client/service.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
".": "./lib/index.js",
|
||||
"./invariant": "./lib/invariant.js",
|
||||
"./client": "./lib/client.js",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src",
|
||||
"cordis.patch.yml",
|
||||
"scripts",
|
||||
"AGENTS.md",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"verify:mount": "node scripts/verify-mount.cjs",
|
||||
"lint:ts": "oxlint src tests tsdown.config.ts vitest.config.ts",
|
||||
"dev": "tsdown --watch",
|
||||
"pack:plugin": "pnpm build && pnpm pack --pack-destination dist"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"dsh-better-sidebar": "^0.18.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"dsh-better-sidebar": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.2",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"dsh-better-sidebar": "^0.18.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"oxlint": "^0.15.0",
|
||||
"react": "~18.3.1",
|
||||
"tsdown": "^0.22.2",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
"betterSidebar",
|
||||
"locale",
|
||||
"sessions"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"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"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch",
|
||||
"prepare": "tsdown",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"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",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@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",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tsdown": "^0.22.2",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
Generated
+1830
-5172
File diff suppressed because it is too large
Load Diff
+1
-64
@@ -1,67 +1,4 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
# node-pty's install script copies its shipped prebuilt binaries into place
|
||||
# (no source compilation on supported platforms); pnpm 10+ blocks dependency
|
||||
# build scripts unless approved.
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1'
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1'
|
||||
- '@deepseek-ai/cordis@4.0.1-rc.1'
|
||||
- '@deepseek-ai/cosmokit@1.8.2-rc.1'
|
||||
- '@deepseek-ai/dsh-agent-default-model@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-agent-presets@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-agent@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-api-remotes@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-atomic-write@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-attachment@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-brand@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-connection@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-client-locale@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-slash@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-code-runtime@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-commands@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-compact@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-credentials@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-goal@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-host-apiproxy@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-host-directory-picker@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-retry@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-llm@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-native-command@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-paths@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-scope@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-persistence@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-projection-cache@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-projection@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-query@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-session-title@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-skill@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-storage-domain@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-storage@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-subagent@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-system-prompt@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-timeout@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-token-meter@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-tools@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-type-meta@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-typert-registry@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-user-approval@0.0.1-rc.1'
|
||||
- '@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'
|
||||
- dsh-better-sidebar@0.18.0
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Assemble the plugin-registry installable root (`registry/`) from the built
|
||||
* `lib/` output. Run after `pnpm build`:
|
||||
*
|
||||
* pnpm build
|
||||
* node scripts/package-registry.mjs
|
||||
* dsh registry install ./registry
|
||||
* dsh registry enable dsh-external/dsh-better-sidebar
|
||||
*
|
||||
* The registry install copies the whole source directory (`cp -r`), so
|
||||
* installing the repo root would drag in `node_modules/` (machine-local
|
||||
* `link:` devDependency symlinks, node-pty binaries) and `.git/`. This
|
||||
* staging directory carries exactly the files the manifest references plus
|
||||
* the docs — same layout as the dsh-subagent-tree `registry/` pattern. It is
|
||||
* gitignored and rebuilt from scratch on every run.
|
||||
*/
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(fileURLToPath(import.meta.url), '..', '..')
|
||||
const out = join(root, 'registry')
|
||||
|
||||
/** Files copied into registry/, preserving relative paths (lib/ stays lib/). */
|
||||
const files = [
|
||||
'dsh.plugin.json',
|
||||
'lib/index.js',
|
||||
'lib/invariant.js',
|
||||
'lib/client-registry.js',
|
||||
'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',
|
||||
'lib/client-editor.js.map',
|
||||
'README.md',
|
||||
'LICENSE',
|
||||
]
|
||||
|
||||
rmSync(out, { recursive: true, force: true })
|
||||
mkdirSync(out, { recursive: true })
|
||||
for (const file of files) {
|
||||
const source = join(root, file)
|
||||
if (!existsSync(source)) {
|
||||
console.error(`missing ${file} — run \`pnpm build\` first`)
|
||||
process.exit(1)
|
||||
}
|
||||
const target = join(out, file)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
cpSync(source, target)
|
||||
}
|
||||
console.log(`registry/ assembled (${files.length} files)`)
|
||||
console.log('next: dsh registry install ./registry && dsh registry enable dsh-external/dsh-better-sidebar')
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 验收验证:模拟 DSH host 的 resolveOverlayPackage + bundle 加载
|
||||
* - 确认 apes-sidebar 在 profile node_modules 可解析
|
||||
* - 确认 host entry (lib/index.js) 的命名导出 shape { name, inject, apply }
|
||||
* - 确认 client entry (lib/client.js) 存在且是 __ModuleLoader__ bundle(browser artifact)
|
||||
* - 确认 invariant surface 可用
|
||||
*/
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { createRequire } = require('module')
|
||||
|
||||
const PROFILE = process.env.HOME + '/.dsh/profiles/desktop'
|
||||
const req = createRequire(path.join(PROFILE, 'noop.js'))
|
||||
|
||||
let pass = 0, fail = 0
|
||||
const check = (label, cond, detail = '') => {
|
||||
if (cond) { pass++; console.log(' ✓', label) }
|
||||
else { fail++; console.log(' ✗', label, detail) }
|
||||
}
|
||||
|
||||
console.log('### apes-sidebar 挂载验收')
|
||||
|
||||
// 1. 包可解析
|
||||
try {
|
||||
const pkgPath = req.resolve('apes-sidebar/package.json')
|
||||
const pkg = req('apes-sidebar/package.json')
|
||||
check('package.json 可解析', true, pkgPath)
|
||||
check('name=apes-sidebar', pkg.name === 'apes-sidebar')
|
||||
check('version=2.0.0', pkg.version === '2.0.0')
|
||||
} catch (e) {
|
||||
check('package.json 可解析', false, e.message)
|
||||
}
|
||||
|
||||
// 2. host entry 加载 + shape
|
||||
try {
|
||||
const host = req('apes-sidebar')
|
||||
check('host entry 加载成功', true)
|
||||
check('导出 name (string)', typeof host.name === 'string', 'got ' + typeof host.name)
|
||||
check('导出 inject (string[])', Array.isArray(host.inject), 'got ' + typeof host.inject)
|
||||
check('inject === [] (无硬依赖 service)', JSON.stringify(host.inject) === '[]', JSON.stringify(host.inject))
|
||||
check('导出 apply (function)', typeof host.apply === 'function')
|
||||
check('无 default export(namespace 规则)', !('default' in host))
|
||||
console.log(' name =', JSON.stringify(host.name))
|
||||
} catch (e) {
|
||||
check('host entry 加载', false, e.message)
|
||||
}
|
||||
|
||||
// 3. invariant surface
|
||||
try {
|
||||
const inv = req('apes-sidebar/invariant')
|
||||
check('invariant 可加载', true)
|
||||
check('invariant.assert 是函数', typeof inv.assert === 'function')
|
||||
} catch (e) {
|
||||
check('invariant 可加载', false, e.message)
|
||||
}
|
||||
|
||||
// 4. client entry 存在 + 是 browser artifact
|
||||
try {
|
||||
const clientPath = req.resolve('apes-sidebar/client')
|
||||
const clientSrc = fs.readFileSync(clientPath, 'utf8')
|
||||
check('client entry 文件存在', true, clientPath)
|
||||
check('client 是 __ModuleLoader__ bundle(line 1 window)',
|
||||
clientSrc.split('\n')[0].includes('window.__ModuleLoader__.load'),
|
||||
'line1=' + clientSrc.split('\n')[0].slice(0, 50))
|
||||
} catch (e) {
|
||||
check('client entry', false, e.message)
|
||||
}
|
||||
|
||||
// 5. peer dep dsh-better-sidebar 版本满足 ^0.18.0
|
||||
try {
|
||||
const bs = req('dsh-better-sidebar/package.json')
|
||||
const v = bs.version
|
||||
check('dsh-better-sidebar 版本满足 ^0.18.0',
|
||||
v === '0.18.0', 'got ' + v)
|
||||
} catch (e) {
|
||||
check('dsh-better-sidebar 版本', false, e.message)
|
||||
}
|
||||
|
||||
console.log('\n=== 验收结果: ' + pass + ' passed, ' + fail + ' failed ===')
|
||||
process.exit(fail > 0 ? 1 : 0)
|
||||
@@ -1,519 +0,0 @@
|
||||
/**
|
||||
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
|
||||
* sessions created by the model through the `terminal_create` tool. Each
|
||||
* handle survives across tool calls (and across WebSocket disconnects from
|
||||
* the sidebar view) until the model calls `terminal_close` or the user
|
||||
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
|
||||
* session.
|
||||
*
|
||||
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
|
||||
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
|
||||
* keyed by uuid and uncapped (the model is trusted to close unused ones).
|
||||
* Both registries share the same shell resolver and spawn-helper fix.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import * as nodePty from 'node-pty'
|
||||
import { ensureSpawnHelper } from './pty-manager.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
|
||||
const TRANSCRIPT_LIMIT = 1 << 20
|
||||
|
||||
/** POSIX signals the registry forwards to a live pty. */
|
||||
export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
|
||||
/** Signal name accepted by `signal()`. */
|
||||
export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
|
||||
|
||||
/** Default read page size (lines) when the caller omits `count`. */
|
||||
export const DEFAULT_READ_COUNT = 500
|
||||
|
||||
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
|
||||
export const TERMINAL_DIM_MIN = 2
|
||||
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
|
||||
export const TERMINAL_DIM_MAX = 1024
|
||||
|
||||
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
|
||||
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
|
||||
const clamp = (value: number): number =>
|
||||
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
|
||||
return { cols: clamp(cols), rows: clamp(rows) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable snapshot of one agent terminal — the shape the model sees
|
||||
* through `terminal_list` and the sidebar sees through the push endpoint.
|
||||
* Carries no pty reference and no transcript (those are reached through
|
||||
* dedicated read/attach paths), and no sessionId: ownership is registry
|
||||
* internals, scoped by the caller (list filters by session, the push
|
||||
* endpoint scopes by its query param), never part of the serialized view.
|
||||
*/
|
||||
export interface AgentTerminalSnapshot {
|
||||
/** Stable opaque handle the model passes back to other terminal_* tools. */
|
||||
uuid: string
|
||||
/** Display title the model chose at create time. */
|
||||
title: string
|
||||
/** The command the model asked to run at create time (verbatim). */
|
||||
command: string
|
||||
/** Whether the top-level process has exited. */
|
||||
exited: boolean
|
||||
/** Exit code if exited normally; absent until the process exits. */
|
||||
exitCode?: number | null
|
||||
/** Exit signal name if the process was killed by a signal; null otherwise. */
|
||||
exitSignal?: string | null
|
||||
}
|
||||
|
||||
/** Map a POSIX signal number to its conventional name (best-effort). */
|
||||
const SIGNAL_NAMES: Record<number, string> = {
|
||||
1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
|
||||
9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
|
||||
17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
|
||||
}
|
||||
|
||||
/** Convert a raw signal number to a name (or null when absent/unknown). */
|
||||
function signalNameOf(signal: number | null | undefined): string | null {
|
||||
if (signal === null || signal === undefined) return null
|
||||
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
|
||||
}
|
||||
|
||||
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
|
||||
function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
|
||||
if (needle === '') return undefined
|
||||
const idx = transcript.indexOf(needle)
|
||||
if (idx === -1) return undefined
|
||||
// Walk the transcript up to the match index, counting newlines to derive
|
||||
// the 0-based line; the column is the offset within that line.
|
||||
let line = 0
|
||||
let lineStart = 0
|
||||
for (let i = 0; i < idx; i += 1) {
|
||||
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
|
||||
line += 1
|
||||
lineStart = i + 1
|
||||
}
|
||||
}
|
||||
return { line, column: idx - lineStart }
|
||||
}
|
||||
|
||||
/** One live agent terminal. */
|
||||
export interface AgentTerminalHandle {
|
||||
/** Stable opaque handle. */
|
||||
uuid: string
|
||||
/** Owning conversation id. */
|
||||
sessionId: string
|
||||
/** Display title. */
|
||||
title: string
|
||||
/** The command written to stdin right after spawn. */
|
||||
command: string
|
||||
/** The working directory the process was spawned with. */
|
||||
cwd: string
|
||||
/** The live pty process. */
|
||||
pty: nodePty.IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
exited: boolean
|
||||
/** Exit code once known. */
|
||||
exitCode?: number | null
|
||||
/** Exit signal number once known (POSIX only; undefined on Windows). */
|
||||
exitSignal?: number | null
|
||||
}
|
||||
|
||||
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
|
||||
export interface AgentTerminalReadResult {
|
||||
/** The slice of transcript text for the requested page. */
|
||||
text: string
|
||||
/** Total lines in the retained transcript (the page may be a subset). */
|
||||
totalLines: number
|
||||
/** 0-based index of the first line in `text` (inclusive). */
|
||||
lineBegin: number
|
||||
/** 0-based index of the last line in `text` (exclusive). */
|
||||
lineEnd: number
|
||||
}
|
||||
|
||||
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
|
||||
export type AgentTerminalWaitResult =
|
||||
| {
|
||||
/** The needle was found in the transcript. */
|
||||
kind: 'found'
|
||||
/** The matched substring. */
|
||||
needle: string
|
||||
/** 0-based line index (in the retained transcript) where the needle first appeared. */
|
||||
line: number
|
||||
/** 0-based column index within that line where the match starts. */
|
||||
column: number
|
||||
/** Elapsed wall-clock milliseconds from the wait start to the match. */
|
||||
elapsedMs: number
|
||||
}
|
||||
| {
|
||||
/** The needle did not appear before the timeout. */
|
||||
kind: 'timeout'
|
||||
/** The needle that was awaited. */
|
||||
needle: string
|
||||
/** The configured timeout in milliseconds. */
|
||||
timeoutMs: number
|
||||
/** Total lines retained when the timeout fired (call terminal_read to inspect). */
|
||||
totalLines: number
|
||||
}
|
||||
| {
|
||||
/** The terminal exited before the needle appeared. */
|
||||
kind: 'exited'
|
||||
/** The needle that was awaited. */
|
||||
needle: string
|
||||
/** The exit code, if known. */
|
||||
exitCode?: number | null
|
||||
/** The exit signal name, if the process was killed by a signal. */
|
||||
exitSignal?: string | null
|
||||
}
|
||||
|
||||
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
||||
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
|
||||
const out: AgentTerminalSnapshot = {
|
||||
uuid: handle.uuid,
|
||||
title: handle.title,
|
||||
command: handle.command,
|
||||
exited: handle.exited,
|
||||
}
|
||||
if (handle.exited) {
|
||||
out.exitCode = handle.exitCode ?? null
|
||||
out.exitSignal = signalNameOf(handle.exitSignal)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent terminal registry. The constructor takes the resolved shell
|
||||
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
|
||||
* spawn-helper chmod fix once at construction so the first agent terminal
|
||||
* does not race a lazy fixer.
|
||||
*/
|
||||
export class AgentPtyRegistry {
|
||||
private readonly sessions = new Map<string, AgentTerminalHandle>()
|
||||
private readonly changeListeners = new Set<() => void>()
|
||||
|
||||
constructor(private readonly shell: string) {
|
||||
ensureSpawnHelper()
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn one agent terminal: start the shell in `cwd`, then write
|
||||
* `command + '\n'` to stdin so the command runs in the fresh shell. The
|
||||
* terminal stays alive after the command exits — the model can send more
|
||||
* input through `terminal_send` until it calls `terminal_close` or the
|
||||
* user closes the sidebar tab. An empty `command` spawns a bare shell.
|
||||
* @returns the new handle's uuid (the model-facing opaque id).
|
||||
*/
|
||||
create(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
command: string,
|
||||
cwd: string,
|
||||
cols = 80,
|
||||
rows = 24,
|
||||
): string {
|
||||
const uuid = randomUUID()
|
||||
const dims = clampDims(cols, rows)
|
||||
const pty = nodePty.spawn(this.shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
})
|
||||
const handle: AgentTerminalHandle = {
|
||||
uuid,
|
||||
sessionId,
|
||||
title,
|
||||
command,
|
||||
cwd,
|
||||
pty,
|
||||
transcript: '',
|
||||
exited: false,
|
||||
}
|
||||
pty.onData((data) => {
|
||||
handle.transcript += data
|
||||
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
|
||||
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
|
||||
}
|
||||
})
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
handle.exited = true
|
||||
handle.exitCode = exitCode
|
||||
handle.exitSignal = signal
|
||||
this.notify()
|
||||
})
|
||||
if (command !== '') {
|
||||
// Write the command + Enter so it runs in the freshly spawned shell.
|
||||
// Use \r (carriage return) — the actual character a terminal sends for
|
||||
// the Enter key — not \n (line feed). PowerShell treats a bare \n as a
|
||||
// soft line break (continuation prompt ">>") rather than a command
|
||||
// submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
|
||||
try {
|
||||
pty.write(`${command}\r`)
|
||||
} catch {
|
||||
// A spawn that failed between onData and onExit surfaces its own
|
||||
// exit; the create call still returns the uuid so the model can
|
||||
// read the transcript and see the failure.
|
||||
}
|
||||
}
|
||||
this.sessions.set(uuid, handle)
|
||||
this.notify()
|
||||
return uuid
|
||||
}
|
||||
|
||||
/** All live agent terminals belonging to one conversation. */
|
||||
list(sessionId: string): AgentTerminalSnapshot[] {
|
||||
const out: AgentTerminalSnapshot[] = []
|
||||
for (const handle of this.sessions.values()) {
|
||||
if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Resolve a live handle by uuid, or throw `not-found`. */
|
||||
private expect(uuid: string): AgentTerminalHandle {
|
||||
const handle = this.sessions.get(uuid)
|
||||
if (handle === undefined) {
|
||||
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
|
||||
* The model-facing tools call this before every uuid-keyed operation: a
|
||||
* uuid from another session is indistinguishable from an unknown one, so a
|
||||
* model can never reach (or probe) a terminal it does not own.
|
||||
*/
|
||||
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
|
||||
const handle = this.expect(uuid)
|
||||
if (handle.sessionId !== sessionId) {
|
||||
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Resolve a handle's snapshot, or undefined if it does not exist. */
|
||||
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
|
||||
const handle = this.sessions.get(uuid)
|
||||
return handle === undefined ? undefined : snapshotOf(handle)
|
||||
}
|
||||
|
||||
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
|
||||
send(uuid: string, text: string): void {
|
||||
const handle = this.expect(uuid)
|
||||
if (handle.exited) {
|
||||
throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
|
||||
}
|
||||
handle.pty.write(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one bounded page of the retained transcript. `offset` is a 0-based
|
||||
* line index from the start of the retained transcript (default 0);
|
||||
* `count` caps the page size (default 500). A negative `offset` reads
|
||||
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
|
||||
* so the model can paginate.
|
||||
*/
|
||||
read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
|
||||
const handle = this.expect(uuid)
|
||||
const lines = handle.transcript.split('\n')
|
||||
const totalLines = lines.length
|
||||
const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
|
||||
let start: number
|
||||
if (offset === undefined || offset === 0) {
|
||||
start = 0
|
||||
} else if (offset < 0) {
|
||||
// Negative offset: read from the end (e.g. -50 → last 50 lines).
|
||||
start = Math.max(0, totalLines + offset)
|
||||
} else {
|
||||
start = Math.min(offset, totalLines)
|
||||
}
|
||||
const end = Math.min(start + pageSize, totalLines)
|
||||
const slice = lines.slice(start, end).join('\n')
|
||||
return {
|
||||
text: slice,
|
||||
totalLines,
|
||||
lineBegin: start,
|
||||
lineEnd: end,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a terminal's pty, clamped to the 2..1024 sane range.
|
||||
* @returns the dimensions actually applied (the caller echoes these, so the
|
||||
* reported value always matches the pty).
|
||||
*/
|
||||
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
||||
const handle = this.expect(uuid)
|
||||
const dims = clampDims(cols, rows)
|
||||
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
|
||||
return dims
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for `needle` to appear in a terminal's transcript, or for the
|
||||
* terminal to exit, or for the timeout to elapse — whichever happens
|
||||
* first. The wait polls the live transcript every ~50ms and short-circuits
|
||||
* on `signal` abort (re-thrown as the abort reason so the tool layer
|
||||
* surfaces cancellation).
|
||||
*
|
||||
* The match scans the FULL retained transcript on each poll, not just the
|
||||
* delta since the last poll — a needle that scrolled past the most recent
|
||||
* chunk but is still within the ~1 MiB bound is still a match. The
|
||||
* returned line/column locate the FIRST occurrence (oldest), which is what
|
||||
* a user watching the terminal would have seen first.
|
||||
*
|
||||
* The implementation uses polling (not pty onData subscription) because
|
||||
* node-pty's onData fires before the registry's own onData listener
|
||||
* updates the transcript (listener order is not guaranteed), and on
|
||||
* Windows ConPTY output can arrive in bursts with batching delays that
|
||||
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
||||
* interactive use and simple enough to be obviously correct.
|
||||
* @param uuid - terminal to watch.
|
||||
* @param needle - substring to search for (case-sensitive, verbatim).
|
||||
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
||||
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
||||
* @returns one of `found` / `timeout` / `exited`.
|
||||
*/
|
||||
async waitFor(
|
||||
uuid: string,
|
||||
needle: string,
|
||||
timeoutMs = 10_000,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentTerminalWaitResult> {
|
||||
if (needle === '') {
|
||||
throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
|
||||
}
|
||||
const handle = this.expect(uuid)
|
||||
const timeout = Math.max(100, Math.floor(timeoutMs))
|
||||
const start = Date.now()
|
||||
const deadline = start + timeout
|
||||
// Fast path: already exited, or the needle is already in the transcript
|
||||
// (a `terminal_send` may have produced the expected output before this
|
||||
// call even started).
|
||||
if (handle.exited) {
|
||||
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
||||
}
|
||||
const firstHit = locateNeedle(handle.transcript, needle)
|
||||
if (firstHit !== undefined) {
|
||||
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
|
||||
}
|
||||
// Poll loop: check the transcript every 50ms, exit on match / exit /
|
||||
// abort / timeout. The handle is read live each iteration (its transcript
|
||||
// and exited fields mutate as the pty produces output).
|
||||
while (true) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
if (handle.exited) {
|
||||
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
||||
}
|
||||
const hit = locateNeedle(handle.transcript, needle)
|
||||
if (hit !== undefined) {
|
||||
return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
|
||||
}
|
||||
await new Promise(resolve => {
|
||||
const t = setTimeout(resolve, 50)
|
||||
// Allow the Node process to exit even if the timer is pending.
|
||||
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a POSIX signal to a terminal's foreground process.
|
||||
*
|
||||
* Two delivery paths, by signal kind:
|
||||
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
|
||||
* writing the corresponding control character to the pty stdin. This is
|
||||
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
|
||||
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
|
||||
* (Windows), which translates it into a SIGINT/SIGTSTP for the
|
||||
* foreground process group. This works on every platform — calling
|
||||
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
|
||||
* but writing `\x03` is universally correct.
|
||||
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
|
||||
* which maps to the platform's process-termination path (POSIX
|
||||
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
|
||||
* control characters.
|
||||
*/
|
||||
signal(uuid: string, signal: AgentTerminalSignal): void {
|
||||
const handle = this.expect(uuid)
|
||||
if (handle.exited) return
|
||||
// Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
|
||||
// master is the cross-platform way to deliver the signal to the
|
||||
// foreground process group through the terminal's input pipeline.
|
||||
if (signal === 'SIGINT' || signal === 'SIGTSTP') {
|
||||
const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
|
||||
try {
|
||||
handle.pty.write(ctrlByte)
|
||||
} catch {
|
||||
// A pty that rejects writes is already tearing down; the next
|
||||
// onExit will mark it exited. Not an error from the caller's view.
|
||||
}
|
||||
return
|
||||
}
|
||||
// SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
|
||||
try {
|
||||
handle.pty.kill(signal)
|
||||
} catch {
|
||||
// node-pty on Windows rejects named signals other than the default;
|
||||
// fall back to the default kill (TerminateProcess on Windows,
|
||||
// SIGKILL-equivalent on POSIX) so the signal still takes effect.
|
||||
try {
|
||||
handle.pty.kill()
|
||||
} catch {
|
||||
// Already exited or gone; nothing left to kill.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a terminal and drop its state. Idempotent: a second close of the
|
||||
* same uuid is a no-op. Returns true iff a live handle was actually
|
||||
* dropped.
|
||||
*/
|
||||
close(uuid: string): boolean {
|
||||
const handle = this.sessions.get(uuid)
|
||||
if (handle === undefined) return false
|
||||
this.sessions.delete(uuid)
|
||||
try {
|
||||
handle.pty.kill()
|
||||
} catch {
|
||||
// Already exited or gone; nothing left to kill.
|
||||
}
|
||||
this.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Resolve a live handle by uuid (for the WS attach path). */
|
||||
get(uuid: string): AgentTerminalHandle | undefined {
|
||||
return this.sessions.get(uuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to registry changes (create / close / exit). The sidebar push
|
||||
* endpoint uses this to forward snapshots to the connected view. Returns
|
||||
* the unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.changeListeners.add(listener)
|
||||
return () => { this.changeListeners.delete(listener) }
|
||||
}
|
||||
|
||||
/** Close every agent terminal (plugin teardown). */
|
||||
disposeAll(): void {
|
||||
for (const uuid of [...this.sessions.keys()]) this.close(uuid)
|
||||
}
|
||||
|
||||
/** Fire every change listener (callers wrap in try/catch if needed). */
|
||||
private notify(): void {
|
||||
for (const listener of [...this.changeListeners]) {
|
||||
try {
|
||||
listener()
|
||||
} catch {
|
||||
// A listener throwing must not break the others or the registry.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Pure helpers for the `browser.probe` route (sidebar browser): the host
|
||||
* fetches the response HEADERS of a URL the user is browsing and the client
|
||||
* decides whether the target site forbids being embedded (X-Frame-Options /
|
||||
* CSP frame-ancestors are exactly the signals the browser enforces when it
|
||||
* refuses an iframe load). Kept dependency-free so the parser is
|
||||
* unit-testable.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the `frame-ancestors` source list of a Content-Security-Policy
|
||||
* header, or undefined when the directive is absent (or empty). The
|
||||
* directive is the only one with a source list; sources are space-separated
|
||||
* tokens (`'none'`, `'self'`, `*`, or origins).
|
||||
*/
|
||||
export function extractFrameAncestors(csp: string | null): string[] | undefined {
|
||||
if (csp === null) return undefined
|
||||
for (const directive of csp.split(';')) {
|
||||
const parts = directive.trim().split(/\s+/)
|
||||
if (parts[0] === 'frame-ancestors') {
|
||||
const sources = parts.slice(1).filter(source => source !== '')
|
||||
return sources.length === 0 ? undefined : sources
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk route: serves the client bundle's chunk scripts
|
||||
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
|
||||
* cannot serve arbitrary file names, so the plugin serves its own split
|
||||
* bundles (lib/client-<name>.js) here; the client injects the script on
|
||||
* first use of the feature that needs it (see src/client/chunk-loader.ts).
|
||||
*
|
||||
* Caching contract: every response carries `cache-control: no-cache` plus an
|
||||
* ETag (content hash, memoized per file by mtime/size) and honors
|
||||
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
|
||||
* re-downloading multi-MB chunks that did not change (page refresh, HMR
|
||||
* re-activation). Same browser-trust fence as every other /sidebar route;
|
||||
* only allowlisted chunk names are servable (no path traversal).
|
||||
*/
|
||||
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'
|
||||
|
||||
/** 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 type ChunkName = (typeof CHUNK_NAMES)[number]
|
||||
|
||||
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
||||
const LIB_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
|
||||
function shortHash(input: string | Buffer): string {
|
||||
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
interface ChunkEtag {
|
||||
mtimeMs: number
|
||||
size: number
|
||||
etag: string
|
||||
}
|
||||
|
||||
/** ETag memo: recompute the content hash only when the file's stat changed. */
|
||||
const etags = new Map<string, ChunkEtag>()
|
||||
|
||||
/**
|
||||
* The chunk file's ETag (quoted hash), or undefined when the file is
|
||||
* missing. Hash is recomputed only when mtime/size changed (hashing a
|
||||
* multi-MB chunk per request is wasteful).
|
||||
*/
|
||||
async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
|
||||
const path = join(chunkDir, `client-${name}.js`)
|
||||
const key = `${chunkDir}:${name}`
|
||||
try {
|
||||
const info = await stat(path)
|
||||
const memo = etags.get(key)
|
||||
if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
|
||||
return memo.etag
|
||||
}
|
||||
const etag = `"${shortHash(await readFile(path))}"`
|
||||
etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
|
||||
return etag
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
|
||||
* trust check every /sidebar route applies; `chunkDir` is the directory the
|
||||
* chunk scripts live in (overridable for tests).
|
||||
*/
|
||||
export function createBundleRouteHandler(
|
||||
fence: (req: IncomingMessage) => boolean,
|
||||
chunkDir: string = LIB_DIR,
|
||||
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
return async (req, res): Promise<void> => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
|
||||
const name = match?.[1] as ChunkName | undefined
|
||||
if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
const etag = await etagOf(name, chunkDir)
|
||||
if (etag === undefined) {
|
||||
// Registered name but unreadable (bundle not built yet): loud 404.
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
// Revalidation hit: unchanged chunk, no body — avoids re-downloading
|
||||
// multi-MB scripts on page refresh / HMR re-activation.
|
||||
res.writeHead(304, { 'cache-control': 'no-cache', etag })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(join(chunkDir, `client-${name}.js`))
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/javascript; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
etag,
|
||||
})
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Read raced a delete/rebuild between the stat and the read.
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
||||
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
|
||||
return ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/bundle',
|
||||
handler: createBundleRouteHandler(fence),
|
||||
})
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* The built-in browser tab: an address bar plus a sandboxed iframe.
|
||||
*
|
||||
* Security model (see browser.ts + the sandbox tokens below): the iframe is
|
||||
* ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited
|
||||
* page can never sit on the GUI's origin, read its storage, or reach
|
||||
* /sidebar/api) and without `allow-top-navigation` (a page must not hijack
|
||||
* the GUI). The address bar only accepts http(s) and refuses loopback /
|
||||
* the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the
|
||||
* sandbox attribute entirely for fully trusted sites — the visited page then
|
||||
* runs with the GUI's own origin and full session access, so a persistent
|
||||
* warning bar renders while it is off.
|
||||
*
|
||||
* The URL is persisted onto the tab (path/title via the patchTab reducer)
|
||||
* so a reload restores the visited page; the back/forward stack only tracks
|
||||
* address-bar navigations (in-frame link clicks are cross-origin and
|
||||
* invisible — a documented limitation).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
IconChevronLeftOutline14,
|
||||
IconChevronRightOutline14,
|
||||
IconLinkOutline14,
|
||||
IconRefreshOutline14,
|
||||
IconRightUpOutline16,
|
||||
IconWarningOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
|
||||
import { patchTab } from './state.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import type { TabComponentProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/**
|
||||
* The browser iframe sandbox tokens. NO allow-same-origin (opaque origin —
|
||||
* no GUI storage/API access), NO allow-top-navigation (a browsed page must
|
||||
* not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals
|
||||
* keep login flows working; allow-popups-to-escape-sandbox lets OAuth
|
||||
* popups open as normal tabs (they are cross-origin to the GUI either way).
|
||||
*/
|
||||
export const BROWSER_IFRAME_SANDBOX =
|
||||
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
|
||||
|
||||
export function BrowserView(props: TabComponentProps) {
|
||||
const { store, tab } = props
|
||||
// The current address (initialized from the persisted tab.path so a
|
||||
// reload restores the visited page).
|
||||
const [url, setUrl] = useState<string | undefined>(tab.path)
|
||||
const [input, setInput] = useState<string>(tab.path ?? '')
|
||||
/** Blocked/invalid hint shown under the address bar (null = none). */
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
/** Address-bar navigation history (in-frame clicks are not tracked). */
|
||||
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
|
||||
const [cursor, setCursor] = useState<number>(tab.path !== undefined ? 0 : -1)
|
||||
/** Bumped on reload to remount the iframe (also remounts on sandbox flip). */
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
|
||||
* side card setting; lasts until the tab unmounts or the user restores). */
|
||||
const [localUnlock, setLocalUnlock] = useState(false)
|
||||
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
|
||||
/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
|
||||
* the probe verdict shown instead of the blank iframe. */
|
||||
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
|
||||
/** The user asked to load the refused site anyway (keeps the plain iframe). */
|
||||
const [forceEmbed, setForceEmbed] = useState(false)
|
||||
|
||||
// Probe every navigation (address bar, history, restored path): when the
|
||||
// target forbids embedding, show the reason + open-in-browser instead of
|
||||
// the browser's cryptic "refused to connect" blank frame. A failed probe
|
||||
// (unreachable) keeps the plain iframe.
|
||||
useEffect(() => {
|
||||
if (url === undefined) return
|
||||
let cancelled = false
|
||||
setEmbedBlocked(null)
|
||||
setForceEmbed(false)
|
||||
void api.browserProbe(url).then((probe) => {
|
||||
if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url)
|
||||
}).catch(() => { /* unreachable: keep the plain iframe */ })
|
||||
return () => { cancelled = true }
|
||||
}, [url])
|
||||
|
||||
const persist = (nextUrl: string): void => {
|
||||
let host = nextUrl
|
||||
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
|
||||
store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host }))
|
||||
}
|
||||
|
||||
const navigateTo = (raw: string): void => {
|
||||
const result = normalizeBrowserUrl(raw, window.location.origin)
|
||||
if (result.kind === 'ok') {
|
||||
const next = result.url
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setMessage(null)
|
||||
// Push onto the stack, dropping any stale forward entries.
|
||||
setHistory(previous => [...previous.slice(0, cursor + 1), next])
|
||||
setCursor(previous => previous + 1)
|
||||
setReloadKey(key => key + 1)
|
||||
persist(next)
|
||||
return
|
||||
}
|
||||
setMessage(result.kind === 'invalid'
|
||||
? t('browserInvalid')
|
||||
: result.reason === 'scheme' ? t('browserBlockedScheme')
|
||||
: t('browserBlockedLoopback'))
|
||||
}
|
||||
|
||||
const goBack = (): void => {
|
||||
if (cursor <= 0) return
|
||||
const next = history[cursor - 1]!
|
||||
setCursor(cursor - 1)
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setReloadKey(key => key + 1)
|
||||
}
|
||||
|
||||
const goForward = (): void => {
|
||||
if (cursor >= history.length - 1) return
|
||||
const next = history[cursor + 1]!
|
||||
setCursor(cursor + 1)
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setReloadKey(key => key + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.browser}>
|
||||
<div className={css.browserBar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserBack')}
|
||||
title={t('browserBack')}
|
||||
disabled={cursor <= 0}
|
||||
onClick={goBack}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserForward')}
|
||||
title={t('browserForward')}
|
||||
disabled={cursor >= history.length - 1}
|
||||
onClick={goForward}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => { setReloadKey(key => key + 1) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
</button>
|
||||
<input
|
||||
className={css.browserInput}
|
||||
value={input}
|
||||
placeholder={t('browserPlaceholder')}
|
||||
spellCheck={false}
|
||||
onChange={event => { setInput(event.target.value) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') navigateTo(input)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserGo')}
|
||||
title={t('browserGo')}
|
||||
onClick={() => { navigateTo(input) }}
|
||||
>
|
||||
<IconLinkOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserOpenExternal')}
|
||||
title={t('browserOpenExternal')}
|
||||
disabled={url === undefined}
|
||||
onClick={() => {
|
||||
if (url !== undefined) window.open(url, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
<IconRightUpOutline16 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{message !== null && <div className={css.browserMessage}>{message}</div>}
|
||||
<SandboxStatusBar
|
||||
sandboxed={!noSandbox}
|
||||
local={localUnlock}
|
||||
dangerCopy={t('browserNoSandboxWarning')}
|
||||
onUnlock={() => { setLocalUnlock(true) }}
|
||||
onRestore={() => { setLocalUnlock(false) }}
|
||||
/>
|
||||
{url === undefined ? (
|
||||
<div className={css.browserStart}>{t('browserStart')}</div>
|
||||
) : embedBlocked !== null && !forceEmbed ? (
|
||||
<BrowserEmbedBlocked
|
||||
url={embedBlocked}
|
||||
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
|
||||
onLoadAnyway={() => { setForceEmbed(true) }}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
|
||||
className={css.browserFrame}
|
||||
src={url}
|
||||
sandbox={noSandbox ? undefined : BROWSER_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The embed-refusal panel: shown when the probed site forbids being
|
||||
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
|
||||
* iframe would only show the browser's "refused to connect" blank. Explains
|
||||
* the reason and offers the real-browser open plus a load-anyway escape.
|
||||
* Exported so the copy and the actions are testable without a DOM.
|
||||
*/
|
||||
export function BrowserEmbedBlocked(props: {
|
||||
url: string
|
||||
onOpenInBrowser: () => void
|
||||
onLoadAnyway: () => void
|
||||
}) {
|
||||
const { url, onOpenInBrowser, onLoadAnyway } = props
|
||||
let host = url
|
||||
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
|
||||
return (
|
||||
<div className={css.browserBlocked}>
|
||||
<IconWarningOutline16 size={16} />
|
||||
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
|
||||
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
|
||||
<div className={css.browserBlockedActions}>
|
||||
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
|
||||
{t('browserOpenExternal')}
|
||||
</button>
|
||||
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
|
||||
{t('browserEmbedAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* The diff tab: one change opened from the git panel, like VSCode's diff
|
||||
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
|
||||
* not; untracked files — which git diff never covers — render as a full-file
|
||||
* addition from their content), a commit ref loads the commit's full patch
|
||||
* (`git.show`-style). The header carries a refresh button because the tab
|
||||
* stays mounted while the git panel's staging/discard operations change the
|
||||
* very content it shows.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import type { SidebarDiffRef } from './state.ts'
|
||||
import { DiffView } from './DiffView.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** The loaded diff surface (untracked content rendered as a full addition). */
|
||||
interface DiffData {
|
||||
diff: string
|
||||
untracked?: string
|
||||
}
|
||||
|
||||
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
|
||||
const { sessionId, cwd, diff } = props
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [data, setData] = useState<DiffData | null>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const scope: SessionScope = { sessionId, cwd }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setData(null)
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
if (diff.kind === 'commit') {
|
||||
const result = await api.gitCommitDiff(scope, diff.hashFull)
|
||||
if (!cancelled) setData({ diff: result.diff })
|
||||
return
|
||||
}
|
||||
let result = await api.gitDiff(scope, diff.path, diff.staged)
|
||||
if (result.diff === '') {
|
||||
// The requested side is empty — try the OTHER side once: the ref
|
||||
// may predate the staged-flag fix, or the change moved sides (a
|
||||
// file staged after its tab opened). Both sides empty means the
|
||||
// file genuinely has no text changes.
|
||||
const other = await api.gitDiff(scope, diff.path, !diff.staged)
|
||||
if (other.diff !== '') result = other
|
||||
}
|
||||
if (result.diff !== '') {
|
||||
if (!cancelled) setData({ diff: result.diff })
|
||||
return
|
||||
}
|
||||
// Empty diff: an untracked file (git diff never lists it) falls back
|
||||
// to a full-file addition; anything else is a genuine no-text-change.
|
||||
if (diff.untracked === true && !diff.staged) {
|
||||
const text = await api.fsRead(scope, diff.path)
|
||||
if (!cancelled) {
|
||||
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!cancelled) setData({ diff: '' })
|
||||
} catch (reason) {
|
||||
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, cwd, diff, tick])
|
||||
|
||||
return (
|
||||
<div className={css.gitDiffTab}>
|
||||
<div className={css.gitDiffTabHeader}>
|
||||
<span className={css.gitDiffTabTitle} title={diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}>
|
||||
{diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={refresh}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
||||
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
|
||||
{!loading && error === null && data !== null && (
|
||||
<>
|
||||
{data.untracked !== undefined
|
||||
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
|
||||
: <DiffView diff={data.diff} />}
|
||||
{data.diff === '' && data.untracked === undefined && (
|
||||
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* The real diff surface for the git panel: parses the host's unified diff
|
||||
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
|
||||
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
|
||||
* gutters, and aligned context / deleted / added rows colored through the
|
||||
* DSH tokens. Untracked files produce no `git diff` output, so the caller
|
||||
* can pass the file content to render as a full-file addition instead.
|
||||
*
|
||||
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
|
||||
* cases are unit-tested without a DOM.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** One rendered diff line. */
|
||||
export interface DiffLine {
|
||||
kind: 'ctx' | 'del' | 'add' | 'meta'
|
||||
/** The line content without its diff marker ('' for the no-newline marker). */
|
||||
text: string
|
||||
/** Old-side line number (null for pure additions / metadata). */
|
||||
oldNum: number | null
|
||||
/** New-side line number (null for pure deletions / metadata). */
|
||||
newNum: number | null
|
||||
}
|
||||
|
||||
/** One parsed hunk. */
|
||||
export interface DiffHunk {
|
||||
/** The old-side start line (`-a[,b]`). */
|
||||
oldStart: number
|
||||
/** The new-side start line (`+c[,d]`). */
|
||||
newStart: number
|
||||
/** The section text after the trailing `@@` (may be empty). */
|
||||
header: string
|
||||
lines: DiffLine[]
|
||||
}
|
||||
|
||||
/** One parsed file section of a unified diff. */
|
||||
export interface DiffFile {
|
||||
/** The `---` path verbatim ('/dev/null' for a new file). */
|
||||
oldPath: string
|
||||
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
|
||||
newPath: string
|
||||
/** The file changed with binary content: no hunks to draw. */
|
||||
binary: boolean
|
||||
hunks: DiffHunk[]
|
||||
}
|
||||
|
||||
/** The parsed unified diff. */
|
||||
export interface ParsedDiff {
|
||||
files: DiffFile[]
|
||||
}
|
||||
|
||||
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
|
||||
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
|
||||
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
|
||||
if (match === null) return null
|
||||
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff --no-color` output into file sections and hunks. Rows
|
||||
* outside a file section (leading noise) and metadata rows between the
|
||||
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
|
||||
* changes, rename/similarity lines) are skipped; a section that never
|
||||
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
|
||||
* can still draw its path.
|
||||
*/
|
||||
export function parseUnifiedDiff(text: string): ParsedDiff {
|
||||
const files: DiffFile[] = []
|
||||
let current: DiffFile | null = null
|
||||
let inHunk = false
|
||||
let hunk: DiffHunk | null = null
|
||||
let oldNum = 0
|
||||
let newNum = 0
|
||||
const flushHunk = (): void => {
|
||||
if (current !== null && hunk !== null) current.hunks.push(hunk)
|
||||
hunk = null
|
||||
inHunk = false
|
||||
}
|
||||
for (const raw of text.split('\n')) {
|
||||
if (raw.startsWith('diff --git ')) {
|
||||
flushHunk()
|
||||
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
|
||||
files.push(current)
|
||||
continue
|
||||
}
|
||||
if (current === null) continue
|
||||
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
|
||||
flushHunk()
|
||||
current.binary = true
|
||||
continue
|
||||
}
|
||||
if (raw.startsWith('--- ')) {
|
||||
flushHunk()
|
||||
current.oldPath = raw.slice(4)
|
||||
continue
|
||||
}
|
||||
if (raw.startsWith('+++ ')) {
|
||||
current.newPath = raw.slice(4)
|
||||
continue
|
||||
}
|
||||
const header = parseHunkHeader(raw)
|
||||
if (header !== null) {
|
||||
flushHunk()
|
||||
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
|
||||
oldNum = header.oldStart
|
||||
newNum = header.newStart
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if (!inHunk || hunk === null) continue
|
||||
const marker = raw[0]
|
||||
if (marker === '\\') {
|
||||
// `\ No newline at end of file`: metadata attached to the previous row.
|
||||
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
|
||||
continue
|
||||
}
|
||||
if (marker === ' ') {
|
||||
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
|
||||
oldNum += 1
|
||||
newNum += 1
|
||||
} else if (marker === '-') {
|
||||
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
|
||||
oldNum += 1
|
||||
} else if (marker === '+') {
|
||||
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
|
||||
newNum += 1
|
||||
} else {
|
||||
// Not a diff line (a hunk can never contain one): stop the hunk.
|
||||
flushHunk()
|
||||
}
|
||||
}
|
||||
flushHunk()
|
||||
return { files }
|
||||
}
|
||||
|
||||
/** Build the untracked-file shape: one file, one hunk of pure additions. */
|
||||
function untrackedFile(path: string, content: string): DiffFile {
|
||||
const lines: DiffLine[] = []
|
||||
const body = content.endsWith('\n') ? content.slice(0, -1) : content
|
||||
if (body !== '') {
|
||||
let num = 1
|
||||
for (const line of body.split('\n')) {
|
||||
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
|
||||
num += 1
|
||||
}
|
||||
}
|
||||
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
|
||||
}
|
||||
|
||||
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
|
||||
function displayPath(path: string): string {
|
||||
if (path === '/dev/null') return path
|
||||
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
|
||||
return path
|
||||
}
|
||||
|
||||
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
|
||||
function fileTag(file: DiffFile): string | null {
|
||||
if (file.binary) return t('diffBinary')
|
||||
if (file.oldPath === '/dev/null') return t('diffAdded')
|
||||
if (file.newPath === '/dev/null') return t('diffDeleted')
|
||||
const oldPath = displayPath(file.oldPath)
|
||||
const newPath = displayPath(file.newPath)
|
||||
if (oldPath !== newPath) return t('diffRenamed')
|
||||
return null
|
||||
}
|
||||
|
||||
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
|
||||
const MAX_DIFF_ROWS = 500
|
||||
|
||||
export interface DiffViewProps {
|
||||
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
|
||||
diff: string
|
||||
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
||||
untrackedPath?: string
|
||||
untrackedContent?: string
|
||||
}
|
||||
|
||||
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
|
||||
const parsed = useMemo<ParsedDiff>(() => {
|
||||
if (untrackedPath !== undefined) {
|
||||
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
||||
}
|
||||
return parseUnifiedDiff(diff)
|
||||
}, [diff, untrackedPath, untrackedContent])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Flatten into display rows so the cap can slice a single list.
|
||||
const rows = useMemo(() => {
|
||||
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
||||
parsed.files.forEach((file, fileIndex) => {
|
||||
out.push({ key: `f${fileIndex}`, file, type: 'path' })
|
||||
if (file.binary) return
|
||||
file.hunks.forEach((hunk, hunkIndex) => {
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
|
||||
hunk.lines.forEach((line, lineIndex) => {
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
|
||||
})
|
||||
})
|
||||
})
|
||||
return out
|
||||
}, [parsed])
|
||||
|
||||
const hidden = rows.length - MAX_DIFF_ROWS
|
||||
const capped = hidden > 0 && !expanded
|
||||
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
|
||||
const tailLines = MAX_DIFF_ROWS - headLines
|
||||
const head = capped ? rows.slice(0, headLines) : rows
|
||||
const tail = capped ? rows.slice(rows.length - tailLines) : []
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const renderRow = (row: (typeof rows)[number]): ReactNode => {
|
||||
if (row.type === 'path') {
|
||||
const tag = fileTag(row.file)
|
||||
const from = displayPath(row.file.oldPath)
|
||||
const to = displayPath(row.file.newPath)
|
||||
return (
|
||||
<div key={row.key} className={css.gitDiffFile}>
|
||||
<span className={css.gitDiffFilePath}>{to}</span>
|
||||
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
|
||||
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.type === 'hunk') {
|
||||
const hunk = row.hunk!
|
||||
return (
|
||||
<div key={row.key} className={css.gitDiffHunk}>
|
||||
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
|
||||
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const line = row.line!
|
||||
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
|
||||
return (
|
||||
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
|
||||
{line.kind === 'meta'
|
||||
? <span className={css.gitDiffMetaText}>{line.text}</span>
|
||||
: (
|
||||
<>
|
||||
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
|
||||
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
|
||||
<span className={css.gitDiffCode}>{line.text}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.gitDiff}>
|
||||
{head.map(renderRow)}
|
||||
{hidden > 0 && (
|
||||
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
|
||||
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
|
||||
</button>
|
||||
)}
|
||||
{tail.map(renderRow)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* The editor tab host: resolves a file's previewer through the sidebar
|
||||
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
|
||||
* fetch strategy, and renders its component — or the shared download pane
|
||||
* when nothing can render the file. The header shows the file title; the
|
||||
* editable code/markdown viewers render their own toolbar below it.
|
||||
*
|
||||
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
||||
* editor-load.ts); this component only wires it to the host APIs.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { BinaryDownload } from './binary-download.tsx'
|
||||
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
| { status: 'binary' }
|
||||
|
||||
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
|
||||
const { ctx, store, scope, path, title } = props
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoad({ status: 'loading' })
|
||||
const mediaUrlOf = (): string => mediaUrl(scope, path)
|
||||
const apply = (action: EditorLoadAction): void => {
|
||||
if (cancelled) return
|
||||
switch (action.kind) {
|
||||
case 'binary':
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
case 'render':
|
||||
setLoad({
|
||||
status: 'ready',
|
||||
viewer: action.viewer,
|
||||
content: action.content,
|
||||
truncated: action.truncated,
|
||||
mediaUrl: action.mediaUrl,
|
||||
customData: action.customData,
|
||||
})
|
||||
return
|
||||
case 'customLoad':
|
||||
void action.viewer.load?.(path, scope).then((data) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
case 'fetchFsRead':
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
// Binary reads carry the head bytes for the detect re-match.
|
||||
const outcome = planFsReadOutcome(action.viewer, {
|
||||
binary: result.kind === 'binary',
|
||||
content: result.kind === 'text' ? result.content : '',
|
||||
truncated: result.truncated,
|
||||
head: result.kind === 'binary' ? result.head : undefined,
|
||||
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
|
||||
apply(outcome)
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true }
|
||||
}, [scope.sessionId, scope.cwd, path, ctx])
|
||||
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{load.status === 'ready' && createElement(load.viewer.component, {
|
||||
ctx, store, scope, path, title,
|
||||
viewerId: load.viewer.id,
|
||||
content: load.content,
|
||||
truncated: load.truncated,
|
||||
mediaUrl: load.mediaUrl,
|
||||
customData: load.customData,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* The file explorer: a lazy VSCode-style tree rooted at the session's
|
||||
* working directory. Levels load on expansion (one API call per directory),
|
||||
* directories sort first, hidden entries render dimmed, and the expansion
|
||||
* set lives in the per-session state. Clicking a file opens an editor tab.
|
||||
*
|
||||
* Row actions: hovering a row reveals an @-reference button on the far
|
||||
* right (appends `@<relative path>` to the composer draft), and right-click
|
||||
* opens a context menu to copy the relative or absolute path (with a brief
|
||||
* "copied" label replacing the button after a successful write); file rows
|
||||
* also offer a download action (the host serves raw bytes, binary-safe).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconRefreshOutline16, Menu, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LevelData {
|
||||
entries?: FsEntry[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Root label: the last path segment (mirror of the host rootLabel). */
|
||||
function baseName(path: string): string {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||
}
|
||||
|
||||
/** How long the row's "copied" label stays after a successful write. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
export function ExplorerView(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft. */
|
||||
onReferenceFile: (path: string) => void
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
|
||||
const storeLevel = useCallback((path: string, level: LevelData) => {
|
||||
dataRef.current = { ...dataRef.current, [path]: level }
|
||||
setData(dataRef.current)
|
||||
}, [])
|
||||
|
||||
const loadDir = useCallback((dir: string) => {
|
||||
if (dataRef.current[dir] !== undefined) return
|
||||
storeLevel(dir, {})
|
||||
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
|
||||
storeLevel(dir, { entries: listing.entries })
|
||||
}).catch((error: unknown) => {
|
||||
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
}, [sessionId, cwd, storeLevel])
|
||||
|
||||
useEffect(() => {
|
||||
// Load the visible set; already-loaded levels (kept in the cache) are
|
||||
// not refetched. Only the refresh button wipes the cache.
|
||||
const root = cwd
|
||||
if (root === undefined) return
|
||||
loadDir(root)
|
||||
for (const dir of expanded) loadDir(dir)
|
||||
}, [cwd, expanded, refreshTick, loadDir])
|
||||
|
||||
/** Copy `text`; on success flip the row's copied label for a moment. */
|
||||
const copyPath = useCallback((text: string, path: string): void => {
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopiedPath(path)
|
||||
window.setTimeout(() => {
|
||||
setCopiedPath(current => current === path ? null : current)
|
||||
}, COPIED_MS)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** The row's trailing actions: the @-reference button, or the copied label. */
|
||||
const rowActions = (entry: FsEntry): ReactNode => {
|
||||
if (copiedPath === entry.path) {
|
||||
return <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(entry.path)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
/** Download a file through the host route (raw bytes, binary-safe). */
|
||||
const downloadFile = (path: string): void => {
|
||||
const url = downloadUrl({ sessionId, cwd }, path)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
const root = cwd
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
const level = data[dir]
|
||||
if (level === undefined) {
|
||||
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
|
||||
}
|
||||
if (level.error !== undefined) {
|
||||
return (
|
||||
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
|
||||
{level.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const entries = level.entries ?? []
|
||||
return entries.map(entry => {
|
||||
if (entry.isDir) {
|
||||
const isOpen = expanded.includes(entry.path)
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
onClick={() => { onToggle(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onToggle(entry.path)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
{isOpen && renderLevel(entry.path, depth + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
title={entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onOpenFile(entry.path)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.explorer}>
|
||||
<div className={css.explorerHeader}>
|
||||
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => {
|
||||
dataRef.current = {}
|
||||
setData({})
|
||||
setRefreshTick(tick => tick + 1)
|
||||
}}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.explorerBody}>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(root)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{data[root] !== undefined && renderLevel(root, 1)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
The one shared context menu, positioned at the right-click cursor
|
||||
(portal so the explorer's overflow clip cannot crop it).
|
||||
*/}
|
||||
<Menu
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
items={[
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
if (target === null) return
|
||||
setRowMenu(null)
|
||||
if (id === 'download') {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
}
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
)
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
/**
|
||||
* The source-control panel: status list (staged vs unstaged), stage/unstage,
|
||||
* commit with a message box, branch switch, and a VSCode-like history — rows
|
||||
* carry branch decorations, author and relative time. Clicking a changed
|
||||
* file or a history row opens a dedicated diff TAB (see {@link DiffTab}),
|
||||
* placed below the git pane on first use. File rows and history rows open a
|
||||
* right-click context menu with advanced operations (open in editor, discard,
|
||||
* revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
|
||||
* focus (no file watcher — KISS).
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
|
||||
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { relativeTime, t } from './locales.ts'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** The XY status letters a row badge shows (X = index, Y = worktree). */
|
||||
function badgeOf(entry: GitStatusEntry): string {
|
||||
const index = entry.xy[0]
|
||||
const worktree = entry.xy[1]
|
||||
if (index !== undefined && index !== ' ' && index !== '?') return index
|
||||
if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
|
||||
return '?'
|
||||
}
|
||||
|
||||
/** Whether the entry carries STAGED (index) changes — the X letter is set. */
|
||||
function isStagedEntry(entry: GitStatusEntry): boolean {
|
||||
const index = entry.xy[0]
|
||||
return index !== undefined && index !== ' ' && index !== '?'
|
||||
}
|
||||
|
||||
/** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
|
||||
* (untracked `??` counts as unstaged: it is a worktree-only change). A file
|
||||
* with both letters set ('MM') lands in BOTH sections. */
|
||||
function isUnstagedEntry(entry: GitStatusEntry): boolean {
|
||||
if (entry.xy === '??') return true
|
||||
const worktree = entry.xy[1]
|
||||
return worktree !== undefined && worktree !== ' ' && worktree !== '?'
|
||||
}
|
||||
|
||||
/** Whether the entry is untracked (`??`): git diff never includes it. */
|
||||
function isUntracked(entry: GitStatusEntry): boolean {
|
||||
return badgeOf(entry) === '?'
|
||||
}
|
||||
|
||||
/** The last path segment (tab title for a file's diff). */
|
||||
function baseName(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return at === -1 ? path : path.slice(at + 1)
|
||||
}
|
||||
|
||||
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
|
||||
function refNames(refs: string): string[] {
|
||||
return [...new Set(
|
||||
refs
|
||||
.split(',')
|
||||
.map(ref => ref.trim())
|
||||
.filter(ref => ref !== '')
|
||||
.map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
|
||||
.map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
|
||||
)]
|
||||
}
|
||||
|
||||
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
|
||||
interface ConfirmState {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => Promise<unknown>
|
||||
}
|
||||
|
||||
/** History batch size: the log loads lazily in pages so a long history never
|
||||
* floods the panel at once (the end of the log is reached by paging). */
|
||||
const LOG_BATCH = 20
|
||||
|
||||
export function GitView(props: {
|
||||
scope: SessionScope
|
||||
onOpenFile: (path: string) => void
|
||||
/** Open a diff tab (the shell places it below the git pane on first use). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
}) {
|
||||
const { scope, onOpenFile, onOpenDiff } = props
|
||||
const [status, setStatus] = useState<GitStatusResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [branchNames, setBranchNames] = useState<string[]>([])
|
||||
const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
|
||||
const [commitMsg, setCommitMsg] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [commitError, setCommitError] = useState<string | null>(null)
|
||||
/** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
|
||||
const [logEnded, setLogEnded] = useState(false)
|
||||
const [logLoadingMore, setLogLoadingMore] = useState(false)
|
||||
|
||||
/** The open file-row context menu (cursor position for the portaled Menu). */
|
||||
const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
|
||||
/** The open history-row context menu. */
|
||||
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
|
||||
/** The pending destructive action awaiting confirmation. */
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [statusResult, branchResult, logResult] = await Promise.all([
|
||||
api.gitStatus(scope),
|
||||
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
|
||||
// The first history page only; the rest arrives via "load more".
|
||||
api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
|
||||
])
|
||||
setStatus(statusResult)
|
||||
setBranchNames(branchResult.names)
|
||||
setLogEntries(logResult)
|
||||
setLogEnded(logResult.length < LOG_BATCH)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
|
||||
/** Append the next history page (lazy: only when the user asks for more). */
|
||||
const loadMoreLog = async (): Promise<void> => {
|
||||
if (logLoadingMore || logEnded) return
|
||||
setLogLoadingMore(true)
|
||||
try {
|
||||
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
|
||||
setLogEntries(entries => [...entries, ...next])
|
||||
if (next.length < LOG_BATCH) setLogEnded(true)
|
||||
} catch (reason) {
|
||||
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
} finally {
|
||||
setLogLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
|
||||
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
|
||||
onOpenDiff({
|
||||
id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
|
||||
type: 'diff',
|
||||
title: baseName(entry.path),
|
||||
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
|
||||
})
|
||||
}
|
||||
|
||||
/** The diff tab for one commit (one tab per commit). */
|
||||
const openCommitDiff = (entry: GitLogEntry): void => {
|
||||
onOpenDiff({
|
||||
id: `diff:c:${entry.hashFull}`,
|
||||
type: 'diff',
|
||||
title: `${entry.hash} ${entry.subject}`,
|
||||
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
|
||||
})
|
||||
}
|
||||
|
||||
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (staged) await api.gitUnstage(scope, entry.path)
|
||||
else await api.gitStage(scope, entry.path)
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stageAll = async (staged: boolean): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (staged) await api.gitUnstage(scope)
|
||||
else await api.gitStage(scope)
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const commit = async (): Promise<void> => {
|
||||
const message = commitMsg.trim()
|
||||
if (message === '' || busy) return
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await api.gitCommit(scope, message)
|
||||
setCommitMsg('')
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkout = async (branch: string): Promise<void> => {
|
||||
if (branch === status?.branch || busy) return
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await api.gitCheckout(scope, branch)
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one destructive operation after the confirm modal, then refresh. */
|
||||
const runConfirmed = (confirmState: ConfirmState): void => {
|
||||
setConfirm({ ...confirmState, onConfirm: async () => {
|
||||
setBusy(true)
|
||||
setCommitError(null)
|
||||
try {
|
||||
await confirmState.onConfirm()
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
setCommitError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
} })
|
||||
}
|
||||
|
||||
/** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
|
||||
const copy = (text: string): void => {
|
||||
void writeClipboard(text)
|
||||
}
|
||||
|
||||
const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
|
||||
const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
|
||||
|
||||
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
|
||||
return (
|
||||
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.gitRowMain}
|
||||
title={entry.path}
|
||||
onClick={() => { openWorktreeDiff(entry, staged) }}
|
||||
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
|
||||
>
|
||||
<span className={css.gitBadge}>{badgeOf(entry)}</span>
|
||||
<span className={css.gitName}>{entry.path}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={staged ? t('unstage') : t('stage')}
|
||||
title={staged ? t('unstage') : t('stage')}
|
||||
disabled={busy}
|
||||
onClick={() => { void stageEntry(entry, staged) }}
|
||||
>
|
||||
{staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.git}>
|
||||
<div className={css.gitHeader}>
|
||||
<select
|
||||
className={css.gitBranchSelect}
|
||||
value={status?.branch ?? ''}
|
||||
onChange={(event) => { void checkout(event.target.value) }}
|
||||
disabled={busy || (status !== null && !status.isRepo)}
|
||||
>
|
||||
{(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
|
||||
{branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => { void refresh() }}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
||||
{!loading && error !== null && <div className={css.gitError}>{error}</div>}
|
||||
{!loading && status !== null && !status.isRepo && (
|
||||
<div className={css.gitPlaceholder}>{t('notRepo')}</div>
|
||||
)}
|
||||
|
||||
{status !== null && status.isRepo && (
|
||||
<>
|
||||
<div className={css.gitSection}>
|
||||
<div className={css.gitSectionHeader}>
|
||||
<span>{t('staged')} ({stagedEntries.length})</span>
|
||||
{stagedEntries.length > 0 && (
|
||||
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
|
||||
{t('unstageAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
|
||||
{stagedEntries.map(entry => renderEntry(entry, true))}
|
||||
</div>
|
||||
<div className={css.gitSection}>
|
||||
<div className={css.gitSectionHeader}>
|
||||
<span>{t('unstaged')} ({unstagedEntries.length})</span>
|
||||
{unstagedEntries.length > 0 && (
|
||||
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
|
||||
{t('stageAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
|
||||
{unstagedEntries.map(entry => renderEntry(entry, false))}
|
||||
</div>
|
||||
|
||||
<div className={css.gitCommit}>
|
||||
<Input
|
||||
className={css.gitCommitInput}
|
||||
placeholder={t('commitPlaceholder')}
|
||||
value={commitMsg}
|
||||
disabled={busy}
|
||||
onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.gitCommitButton}
|
||||
disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
|
||||
onClick={() => { void commit() }}
|
||||
>
|
||||
{t('commit')}
|
||||
</button>
|
||||
</div>
|
||||
{commitError !== null && <div className={css.gitError}>{commitError}</div>}
|
||||
|
||||
<div className={css.gitSection}>
|
||||
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
|
||||
{logEntries.map(entry => (
|
||||
<div
|
||||
key={entry.hashFull}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={css.gitLogRow}
|
||||
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
|
||||
onClick={() => { openCommitDiff(entry) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openCommitDiff(entry)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
|
||||
>
|
||||
<span className={css.gitLogLine1}>
|
||||
<span className={css.gitLogHash}>{entry.hash}</span>
|
||||
<span className={css.gitLogSubject}>{entry.subject}</span>
|
||||
</span>
|
||||
<span className={css.gitLogLine2}>
|
||||
{refNames(entry.refs).map(ref => (
|
||||
<span key={ref} className={css.gitLogRef}>{ref}</span>
|
||||
))}
|
||||
<span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!logEnded && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.gitLogMore}
|
||||
disabled={logLoadingMore || busy}
|
||||
onClick={() => { void loadMoreLog() }}
|
||||
>
|
||||
{logLoadingMore ? t('loading') : t('loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The one shared file-row context menu, positioned at the right-click
|
||||
cursor (portal so the panel's overflow clip cannot crop it).
|
||||
*/}
|
||||
<Menu
|
||||
open={fileMenu !== null}
|
||||
onClose={() => { setFileMenu(null) }}
|
||||
items={[
|
||||
{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> },
|
||||
fileMenu?.staged === true
|
||||
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
|
||||
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
|
||||
...(fileMenu !== null && !isUntracked(fileMenu.entry)
|
||||
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
|
||||
: []),
|
||||
{ type: 'separator', id: 'sep1' },
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = fileMenu
|
||||
if (target === null) return
|
||||
setFileMenu(null)
|
||||
if (id === 'open') {
|
||||
onOpenFile(target.entry.path)
|
||||
return
|
||||
}
|
||||
if (id === 'stage') {
|
||||
void stageEntry(target.entry, target.staged)
|
||||
return
|
||||
}
|
||||
if (id === 'discard') {
|
||||
runConfirmed({
|
||||
title: t('discardTitle'),
|
||||
description: t('discardDesc', { path: target.entry.path }),
|
||||
confirmLabel: t('discard'),
|
||||
onConfirm: () => api.gitDiscard(scope, target.entry.path),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (id === 'relative') {
|
||||
copy(relativeTo(scope.cwd ?? '', target.entry.path))
|
||||
return
|
||||
}
|
||||
if (id === 'absolute') copy(target.entry.path)
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
|
||||
{/* The shared history-row context menu. */}
|
||||
<Menu
|
||||
open={historyMenu !== null}
|
||||
onClose={() => { setHistoryMenu(null) }}
|
||||
items={[
|
||||
{ id: 'view', label: t('viewCommitDiff') },
|
||||
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ type: 'separator', id: 'sep2' },
|
||||
{ id: 'revert', label: t('revertCommit'), danger: true },
|
||||
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = historyMenu
|
||||
if (target === null) return
|
||||
setHistoryMenu(null)
|
||||
if (id === 'view') {
|
||||
openCommitDiff(target.entry)
|
||||
return
|
||||
}
|
||||
if (id === 'copyShort') {
|
||||
copy(target.entry.hash)
|
||||
return
|
||||
}
|
||||
if (id === 'copyFull') {
|
||||
copy(target.entry.hashFull)
|
||||
return
|
||||
}
|
||||
if (id === 'copySubject') {
|
||||
copy(target.entry.subject)
|
||||
return
|
||||
}
|
||||
if (id === 'revert') {
|
||||
runConfirmed({
|
||||
title: t('revertTitle'),
|
||||
description: t('revertDesc', { subject: target.entry.subject }),
|
||||
confirmLabel: t('revertCommit'),
|
||||
onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (id === 'cherryPick') {
|
||||
runConfirmed({
|
||||
title: t('cherryPickTitle'),
|
||||
description: t('cherryPickDesc', { subject: target.entry.subject }),
|
||||
confirmLabel: t('cherryPickCommit'),
|
||||
onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
|
||||
})
|
||||
}
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
|
||||
{/* Destructive actions land here first: Cancel / Confirm. */}
|
||||
<Modal
|
||||
open={confirm !== null}
|
||||
onClose={() => { setConfirm(null) }}
|
||||
title={confirm?.title ?? ''}
|
||||
closeLabel={t('cancel')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const pending = confirm
|
||||
if (pending === null) return
|
||||
setConfirm(null)
|
||||
void pending.onConfirm()
|
||||
}}
|
||||
>
|
||||
{confirm?.confirmLabel ?? ''}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className={css.gitConfirmDesc}>{confirm?.description}</p>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Placeholder shown when a persisted tab's type is not registered (the
|
||||
* contributing plugin is not installed / not yet loaded). The tab is kept
|
||||
* in state so it can recover if the plugin loads later; the user can close
|
||||
* it through the tab bar's X button as usual. This is the graceful-
|
||||
* degradation path for the open tab-type set: {@link Sidebar} renders this
|
||||
* instead of crashing on an unknown type.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import type { TabComponentProps } from './service.ts'
|
||||
|
||||
export function OrphanedTab(props: TabComponentProps): ReactNode {
|
||||
const { tab } = props
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={tab.type}>{tab.title}</span>
|
||||
</div>
|
||||
<div className={css.editorPlaceholder}>
|
||||
<span>{t('pluginNotLoaded')}</span>
|
||||
<code className={css.orphanedType}>{tab.type}</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/** Browser-native PDF preview with an always-available download fallback. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const [load, setLoad] = useState<
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; url: string }
|
||||
| { status: 'error'; message: string }
|
||||
>({ status: 'loading' })
|
||||
const [interactionBlocked, setInteractionBlocked] = useState(false)
|
||||
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||
const shieldRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let objectUrl: string | undefined
|
||||
setLoad({ status: 'loading' })
|
||||
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
|
||||
// A direct iframe navigation may download when an old host process or
|
||||
// proxy cached application/octet-stream. The Blob URL owns an explicit
|
||||
// PDF MIME and therefore consistently opens the browser PDF viewer.
|
||||
objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
||||
setLoad({ status: 'ready', url: objectUrl })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const block = (): void => {
|
||||
setInteractionBlocked(true)
|
||||
if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
|
||||
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
|
||||
}
|
||||
const unblock = (): void => {
|
||||
setInteractionBlocked(false)
|
||||
if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
|
||||
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
|
||||
}
|
||||
const blockForResize = (event: PointerEvent): void => {
|
||||
const target = event.target
|
||||
if (target instanceof Element
|
||||
&& target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
// An iframe is a separate browsing context and otherwise swallows the
|
||||
// dragover/pointer stream as soon as the cursor crosses it. Temporarily
|
||||
// remove it from hit-testing while a tab drag or pane resize is active.
|
||||
document.addEventListener('dragstart', block, true)
|
||||
document.addEventListener('dragend', unblock, true)
|
||||
document.addEventListener('drop', unblock, true)
|
||||
window.addEventListener('pointerdown', blockForResize, true)
|
||||
window.addEventListener('pointerup', unblock, true)
|
||||
window.addEventListener('pointercancel', unblock, true)
|
||||
window.addEventListener('blur', unblock)
|
||||
return () => {
|
||||
document.removeEventListener('dragstart', block, true)
|
||||
document.removeEventListener('dragend', unblock, true)
|
||||
document.removeEventListener('drop', unblock, true)
|
||||
window.removeEventListener('pointerdown', blockForResize, true)
|
||||
window.removeEventListener('pointerup', unblock, true)
|
||||
window.removeEventListener('pointercancel', unblock, true)
|
||||
window.removeEventListener('blur', unblock)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorPdf}>
|
||||
<div className={css.editorPdfToolbar}>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={css.editorPdfStage}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'ready' && (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
|
||||
src={load.url}
|
||||
title={title}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={shieldRef}
|
||||
className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* The live sandbox status row of the two built-in web surfaces (HTML
|
||||
* preview and the browser tab): a green "sandbox on" state with a one-tap
|
||||
* TEMPORARY unlock, or a RED "sandbox off" state (global setting or the
|
||||
* temporary unlock) with a restore action.
|
||||
*
|
||||
* The temporary unlock is component state only — it never writes the
|
||||
* global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`);
|
||||
* it lasts until the surface unmounts (tab switch / file switch) or the
|
||||
* user restores the sandbox from the row. When the global setting already
|
||||
* drops the sandbox, no unlock/restore action is offered (changing the
|
||||
* global setting is the settings page's job) — the red warning stands.
|
||||
*/
|
||||
import clsx from 'clsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function SandboxStatusBar(props: {
|
||||
/** The effective sandbox state (global pref OR the local temporary unlock). */
|
||||
sandboxed: boolean
|
||||
/** Whether the sandbox is off due to the LOCAL temporary unlock (shows the restore action). */
|
||||
local: boolean
|
||||
/** The red-state explanation (e.g. "the page runs with full GUI privileges"). */
|
||||
dangerCopy: string
|
||||
onUnlock: () => void
|
||||
onRestore: () => void
|
||||
}) {
|
||||
const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props
|
||||
if (sandboxed) {
|
||||
const copy = t('sandboxStatusOn')
|
||||
return (
|
||||
<div className={clsx(css.sandboxStatus, css.sandboxStatusOn)}>
|
||||
<span className={css.sandboxDot} />
|
||||
<span className={css.sandboxStatusText} title={copy}>{copy}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sandboxAction}
|
||||
onClick={onUnlock}
|
||||
>
|
||||
{t('sandboxUnlock')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={clsx(css.sandboxStatus, css.sandboxStatusOff)}>
|
||||
<span className={css.sandboxDot} />
|
||||
<span className={css.sandboxStatusText} title={dangerCopy}>{dangerCopy}</span>
|
||||
{local && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.sandboxAction}
|
||||
onClick={onRestore}
|
||||
>
|
||||
{t('sandboxRestore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
/**
|
||||
* Side card settings section chrome (DSH-native tokens throughout):
|
||||
* - 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;
|
||||
* - 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);
|
||||
* - the width input stays a native settings row (a value, not a switch).
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
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
|
||||
and scroll inside the section when the content outgrows the panel. */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
/* Hover affordance on disabled cards only (enabled cards are already lit). */
|
||||
.card:not(.cardOn):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Enabled = highlighted: brand border + tinted fill. */
|
||||
.cardOn {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* The card's switch: the whole main area (top row + desc) flips the feature. */
|
||||
.cardMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardMain:focus-visible,
|
||||
.cardGear:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cardOn .cardIcon {
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cardOn .cardTitle {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
|
||||
.cardCheck {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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. */
|
||||
.cardWithGear .cardDesc {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.cardGear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardGear:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
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). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
|
||||
.toggle {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.control {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.percentInput {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 0 2px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
/**
|
||||
* "Side card" settings section: the user-facing preferences for the sidebar
|
||||
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
||||
*
|
||||
* 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).
|
||||
* - 侧边栏内容: 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]`.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* 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
|
||||
* third-party namespaces to configuration clients); the shared SidebarStore
|
||||
* is refreshed on success so the very next brand-new session seeds from the
|
||||
* new values and the sidebar's consumption points (the + menu, derived
|
||||
* flows) re-render immediately. Any failure reverts the optimistic UI and
|
||||
* shows the wire error inline — a broken settings surface never crashes the
|
||||
* shell.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconCodeOutline16,
|
||||
IconPanelLeftOutline16,
|
||||
IconSettingsOutline16,
|
||||
Input,
|
||||
Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import clsx from 'clsx'
|
||||
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
FileViewerDescriptor,
|
||||
SidebarSettingToggle,
|
||||
TabDescriptor,
|
||||
} from './service.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
|
||||
export interface SideCardSectionInjected {
|
||||
store: SidebarStore
|
||||
service: BetterSidebarService
|
||||
}
|
||||
|
||||
/** Full section props: the runtime share plus the injected face. */
|
||||
export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
|
||||
|
||||
/** Map one wire failure to the inline message (the conflict gets friendly copy). */
|
||||
function messageOf(error: unknown): string {
|
||||
if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
|
||||
return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
|
||||
}
|
||||
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
/** Resolve an i18n-friendly string-or-function value. */
|
||||
function textOf(value: string | (() => string) | undefined): string {
|
||||
if (value === undefined) return ''
|
||||
return typeof value === 'function' ? value() : value
|
||||
}
|
||||
|
||||
/** Resolve a descriptor icon (ReactNode or size function). */
|
||||
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
|
||||
if (icon === undefined) return null
|
||||
return typeof icon === 'function' ? icon(size) : icon
|
||||
}
|
||||
|
||||
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
|
||||
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
||||
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
|
||||
return (a.order ?? 100) - (b.order ?? 100)
|
||||
}
|
||||
|
||||
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function FeatureSettingsRows(props: {
|
||||
toggles: readonly SidebarSettingToggle[]
|
||||
prefs: SidebarPrefs
|
||||
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
}) {
|
||||
const { toggles, prefs, onToggle } = props
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.map(toggle => {
|
||||
const title = textOf(toggle.title)
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store/service).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
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)
|
||||
|
||||
// The declarative inventory: the registered tab types and file viewers.
|
||||
// Local state + service.subscribe (registry changes are rare — plugin
|
||||
// load/unload — so a plain effect is enough; no external-store ceremony).
|
||||
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
|
||||
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
|
||||
useEffect(() => service.subscribe(() => {
|
||||
setTabs([...service.getTabs()].sort(tabOrder))
|
||||
setViewers([...service.getFileViewers()].sort(viewerOrder))
|
||||
}), [service])
|
||||
|
||||
// The settings document revision (guards concurrent writes). A ref: commits
|
||||
// read the freshest value at execution time, no re-render needed.
|
||||
const revisionRef = useRef<number | undefined>(undefined)
|
||||
// Whether the user already wrote since mount: the mount read must not
|
||||
// clobber a newer optimistic edit (the window is milliseconds, but a slow
|
||||
// route must never silently revert a just-made change).
|
||||
const dirtyRef = useRef(false)
|
||||
// Serialize commits: a queued write must observe the previous write's
|
||||
// revision; a failed write must not poison the queue for later ones.
|
||||
const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
|
||||
// Sync the persisted document once on mount: the revision and the current
|
||||
// values (another tab may have changed them since the store hydrated).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void api.settingsGet().then((view) => {
|
||||
if (cancelled) return
|
||||
revisionRef.current = view.revision
|
||||
if (dirtyRef.current) return
|
||||
const next = parsePrefs(view.value)
|
||||
setPrefs(next)
|
||||
setWidthDraft(String(next.defaultWidthPercent))
|
||||
}).catch(() => { /* the store's defaults stay authoritative */ })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
||||
const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
dirtyRef.current = true
|
||||
const run = inFlightRef.current.then(async () => {
|
||||
const view = await api.settingsUpdate(
|
||||
{ ...patch },
|
||||
revisionRef.current,
|
||||
)
|
||||
const next = parsePrefs(view.value)
|
||||
revisionRef.current = view.revision
|
||||
store.setPrefs(next)
|
||||
return next
|
||||
})
|
||||
// A failed commit must not poison the queue: later writes still run.
|
||||
inFlightRef.current = run.then(() => undefined, () => undefined)
|
||||
return run.then(
|
||||
(next) => ({ ok: true, prefs: next }),
|
||||
(caught) => {
|
||||
setError(messageOf(caught))
|
||||
return { ok: false, prefs }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Settle one commit: success adopts the server values, failure reverts. */
|
||||
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
|
||||
const settled = outcome.ok ? outcome.prefs : previous
|
||||
setPrefs(settled)
|
||||
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)
|
||||
setError(null)
|
||||
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
togglePref({ 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 } })
|
||||
}
|
||||
|
||||
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
|
||||
const onToggleViewer = (id: string, next: boolean): void => {
|
||||
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
|
||||
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
|
||||
togglePref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
const parsed = Number(widthDraft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setWidthDraft(String(prefs.defaultWidthPercent))
|
||||
return
|
||||
}
|
||||
const clamped = clampWidthPercent(parsed)
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, defaultWidthPercent: clamped })
|
||||
setWidthDraft(String(clamped))
|
||||
setError(null)
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const renderCard = (props: {
|
||||
title: string
|
||||
desc: string
|
||||
icon?: ReactNode
|
||||
enabled: boolean
|
||||
onToggle: (next: boolean) => void
|
||||
/** A feature with declared related settings shows the gear corner button. */
|
||||
onOpenSettings?: () => void
|
||||
}) => {
|
||||
const hasSettings = props.onOpenSettings !== undefined
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={props.enabled}
|
||||
title={props.desc}
|
||||
onClick={() => { props.onToggle(!props.enabled) }}
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
{props.icon !== null && props.icon !== undefined && (
|
||||
<span className={css.cardIcon}>{props.icon}</span>
|
||||
)}
|
||||
<span className={css.cardTitle}>{props.title}</span>
|
||||
{props.enabled && (
|
||||
<span className={css.cardCheck}>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{props.desc}</span>
|
||||
</button>
|
||||
{hasSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardGear}
|
||||
aria-label={`${props.title} ${t('settingsPopup')}`}
|
||||
title={t('settingsPopup')}
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* 文件预览: 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>
|
||||
|
||||
{/* 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. */}
|
||||
{settingsFor !== null && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
title={textOf(settingsFor.title)}
|
||||
closeLabel={t('close')}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={settingsFor.settings?.toggles ?? []}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,861 +0,0 @@
|
||||
/**
|
||||
* The sidebar shell: fixed-position panels portalled onto document.body
|
||||
* (the core AppFrame owns the left sidebar / center / details columns and
|
||||
* has no right-side hole for plugins). The right panel hosts the original
|
||||
* workbench; the bottom panel hosts a second, independent workbench. The
|
||||
* bottom panel squeezes ONLY the center column (the agent output area): it
|
||||
* spans from the app shell's own left sidebar to the right panel's left
|
||||
* edge, so neither sidebar gives up any position (the right panel keeps its
|
||||
* full height). A persistent two-button cluster at the top-right corner
|
||||
* toggles each panel; the right panel's width drags from its left edge, the
|
||||
* bottom panel's height from its top edge, and the shared corner drags both
|
||||
* at once. The whole layout lives in the per-session store, so switching
|
||||
* conversations swaps the sidebar.
|
||||
*
|
||||
* The shell binds the workbench actions to the store and dispatches tab
|
||||
* content to the views. New tabs come from the + menu (explorer / git /
|
||||
* terminal; editors open from the explorer). Tabs live in one tree only —
|
||||
* they never cross panels; only the panel sizes drag against each other.
|
||||
*
|
||||
* Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
|
||||
* narrow migrates the bottom panel's tabs INTO the right tree
|
||||
* (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
|
||||
* strips. The right panel becomes a full-width drawer, the bottom panel
|
||||
* and its toggle button disappear, and the layout push is disabled (the
|
||||
* 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 { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
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,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
} from './state.ts'
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { useNarrowViewport } from './breakpoints.ts'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.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'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
function TabContent(props: {
|
||||
tab: SidebarTab
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
|
||||
visible: boolean
|
||||
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
||||
onSubagentJump: (childSessionId: string) => void
|
||||
/** Open a diff tab from the git panel (placement handled by the store). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
}) {
|
||||
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
||||
const scope = { sessionId, cwd }
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
|
||||
* Tabs the user disabled in the side card settings are filtered out
|
||||
* entirely — re-enabling them is the settings page's job. */
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
const service = ctx.betterSidebar
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
.filter(d => !d.hidden && service.isTabEnabled(d.id))
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
.map(d => ({
|
||||
id: d.id,
|
||||
label: typeof d.title === 'function' ? d.title() : d.title,
|
||||
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
||||
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const { ctx, store } = props
|
||||
|
||||
// Copy freshness: re-render the whole tree when the DSH locale switches.
|
||||
// The module-level t() reads the active locale at call time, so a root
|
||||
// re-render alone re-localizes every panel (no memo barriers below).
|
||||
const localeRevision = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
|
||||
useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
|
||||
)
|
||||
void localeRevision
|
||||
|
||||
// Narrow (mobile) viewports collapse the two panels into one: the right
|
||||
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
|
||||
// panel (and its toggle button) disappears, and the layout push is
|
||||
// disabled (the drawer floats over the app shell). Entering narrow
|
||||
// MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
|
||||
// — the merged display is the right sidebar alone, the bottom tabs thrown
|
||||
// into its strips. Widening never rewrites the migrated state: the tabs
|
||||
// keep living in the right tree.
|
||||
const narrow = useNarrowViewport()
|
||||
|
||||
// Current conversation (the sessions list feed).
|
||||
const sessionList = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
||||
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
||||
)
|
||||
const current = sessionList.current
|
||||
|
||||
// Per-session sidebar state.
|
||||
const snapshot = useSyncExternalStore(
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot(), [store]),
|
||||
)
|
||||
useEffect(() => { store.setSession(current) }, [current, store])
|
||||
|
||||
const state = snapshot.state
|
||||
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),
|
||||
* throw the bottom tree's tabs into the right tree. Idempotent — after
|
||||
* the first migration the bottom tree is empty and the reducer returns
|
||||
* the same reference, so this effect settles immediately.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!narrow || sessionId === undefined) return
|
||||
store.reduce(migrateBottomTabs)
|
||||
}, [narrow, sessionId, store])
|
||||
|
||||
// While the session's header is still hydrating (or the session is blank),
|
||||
// the list summary may carry no cwd; ask the host once (it falls back to
|
||||
// the process cwd) so the explorer root and terminal cwd are real from
|
||||
// first paint instead of showing "no session".
|
||||
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
setFetchedCwd(undefined)
|
||||
if (sessionId === undefined || summaryCwd !== undefined) return
|
||||
let cancelled = false
|
||||
api.sessionCwd({ sessionId })
|
||||
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
|
||||
.catch(() => { /* the explorer/git rows surface their own errors */ })
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, summaryCwd])
|
||||
const cwd = summaryCwd ?? fetchedCwd
|
||||
|
||||
/**
|
||||
* Agent terminals push: subscribe to the host's live list of agent-owned
|
||||
* terminals for this session (created by the model through the
|
||||
* `terminal_create` tool). The host pushes a JSON array on every
|
||||
* create / close / exit; the sidebar reconciles the list into tabs
|
||||
* (id `agent:<uuid>`, title from the agent). A disconnected socket
|
||||
* retries with a short backoff so a refresh or transient drop reattaches
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
* While the terminal tab type is disabled in settings, pushes are
|
||||
* ignored (no auto-added tabs); re-enabling makes the next push converge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.search = new URLSearchParams({ sessionId }).toString()
|
||||
socket = new WebSocket(url.toString())
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') return
|
||||
try {
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
||||
if (!Array.isArray(list)) return
|
||||
store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
|
||||
? s
|
||||
: reconcileAgentTerminals(s, list))
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push will reconcile).
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (closed) return
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
|
||||
return
|
||||
}
|
||||
retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => { socket?.close() }
|
||||
}
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [sessionId, store])
|
||||
|
||||
/**
|
||||
* Subagent auto-activation: the moment the current conversation spawns its
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
||||
* open" pref is on, and the Subagent tab type is enabled in settings,
|
||||
* open the panel (if collapsed) and focus the Subagent page
|
||||
* (single-instance: an existing tab is focused, never duplicated).
|
||||
* Switching to a session that already has subagents never triggers — its
|
||||
* baseline starts at the current count — so a deliberate layout is never
|
||||
* fought.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = listBaselineRef.current
|
||||
listBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
// Pin the landing to the right panel: the auto-opened Subagent page must
|
||||
// appear where the panel just expanded, not in a bottom-panel pane the
|
||||
// user last touched.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* 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 jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = jobBaselineRef.current
|
||||
jobBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) 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 }))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
* the official `openSubagent`, which switches the sidebar to that child
|
||||
* session's OWN layout (a fresh child session defaults to the explorer).
|
||||
* The README contract says the Subagent page must stay open with the jumped
|
||||
* node highlighted — so once the current session becomes the recorded jump
|
||||
* target, re-open the Subagent page on top of the child's layout (expanding
|
||||
* the panel first if it is collapsed). Only this explicit node click arms
|
||||
* the flag, so switching to a subagent session by any other means keeps
|
||||
* that session's own layout untouched.
|
||||
*/
|
||||
const subagentJumpRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
// 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 (children[1], the layout.css
|
||||
// nth-child(2) 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 (children[1] of the frame div —
|
||||
// layout.css's nth-child(2)). 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 frame = document.querySelector('#root > div')
|
||||
const col = frame?.children[1] 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
|
||||
* expands the bottom panel in a session, try to open a fresh terminal tab
|
||||
* there. "Try" is literal — the terminal's own quota and enable switch
|
||||
* gate the attempt (a full quota or a disabled terminal type makes it a
|
||||
* no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
|
||||
* nested settings toggle, default on). Only a false→true TRANSITION fires
|
||||
* (a panel persisted open never counts as an expansion), and the session's
|
||||
* bottomOpenedOnce flag is set atomically with the first fire so later
|
||||
* expansions never repeat it.
|
||||
*/
|
||||
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
// The bottom panel does not exist on narrow viewports (the two
|
||||
// workbenches merge into one panel), so the first-expansion auto
|
||||
// terminal is a desktop-only behavior.
|
||||
if (narrow) return
|
||||
if (state === undefined) return
|
||||
const wasOpen = bottomWasOpenRef.current
|
||||
bottomWasOpenRef.current = state.bottomOpen
|
||||
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
|
||||
if (state.bottomOpenedOnce) return
|
||||
if (store.getPrefs().bottomPanelAutoTerminal === false) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('terminal') === false) return
|
||||
// Land the tab in the bottom panel's first pane; the once-flag is set
|
||||
// atomically so later expansions never repeat the auto-open.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
|
||||
ctx.betterSidebar?.openTab({ type: 'terminal' })
|
||||
}, [state, store, ctx, narrow])
|
||||
|
||||
// Panel drags: the right panel's width (left edge strip), the bottom
|
||||
// panel's height (top edge strip), and the shared corner (both at once).
|
||||
// Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
|
||||
// variables) instead of round-tripping the store on every pointer move —
|
||||
// a store reduce re-renders both workbenches (terminals, editors…) per
|
||||
// move, which is the visible drag lag. The store is committed once on
|
||||
// pointer up (clamping + persistence).
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const cornerRef = useRef<HTMLDivElement | null>(null)
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
|
||||
const [draggingBottom, setDraggingBottom] = useState(false)
|
||||
const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
|
||||
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 =>
|
||||
Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
|
||||
const clampHeight = (height: number): number =>
|
||||
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
|
||||
|
||||
/** Apply a drag size to the DOM without touching React state or the store.
|
||||
* The bottom panel's right edge tracks the right panel's left edge HERE
|
||||
* too — React state only updates on release, so the inline right must be
|
||||
* written directly or the bottom panel would lag the sidebar mid-drag. */
|
||||
const applyDrag = (width: number, height: number): void => {
|
||||
panelRef.current?.style.setProperty('width', `${width}px`)
|
||||
bottomRef.current?.style.setProperty('height', `${height}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) {
|
||||
cornerRef.current.style.left = `${window.innerWidth - width - 6}px`
|
||||
cornerRef.current.style.top = `${window.innerHeight - height - 6}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Drags write at most once per frame: pointer events fire several times
|
||||
// faster than the display refresh, and each write reflows the app shell
|
||||
// (the layout push) plus the panels — batching to one write per frame is
|
||||
// what keeps the drag smooth. The store is still committed once on release.
|
||||
const dragFrame = useRef<number | null>(null)
|
||||
const pendingDrag = useRef<{ width: number; height: number } | null>(null)
|
||||
const scheduleDrag = (width: number, height: number): void => {
|
||||
pendingDrag.current = { width, height }
|
||||
if (dragFrame.current !== null) return
|
||||
dragFrame.current = requestAnimationFrame(() => {
|
||||
dragFrame.current = null
|
||||
const pending = pendingDrag.current
|
||||
if (pending !== null) {
|
||||
pendingDrag.current = null
|
||||
applyDrag(pending.width, pending.height)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Flush any pending drag write and stop scheduling (the store commit on
|
||||
* pointer up applies the final clamped values). */
|
||||
const stopDragScheduling = (): void => {
|
||||
if (dragFrame.current !== null) {
|
||||
cancelAnimationFrame(dragFrame.current)
|
||||
dragFrame.current = null
|
||||
}
|
||||
pendingDrag.current = null
|
||||
}
|
||||
|
||||
// Layout push: the app shell gives up the panel's width/height while the
|
||||
// panels are open (0 while collapsed), so the conversation and input bar
|
||||
// are squeezed instead of covered. The margins are capped at the viewport
|
||||
// so a stale persisted size (e.g. fullscreen on a bigger window) can never
|
||||
// crush the app shell to zero. Dragging disables the layout transition.
|
||||
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
|
||||
// conversation keeps the full width behind the drawer.
|
||||
useEffect(() => {
|
||||
const width = !narrow && snapshot.state?.panelOpen === true
|
||||
? Math.min(snapshot.state.width, window.innerWidth)
|
||||
: 0
|
||||
const height = !narrow && snapshot.state?.bottomOpen === true
|
||||
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
|
||||
: 0
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
|
||||
useEffect(() => {
|
||||
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
||||
else document.body.removeAttribute('data-dsh-sidebar-dragging')
|
||||
}, [anyDragging])
|
||||
|
||||
|
||||
const actions: WorkbenchActions = useMemo(() => ({
|
||||
closeTab: (paneId, tabId) => {
|
||||
// A closed terminal releases its pty immediately — including when its
|
||||
// socket is mid-reconnect, where the unmount close frame never reaches
|
||||
// the host and the process would hold the quota until the grace ends.
|
||||
// Agent terminals (tabId `agent:<uuid>`) close through a different
|
||||
// host route: the WS close frame is the primary path (sent by
|
||||
// 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)
|
||||
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
|
||||
store.reduce(s => closeTab(s, paneId, tabId))
|
||||
if (tab?.type === 'terminal') {
|
||||
if (isAgentTabId(tabId)) {
|
||||
const uuid = agentUuidOf(tabId)
|
||||
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
|
||||
} else if (sessionId !== undefined) {
|
||||
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
|
||||
}
|
||||
}
|
||||
},
|
||||
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
|
||||
}),
|
||||
}))
|
||||
},
|
||||
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
|
||||
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
|
||||
store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
|
||||
},
|
||||
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
|
||||
store.reduce((s) => {
|
||||
let index = -1
|
||||
const source = leafWithTab(s.splits, beforeTabId)
|
||||
if (source !== undefined && source.id === toPane) {
|
||||
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
|
||||
}
|
||||
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
|
||||
})
|
||||
},
|
||||
resizeSplit: (splitId, index, deltaFrac) => {
|
||||
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
|
||||
},
|
||||
}), [store, sessionId, cwd])
|
||||
|
||||
/**
|
||||
* The explorer's @-reference button: append `@<relative path>` to the
|
||||
* session's composer draft (space-separated). The conversation service is
|
||||
* resolved lazily through `ctx.get` (the inject-free read — the app's own
|
||||
* plugins read 'conversation' the same way); a missing service or scope
|
||||
* degrades to a logged no-op, never a crash. Defined above the no-session
|
||||
* early return — a hook must never sit behind a conditional return
|
||||
* (React counts hooks per render).
|
||||
*/
|
||||
const referenceInChat = useCallback((path: string): void => {
|
||||
if (sessionId === undefined) return
|
||||
appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
|
||||
}, [ctx, sessionId, cwd])
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
return (
|
||||
<div className={css.toggleCluster}>
|
||||
{!narrow && (
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelBottomOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onNewTab = (optionId: string): void => {
|
||||
const service = ctx.betterSidebar
|
||||
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 explorer's @-reference button: append `@<relative path>` to the
|
||||
* session's composer draft (space-separated). Resolves the session-scope
|
||||
* ctx and the conversation input service at click time; a missing service
|
||||
* or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
/** The tab icon from the tab-type registry (shared by every workbench). */
|
||||
const tabIconOf = (tab: SidebarTab): ReactNode => {
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
if (descriptor === undefined) return null
|
||||
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* open/closed state it gates live views (the Subagent topology pauses its
|
||||
* polling while the page is not actually visible). The pane id travels
|
||||
* with the tab so diff tabs can split below their source pane.
|
||||
*/
|
||||
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
|
||||
<TabContent
|
||||
tab={tab}
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={state.expanded}
|
||||
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
||||
onReferenceFile={referenceInChat}
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
|
||||
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
|
||||
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
The persistent toggle cluster at the top-right corner: the bottom
|
||||
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
|
||||
Always pinned to the viewport corner — inside the right panel's
|
||||
top-right while it is open, sitting flush in the tab strip whose
|
||||
right end it really squeezes (the strip reserves its width via CSS),
|
||||
so the tabs genuinely yield space to it.
|
||||
*/}
|
||||
<div className={css.toggleCluster}>
|
||||
{/*
|
||||
Narrow viewports merge the two workbenches into the one drawer —
|
||||
there is no bottom panel, so its toggle button is not offered.
|
||||
*/}
|
||||
{!narrow && (
|
||||
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggleButton}
|
||||
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
|
||||
onClick={() => { store.reduce(toggleBottomPanel) }}
|
||||
>
|
||||
<IconPanelBottomOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggleButton}
|
||||
aria-label={state.panelOpen ? t('collapse') : t('expand')}
|
||||
onClick={() => { store.reduce(togglePanel) }}
|
||||
>
|
||||
<IconPanelRightOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/*
|
||||
The right panel stays mounted while collapsed (hidden off-screen) so
|
||||
the slide in/out can animate; visibility hides it after the slide
|
||||
settles. Its bottom edge follows the bottom panel's height (0 while
|
||||
the bottom panel is closed) — the VSCode-style "sidebar above panel".
|
||||
On NARROW viewports it is a full-width drawer holding both
|
||||
workbenches (see MobileWorkbench); the width drag strip is not
|
||||
offered there — a full-screen sheet has nothing to drag.
|
||||
*/}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
||||
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
|
||||
data-dragging={anyDragging || undefined}
|
||||
>
|
||||
{!narrow && (
|
||||
<div
|
||||
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
widthDrag.current = { startX: event.clientX, startWidth: state.width }
|
||||
setDraggingWidth(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
scheduleDrag(width, height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
|
||||
setDraggingWidth(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={tabIconOf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/*
|
||||
The bottom panel: a second, independent workbench. It squeezes ONLY
|
||||
the center column (the agent output area): it starts at the app
|
||||
shell's own left sidebar and ends at the right panel's left edge —
|
||||
neither sidebar gives up any position (the right panel keeps its
|
||||
full height). Its resize strip is the top edge; hidden by sliding
|
||||
down like the right panel. On NARROW viewports it does not exist —
|
||||
the bottom workbench lives inside the drawer (MobileWorkbench).
|
||||
*/}
|
||||
{!narrow && (
|
||||
<div
|
||||
ref={bottomRef}
|
||||
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
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).
|
||||
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
|
||||
}}
|
||||
data-dragging={(draggingBottom || draggingCorner) || undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
|
||||
setDraggingBottom(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
scheduleDrag(Math.min(state.width, window.innerWidth), height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
|
||||
setDraggingBottom(false)
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
The bottom panel's own close control at its tab strip's right end
|
||||
(the strip reserves the width via CSS so the + menu never hides
|
||||
under it): one tap collapses the panel.
|
||||
*/}
|
||||
<Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.bottomClose}
|
||||
aria-label={t('collapseBottomPanel')}
|
||||
onClick={() => { store.reduce(toggleBottomPanel) }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className={css.panelBody}>
|
||||
<Workbench
|
||||
state={state}
|
||||
tree={state.bottomSplits}
|
||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
|
||||
getTabIcon={tabIconOf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
The shared corner (only while BOTH panels are open): the intersection
|
||||
of the right panel's left edge and the bottom panel's top edge.
|
||||
Horizontal drags resize the right panel's width, vertical drags the
|
||||
bottom panel's height — the two panels drag against each other.
|
||||
(Never on narrow viewports: the bottom panel does not exist there.)
|
||||
*/}
|
||||
{!narrow && state.panelOpen && state.bottomOpen && (
|
||||
<div
|
||||
ref={cornerRef}
|
||||
className={css.cornerHandle}
|
||||
style={{
|
||||
left: window.innerWidth - state.width - 6,
|
||||
top: window.innerHeight - state.bottomHeight - 6,
|
||||
}}
|
||||
data-dragging={draggingCorner || undefined}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
cornerDrag.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startWidth: state.width,
|
||||
startHeight: state.bottomHeight,
|
||||
}
|
||||
setDraggingCorner(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
scheduleDrag(width, height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
stopDragScheduling()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
|
||||
setDraggingCorner(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
/* Subagent page: main-agent topology tree. Row/card chrome mirrors the
|
||||
official ui-subagent catalog row (min-height 50, 7/8/11 padding, radius 8,
|
||||
flex-start alignment, tree connector lines via border-left hairlines). */
|
||||
.subagent {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.subagentHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 8px 0 12px;
|
||||
}
|
||||
|
||||
.subagentTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The descendant count badge ("3 · 1 运行中") beside the title. */
|
||||
.subagentCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.subagentRefresh {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subagentRefresh:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.subagentBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 2px 6px 8px;
|
||||
}
|
||||
|
||||
/* One node card: state dot + label + secondary + (running) live lines. */
|
||||
.subagentRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 7px 8px 7px 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.subagentRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Keyboard focus (mirror of the official catalog row): a visible fill only
|
||||
when focused via keyboard; the selected state below still wins on top. */
|
||||
.subagentRow:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The currently-open session, highlighted in place (mirror of the workspace
|
||||
`.sessionRow.selected` — declared after the hover/focus rules so it wins). */
|
||||
.subagentRowActive,
|
||||
.subagentRowActive:hover,
|
||||
.subagentRowActive:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.subagentRowDisabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.subagentRowDisabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Loading placeholders are disabled but keep the default cursor (official
|
||||
`.loadingRow` distinction from `.disabled`). */
|
||||
.subagentRowLoading {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The state dot aligns to the first text line (official catalog detail). */
|
||||
.subagentDot {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.subagentContent {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.subagentLabel,
|
||||
.subagentSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagentLabel {
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subagentSecondary {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* ── Live lines (running cards only) ──────────────────────────────────── */
|
||||
|
||||
.subagentLive {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subagentLiveTool {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.subagentLiveArgs {
|
||||
min-width: 0;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagentLiveText {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* ── Tree connector lines (official catalog recipe) ────────────────────── */
|
||||
|
||||
.subagentNode {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subagentChildren {
|
||||
position: relative;
|
||||
margin-left: 18px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.subagentChildren::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -26px;
|
||||
height: 26px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentChildren[aria-busy='true']::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode:last-child::before {
|
||||
bottom: auto;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode > .subagentRow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: -4px;
|
||||
width: 14px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 16px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subagentEmptyHint {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.subagentError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.subagentErrorRetry {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subagentErrorRetry:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* ── Background-job section (below the topology tree) ────────────────── */
|
||||
|
||||
.jobs {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.jobsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.jobsTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobsCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
.jobsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.jobsRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsRowSettled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The row whose output is shown in the bottom dock. */
|
||||
.jobsRowSelected,
|
||||
.jobsRowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.jobsRowMain {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 6px 8px 6px 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.jobsRowMain:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsDot {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.jobsContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* The command line: a kind badge followed by the command itself in the code
|
||||
face, like the terminal's live lines. */
|
||||
.jobsLabelLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jobsKind {
|
||||
flex: none;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
line-height: 14px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.jobsSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsKill {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: 4px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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. */
|
||||
.jobsKillArmed,
|
||||
.jobsKillArmed:hover {
|
||||
width: auto;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobsKill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jobsKillError {
|
||||
flex: none;
|
||||
margin-right: 4px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ── 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 job list scrolls above it; a solid background keeps the
|
||||
tree from showing through. */
|
||||
.jobsPane {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jobsPaneHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.jobsPaneDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.jobsPaneLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.jobsPaneStatus {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsPaneClose {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jobsPaneClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsPanePre {
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.jobsPaneHint {
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsPaneError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
@@ -1,866 +0,0 @@
|
||||
/**
|
||||
* Subagent page: the FULL agent topology of the current tree's main session.
|
||||
*
|
||||
* The root is resolved by walking the durable parent chain upward from the
|
||||
* current session to the first non-subagent session — the MAIN session — and
|
||||
* every subagent under it shares this one topology view, no matter how deep
|
||||
* the current selection is (including a subagent transcript opened in the
|
||||
* main view). The main agent renders as the root node card (click it to jump
|
||||
* back to the main session), with its subagents hanging below it in clearly
|
||||
* LAYERED levels: tree connector lines (first level included) and per-level
|
||||
* indentation show the hierarchy, and the currently-open session is
|
||||
* highlighted in place. Every branch is expanded automatically (lazy
|
||||
* catalogs hydrate on demand and consume live membership while visible).
|
||||
*
|
||||
* Each node card carries live status (state dot, durable label, mode and
|
||||
* activity); while a child RUNS, its card additionally shows the LAST text
|
||||
* output and LAST tool call pulled from its history tail, auto-refreshing
|
||||
* every few seconds while the page is visible. Clicking a card jumps
|
||||
* straight into the child transcript (`openSubagent`); the page stays open
|
||||
* and the topology remains rooted at the main session.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconRefreshOutline14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentDiagnosticEntry,
|
||||
SidebarJobView,
|
||||
} from '../context-types.ts'
|
||||
import {
|
||||
collectBranchIds,
|
||||
countSubagentDescendants,
|
||||
rootAncestor,
|
||||
} from './subagent-detect.ts'
|
||||
import { lastActivity } from './subagent-activity.ts'
|
||||
import {
|
||||
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'
|
||||
|
||||
/** Refresh cadence of the live "last text + tool call" lines while a child runs. */
|
||||
const POLL_MS = 3000
|
||||
/** Preview cap of one tool-call argument line. */
|
||||
const ARGS_PREVIEW = 60
|
||||
/** 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 JOB_KILL_ARM_MS = 3000
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows). */
|
||||
function directChildren(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
parentSessionId: string,
|
||||
): SidebarSessionSummary[] {
|
||||
return Object.values(byId).filter(
|
||||
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
|
||||
)
|
||||
}
|
||||
|
||||
/** Human label of one catalog child: durable label, then summary title, then id. */
|
||||
function childLabel(
|
||||
entry: SidebarSubagentChildEntry,
|
||||
summary: SidebarSessionSummary | undefined,
|
||||
): string {
|
||||
return entry.label ?? summary?.displayTitle ?? entry.id
|
||||
}
|
||||
|
||||
function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string {
|
||||
switch (entry.reason) {
|
||||
case 'corrupt': return t('subagentDiagCorrupt')
|
||||
case 'unsupported': return t('subagentDiagUnsupported')
|
||||
case 'unavailable': return t('subagentDiagUnavailable')
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line of one card: title · mode · activity (skips empty parts). */
|
||||
function cardSecondary(
|
||||
summary: SidebarSessionSummary | undefined,
|
||||
entry: SidebarSubagentChildEntry,
|
||||
): string {
|
||||
return [
|
||||
summary?.displayTitle,
|
||||
entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'),
|
||||
entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
|
||||
].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** First `limit` characters with an ellipsis when truncated. */
|
||||
function preview(text: string, limit: number): string {
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text
|
||||
}
|
||||
|
||||
/** Collapse whitespace for the single-paragraph live-text preview. */
|
||||
function flatten(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Disabled "loading…" cards backed by the summary mirror while a catalog hydrates. */
|
||||
function CatalogLoadingRows(props: {
|
||||
parentSessionId: string
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>
|
||||
level: number
|
||||
}) {
|
||||
const { parentSessionId, byId, level } = props
|
||||
const children = directChildren(byId, parentSessionId)
|
||||
if (children.length === 0) {
|
||||
return <div className={css.subagentEmpty}>{t('loading')}</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children.map(summary => (
|
||||
<div
|
||||
key={summary.id}
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
aria-label={t('loading')}
|
||||
className={`${css.subagentRow} ${css.subagentRowDisabled} ${css.subagentRowLoading}`}
|
||||
>
|
||||
<StateDot state={summary.running === true ? 'ongoing' : 'done'} className={css.subagentDot} />
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{t('loading')}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The live lines of one RUNNING subagent card: the last text output and the
|
||||
* last tool call of the child's history tail, refreshed every few seconds
|
||||
* while the page is visible. Idle cards render nothing (a quiet topology); a
|
||||
* running child with neither output yet reads "thinking…".
|
||||
*/
|
||||
function SubagentLiveLines(props: {
|
||||
ctx: Context
|
||||
parentSessionId: string
|
||||
childSessionId: string
|
||||
mode: SidebarSubagentAddress['mode']
|
||||
running: boolean
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { ctx, parentSessionId, childSessionId, mode, running, active } = props
|
||||
const [live, setLive] = useState<ReturnType<typeof lastActivity>>({})
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const address = useMemo(
|
||||
() => ({ parentSessionId, childSessionId, mode }),
|
||||
[parentSessionId, childSessionId, mode],
|
||||
)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const response = await ctx.connection.api.subagents.history(
|
||||
{ ...address, maxMessages: 12 },
|
||||
controller.signal,
|
||||
)
|
||||
if (!response.result.ok) return
|
||||
setLive(lastActivity(response.result.value.events))
|
||||
} catch {
|
||||
// Aborted by a newer pull or a wire failure: keep the last known lines.
|
||||
}
|
||||
}, [ctx, address])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
void load()
|
||||
if (!running) return
|
||||
const timer = window.setInterval(() => { void load() }, POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, running, active])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
if (!running) return null
|
||||
if (live.text === undefined && live.tool === undefined) {
|
||||
return <span className={css.subagentLive}>{t('subagentThinking')}</span>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{live.tool !== undefined && (
|
||||
<span className={css.subagentLive}>
|
||||
<span className={css.subagentLiveTool}>{live.tool.name}</span>
|
||||
{live.tool.args !== '' && (
|
||||
<span className={css.subagentLiveArgs}>{preview(live.tool.args, ARGS_PREVIEW)}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{live.text !== undefined && (
|
||||
<span className={css.subagentLiveText}>{flatten(live.text)}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface RowsProps {
|
||||
parentSessionId: string
|
||||
catalog: SidebarSubagentCatalog | undefined
|
||||
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>
|
||||
level: number
|
||||
/** The currently-open session id (highlighted in the topology). */
|
||||
currentSessionId: string
|
||||
/** The page is visible (active tab + open panel): live polling pauses otherwise. */
|
||||
active: boolean
|
||||
ctx: Context
|
||||
openChild: (address: SidebarSubagentAddress) => void
|
||||
refresh: (parentSessionId: string) => void
|
||||
}
|
||||
|
||||
/** Render one topology level; branches are always expanded (lazy catalogs). */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, byId, level, currentSessionId, active, ctx,
|
||||
openChild, refresh,
|
||||
}: RowsProps) {
|
||||
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
|
||||
return (
|
||||
<>
|
||||
{emptyLoading && (
|
||||
<CatalogLoadingRows parentSessionId={parentSessionId} byId={byId} level={level} />
|
||||
)}
|
||||
{catalog?.state === 'error' && (
|
||||
<div className={css.subagentError}>
|
||||
<span>{catalog.error?.message ?? t('error')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.subagentErrorRetry}
|
||||
onClick={() => { refresh(parentSessionId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(catalog?.entries ?? []).map((entry) => {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
return (
|
||||
<div key={entry.id} className={css.subagentNode}>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
className={`${css.subagentRow} ${css.subagentRowDisabled}`}
|
||||
title={diagnosticReason(entry)}
|
||||
>
|
||||
<StateDot state="error" className={css.subagentDot} />
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{entry.id}</span>
|
||||
<span className={css.subagentSecondary}>{diagnosticReason(entry)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const childCatalog = catalogs[entry.id]
|
||||
const knownLeaf = !entry.hasChildren
|
||||
const summary = byId[entry.id]
|
||||
const label = childLabel(entry, summary)
|
||||
const secondary = cardSecondary(summary, entry)
|
||||
const childLoading = childCatalog === undefined
|
||||
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
|
||||
const address: SidebarSubagentAddress = {
|
||||
parentSessionId,
|
||||
childSessionId: entry.id,
|
||||
mode: entry.mode,
|
||||
}
|
||||
const current = entry.id === currentSessionId
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={css.subagentNode}>
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={`${label} ${secondary}`}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': true }}
|
||||
className={clsx(css.subagentRow, current && css.subagentRowActive)}
|
||||
onClick={() => { openChild(address) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openChild(address)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={entry.activity === 'running' ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{label}</span>
|
||||
<span className={css.subagentSecondary}>{secondary}</span>
|
||||
<SubagentLiveLines
|
||||
ctx={ctx}
|
||||
parentSessionId={parentSessionId}
|
||||
childSessionId={entry.id}
|
||||
mode={entry.mode}
|
||||
running={entry.activity === 'running'}
|
||||
active={active}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{!knownLeaf && (
|
||||
<div role="group" className={css.subagentChildren} aria-busy={childLoading || undefined}>
|
||||
{childCatalog === undefined
|
||||
? (
|
||||
<CatalogLoadingRows
|
||||
parentSessionId={entry.id}
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<CatalogRows
|
||||
parentSessionId={entry.id}
|
||||
catalog={childCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
currentSessionId={currentSessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 job. A single dock — not a panel per row — keeps the
|
||||
* job list compact and stable when many jobs are running.
|
||||
*/
|
||||
function JobOutputPane(props: {
|
||||
ownerSessionId: string
|
||||
job: SidebarJobView
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
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)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
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, job.id])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (!active || !isJobLive(job)) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
// 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 (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
}, [state, job.status])
|
||||
|
||||
return (
|
||||
<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.jobsPaneClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconStopOutline16 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
|
||||
{state === 'error' && (
|
||||
<div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
|
||||
)}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/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 jobs.
|
||||
*/
|
||||
function JobsSection(props: {
|
||||
byId: SidebarSessionList['byId']
|
||||
jobsBySession: SidebarSessionList['jobsBySession']
|
||||
rootId: string | undefined
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { byId, jobsBySession, rootId, active } = props
|
||||
const rows = useMemo(
|
||||
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
|
||||
[byId, jobsBySession, rootId],
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
||||
const [armedId, setArmedId] = useState<string | undefined>(undefined)
|
||||
const [killingId, setKillingId] = useState<string | undefined>(undefined)
|
||||
const [killErrorId, setKillErrorId] = useState<string | undefined>(undefined)
|
||||
// The duration clock only runs while a live row is on screen.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)),
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
const liveCount = useMemo(
|
||||
() => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0),
|
||||
[rows],
|
||||
)
|
||||
const multiOwner = useMemo(
|
||||
() => new Set(rows.map(row => row.ownerSessionId)).size > 1,
|
||||
[rows],
|
||||
)
|
||||
|
||||
// The kill button stays armed only briefly; a stray click must never kill.
|
||||
useEffect(() => {
|
||||
if (armedId === undefined) return
|
||||
const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [armedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (liveCount === 0) return
|
||||
setNow(Date.now())
|
||||
const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [liveCount])
|
||||
|
||||
// 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)
|
||||
}, [selectedId, selectedRow])
|
||||
|
||||
// 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: TreeJob): Promise<void> => {
|
||||
setKillingId(row.job.id)
|
||||
setKillErrorId(undefined)
|
||||
try {
|
||||
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
|
||||
} catch {
|
||||
setKillErrorId(row.job.id)
|
||||
} finally {
|
||||
setKillingId(undefined)
|
||||
setArmedId(undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const countLabel = liveCount > 0
|
||||
? t('jobsCountRunning', { count: rows.length, running: liveCount })
|
||||
: t('jobsCount', { count: rows.length })
|
||||
|
||||
return (
|
||||
<>
|
||||
<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.jobsList} aria-label={t('jobs')}>
|
||||
{rows.map((row) => {
|
||||
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 - job.startedAt
|
||||
: (job.finishedAt ?? job.startedAt) - job.startedAt
|
||||
const secondary = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
jobStatusLabel(job.status, t),
|
||||
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
|
||||
formatJobDuration(elapsed, t),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<li
|
||||
key={job.id}
|
||||
className={clsx(
|
||||
css.jobsRow,
|
||||
!live && css.jobsRowSettled,
|
||||
selected && css.jobsRowSelected,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.jobsRowMain}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${job.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : job.id) }}
|
||||
>
|
||||
<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.jobsSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</button>
|
||||
{job.status === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
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(job.id)
|
||||
}}
|
||||
>
|
||||
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{selectedRow !== undefined && (
|
||||
<JobOutputPane
|
||||
ownerSessionId={selectedRow.ownerSessionId}
|
||||
job={selectedRow.job}
|
||||
active={active}
|
||||
onClose={() => { setSelectedId(undefined) }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's Subagent topology page.
|
||||
* @param props - current session id, whether the page is actually visible
|
||||
* (active tab + open panel), the client context, and an optional
|
||||
* jump-notify hook fired right before `openSubagent` (lets the sidebar
|
||||
* shell re-open the Subagent page after the conversation switch lands on
|
||||
* the child session).
|
||||
* @returns the main agent's topology tree, or the empty/error/loading states.
|
||||
*/
|
||||
export function SubagentView(props: {
|
||||
sessionId: string
|
||||
active: boolean
|
||||
ctx: Context
|
||||
onOpenChild?: (address: SidebarSubagentAddress) => void
|
||||
}) {
|
||||
const { sessionId, active, ctx, onOpenChild } = props
|
||||
const sessions = ctx.sessions
|
||||
|
||||
// The same list feed the official catalog consumes (byId lineage + the
|
||||
// lazy per-parent catalogs). Older DSH snapshots without the subagent seam
|
||||
// simply leave these surfaces empty — the page degrades to the empty state.
|
||||
const list = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
|
||||
useCallback(() => sessions.list.getSnapshot(), [sessions]),
|
||||
)
|
||||
const byId = list.byId
|
||||
const catalogs = list.subagentsByParent ?? {}
|
||||
|
||||
// The topology root: the main agent of the current session's tree.
|
||||
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
|
||||
const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
|
||||
const rootSummary = rootId === undefined ? undefined : byId[rootId]
|
||||
|
||||
/** Catalog owners currently consuming live membership updates. */
|
||||
const observedRef = useRef(new Set<string>())
|
||||
|
||||
const observe = useCallback((parentSessionId: string, open: boolean): void => {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, open)
|
||||
if (open) observedRef.current.add(parentSessionId)
|
||||
else observedRef.current.delete(parentSessionId)
|
||||
}, [sessions])
|
||||
|
||||
// While the page is visible the topology root consumes live membership; a
|
||||
// root change (switching to another main agent's tree) or the page hiding
|
||||
// (tab switched away / panel collapsed) releases everything observed.
|
||||
useEffect(() => {
|
||||
if (rootId === undefined || !active) return
|
||||
observe(rootId, true)
|
||||
return () => {
|
||||
for (const parentSessionId of observedRef.current) {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
|
||||
}
|
||||
observedRef.current.clear()
|
||||
}
|
||||
}, [rootId, active, observe, sessions])
|
||||
|
||||
// Every branch of the always-expanded topology consumes live membership
|
||||
// (add-only: a branch stays observed until the root changes or the page
|
||||
// hides, which releases the whole set via the root effect's cleanup).
|
||||
const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId])
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
for (const id of branches) {
|
||||
if (!observedRef.current.has(id)) observe(id, true)
|
||||
}
|
||||
}, [branches, active, observe])
|
||||
|
||||
// Unobserve everything on unmount (the host stops refreshing unused catalogs).
|
||||
useEffect(() => () => {
|
||||
for (const parentSessionId of observedRef.current) {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
|
||||
}
|
||||
observedRef.current.clear()
|
||||
}, [sessions])
|
||||
|
||||
const openChild = useCallback((address: SidebarSubagentAddress): void => {
|
||||
// Notify the shell first: the jump switches the sidebar to the child
|
||||
// session's own layout, and the shell re-opens the Subagent page on top
|
||||
// of it (the topology stays rooted at the main agent with the child
|
||||
// highlighted) — the README "page stays open" contract.
|
||||
onOpenChild?.(address)
|
||||
try {
|
||||
sessions.openSubagent?.(address)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] openSubagent failed:', error)
|
||||
}
|
||||
}, [sessions, onOpenChild])
|
||||
|
||||
/** Jump back to the main agent (the topology root) from its node. */
|
||||
const openMain = useCallback((): void => {
|
||||
if (rootId === undefined) return
|
||||
try {
|
||||
sessions.open?.(rootId)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] open session failed:', error)
|
||||
}
|
||||
}, [sessions, rootId])
|
||||
|
||||
const refresh = useCallback((parentSessionId: string): void => {
|
||||
void sessions.refreshSubagents?.(parentSessionId)
|
||||
}, [sessions])
|
||||
|
||||
const totals = useMemo(
|
||||
() => rootId === undefined
|
||||
? { count: 0, runningCount: 0 }
|
||||
: countSubagentDescendants(byId, rootId),
|
||||
[byId, rootId],
|
||||
)
|
||||
// Session summaries can announce membership before the descriptor-backed
|
||||
// catalog catches up (or a catalog that just went ready is still empty).
|
||||
const summaryBackedLoading = rootId !== undefined
|
||||
&& (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
|
||||
&& directChildren(byId, rootId).length > 0
|
||||
const readyEmpty = rootCatalog?.state === 'ready'
|
||||
&& rootCatalog.entries.length === 0
|
||||
&& directChildren(byId, rootId ?? '').length === 0
|
||||
const countLabel = totals.count === 0
|
||||
? undefined
|
||||
: totals.runningCount > 0
|
||||
? t('subagentCountRunning', { count: totals.count, running: totals.runningCount })
|
||||
: t('subagentCount', { count: totals.count })
|
||||
|
||||
/** Arrow-key tree navigation over the visible rows (official catalog recipe). */
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const focusAt = useCallback((index: number): void => {
|
||||
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
|
||||
'[role="treeitem"]:not([aria-disabled="true"])',
|
||||
) ?? []
|
||||
if (items.length === 0) return
|
||||
items[(index + items.length) % items.length]?.focus()
|
||||
}, [])
|
||||
const onTreeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
|
||||
'[role="treeitem"]:not([aria-disabled="true"])',
|
||||
) ?? []
|
||||
const index = Array.prototype.indexOf.call(items, document.activeElement)
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusAt(index + 1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusAt(index < 0 ? items.length - 1 : index - 1)
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
focusAt(0)
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
focusAt(items.length - 1)
|
||||
}
|
||||
}, [focusAt])
|
||||
|
||||
return (
|
||||
<div className={css.subagent}>
|
||||
<div className={css.subagentHeader}>
|
||||
<span className={css.subagentTitle}>
|
||||
{t('subagent')}
|
||||
{rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
|
||||
? ` · ${rootSummary.displayTitle}`
|
||||
: ''}
|
||||
</span>
|
||||
{countLabel !== undefined && <span className={css.subagentCount}>{countLabel}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={css.subagentRefresh}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
disabled={rootId === undefined}
|
||||
onClick={() => { if (rootId !== undefined) refresh(rootId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.subagentBody}
|
||||
onKeyDown={onTreeKeyDown}
|
||||
>
|
||||
<div
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<JobsSection
|
||||
byId={byId}
|
||||
jobsBySession={list.jobsBySession}
|
||||
rootId={rootId}
|
||||
active={active}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized),
|
||||
* overflow scrolls horizontally, a close button per tab, a four-way split
|
||||
* button cluster, and the + menu that opens new tabs (explorer / git /
|
||||
* 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 clsx from 'clsx'
|
||||
import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** One + menu option. */
|
||||
export interface NewTabOption {
|
||||
id: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
/** Leading icon (Menu row). */
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/** Drag payload for tab moves (HTML5 DnD dataTransfer). */
|
||||
export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
|
||||
|
||||
export interface TabDragPayload {
|
||||
tabId: string
|
||||
paneId: string
|
||||
}
|
||||
|
||||
export function serializeDrag(payload: TabDragPayload): string {
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
export function parseDrag(raw: string): TabDragPayload | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as TabDragPayload
|
||||
if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
|
||||
function setTabDragging(active: boolean): void {
|
||||
if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
|
||||
else document.body.removeAttribute('data-dsh-tab-dragging')
|
||||
}
|
||||
|
||||
export function TabBar(props: {
|
||||
paneId: string
|
||||
tabs: SidebarTab[]
|
||||
active: string | null
|
||||
onActivate: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onNewTab: (optionId: string) => void
|
||||
newTabOptions: NewTabOption[]
|
||||
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
|
||||
onDropTab: (payload: TabDragPayload, before: string | null) => void
|
||||
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('drop', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('drop', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
|
||||
onDragOver={(event) => {
|
||||
// The strip owns drops on itself (merge into this pane); stopping
|
||||
// propagation keeps the pane root from also running its edge-zone
|
||||
// handler on the same drop.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => { setDragOver(false) }}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDragOver(false)
|
||||
setTabDragging(false)
|
||||
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
||||
const payload = parseDrag(raw)
|
||||
if (payload !== null) onDropTab(payload, null)
|
||||
}}
|
||||
>
|
||||
<div className={css.tabList}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={clsx(css.tab, active === tab.id && css.tabActive)}
|
||||
title={tab.title}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setTabDragging(true)
|
||||
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setTabDragging(false)
|
||||
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
||||
const payload = parseDrag(raw)
|
||||
if (payload !== null) onDropTab(payload, tab.id)
|
||||
}}
|
||||
onClick={() => { onActivate(tab.id) }}
|
||||
onAuxClick={(event) => {
|
||||
// Middle-click closes the tab (and suppresses autoscroll).
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
onClose(tab.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getTabIcon?.(tab) ?? null}
|
||||
<span className={css.tabTitle}>{tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabClose}
|
||||
aria-label={t('close')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClose(tab.id)
|
||||
}}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/*
|
||||
The + sits immediately after the rightmost tab (sticky at the
|
||||
right edge of the scrollport when the tabs overflow, so it stays
|
||||
reachable no matter how many tabs are open).
|
||||
*/}
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={newTabOptions.map(option => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
...(option.disabled === true ? { disabled: true } : {}),
|
||||
...(option.icon !== undefined ? { icon: option.icon } : {}),
|
||||
}))}
|
||||
onSelect={(id) => {
|
||||
onNewTab(id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
portal
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabBarPlus}
|
||||
aria-label={t('newTab')}
|
||||
title={t('newTab')}
|
||||
onClick={() => { setMenuOpen(v => !v) }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* The interactive terminal: xterm.js over a WebSocket to the host pty.
|
||||
* The host replays the session's transcript on connect, then streams live
|
||||
* output; input frames are raw text, resize frames are JSON with
|
||||
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
|
||||
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
|
||||
* a failed pty spawn) stops the loop and shows the reason with a manual
|
||||
* retry, and repeated unreasoned failures surface the close code after three
|
||||
* attempts, so the banner never spins forever.
|
||||
*
|
||||
* Two attach modes share one upgrade endpoint:
|
||||
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
|
||||
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
|
||||
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
|
||||
* terminal closes when the user closes the tab); a bare socket drop
|
||||
* leaves the pty alive (the agent owns the lifetime).
|
||||
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
|
||||
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
|
||||
* frame schedules a 0-ms close; a bare socket drop gets the host's
|
||||
* reconnect grace.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal, type ITheme } from 'xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* Curated ANSI palettes for the terminal. The surface colors (background,
|
||||
* foreground, cursor, selection) ride the theme tokens so the terminal
|
||||
* blends with the panel in both schemes; the 16 ANSI colors are the same
|
||||
* designed palettes the app's code surfaces use (one-dark family for dark,
|
||||
* one-light family for light), read live so a scheme flip re-themes in
|
||||
* place.
|
||||
*/
|
||||
const ANSI_DARK: Record<string, string> = {
|
||||
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
|
||||
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
|
||||
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2', brightWhite: '#ffffff',
|
||||
}
|
||||
|
||||
const ANSI_LIGHT: Record<string, string> = {
|
||||
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
|
||||
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
|
||||
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
|
||||
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
|
||||
brightCyan: '#0997b3', brightWhite: '#fafafa',
|
||||
}
|
||||
|
||||
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
|
||||
function xtermTheme(): ITheme {
|
||||
const dark = isDarkScheme()
|
||||
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
|
||||
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
cursorAccent: background,
|
||||
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
||||
...(dark ? ANSI_DARK : ANSI_LIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
|
||||
const { scope, tabId, store } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
||||
const connectRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
allowTransparency: true,
|
||||
convertEol: false,
|
||||
scrollback: 4000,
|
||||
theme: xtermTheme(),
|
||||
})
|
||||
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()
|
||||
term.refresh(0, term.rows - 1)
|
||||
}
|
||||
const schemeSub = subscribeColorScheme(applyTheme)
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let closed = false
|
||||
let retry: number | undefined
|
||||
let failures = 0
|
||||
|
||||
const wsUrl = (): string => {
|
||||
const url = new URL('/sidebar/ws/terminal', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
// Agent terminals attach by uuid (the host looks them up in the agent
|
||||
// pty registry); UI-tab terminals attach by sessionId+tab (the host
|
||||
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
|
||||
if (isAgentTabId(tabId)) {
|
||||
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
|
||||
} else {
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
url.search = params.toString()
|
||||
}
|
||||
// Same construction the app's own downlink WebSockets use (new URL
|
||||
// over location.origin + protocol swap): whatever the environment
|
||||
// does to the app's websockets applies identically here.
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const sendResize = (): void => {
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
}
|
||||
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = wsUrl()
|
||||
setLastUrl(url)
|
||||
socket = new WebSocket(url)
|
||||
socket.onopen = () => {
|
||||
failures = 0
|
||||
setConnected(true)
|
||||
setFatal(null)
|
||||
sendResize()
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') term.write(event.data)
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
setConnected(false)
|
||||
// A server-side refusal carries a close code + reason; retrying it
|
||||
// forever would only spin the banner, so surface it with a retry.
|
||||
if (event.code === 1011 && event.reason !== '') {
|
||||
setFatal(event.reason)
|
||||
return
|
||||
}
|
||||
// Unreasoned drops (upgrade rejected, host down, mid-handshake
|
||||
// refusal) normally recover on the next attempt; after a few
|
||||
// consecutive failures stop spinning and show the close code.
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
|
||||
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
|
||||
setFatal(`${t('terminalConnectFailed')}${detail}`)
|
||||
return
|
||||
}
|
||||
if (!closed) retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
socket?.close()
|
||||
}
|
||||
}
|
||||
connectRef.current = connect
|
||||
|
||||
const inputSub = term.onData((data) => {
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
|
||||
})
|
||||
const observer = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch {
|
||||
// The terminal may be mid-dispose; ignore.
|
||||
}
|
||||
})
|
||||
observer.observe(host)
|
||||
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
observer.disconnect()
|
||||
schemeSub()
|
||||
inputSub.dispose()
|
||||
// The close frame tells the host the owning tab is GONE (immediate
|
||||
// pty release). A bare unmount — conversation switch, re-render,
|
||||
// page unload — leaves the tab open, so the socket drop alone hands
|
||||
// the process to the host's reconnect grace: switching back or
|
||||
// refreshing reattaches the SAME shell instead of respawning one.
|
||||
// (The host respawns on its own when the authoritative cwd changed.)
|
||||
// Agent terminals follow the same rule: a close frame kills the pty
|
||||
// (the user closed the sidebar tab); a bare socket drop leaves it
|
||||
// alive (the agent owns the lifetime).
|
||||
if (!store.tabOpen(scope.sessionId, tabId)
|
||||
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'close' }))
|
||||
}
|
||||
socket?.close()
|
||||
term.dispose()
|
||||
connectRef.current = null
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, tabId, store])
|
||||
|
||||
return (
|
||||
<div className={css.terminalWrap}>
|
||||
{fatal !== null && (
|
||||
<div className={css.terminalBanner}>
|
||||
{t('terminalError')}: {fatal}
|
||||
{lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { setFatal(null); connectRef.current?.() }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
|
||||
<div ref={hostRef} className={css.terminal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
/**
|
||||
* The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
|
||||
* syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
|
||||
* save, and a preview/edit toggle for markdown files. Registered as the
|
||||
* `code` (catch-all) and `markdown` built-in viewers; the editor tab host
|
||||
* fetches the content through the fsRead strategy and passes it in props,
|
||||
* so this component never fetches or dispatches — it only edits.
|
||||
*
|
||||
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
|
||||
* 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'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, htmlUrl } from './api.ts'
|
||||
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'
|
||||
|
||||
/** 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
|
||||
* could read session data) and NO allow-top-navigation (a previewed page
|
||||
* must not hijack the GUI). The user can disable the sandbox per-feature
|
||||
* in the side card settings (warned); the toggle below reflects it.
|
||||
*/
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
export function TextEditor(props: FileViewerProps) {
|
||||
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)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<CodeMirrorView | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
/** The theme compartment of the current view (reconfigured on scheme flip). */
|
||||
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()) }), [])
|
||||
|
||||
// A new file (tab switch) starts clean: fresh preview mode, no draft.
|
||||
useEffect(() => {
|
||||
setMode('preview')
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
hidePopup()
|
||||
}, [content])
|
||||
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
// the document; React only tracks dirty/draft state through the update
|
||||
// listener. For markdown the view stays mounted while previewing (hidden),
|
||||
// so unsaved edits survive the preview/edit toggle. The theme + syntax
|
||||
// colors live in a compartment so a scheme flip reconfigures only that
|
||||
// part — the document, undo history and scroll position survive.
|
||||
useEffect(() => {
|
||||
if (content === undefined) return
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
const language = languageForPath(path)
|
||||
const themeComp = new CmThemeCompartment()
|
||||
themeCompRef.current = themeComp
|
||||
const state = EditorState.create({
|
||||
doc: content,
|
||||
extensions: [
|
||||
CodeMirrorView.lineWrapping,
|
||||
lineNumbers(),
|
||||
history(),
|
||||
EditorState.tabSize.of(2),
|
||||
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
|
||||
cmSurfaceTheme,
|
||||
themeComp.of(dark),
|
||||
...(language !== null ? [language] : []),
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
setDraft(update.state.doc.toString())
|
||||
setDirty(true)
|
||||
}
|
||||
}),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
preventDefault: true,
|
||||
run: () => { save(); return true },
|
||||
},
|
||||
...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 })
|
||||
viewRef.current = view
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
themeCompRef.current = null
|
||||
}
|
||||
// The keymap's save() reads live refs; scope/path are stable for a
|
||||
// tab's lifetime, and the dark flip is handled by the reconfigure
|
||||
// effect below (recreating the view here would drop the draft).
|
||||
}, [content, path])
|
||||
|
||||
// Scheme flip: re-theme in place (the compartment holds only the
|
||||
// scheme-dependent extensions; everything else is untouched).
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
const themeComp = themeCompRef.current
|
||||
if (view === null || themeComp === null) return
|
||||
view.dispatch({ effects: themeComp.reconfigure(dark) })
|
||||
}, [dark])
|
||||
|
||||
// The editor may have been display:none while previewing; re-measure when
|
||||
// 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])
|
||||
|
||||
const save = (): void => {
|
||||
const view = viewRef.current
|
||||
if (view === null || savingRef.current) return
|
||||
savingRef.current = true
|
||||
setSaveState('saving')
|
||||
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
|
||||
savingRef.current = false
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('saved')
|
||||
}).catch(() => {
|
||||
savingRef.current = false
|
||||
setSaveState('failed')
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
// plus a per-surface temporary unlock. The unlock state starts at the
|
||||
// "default unsafe" pref so a preview can open straight into the red
|
||||
// unsandboxed state (still restorable from the status row). With the
|
||||
// sandbox OFF the preview iframe drops its sandbox attribute entirely —
|
||||
// the previewed page then runs on the GUI's own origin with full session
|
||||
// access.
|
||||
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
|
||||
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={css.editorHeader}>
|
||||
{(markdown || html) && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
|
||||
onClick={() => { setMode('preview') }}
|
||||
>
|
||||
{t('preview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
|
||||
onClick={() => { setMode('edit') }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('save')}
|
||||
title={`${t('save')} (Ctrl/Cmd+S)`}
|
||||
onClick={save}
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
</button>
|
||||
)}
|
||||
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
||||
</div>
|
||||
{editable && (
|
||||
<>
|
||||
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
<div
|
||||
className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
|
||||
ref={hostRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{markdown && mode === 'preview' && (
|
||||
<div
|
||||
className={css.editorMd}
|
||||
ref={mdRef}
|
||||
onMouseUp={handlePreviewMouseUp}
|
||||
onScroll={hidePopup}
|
||||
>
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
<>
|
||||
<SandboxStatusBar
|
||||
sandboxed={!htmlNoSandbox}
|
||||
local={localUnlock}
|
||||
dangerCopy={t('htmlNoSandboxWarning')}
|
||||
onUnlock={() => { setLocalUnlock(true) }}
|
||||
onRestore={() => { setLocalUnlock(false) }}
|
||||
/>
|
||||
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
|
||||
origin when unsandboxed; the route URL keeps the frame
|
||||
cross-origin by construction). The preview shows the SAVED
|
||||
file; the draft is only visible in edit mode. */}
|
||||
<iframe
|
||||
className={css.editorHtml}
|
||||
src={htmlUrl(scope, path)}
|
||||
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={path}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
|
||||
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
|
||||
* cwd from the client's own list summary. The host prefers its attached
|
||||
* session header and uses the summary cwd only while the session is still
|
||||
* hydrating at page load (a detached session would otherwise fail the
|
||||
* request). Failures surface as {@link SidebarApiError} with the wire code.
|
||||
*/
|
||||
import { encodeHtmlUrl } from '../html-route.ts'
|
||||
import type { BrowserProbeResult } from './browser.ts'
|
||||
|
||||
/** One wire failure. */
|
||||
export class SidebarApiError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Explorer row (host fs-tree shape). */
|
||||
export interface FsEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
/** Git status entry (host git shape). */
|
||||
export interface GitStatusEntry {
|
||||
path: string
|
||||
xy: string
|
||||
}
|
||||
|
||||
/** Git status snapshot. */
|
||||
export interface GitStatusResult {
|
||||
isRepo: boolean
|
||||
branch?: string
|
||||
entries: GitStatusEntry[]
|
||||
}
|
||||
|
||||
/** One git log row. */
|
||||
export interface GitLogEntry {
|
||||
/** Short hash (7+ chars, display). */
|
||||
hash: string
|
||||
/** Full 40-char hash (advanced operations). */
|
||||
hashFull: string
|
||||
subject: string
|
||||
author: string
|
||||
/** ISO 8601 author date (`%ai`). */
|
||||
date: string
|
||||
/** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
|
||||
refs: string
|
||||
}
|
||||
|
||||
/** Text read result. */
|
||||
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
|
||||
/** Binary read result (no content; images load through the media route).
|
||||
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/**
|
||||
* 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
|
||||
* job_output for the job.
|
||||
*/
|
||||
export interface JobOutputResult {
|
||||
text: string
|
||||
/** True when the host capped the text at its output limit. */
|
||||
truncated: boolean
|
||||
/** Whether the model has read the job at least once. */
|
||||
read: boolean
|
||||
}
|
||||
|
||||
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`/sidebar/api/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
|
||||
= await response.json().catch(() => null)
|
||||
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
|
||||
throw new SidebarApiError(
|
||||
parsed?.error?.code ?? 'http',
|
||||
parsed?.error?.message ?? `HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
return parsed.value as T
|
||||
}
|
||||
|
||||
/** One request's session scope: the conversation id plus its cwd when known. */
|
||||
export interface SessionScope {
|
||||
sessionId: string
|
||||
/** The session's working directory from the client list summary (optional). */
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** Fold a scope into a JSON payload ({cwd} only when present). */
|
||||
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
|
||||
}
|
||||
|
||||
/** The sidebar API surface (session scope threaded through every call). */
|
||||
export const api = {
|
||||
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
|
||||
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
|
||||
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
||||
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
||||
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
||||
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
|
||||
gitStage: (scope: SessionScope, path?: string) =>
|
||||
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitUnstage: (scope: SessionScope, path?: string) =>
|
||||
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
||||
gitCommit: (scope: SessionScope, message: string) =>
|
||||
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
|
||||
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
|
||||
gitCheckout: (scope: SessionScope, branch: string) =>
|
||||
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
|
||||
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
|
||||
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
|
||||
call<GitLogEntry[]>('git.log', scopePayload(scope, {
|
||||
...(count !== undefined ? { count } : {}),
|
||||
...(skip !== undefined ? { skip } : {}),
|
||||
}), signal),
|
||||
/** Full patch text of one commit (diff display for the history rows). */
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
|
||||
/** Discard the worktree changes of one file (the index is untouched). */
|
||||
gitDiscard: (scope: SessionScope, path: string) =>
|
||||
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
|
||||
/** Revert one commit onto the current branch. */
|
||||
gitRevert: (scope: SessionScope, hash: string) =>
|
||||
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
|
||||
/** Cherry-pick one commit onto the current branch. */
|
||||
gitCherryPick: (scope: SessionScope, hash: string) =>
|
||||
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
|
||||
/** Release a terminal's process immediately (tab closed; the WS close frame
|
||||
* may be unreachable while the socket is down, so the host also accepts
|
||||
* this explicit route). */
|
||||
ptyClose: (scope: SessionScope, tab: string) =>
|
||||
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
|
||||
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
||||
agentPtyClose: (uuid: string) =>
|
||||
call<{ ok: true }>('agent-pty.close', { uuid }),
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 } : {}),
|
||||
})),
|
||||
/** Read the side card preferences (plugin-global, no session scope). */
|
||||
settingsGet: () =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
||||
/** Merge a patch into the side card preferences (revision-guarded). */
|
||||
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.update', {
|
||||
patch,
|
||||
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
||||
}),
|
||||
/** Probe a URL's response headers (the sidebar browser's embeddability
|
||||
* check; see the host's browser.probe route). */
|
||||
browserProbe: (url: string, signal?: AbortSignal) =>
|
||||
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
export function mediaUrl(scope: SessionScope, path: string): string {
|
||||
return fileUrl(scope, path, false)
|
||||
}
|
||||
|
||||
/** Absolute URL of the download route: serves raw bytes (binary-safe) with
|
||||
* `Content-Disposition: attachment`, so the browser saves the file. */
|
||||
export function downloadUrl(scope: SessionScope, path: string): string {
|
||||
return fileUrl(scope, path, true)
|
||||
}
|
||||
|
||||
/** Shared URL builder for the /sidebar/file route (media vs download). */
|
||||
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
if (download) params.set('download', '1')
|
||||
return `/sidebar/file?${params.toString()}`
|
||||
}
|
||||
|
||||
/** Absolute URL of the HTML preview route (see html-route.ts): the path is
|
||||
* fully encoded so the previewed page's relative assets resolve back into
|
||||
* the same route with the session scope intact. */
|
||||
export function htmlUrl(scope: SessionScope, path: string): string {
|
||||
return encodeHtmlUrl(scope.sessionId, path)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* The "no preview — download instead" pane: shown by the editor host when no
|
||||
* viewer can render a file (binary without a registered renderer, or the
|
||||
* `binary-download` strategy) and registered as the `binary-download` viewer
|
||||
* component so the declarative route and the host fallback share one UI.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { downloadUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode {
|
||||
const { scope, path } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
|
||||
* by the layout logic (JS) and the style gates (CSS). The CSS side pairs
|
||||
* with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
|
||||
* — 767px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
|
||||
*
|
||||
* "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
|
||||
* bottom panel's tabs merged into the right sidebar) is a phone / portrait
|
||||
* tablet experience. The value is deliberately NOT aligned to the DSH app
|
||||
* shell's own 1024px breakpoint — 1024px windows (small laptops, split
|
||||
* panes) keep the desktop two-panel layout.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
|
||||
export const NARROW_MAX_WIDTH = 768
|
||||
|
||||
/** Whether a viewport width is narrow (mobile). */
|
||||
export function isNarrowWidth(width: number): boolean {
|
||||
return width < NARROW_MAX_WIDTH
|
||||
}
|
||||
|
||||
/**
|
||||
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
|
||||
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
|
||||
* Deliberately avoids `matchMedia` (jsdom does not implement it) — the
|
||||
* resize listener is equally exact for a breakpoint that never changes
|
||||
* while the page is open.
|
||||
*/
|
||||
export function useNarrowViewport(): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
|
||||
)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
let frame: number | null = null
|
||||
const measure = (): void => {
|
||||
frame = null
|
||||
setNarrow(isNarrowWidth(window.innerWidth))
|
||||
}
|
||||
const onResize = (): void => {
|
||||
if (frame === null) frame = requestAnimationFrame(measure)
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
return narrow
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Pure URL policy for the built-in browser tab: normalize user input into
|
||||
* an http(s) URL, and refuse destinations that would be dangerous to embed
|
||||
* in the sidebar iframe. Kept dependency-free so it is unit-testable.
|
||||
*
|
||||
* The iframe sandbox (opaque origin, no allow-same-origin / top-navigation)
|
||||
* is the primary security boundary; this module is the address-bar gate on
|
||||
* top of it: only http/https may be navigated, and loopback addresses are
|
||||
* refused so a browsed page cannot probe local services by user action.
|
||||
* The GUI's OWN origin is explicitly ALLOWED — the user may open the GUI
|
||||
* itself in the sidebar (debugging, mirroring); the sandbox still renders
|
||||
* it in an opaque origin with no same-origin privileges, exactly like any
|
||||
* other site.
|
||||
*/
|
||||
|
||||
/** Why a navigation attempt was refused. */
|
||||
export type BrowserBlockReason = 'scheme' | 'loopback'
|
||||
|
||||
/** Result of normalizing one address-bar input. */
|
||||
export type BrowserNavigateResult =
|
||||
| { kind: 'ok'; url: string }
|
||||
| { kind: 'blocked'; reason: BrowserBlockReason }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
/** One browser.probe wire result (host fetch of the target's headers). */
|
||||
export interface BrowserProbeResult {
|
||||
reachable: boolean
|
||||
/** The final (post-redirect) URL; present when reachable. */
|
||||
url?: string
|
||||
status?: number
|
||||
xFrameOptions?: string
|
||||
/** The CSP frame-ancestors source list; present when the directive exists. */
|
||||
frameAncestors?: string[]
|
||||
}
|
||||
|
||||
/** Embeddability verdict of one probe. */
|
||||
export type Embeddability = 'embeddable' | 'blocked' | 'unknown'
|
||||
|
||||
/**
|
||||
* Decide whether a site can render inside the sidebar iframe. The signals
|
||||
* are exactly the ones the BROWSER enforces when it refuses an iframe load:
|
||||
* X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
|
||||
* not allow `*` ('self' here means the SITE's own origin — never ours, so
|
||||
* it also blocks the sidebar). A site we could not reach yields 'unknown'
|
||||
* and the plain iframe stays.
|
||||
*/
|
||||
export function embeddabilityOf(probe: BrowserProbeResult): Embeddability {
|
||||
if (probe.reachable !== true) return 'unknown'
|
||||
const xfo = probe.xFrameOptions?.trim().toUpperCase()
|
||||
if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return 'blocked'
|
||||
if (probe.frameAncestors !== undefined && !probe.frameAncestors.some(source => source === '*')) return 'blocked'
|
||||
return 'embeddable'
|
||||
}
|
||||
|
||||
/** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
if (host === 'localhost' || host === '::1' || host === '0.0.0.0') return true
|
||||
const parts = host.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one address-bar input against the navigation policy.
|
||||
* @param input - raw user text.
|
||||
* @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
|
||||
* itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
|
||||
* is let through BEFORE the loopback check — its host is normally loopback.
|
||||
*/
|
||||
/** Schemes that must never reach the iframe, even without `//` (javascript:,
|
||||
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
|
||||
* they parse as hosts below. */
|
||||
const FORBIDDEN_SCHEMES = new Set([
|
||||
'javascript', 'data', 'file', 'about', 'vbscript', 'blob',
|
||||
'mailto', 'tel', 'ftp', 'ftps', 'ws', 'wss', 'sftp', 'ssh',
|
||||
'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
|
||||
])
|
||||
|
||||
export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult {
|
||||
const trimmed = input.trim()
|
||||
if (trimmed === '') return { kind: 'invalid' }
|
||||
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
|
||||
// would match a naive scheme regex (dots are legal in schemes), so a
|
||||
// scheme prefix is only honored when it is http(s) or a known-forbidden
|
||||
// scheme; anything else is treated as a host and gets https://.
|
||||
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
|
||||
let withScheme: string
|
||||
if (schemeMatch === null) {
|
||||
withScheme = `https://${trimmed}`
|
||||
} else {
|
||||
const scheme = schemeMatch[1]!.toLowerCase()
|
||||
if (scheme === 'http' || scheme === 'https') withScheme = trimmed
|
||||
else if (FORBIDDEN_SCHEMES.has(scheme)) return { kind: 'blocked', reason: 'scheme' }
|
||||
else withScheme = `https://${trimmed}`
|
||||
}
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(withScheme)
|
||||
} catch {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
// The protocol backstop: any URL that still parses to a non-http(s)
|
||||
// scheme (e.g. ftp://, ws:// — which carry `//` and skip the list) is
|
||||
// refused here.
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'blocked', reason: 'scheme' }
|
||||
// The GUI's own origin is ALLOWED (the user may browse the GUI itself in
|
||||
// the sidebar; the sandbox renders it in an opaque origin like any other
|
||||
// site). It must be checked before the loopback gate because its host is
|
||||
// normally loopback.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return { kind: 'ok', url: url.href }
|
||||
} catch {
|
||||
// Unparsable selfOrigin (never in practice): fall through to the loopback gate.
|
||||
}
|
||||
if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' }
|
||||
return { kind: 'ok', url: url.href }
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Built-in registration: the plugin registers its own tab pages and file
|
||||
* previewers through the same {@link BetterSidebarService} external plugins
|
||||
* use — eating its own dogfood. The descriptors live next to their feature
|
||||
* modules (tabs.tsx / viewers.tsx); this module only aggregates them and
|
||||
* owns the disposer lifecycle (cordis auto-invokes it on fiber disposal,
|
||||
* HMR-safe).
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { BetterSidebarService } from '../service.ts'
|
||||
import { builtinTabs } from './tabs.tsx'
|
||||
import { builtinViewers } from './viewers.tsx'
|
||||
|
||||
/**
|
||||
* Register all built-in tabs and viewers with the service. Returns a
|
||||
* disposer that unregisters everything (cordis auto-invokes it on fiber
|
||||
* disposal). The `ctx` is threaded into tab descriptors that need it
|
||||
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
|
||||
*/
|
||||
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
|
||||
const disposers: (() => void)[] = []
|
||||
for (const tab of builtinTabs(ctx)) {
|
||||
disposers.push(service.registerTab(tab))
|
||||
}
|
||||
for (const viewer of builtinViewers()) {
|
||||
disposers.push(service.registerFileViewer(viewer))
|
||||
}
|
||||
return () => {
|
||||
for (const d of disposers) {
|
||||
try { d() } catch { /* already disposed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* The 7 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / browser / subagent / editor / diff) through
|
||||
* the same {@link BetterSidebarService} external plugins use — eating its
|
||||
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
|
||||
* and mints `terminal:<n>` ids through `createTab`; the browser mints
|
||||
* `browser:<n>` the same way (no quota).
|
||||
*/
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
import { ExplorerView } from '../ExplorerView.tsx'
|
||||
import { EditorHost } from '../EditorHost.tsx'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { BrowserView } from '../BrowserView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import type { TabDescriptor } from '../service.ts'
|
||||
|
||||
/**
|
||||
* Lazy wrapper over the terminal view: xterm (and its stylesheet) is fetched
|
||||
* only when a terminal tab is first opened (see chunk-loader.ts). The
|
||||
* wrapper keeps the descriptor contract `(props) => ReactNode` — Sidebar
|
||||
* calls it as a plain function.
|
||||
*
|
||||
* TerminalView's props are { scope, tabId, store } — `tabId` is NOT part of
|
||||
* TabComponentProps (it carries `tab: SidebarTab` instead), so the
|
||||
* descriptor maps it explicitly; a bare pass-through would leave tabId
|
||||
* undefined and TerminalView's isAgentTabId(tabId) would crash on
|
||||
* `undefined.startsWith` (regression-pinned in tests/lazy-chunk.spec.tsx).
|
||||
*/
|
||||
const LazyTerminal = lazyChunkComponent<TerminalViewProps>(
|
||||
'terminal',
|
||||
(mod) => mod.TerminalView as ComponentType<TerminalViewProps> | undefined,
|
||||
)
|
||||
|
||||
/** The terminal view's props (mirror of TerminalView's own signature). */
|
||||
interface TerminalViewProps {
|
||||
scope: SessionScope
|
||||
tabId: string
|
||||
store: SidebarStore
|
||||
}
|
||||
|
||||
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
|
||||
function uiTerminalCount(state: SidebarState): number {
|
||||
return allLeaves(state.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
|
||||
/** The 7 built-in tab descriptors. */
|
||||
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'editor',
|
||||
title: () => t('editor'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.path,
|
||||
component: ({ ctx, store, scope, tab }) => (
|
||||
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
title: () => t('explorer'),
|
||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||
order: 10,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<ExplorerView
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded ?? []}
|
||||
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconBranchOutline16 size={size} />,
|
||||
order: 20,
|
||||
single: true,
|
||||
component: ({ ctx, store, scope, onOpenDiff }) => (
|
||||
<GitView
|
||||
scope={scope}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'subagent',
|
||||
title: () => t('subagent'),
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switches render under this row in
|
||||
// the Side card settings page (the Jobs page's own related settings).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}, {
|
||||
key: 'autoOpenJobs',
|
||||
title: () => t('settingsJobsTitle'),
|
||||
desc: () => t('settingsJobsDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
<SubagentView
|
||||
sessionId={scope.sessionId}
|
||||
ctx={ctx}
|
||||
active={visible}
|
||||
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
title: () => t('terminal'),
|
||||
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).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'agentTerminalTools',
|
||||
title: () => t('settingsToolsTitle'),
|
||||
desc: () => t('settingsToolsDesc'),
|
||||
}, {
|
||||
key: 'bottomPanelAutoTerminal',
|
||||
title: () => t('settingsBottomTerminalTitle'),
|
||||
desc: () => t('settingsBottomTerminalDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => {
|
||||
const count = uiTerminalCount(state)
|
||||
if (count >= TERMINAL_LIMIT) return null
|
||||
return {
|
||||
tab: {
|
||||
id: `terminal:${state.nextTerminal}`,
|
||||
type: 'terminal',
|
||||
title: `${t('terminal')} ${state.nextTerminal}`,
|
||||
},
|
||||
patch: { nextTerminal: state.nextTerminal + 1 },
|
||||
}
|
||||
},
|
||||
component: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
title: () => t('browser'),
|
||||
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
||||
order: 50,
|
||||
// Declarative settings: the sandbox escape hatch and the link
|
||||
// takeover render under this tab's row in the Side card settings
|
||||
// page (the sandbox one is warned on).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'browserNoSandbox',
|
||||
title: () => t('settingsBrowserSandboxTitle'),
|
||||
desc: () => t('settingsBrowserSandboxDesc'),
|
||||
}, {
|
||||
key: 'browserInterceptLinks',
|
||||
title: () => t('settingsBrowserLinksTitle'),
|
||||
desc: () => t('settingsBrowserLinksDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => ({
|
||||
tab: {
|
||||
id: `browser:${state.nextBrowser}`,
|
||||
type: 'browser',
|
||||
title: t('browser'),
|
||||
},
|
||||
patch: { nextBrowser: state.nextBrowser + 1 },
|
||||
}),
|
||||
component: (props) => <BrowserView {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
title: () => t('git'),
|
||||
icon: (size: number) => <IconDiffOutline16 size={size} />,
|
||||
order: -1,
|
||||
hidden: true,
|
||||
dedupeKey: (tab) => tab.id,
|
||||
component: ({ scope, tab }) => (
|
||||
tab.diff === undefined ? null
|
||||
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* 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 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`.
|
||||
*
|
||||
* Every viewer carries the declarative settings-surface fields — `title`
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
* inventory without hardcoding (eating our own dogfood).
|
||||
*/
|
||||
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
import { BinaryDownload } from '../binary-download.tsx'
|
||||
import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
IconHtmlOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { FileViewerDescriptor, FileViewerProps } from '../service.ts'
|
||||
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).
|
||||
*/
|
||||
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. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'image',
|
||||
title: () => t('viewerImage'),
|
||||
icon: (size: number) => <IconImageOutline16 size={size} />,
|
||||
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ mediaUrl: url, title }) => (
|
||||
<div className={css.editorImageWrap}>
|
||||
<img className={css.editorImage} src={url} alt={title} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
title: () => t('viewerPdf'),
|
||||
icon: (size: number) => <IconPdfOutline16 size={size} />,
|
||||
exts: ['pdf'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: ({ scope, path, title }) => (
|
||||
<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'),
|
||||
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
|
||||
exts: ['md', 'markdown'],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'html',
|
||||
title: () => t('viewerHtml'),
|
||||
icon: (size: number) => <IconHtmlOutline16 size={size} />,
|
||||
exts: ['html', 'htm'],
|
||||
fetchStrategy: 'fsRead',
|
||||
// Declarative settings: the sandbox escape hatch and the default-unsafe
|
||||
// start state render under this viewer's row in the Side card settings
|
||||
// page (both warned on).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'htmlViewerNoSandbox',
|
||||
title: () => t('settingsHtmlSandboxTitle'),
|
||||
desc: () => t('settingsHtmlSandboxDesc'),
|
||||
}, {
|
||||
key: 'htmlViewerDefaultUnsafe',
|
||||
title: () => t('settingsHtmlDefaultUnsafeTitle'),
|
||||
desc: () => t('settingsHtmlDefaultUnsafeDesc'),
|
||||
}],
|
||||
},
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
title: () => t('viewerCode'),
|
||||
icon: (size: number) => <IconCodeOutline16 size={size} />,
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <LazyTextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
title: () => t('viewerBinary'),
|
||||
icon: (size: number) => <IconDownloadOutline16 size={size} />,
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
// NUL probe: a file whose head bytes contain a NUL is binary — claimed
|
||||
// before the catch-all code viewer on the head re-match.
|
||||
detect: (_path, head) => head.includes(0),
|
||||
component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* How a chunk script works (see tsdown.config.ts chunkBundle):
|
||||
*
|
||||
* globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
|
||||
* globalThis.__dshChunks__["terminal"] = (require) => { ...exports };
|
||||
*
|
||||
* The script registers its factory on a plugin-owned global registry (NOT
|
||||
* through window.__ModuleLoader__.load — the module loader's import() only
|
||||
* resolves seed words, shell-own modules, registered factories, and boot
|
||||
* graph rows; a chunk id is none of those, so resolution would be version-
|
||||
* dependent). Materialization is plugin-owned:
|
||||
*
|
||||
* 1. inject <script src="/sidebar/bundle/<name>.js"> (classic same-origin
|
||||
* script; the official /plugins/<id>/client.js route cannot serve
|
||||
* arbitrary file names, so the plugin's own host route serves the chunks),
|
||||
* 2. read the factory from the global registry,
|
||||
* 3. call it with a require that resolves the platform externals through
|
||||
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
|
||||
* the module system that is stable across versions.
|
||||
*
|
||||
* Caching contract (three layers, each with a failure path):
|
||||
* - In-memory: one in-flight promise per chunk, memoized until
|
||||
* {@link resetChunks}; a failed load removes its entry so the next call
|
||||
* retries from scratch.
|
||||
* - Script execution: each re-execution overwrites the global registry slot
|
||||
* (assignment, never registration) — no "duplicate factory registration"
|
||||
* class of errors; a failed materialization clears the cache so the retry
|
||||
* re-injects and re-executes.
|
||||
* - HTTP: the bundle route revalidates every request (`cache-control:
|
||||
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
|
||||
* re-activations never re-download a multi-MB chunk that did not change.
|
||||
*
|
||||
* HMR: each plugin activation calls {@link resetChunks}, which drops the
|
||||
* in-memory cache (and any test registry), so a hot-reloaded core bundle
|
||||
* re-fetches and re-executes the current chunk scripts on the next lazy
|
||||
* 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'
|
||||
|
||||
/** The module exports a chunk factory provides (namespace-ish record). */
|
||||
export type ChunkExports = Record<string, unknown>
|
||||
|
||||
/** A chunk factory: (require) => exports (the chunk's CJS closure shape). */
|
||||
type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
|
||||
|
||||
/**
|
||||
* The platform externals a chunk bundle may require (mirror of
|
||||
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
|
||||
* external and the loader resolves them here). A superset is safe: the
|
||||
* require only answers what the chunk actually asks for.
|
||||
*/
|
||||
export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
|
||||
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
|
||||
|
||||
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
|
||||
interface ChunkModuleSystem {
|
||||
import(specifier: string): Promise<unknown>
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (set before any plugin activates). */
|
||||
function moduleSystem(): ChunkModuleSystem | undefined {
|
||||
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The plugin-owned chunk factory registry the chunk scripts populate. */
|
||||
interface ChunkRegistry {
|
||||
[name: string]: ChunkFactory | undefined
|
||||
}
|
||||
|
||||
function chunkRegistry(): ChunkRegistry {
|
||||
const g = globalThis as { __dshChunks__?: ChunkRegistry }
|
||||
return g.__dshChunks__ ??= {}
|
||||
}
|
||||
|
||||
/** Script-load hook; tests replace it with a stub (the default needs a real DOM + network). */
|
||||
export type ChunkScriptLoader = (src: string) => Promise<void>
|
||||
|
||||
const defaultScriptLoader: ChunkScriptLoader = (src) => new Promise((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
el.async = true
|
||||
el.src = src
|
||||
el.addEventListener('load', () => {
|
||||
el.remove()
|
||||
resolve()
|
||||
}, { once: true })
|
||||
el.addEventListener('error', () => {
|
||||
el.remove()
|
||||
reject(new Error(`[dsh-better-sidebar] chunk script ${src} failed to load`))
|
||||
}, { once: true })
|
||||
document.head.append(el)
|
||||
})
|
||||
|
||||
let scriptLoader: ChunkScriptLoader = defaultScriptLoader
|
||||
|
||||
/** Test hook: replace the chunk-script loader (pass null to restore the default). */
|
||||
export function setChunkScriptLoaderForTests(loader: ChunkScriptLoader | null): void {
|
||||
scriptLoader = loader ?? defaultScriptLoader
|
||||
}
|
||||
|
||||
/** Test/dev hook: resolve a chunk without fetching a script (e.g. vitest). */
|
||||
const testLoaders = new Map<ChunkName, () => Promise<ChunkExports>>()
|
||||
export function registerChunkForTests(name: ChunkName, loader: () => Promise<ChunkExports>): void {
|
||||
testLoaders.set(name, loader)
|
||||
}
|
||||
|
||||
/** Memoized externals require, resolved once per page from the seed table. */
|
||||
let externalsRequire: ((spec: string) => unknown) | undefined
|
||||
|
||||
async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec: string) => unknown> {
|
||||
if (externalsRequire !== undefined) return externalsRequire
|
||||
// Per-spec tolerance: a spec the running DSH version cannot resolve (e.g.
|
||||
// the runtime/client exemption row) stays unresolved until a chunk
|
||||
// actually requires it — only then it is a loud error.
|
||||
const entries = await Promise.all(CHUNK_EXTERNALS.map(async (spec) => {
|
||||
try {
|
||||
return [spec, await modules.import(spec)] as const
|
||||
} catch {
|
||||
return [spec, undefined] as const
|
||||
}
|
||||
}))
|
||||
const table = new Map<string, unknown>(entries)
|
||||
externalsRequire = (spec: string): unknown => {
|
||||
if (!table.has(spec)) {
|
||||
// Single quotes: the bundle-consistency scan regexes for require("...")
|
||||
// lexical calls — a double-quoted literal here would trip it.
|
||||
throw new Error(`[dsh-better-sidebar] chunk require('${spec}') missed the module table`)
|
||||
}
|
||||
return table.get(spec)
|
||||
}
|
||||
return externalsRequire
|
||||
}
|
||||
|
||||
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
|
||||
const cache = new Map<ChunkName, Promise<ChunkExports>>()
|
||||
|
||||
/**
|
||||
* Load (once) and materialize a lazy chunk, returning its module exports.
|
||||
* Concurrent callers share one in-flight load; a failure clears the cache
|
||||
* entry so the next call retries (the script re-executes and overwrites its
|
||||
* global registry slot — assignments are idempotent).
|
||||
* @param name - the chunk to load.
|
||||
*/
|
||||
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
const cached = cache.get(name)
|
||||
if (cached !== undefined) return cached
|
||||
const task = (async (): Promise<ChunkExports> => {
|
||||
const test = testLoaders.get(name)
|
||||
if (test !== undefined) return test()
|
||||
const modules = moduleSystem()
|
||||
if (modules === undefined) {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}": client module system unavailable`)
|
||||
}
|
||||
await scriptLoader(CHUNK_URL(name))
|
||||
const factory = chunkRegistry()[name]
|
||||
if (typeof factory !== 'function') {
|
||||
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
|
||||
}
|
||||
const require = await buildExternalsRequire(modules)
|
||||
return factory(require)
|
||||
})()
|
||||
cache.set(name, task)
|
||||
void task.catch(() => { cache.delete(name) })
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
|
||||
* in-memory cache and any test-registry entries, so the next lazy open
|
||||
* re-fetches and re-executes the current chunk scripts (the registry slots
|
||||
* are overwritten by the re-execution — no cleanup needed).
|
||||
*/
|
||||
export function resetChunks(): void {
|
||||
cache.clear()
|
||||
testLoaders.clear()
|
||||
externalsRequire = undefined
|
||||
}
|
||||
@@ -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,9 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the code/markdown/html text editor (CodeMirror 6 + the
|
||||
* language packages). Built as `lib/client-editor.js` and registered under
|
||||
* `dsh-better-sidebar/editor` — fetched only when a text file 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 CodeMirror into
|
||||
* the startup path.
|
||||
*/
|
||||
export { TextEditor } from '../TextEditor.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 interactive terminal (xterm + fit addon). Built as
|
||||
* `lib/client-terminal.js` and registered under
|
||||
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab 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 xterm (and its
|
||||
* stylesheet) into the startup path.
|
||||
*/
|
||||
export { TerminalView } from '../TerminalView.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'
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
|
||||
* (background, caret, gutter) rides the DSH theme tokens so it blends with
|
||||
* the panel in both schemes; only the syntax token colors need concrete
|
||||
* values, and those come from the same designed palettes the app's code
|
||||
* surfaces use — the one-dark family for dark, the one-light family for
|
||||
* light. The scheme flip reconfigures these via a compartment (see
|
||||
* TextEditor), so the document, undo history and scroll survive re-theming.
|
||||
*/
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags, type Tag } from '@lezer/highlight'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
/** Token-driven surface shared by both schemes (pure CSS values). */
|
||||
export const cmSurfaceTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '13px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--dsw-alias-label-primary)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'var(--ds-font-family-code)',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--dsw-alias-label-primary)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--dsw-alias-label-tertiary)',
|
||||
border: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
/** Scheme-specific surface tints (selection, active line). */
|
||||
function cmSurfaceTint(dark: boolean): ReturnType<typeof EditorView.theme> {
|
||||
return EditorView.theme({
|
||||
'.cm-selectionBackground, .cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
||||
},
|
||||
'.cm-activeLine, .cm-activeLineGutter': {
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** One syntax rule: a tag (or tag set) mapped to a concrete color/style. */
|
||||
interface HighlightRule {
|
||||
tag: Tag | readonly Tag[]
|
||||
color?: string
|
||||
fontStyle?: string
|
||||
}
|
||||
|
||||
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
|
||||
const HIGHLIGHTS_DARK: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{ tag: tags.string, color: '#98c379' },
|
||||
{ tag: tags.number, color: '#d19a66' },
|
||||
{ tag: tags.bool, color: '#d19a66' },
|
||||
{ tag: tags.atom, color: '#d19a66' },
|
||||
{ tag: tags.typeName, color: '#e5c07b' },
|
||||
{ tag: tags.className, color: '#e5c07b' },
|
||||
{ tag: tags.propertyName, color: '#e06c75' },
|
||||
{ tag: tags.function(tags.variableName), color: '#61afef' },
|
||||
{ tag: tags.variableName, color: '#e06c75' },
|
||||
{ tag: tags.operator, color: '#56b6c2' },
|
||||
{ tag: tags.tagName, color: '#e06c75' },
|
||||
{ tag: tags.attributeName, color: '#d19a66' },
|
||||
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#e5c07b' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** one-light syntax palette (the light counterpart of one-dark). */
|
||||
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#a626a4' },
|
||||
{ tag: tags.string, color: '#50a14f' },
|
||||
{ tag: tags.number, color: '#986801' },
|
||||
{ tag: tags.bool, color: '#0184bc' },
|
||||
{ tag: tags.atom, color: '#0184bc' },
|
||||
{ tag: tags.typeName, color: '#c18401' },
|
||||
{ tag: tags.className, color: '#c18401' },
|
||||
{ tag: tags.propertyName, color: '#e45649' },
|
||||
{ tag: tags.function(tags.variableName), color: '#c18401' },
|
||||
{ tag: tags.variableName, color: '#e45649' },
|
||||
{ tag: tags.operator, color: '#383a42' },
|
||||
{ tag: tags.tagName, color: '#e45649' },
|
||||
{ tag: tags.attributeName, color: '#986801' },
|
||||
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#c18401' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
|
||||
function cmThemeExtensions(dark: boolean): Array<ReturnType<typeof EditorView.theme>> {
|
||||
return [
|
||||
cmSurfaceTint(dark),
|
||||
syntaxHighlighting(HighlightStyle.define(dark ? HIGHLIGHTS_DARK : HIGHLIGHTS_LIGHT)),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* A Compartment holding the two scheme-dependent extensions. Created once
|
||||
* per editor view; a scheme flip dispatches `reconfigure(dark)` on it, so
|
||||
* the document, undo history, scroll and keymaps survive re-theming.
|
||||
*/
|
||||
export class CmThemeCompartment {
|
||||
private readonly compartment = new Compartment()
|
||||
|
||||
/** `of(...)` payload for EditorState.create. */
|
||||
of(dark: boolean): ReturnType<Compartment['of']> {
|
||||
return this.compartment.of(cmThemeExtensions(dark))
|
||||
}
|
||||
|
||||
/** Reconfigure for a new scheme. */
|
||||
reconfigure(dark: boolean): ReturnType<Compartment['reconfigure']> {
|
||||
return this.compartment.reconfigure(cmThemeExtensions(dark))
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/** CSS Modules class-map declaration (tsdown compiles .module.css at build). */
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -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,92 +0,0 @@
|
||||
/**
|
||||
* Pure editor-load planning: the decision logic the editor host needs to
|
||||
* turn a matched file viewer + a host fs.read result into a render action.
|
||||
* Kept dependency-free (no React, no fetch) so the strategy dispatch is
|
||||
* unit-testable and the wire contract (head bytes, binary flag) is pinned.
|
||||
*
|
||||
* The host flow this module drives:
|
||||
* 1. `matchFileViewer(path)` picks a viewer by extension/priority.
|
||||
* 2. `planFirstMatch` dispatches its fetchStrategy.
|
||||
* 3. An fsRead viewer fetches through the host; `planFsReadOutcome`
|
||||
* decides what to do with the result — including the head-based
|
||||
* re-match that lets a `detect` viewer claim a binary the extension
|
||||
* match could not see (the builtin NUL probe on `binary-download`).
|
||||
*/
|
||||
import type { FileViewerDescriptor } from './service.ts'
|
||||
|
||||
/** One host fs.read result (mirror of the wire; `head` present when binary). */
|
||||
export interface FsReadOutcome {
|
||||
binary: boolean
|
||||
content: string
|
||||
truncated: boolean
|
||||
/** base64 of the first bytes (present on binary reads; sniffing material). */
|
||||
head?: string
|
||||
}
|
||||
|
||||
/** What the editor host should do next. */
|
||||
export type EditorLoadAction =
|
||||
/** No renderer: show the download UI. */
|
||||
| { kind: 'binary' }
|
||||
/** Render `viewer`'s component with the carried payload. */
|
||||
| { kind: 'render'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
||||
/** Fetch the file through the host (fsRead strategy). */
|
||||
| { kind: 'fetchFsRead'; viewer: FileViewerDescriptor }
|
||||
/** Call the viewer's load() and render with its return value. */
|
||||
| { kind: 'customLoad'; viewer: FileViewerDescriptor }
|
||||
|
||||
/** Decode the host's base64 head bytes into the sniffing buffer. */
|
||||
export function decodeHead(headBase64: string): Uint8Array {
|
||||
const binary = atob(headBase64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one matched viewer's fetchStrategy. A missing viewer or a
|
||||
* `binary-download` strategy both mean "no client-side renderer" → the
|
||||
* download UI. `mediaUrlOf` builds the media URL for `mediaUrl`/`none`
|
||||
* strategies (pure, but scope-bound — injected by the host).
|
||||
*/
|
||||
export function planFirstMatch(
|
||||
viewer: FileViewerDescriptor | undefined,
|
||||
mediaUrlOf: () => string,
|
||||
): EditorLoadAction {
|
||||
if (viewer === undefined || viewer.fetchStrategy === 'binary-download') return { kind: 'binary' }
|
||||
switch (viewer.fetchStrategy) {
|
||||
case 'mediaUrl':
|
||||
case 'none':
|
||||
return { kind: 'render', viewer, mediaUrl: mediaUrlOf() }
|
||||
case 'custom':
|
||||
return { kind: 'customLoad', viewer }
|
||||
case 'fsRead':
|
||||
return { kind: 'fetchFsRead', viewer }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what an fsRead result means for the editor.
|
||||
* - Text: the first match stands (content is valid for any fsRead viewer).
|
||||
* - Binary: the host head bytes enable a re-match — a `detect` viewer (e.g.
|
||||
* a plugin sniffing a binary format) may claim the file. `custom` viewers
|
||||
* load their own bytes; `mediaUrl`/`none` viewers render the media route;
|
||||
* an fsRead viewer or nothing cannot render binary → download UI.
|
||||
*/
|
||||
export function planFsReadOutcome(
|
||||
viewer: FileViewerDescriptor,
|
||||
result: FsReadOutcome,
|
||||
rematch: (head: Uint8Array) => FileViewerDescriptor | undefined,
|
||||
mediaUrlOf: () => string,
|
||||
): EditorLoadAction {
|
||||
if (!result.binary) {
|
||||
return { kind: 'render', viewer, content: result.content, truncated: result.truncated }
|
||||
}
|
||||
const claimed = result.head === undefined ? undefined : rematch(decodeHead(result.head))
|
||||
if (claimed !== undefined && claimed.fetchStrategy === 'custom') {
|
||||
return { kind: 'customLoad', viewer: claimed }
|
||||
}
|
||||
if (claimed !== undefined && (claimed.fetchStrategy === 'mediaUrl' || claimed.fetchStrategy === 'none')) {
|
||||
return { kind: 'render', viewer: claimed, mediaUrl: mediaUrlOf() }
|
||||
}
|
||||
return { kind: 'binary' }
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
|
||||
* icon library has none), a diff glyph, and the two panel-toggle glyphs for
|
||||
* the top-right cluster. Per-tab icons live on the tab descriptors
|
||||
* (`descriptor.icon`), not in a type-keyed switch — the icon mapping was
|
||||
* registry-ized with the tab types.
|
||||
*/
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/**
|
||||
* Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip
|
||||
* along its RIGHT edge, in the app's outline style (1.5px stroke,
|
||||
* currentColor).
|
||||
*/
|
||||
export const IconPanelRightOutline16 = ({ 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.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="10.5" y="3.25" width="2.75" height="9.5" rx="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip
|
||||
* along its BOTTOM edge, in the app's outline style.
|
||||
*/
|
||||
export const IconPanelBottomOutline16 = ({ 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.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="3.25" y="10" width="9.5" height="2.75" rx="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
|
||||
* a rounded frame with a prompt chevron and underscore cursor.
|
||||
*/
|
||||
export const IconTerminalOutline16 = ({ 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.5 6.25 6.75 8 4.5 9.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.5 10.4h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Diff glyph in the app's outline style: a file frame with a plus and a minus row. */
|
||||
export const IconDiffOutline16 = ({ 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="1.5" width="13" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 5h3M5.5 3.5v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
export const IconImageOutline16 = ({ 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" />
|
||||
<circle cx="5.5" cy="6" r="1.2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="m3.5 12 3-3 2.25 2.25L11.5 8.5 13 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PDF viewer glyph: a document frame with the "PDF" label. */
|
||||
export const IconPdfOutline16 = ({ 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="M5 13.5v-3h1.4c.75 0 1.1.32 1.1.85 0 .54-.35.85-1.1.85H5.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.3 13.5v-3h1.05c.8 0 1.35.5 1.35 1.5s-.55 1.5-1.35 1.5z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.6 13.5v-3h1.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</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">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** HTML viewer glyph: a document frame with a "‹/›" tag pair. */
|
||||
export const IconHtmlOutline16 = ({ 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="M5.6 13.2 4.2 10l1.4-3.2M7.4 6.8 8.8 10l-1.4 3.2" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Browser tab glyph: a globe with meridians. */
|
||||
export const IconGlobeOutline16 = ({ 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">
|
||||
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<ellipse cx="8" cy="8" rx="2.8" ry="6.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
/** Image extension dispatch, separate from React for unit testing. */
|
||||
export const IMAGE_EXTENSIONS = [
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif',
|
||||
] as readonly string[]
|
||||
|
||||
export function isImageExt(ext: string): boolean {
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* IME-composition key guard.
|
||||
*
|
||||
* While a Chinese/Japanese/Korean input method is composing (the user is
|
||||
* picking a candidate from the IME window), every pressed key BELONGS to the
|
||||
* input method: arrows move the candidate highlight, Enter/Space confirm the
|
||||
* composition, Escape cancels it. Page code must not process those keys —
|
||||
* a component that does (a number stepper calling preventDefault() on
|
||||
* ArrowUp/ArrowDown, a submit handler reacting to Enter, ...) silently
|
||||
* breaks the IME: candidates stop responding, the composition gets torn
|
||||
* apart, and only bare letters come out.
|
||||
*
|
||||
* This guard enforces that rule at the document boundary: a capture-phase
|
||||
* keydown/keyup listener that stops the event from propagating further
|
||||
* whenever a composition is in progress. Because it runs in the capture
|
||||
* phase on `document` — the outermost node — it fires BEFORE React's
|
||||
* delegated handlers (attached at the root container) and before any native
|
||||
* target/bubble listener, so an inlined third-party component (e.g. the
|
||||
* Univer office UI bundled into this plugin) can never intercept
|
||||
* composition keys. The browser's native IME processing is untouched:
|
||||
* stopPropagation only silences page JS, not the default action.
|
||||
*
|
||||
* The composition signal follows the DSH core convention (InputBar's IME
|
||||
* guard, issue #535): `isComposing` for modern engines, keyCode 229 as the
|
||||
* legacy signal engines emit without isComposing.
|
||||
*/
|
||||
|
||||
/** The pure decision: is this keyboard event part of an IME composition? */
|
||||
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing || event.keyCode === 229
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the document-level capture guard. Returns the disposer
|
||||
* (HMR-safe; call through `ctx.effect`).
|
||||
*/
|
||||
export function registerImeGuard(): () => void {
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (isImeComposition(event)) event.stopPropagation()
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
document.addEventListener('keyup', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
document.removeEventListener('keyup', onKey, true)
|
||||
}
|
||||
}
|
||||
+454
-221
@@ -1,238 +1,471 @@
|
||||
/**
|
||||
* Client half of dsh-better-sidebar: resolves the user's "Side card"
|
||||
* preferences through the plugin's own fenced settings route, mounts the
|
||||
* right sidebar portal (inside an error boundary so a rendering failure
|
||||
* shows an error strip instead of a blank panel), registers the turn-tail
|
||||
* interception, and contributes the Side card settings section to the DSH
|
||||
* Settings shell. Requires the runtime's slots and sessions services; the
|
||||
* bundle itself is a module-table consumer only (react + ui-primitives +
|
||||
* xterm, all provided or inlined).
|
||||
* apes-sidebar client-half entry (Phase 1: the five real `apes:*` tabs).
|
||||
*
|
||||
* Loaded through the package.json `dsh.client.inject` declaration (web
|
||||
* platform). The entry registers the five apes-sidebar tab descriptors —
|
||||
* `apes:tasks`, `apes:agents`, `apes:apps`, `apes:messages`, `apes:profile` —
|
||||
* into the better-sidebar host's `ctx.betterSidebar` registry, then disposes
|
||||
* cleanly on fiber teardown (HMR-safe).
|
||||
*
|
||||
* Registration is wrapped in a single `ctx.effect(...)`: the effect body
|
||||
* registers the i18n dictionaries plus all five tabs and returns the combined
|
||||
* disposer, which cordis invokes on activation teardown. A missing
|
||||
* `betterSidebar` (bare DSH, no better-sidebar installed) degrades silently —
|
||||
* a missing peer must never break the shell.
|
||||
*
|
||||
* Type base: the descriptors and the component props are the REAL
|
||||
* better-sidebar `TabDescriptor` / `TabComponentProps` (type-only import —
|
||||
* erased at build, never bundled, and it triggers the `declare module
|
||||
* '@deepseek-ai/cordis'` augmentation so `ctx.betterSidebar` type-checks).
|
||||
*
|
||||
* inject list (client-only):
|
||||
* - betterSidebar: the better-sidebar registry service (peer, optional)
|
||||
* - locale: the DSH locale runtime (dictionaries register under apesSidebar)
|
||||
* - sessions: the client session list feed (drives tasks + agents)
|
||||
*/
|
||||
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { createSidebarStore } from './state.ts'
|
||||
import { createBetterSidebarService } from './service.ts'
|
||||
import { resetChunks } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime; the
|
||||
* locale service backs the sidebar's copy — see locales.ts). */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
|
||||
import { useSyncExternalStore, type ReactElement, type ReactNode } from 'react'
|
||||
import type { TabDescriptor, TabComponentProps, Context } from 'dsh-better-sidebar'
|
||||
import { t, registerLocales, useApesLocale } from './locales.ts'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The five tab ids, in + menu order (ascending). Phase 2 adds badge counts
|
||||
* and auto-collapse on top of these same descriptors.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
const TABS = {
|
||||
tasks: 'apes:tasks',
|
||||
agents: 'apes:agents',
|
||||
apps: 'apes:apps',
|
||||
messages: 'apes:messages',
|
||||
profile: 'apes:profile',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Client plugin body.
|
||||
* @param ctx - the client cordis context (slots, sessions).
|
||||
* The cordis Context face this entry resolves against: the better-sidebar
|
||||
* `Context` (a `CordisContext & SidebarContextShape` intersection that
|
||||
* carries `sessions`, `locale`, and `betterSidebar`). `betterSidebar` is
|
||||
* typed required by the peer, but a bare DSH profile lacks it at runtime —
|
||||
* the apply() guard handles the absence, so we keep the peer's type as-is
|
||||
* and guard on the value.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// The sidebar follows the DSH i18n system: attach the locale service so
|
||||
// the module-level t()/isZh() resolve the Host-backed language preference
|
||||
// (and switch live — the Sidebar root subscribes to it), and register the
|
||||
// plugin's dictionaries into the shared locale registry. The disposers
|
||||
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
|
||||
attachLocale(ctx.locale)
|
||||
ctx.effect(() => {
|
||||
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
||||
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
||||
return () => { offZh(); offEn() }
|
||||
}, 'dsh-better-sidebar: dictionaries')
|
||||
// One store instance per activation: production code creates it only here,
|
||||
// then hands it to the mounted panel and closes over it in the slot
|
||||
// registrations (the official createXXXStore() factory rule — no
|
||||
// module-level singleton).
|
||||
const sidebarStore = createSidebarStore()
|
||||
// The sidebar registry service: external plugins register tab types and
|
||||
// file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`.
|
||||
// Published before the panel mounts so consumers injecting 'betterSidebar'
|
||||
// are ready by the time the sidebar renders.
|
||||
const service = createBetterSidebarService(sidebarStore)
|
||||
ctx.provide('betterSidebar', service)
|
||||
// Register the plugin's own built-in tabs and viewers through the same
|
||||
// service (eating our own dogfood). The disposer unregisters them on
|
||||
// fiber disposal (HMR-safe).
|
||||
ctx.effect(
|
||||
() => registerBuiltins(ctx, service),
|
||||
'dsh-better-sidebar: register built-in tabs and viewers',
|
||||
type ApesClientContext = Context
|
||||
|
||||
/**
|
||||
* Read the client session list uSES-safely, with `visible`-gated pausing
|
||||
* (Phase 2 auto-collapse). `useSyncExternalStore` re-subscribes whenever the
|
||||
* `subscribe` identity changes, so passing a no-op subscribe while the tab is
|
||||
* not the active-open one stops live session-list churn from re-rendering a
|
||||
* hidden panel — exactly better-sidebar's "live views pause otherwise"
|
||||
* contract. The snapshot itself is always read (cheap), so a hidden tab still
|
||||
* shows its last-known data when re-shown.
|
||||
*/
|
||||
const NOOP_SUBSCRIBE = () => () => {}
|
||||
function useSessionList(ctx: Context, visible: boolean) {
|
||||
const list = ctx.sessions.list
|
||||
return useSyncExternalStore(
|
||||
visible ? list.subscribe : NOOP_SUBSCRIBE,
|
||||
list.getSnapshot,
|
||||
list.getSnapshot,
|
||||
)
|
||||
// A failure anywhere in the client lifecycle must never take the app down
|
||||
// silently: log with the plugin prefix and pin a visible diagnostic strip
|
||||
// to the page so a blank panel is never the only symptom.
|
||||
const fail = (phase: string, error: unknown): void => {
|
||||
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
|
||||
try {
|
||||
const bar = document.createElement('div')
|
||||
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
|
||||
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
|
||||
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
|
||||
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
|
||||
document.body.appendChild(bar)
|
||||
} catch {
|
||||
// Nothing left to report with.
|
||||
}
|
||||
}
|
||||
|
||||
/** Count of sessions whose agent is currently running (badge source). */
|
||||
function runningCount(ctx: Context): number {
|
||||
const { byId } = ctx.sessions.list.getSnapshot()
|
||||
let n = 0
|
||||
for (const id in byId) {
|
||||
if (byId[id]?.running) n++
|
||||
}
|
||||
try {
|
||||
// Fresh chunk state for this activation: invalidate any chunk factories
|
||||
// registered by a previous fiber (HMR) and drop the in-memory load cache
|
||||
// so the next lazy open re-fetches the current chunk scripts.
|
||||
resetChunks()
|
||||
ctx.effect(() => {
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
void (async () => {
|
||||
// Resolve the user's side card prefs BEFORE the first session seeds,
|
||||
// so a brand-new conversation opens (or stays closed) at the chosen
|
||||
// width from first paint. A settings route failure falls back to the
|
||||
// schema defaults; the sidebar still mounts (a stalled wire gives up
|
||||
// after the timeout and mounts on the defaults).
|
||||
const prefs = await Promise.race([
|
||||
loadPrefs(api),
|
||||
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
|
||||
])
|
||||
if (prefs !== null) sidebarStore.setPrefs(prefs)
|
||||
if (disposed) return
|
||||
try {
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
root?.unmount()
|
||||
host?.remove()
|
||||
}
|
||||
}, 'dsh-better-sidebar: sidebar mount')
|
||||
return n
|
||||
}
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerTurnTailInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: turn-tail interception',
|
||||
)
|
||||
/** Count of subagent-origin sessions (badge source for apes:agents). */
|
||||
function subagentCount(ctx: Context): number {
|
||||
const { byId } = ctx.sessions.list.getSnapshot()
|
||||
let n = 0
|
||||
for (const id in byId) {
|
||||
if (byId[id]?.origin === 'subagent') n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerOpenPathInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: open-path interception',
|
||||
)
|
||||
/**
|
||||
* Per-tab state (Phase 2): the apes:tasks panel's `showInactive` preference,
|
||||
* persisted across reloads through better-sidebar's settings service
|
||||
* (`ctx.settings.register`). The namespace handle is registered ONCE at
|
||||
* module scope (registering per render would re-stack schemas); the hook
|
||||
* reads it uSES-safely and writes back via `update`.
|
||||
*
|
||||
* Degrades to the default (`showInactive: true`) when `ctx.settings` is
|
||||
* absent — a bare DSH profile or a unit-test fake that omits the service.
|
||||
*/
|
||||
const TASKS_NS = 'apesSidebar.tasks'
|
||||
interface TasksPrefs {
|
||||
showInactive: boolean
|
||||
}
|
||||
let tasksPrefsHandle: {
|
||||
get(): TasksPrefs
|
||||
watch(cb: (next: TasksPrefs, prev: TasksPrefs) => void): () => void
|
||||
update(patch: Partial<TasksPrefs>): Promise<void>
|
||||
} | undefined
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
// External http(s) links in the chat/GUI open the sidebar browser
|
||||
// instead of a new window (gated on the browserInterceptLinks pref
|
||||
// AND the browser tab's enable switch; Ctrl/Cmd+click bypasses).
|
||||
return registerLinkInterception({
|
||||
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false
|
||||
&& sidebarStore.getPrefs().tabsEnabled['browser'] !== false,
|
||||
openInSidebar: (url) => {
|
||||
let title: string | undefined
|
||||
try { title = new URL(url).hostname } catch { /* keep the default title */ }
|
||||
ctx.betterSidebar?.openTab({ type: 'browser', url, title })
|
||||
},
|
||||
selfOrigin: window.location.origin,
|
||||
})
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: link interception',
|
||||
)
|
||||
function getTasksPrefsHandle(ctx: Context) {
|
||||
if (tasksPrefsHandle) return tasksPrefsHandle
|
||||
if (!ctx.settings?.register) return undefined
|
||||
tasksPrefsHandle = ctx.settings.register<TasksPrefs>(TASKS_NS, null, {
|
||||
base: { showInactive: true },
|
||||
applies: 'live',
|
||||
})
|
||||
return tasksPrefsHandle
|
||||
}
|
||||
|
||||
// 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.
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerImeGuard()
|
||||
} catch (error) {
|
||||
fail('ime guard', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: IME composition guard',
|
||||
)
|
||||
const DEFAULT_TASKS_PREFS: TasksPrefs = { showInactive: true }
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
// fenced settings route, keeps the shared store in sync, and renders the
|
||||
// declarative enable/disable inventory from the tab/viewer registry.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'better-sidebar',
|
||||
order: 100,
|
||||
label: () => t('settingsNav'),
|
||||
inject: () => ({ store: sidebarStore, service }),
|
||||
}, SideCardSection))
|
||||
} catch (error) {
|
||||
fail('load', error)
|
||||
/**
|
||||
* Pure row filter for the tasks list: when `showInactive` is false, only
|
||||
* running sessions render. Exported for unit testing (the hook itself needs
|
||||
* a render, but this decision is a plain function).
|
||||
*/
|
||||
export function filterTasksRows<T extends { running?: boolean }>(
|
||||
all: T[],
|
||||
showInactive: boolean,
|
||||
): T[] {
|
||||
return showInactive ? all : all.filter((s) => s.running)
|
||||
}
|
||||
|
||||
function useTasksPrefs(ctx: Context): { prefs: TasksPrefs; setShowInactive: (v: boolean) => void } {
|
||||
const handle = getTasksPrefsHandle(ctx)
|
||||
const prefs = useSyncExternalStore(
|
||||
handle ? (cb) => handle.watch(cb) : NOOP_SUBSCRIBE,
|
||||
handle ? handle.get : () => DEFAULT_TASKS_PREFS,
|
||||
handle ? handle.get : () => DEFAULT_TASKS_PREFS,
|
||||
)
|
||||
return {
|
||||
prefs,
|
||||
setShowInactive: (v: boolean) => {
|
||||
void handle?.update({ showInactive: v })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A row in a list view: id, title, and optional flag chips. */
|
||||
function SessionRow({
|
||||
title,
|
||||
running,
|
||||
current,
|
||||
subagent,
|
||||
}: {
|
||||
title: string
|
||||
running?: boolean
|
||||
current?: boolean
|
||||
subagent?: boolean
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-apes-row
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 6px',
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{title}
|
||||
</span>
|
||||
{running && <Chip label={t('tasks.running')} tone="run" />}
|
||||
{current && <Chip label={t('tasks.current')} tone="cur" />}
|
||||
{subagent && <Chip label={t('tasks.subagent')} tone="sub" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small status pill. */
|
||||
function Chip({ label, tone }: { label: string; tone: 'run' | 'cur' | 'sub' }): ReactElement {
|
||||
const bg = tone === 'run' ? '#2ea04333' : tone === 'cur' ? '#1f6feb33' : '#6e40c933'
|
||||
const fg = tone === 'run' ? '#2ea043' : tone === 'cur' ? '#1f6feb' : '#6e40c9'
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
fontSize: 10,
|
||||
lineHeight: '16px',
|
||||
padding: '0 5px',
|
||||
borderRadius: 8,
|
||||
background: bg,
|
||||
color: fg,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** A panel shell: a title bar + a scrollable body. */
|
||||
function Panel({ titleKey, children }: { titleKey: string; children: ReactNode }): ReactElement {
|
||||
return (
|
||||
<div data-apes-panel style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 10, fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t(titleKey)}</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The empty-state line every panel falls back to. */
|
||||
function Empty({ key: key }: { key: string }): ReactElement {
|
||||
return <div style={{ opacity: 0.55, fontSize: 12 }}>{t(key)}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* apes:tasks — the session list. Running/current/subagent rows are flagged.
|
||||
* Clicking a row selects it as current (when the host exposes `open`).
|
||||
*/
|
||||
function TasksView(props: TabComponentProps): ReactElement {
|
||||
useApesLocale()
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const { prefs, setShowInactive } = useTasksPrefs(props.ctx)
|
||||
// Per-tab state: when showInactive is off, only running sessions render.
|
||||
const all = Object.values(list.byId)
|
||||
const rows = prefs.showInactive ? all : all.filter((s) => s.running)
|
||||
const open = (id: string) => props.ctx.sessions.open?.(id)
|
||||
return (
|
||||
<Panel titleKey="tab.tasks.title">
|
||||
{/* The showInactive toggle — the persisted per-tab state. */}
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 11,
|
||||
opacity: 0.8,
|
||||
marginBottom: 6,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
{t('tasks.showInactive')}
|
||||
</label>
|
||||
{rows.length === 0 ? (
|
||||
<Empty key="tasks.empty" />
|
||||
) : (
|
||||
rows.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
title={s.displayTitle}
|
||||
running={s.running}
|
||||
current={s.id === list.current}
|
||||
subagent={s.origin === 'subagent'}
|
||||
// The row is a button only when a host `open` is available.
|
||||
{...{ onClick: () => open(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* apes:agents — the subagent children, grouped by their durable parent
|
||||
* session. Reads the same sessions feed and filters `origin === 'subagent'`.
|
||||
*/
|
||||
function AgentsView(props: TabComponentProps): ReactElement {
|
||||
useApesLocale()
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const children = Object.values(list.byId).filter((s) => s.origin === 'subagent')
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<Panel titleKey="tab.agents.title">
|
||||
<Empty key="agents.empty" />
|
||||
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('agents.hint')}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
// Group by parentId (undefined groups under a synthetic root).
|
||||
const groups = new Map<string, typeof children>()
|
||||
for (const s of children) {
|
||||
const key = s.parentId ?? ''
|
||||
const arr = groups.get(key) ?? []
|
||||
arr.push(s)
|
||||
groups.set(key, arr)
|
||||
}
|
||||
const jump = (childSessionId: string) => props.onSubagentJump?.(childSessionId)
|
||||
return (
|
||||
<Panel titleKey="tab.agents.title">
|
||||
{[...groups.entries()].map(([parentId, rows]) => (
|
||||
<div key={parentId || 'root'} style={{ marginBottom: 10 }}>
|
||||
{parentId && (
|
||||
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 4 }}>
|
||||
{list.byId[parentId]?.displayTitle ?? parentId}
|
||||
</div>
|
||||
)}
|
||||
{rows.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
title={s.displayTitle}
|
||||
running={s.running}
|
||||
subagent
|
||||
{...{ onClick: () => jump(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:apps — placeholder (Phase 2 wires the workspace app feed). */
|
||||
function AppsView(): ReactElement {
|
||||
useApesLocale()
|
||||
return (
|
||||
<Panel titleKey="tab.apps.title">
|
||||
<Empty key="apps.empty" />
|
||||
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('apps.hint')}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:messages — placeholder (Phase 2 wires the message/reminder stream). */
|
||||
function MessagesView(): ReactElement {
|
||||
useApesLocale()
|
||||
return (
|
||||
<Panel titleKey="tab.messages.title">
|
||||
<Empty key="messages.empty" />
|
||||
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('messages.hint')}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* apes:profile — account/runtime overview. Phase 1 shows the shell + a hint;
|
||||
* Phase 2 reads model/provider from a host route (none exposed yet).
|
||||
*/
|
||||
function ProfileView(): ReactElement {
|
||||
useApesLocale()
|
||||
return (
|
||||
<Panel titleKey="tab.profile.title">
|
||||
<div style={{ fontSize: 12, marginBottom: 8 }}>
|
||||
<div>
|
||||
<span style={{ opacity: 0.55 }}>{t('profile.model')}: </span>
|
||||
<span>{t('profile.unknown')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ opacity: 0.55 }}>{t('profile.provider')}: </span>
|
||||
<span>{t('profile.unknown')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ opacity: 0.45, fontSize: 12 }}>{t('profile.hint')}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the five tab descriptors. Each `component` receives the real
|
||||
* better-sidebar `TabComponentProps` (ctx / store / scope / tab / visible).
|
||||
*
|
||||
* Phase 2 additions:
|
||||
* - `badge` on tasks/agents: a cheap count pill (running sessions / subagent
|
||||
* count). Called on every tab-bar render, so it reads the live sessions
|
||||
* snapshot synchronously and returns `null` when the count is 0 (hidden).
|
||||
* - `visible` is now honored by the live views: `useSessionList` swaps to a
|
||||
* no-op subscribe when the tab is not the active-open one (auto-collapse —
|
||||
* hidden panels stop re-rendering on session-list churn).
|
||||
*/
|
||||
function buildDescriptors(): TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: TABS.tasks,
|
||||
title: () => t('tab.tasks.title'),
|
||||
order: 90,
|
||||
single: true,
|
||||
// Running-session count pill (99+ capped by the host; 0 → hidden).
|
||||
badge: (ctx) => {
|
||||
const n = runningCount(ctx)
|
||||
return n === 0 ? null : n
|
||||
},
|
||||
component: (props) => <TasksView {...props} />,
|
||||
},
|
||||
{
|
||||
id: TABS.agents,
|
||||
title: () => t('tab.agents.title'),
|
||||
order: 91,
|
||||
single: true,
|
||||
// Active-subagent count pill (0 → hidden).
|
||||
badge: (ctx) => {
|
||||
const n = subagentCount(ctx)
|
||||
return n === 0 ? null : n
|
||||
},
|
||||
component: (props) => <AgentsView {...props} />,
|
||||
},
|
||||
{
|
||||
id: TABS.apps,
|
||||
title: () => t('tab.apps.title'),
|
||||
order: 92,
|
||||
single: true,
|
||||
component: () => <AppsView />,
|
||||
},
|
||||
{
|
||||
id: TABS.messages,
|
||||
title: () => t('tab.messages.title'),
|
||||
order: 93,
|
||||
single: true,
|
||||
component: () => <MessagesView />,
|
||||
},
|
||||
{
|
||||
id: TABS.profile,
|
||||
title: () => t('tab.profile.title'),
|
||||
order: 94,
|
||||
single: true,
|
||||
component: () => <ProfileView />,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the i18n dictionaries plus all five tabs; returns the combined
|
||||
* disposer (locale registrations + the five tab disposers). Safe to call
|
||||
* from inside a cordis effect.
|
||||
*/
|
||||
function registerAll(ctx: ApesClientContext): () => void {
|
||||
const better = ctx.betterSidebar
|
||||
const disposers: (() => void)[] = [registerLocales(ctx.locale)]
|
||||
for (const descriptor of buildDescriptors()) {
|
||||
disposers.push(better.registerTab(descriptor))
|
||||
}
|
||||
return () => {
|
||||
for (const d of disposers) {
|
||||
try {
|
||||
d()
|
||||
} catch {
|
||||
// Already disposed (double-dispose on HMR) — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The cordis client entry. */
|
||||
export function apply(ctx: ApesClientContext): void {
|
||||
if (!ctx.betterSidebar) {
|
||||
// better-sidebar not present in this profile: the tabs cannot register.
|
||||
// Expected on a bare DSH; the host half still mounts (logs only).
|
||||
// Do NOT throw — a missing peer must degrade, never break the shell.
|
||||
return
|
||||
}
|
||||
if (typeof ctx.effect === 'function') {
|
||||
// cordis effect takes a zero-arg thunk; capture ctx in the closure.
|
||||
// The body returns the combined disposer; cordis runs it on activation
|
||||
// teardown (HMR-safe).
|
||||
ctx.effect(() => registerAll(ctx))
|
||||
} else {
|
||||
// No effect API (non-cordis harness / unit test): register synchronously;
|
||||
// disposal rides process teardown.
|
||||
registerAll(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Interception of the chat's produced-files row: the turn-tail chain entry
|
||||
* that replaces ui-deliverables' row when the closing turn produced files.
|
||||
* The takeover looks identical (same chip row); the chips open the file in
|
||||
* the sidebar instead of the host OS. Priority -1 runs before the default-0
|
||||
* deliverables entry; when nothing was produced the selector returns null
|
||||
* and the original row renders unchanged.
|
||||
*/
|
||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||
import { wrapOpenPath } from './openpath-intercept.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
|
||||
export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: string, path: string): void {
|
||||
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
|
||||
const absolute = resolveSidebarPath(summary?.cwd, path)
|
||||
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
|
||||
const title = at === -1 ? absolute : absolute.slice(at + 1)
|
||||
// Route through the sidebar service so the editor descriptor's dedupeKey
|
||||
// (per-path) applies; the id is path-derived so multiple editors coexist.
|
||||
ctx.betterSidebar?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
|
||||
}
|
||||
|
||||
/** The intercepted produced-files row (visual twin of the deliverables chips). */
|
||||
export function SidebarProducedFiles(props: {
|
||||
matched: readonly string[]
|
||||
openInSidebar: (path: string) => void
|
||||
}) {
|
||||
const { matched, openInSidebar } = props
|
||||
const shown = matched.slice(0, 6)
|
||||
const hidden = matched.length - shown.length
|
||||
return (
|
||||
<div className={css.producedRow}>
|
||||
<span className={css.producedLabel}>{t('produced')}</span>
|
||||
{shown.map(path => {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
const name = at === -1 ? path : path.slice(at + 1)
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={css.producedChip}
|
||||
title={path}
|
||||
onClick={() => { openInSidebar(path) }}
|
||||
>
|
||||
<IconCodeOutline16 size={12} />
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{hidden > 0 && <span className={css.producedMore}>+{hidden}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Register the turn-tail interception (returns the disposer). */
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return 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
|
||||
// deliverables behavior instead of offering chips that cannot open.
|
||||
select: (owner) => {
|
||||
if (store.getPrefs().tabsEnabled['editor'] === false) return null
|
||||
return selectProducedFiles(owner)
|
||||
},
|
||||
priority: -1,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
inject: (sessionId: string) => ({
|
||||
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
}),
|
||||
}, SidebarProducedFiles)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
|
||||
* — the single funnel every chat-side file open goes through (tool-row path
|
||||
* links, the produced-files row, prose mentions) — so opens land in the
|
||||
* sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath`
|
||||
* pref and the editor tab's enable switch; declined opens fall through to
|
||||
* the original method. Returns the disposer restoring the original (HMR-safe).
|
||||
*/
|
||||
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return wrapOpenPath(ctx.workspaces, {
|
||||
takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false
|
||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
})
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Syntax highlighting for the file editor: extension → CodeMirror language
|
||||
* mapping. The key derivation is pure and unit-tested; the factories pull in
|
||||
* the CodeMirror language packages (bundled into the client).
|
||||
*/
|
||||
import { Language, LanguageSupport, StreamLanguage } from '@codemirror/language'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { xml } from '@codemirror/lang-xml'
|
||||
import { yaml } from '@codemirror/lang-yaml'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { java } from '@codemirror/lang-java'
|
||||
import { cpp } from '@codemirror/lang-cpp'
|
||||
import { rust } from '@codemirror/lang-rust'
|
||||
import { go } from '@codemirror/lang-go'
|
||||
import { php } from '@codemirror/lang-php'
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell'
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml'
|
||||
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
|
||||
import { properties } from '@codemirror/legacy-modes/mode/properties'
|
||||
|
||||
/** The lowercased file extension of a path ('' when none). */
|
||||
export function extOf(path: string): string {
|
||||
const at = path.lastIndexOf('.')
|
||||
if (at === -1) return ''
|
||||
const base = path.slice(at + 1).toLowerCase()
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
|
||||
/** Language key for an extension, or null for plain text. Pure (tested). */
|
||||
export function languageKeyForExt(ext: string): string | null {
|
||||
switch (ext) {
|
||||
case 'js': case 'mjs': case 'cjs': return 'js'
|
||||
case 'jsx': return 'jsx'
|
||||
case 'ts': case 'mts': case 'cts': return 'ts'
|
||||
case 'tsx': return 'tsx'
|
||||
case 'json': case 'jsonc': return 'json'
|
||||
case 'md': case 'markdown': return 'md'
|
||||
case 'py': case 'pyw': return 'python'
|
||||
case 'html': case 'htm': return 'html'
|
||||
case 'css': return 'css'
|
||||
case 'xml': case 'xsl': return 'xml'
|
||||
case 'yaml': case 'yml': return 'yaml'
|
||||
case 'sql': return 'sql'
|
||||
case 'java': return 'java'
|
||||
case 'c': case 'h': return 'c'
|
||||
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
|
||||
case 'rs': return 'rust'
|
||||
case 'go': return 'go'
|
||||
case 'php': return 'php'
|
||||
case 'sh': case 'bash': case 'zsh': return 'shell'
|
||||
case 'toml': return 'toml'
|
||||
case 'nginx': case 'conf': return 'nginx'
|
||||
case 'dockerfile': case 'docker': return 'dockerfile'
|
||||
case 'properties': case 'env': return 'properties'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
const FACTORIES: Record<string, () => Language | LanguageSupport> = {
|
||||
js: () => javascript({ jsx: true }),
|
||||
jsx: () => javascript({ jsx: true }),
|
||||
ts: () => javascript({ typescript: true }),
|
||||
tsx: () => javascript({ typescript: true, jsx: true }),
|
||||
json: () => json(),
|
||||
md: () => markdown(),
|
||||
python: () => python(),
|
||||
html: () => html(),
|
||||
css: () => css(),
|
||||
xml: () => xml(),
|
||||
yaml: () => yaml(),
|
||||
sql: () => sql(),
|
||||
java: () => java(),
|
||||
c: () => cpp(),
|
||||
cpp: () => cpp(),
|
||||
rust: () => rust(),
|
||||
go: () => go(),
|
||||
php: () => php(),
|
||||
shell: () => StreamLanguage.define(shell),
|
||||
toml: () => StreamLanguage.define(toml),
|
||||
nginx: () => StreamLanguage.define(nginx),
|
||||
dockerfile: () => StreamLanguage.define(dockerFile),
|
||||
properties: () => StreamLanguage.define(properties),
|
||||
}
|
||||
|
||||
/** The CodeMirror language support for a path, or null for plain text. */
|
||||
export function languageForPath(path: string): Language | LanguageSupport | null {
|
||||
const key = languageKeyForExt(extOf(path))
|
||||
return key === null ? null : FACTORIES[key]!()
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Layout push: when a panel is open it OCCUPIES the layout instead of
|
||||
* floating over it — the app shell (#root, the AppFrame three-column grid)
|
||||
* gives up space. Only the center column is flexible (1fr), so the right
|
||||
* panel's width squeeze (margin-right on #root) lands exactly on the
|
||||
* 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.
|
||||
*
|
||||
* The sizes ride CSS variables updated by the Sidebar shell (0 while
|
||||
* collapsed); expand/collapse animates both the margins and the panel
|
||||
* slides on the same theme duration. Drags disable the transition so the
|
||||
* layout tracks the pointer.
|
||||
*/
|
||||
#root {
|
||||
margin-right: var(--dsh-sidebar-width, 0px);
|
||||
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
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. The
|
||||
Sidebar shell toggles the body attribute with the panel open state. */
|
||||
body[data-dsh-sidebar-collapsed] #root > div > div:nth-child(2) header {
|
||||
padding-right: 78px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root,
|
||||
#root > div > div:nth-child(2) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk view wrapper: mounts a component that lives in a lazy chunk,
|
||||
* showing a loading placeholder while the chunk script loads and an error +
|
||||
* retry affordance on failure. Used by the built-in tab/viewer descriptors.
|
||||
*
|
||||
* Contract note: {@link lazyChunkComponent} returns a plain render-prop
|
||||
* function — the descriptor contract is `component: (props) => ReactNode`,
|
||||
* and the repo renders descriptors BOTH ways: Sidebar calls
|
||||
* `descriptor.component(props)` directly, EditorHost renders it via
|
||||
* `createElement`. The wrapper function body therefore contains no hooks;
|
||||
* all state lives in the inner {@link LazyChunkView} component.
|
||||
*/
|
||||
import { createElement, useEffect, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { loadChunk, type ChunkExports, type ChunkName } from './chunk-loader.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LazyChunkViewProps<P> {
|
||||
chunk: ChunkName
|
||||
/** Module-level-stable selector (an inline lambda would re-trigger the effect). */
|
||||
pick: (mod: ChunkExports) => ComponentType<P> | undefined
|
||||
props: P
|
||||
}
|
||||
|
||||
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [state, setState] = useState<
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; Comp: ComponentType<any> }
|
||||
>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ status: 'loading' })
|
||||
loadChunk(chunk).then((mod) => {
|
||||
if (cancelled) return
|
||||
const Comp = pick(mod)
|
||||
if (Comp === undefined) {
|
||||
setState({ status: 'error', message: `[dsh-better-sidebar] chunk "${chunk}" is missing its component` })
|
||||
return
|
||||
}
|
||||
setState({ status: 'ready', Comp })
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setState({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [chunk, pick, attempt])
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return <div className={css.editorPlaceholder}>{t('loading')}</div>
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div className={css.editorError}>
|
||||
<span>{state.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.terminalRetry}
|
||||
onClick={() => { setAttempt(current => current + 1) }}
|
||||
>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return createElement(state.Comp, props)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a descriptor-compatible lazy wrapper for a chunk-resident component.
|
||||
* The returned function is the descriptor `component` itself: it returns an
|
||||
* element and never calls hooks, so both invocation styles (plain function
|
||||
* call and createElement/JSX render) work. `pick` must be a module-level
|
||||
* function (stable identity) — an inline lambda would re-trigger the load
|
||||
* effect on every render.
|
||||
* @param chunk - the chunk name (see chunk-loader.ts).
|
||||
* @param pick - select the component from the chunk's exports.
|
||||
*/
|
||||
export function lazyChunkComponent<P extends object>(
|
||||
chunk: ChunkName,
|
||||
pick: (mod: ChunkExports) => ComponentType<P> | undefined,
|
||||
): (props: P) => ReactNode {
|
||||
return (props: P) => createElement(
|
||||
LazyChunkView as ComponentType<LazyChunkViewProps<P>>,
|
||||
{ chunk, pick, props },
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Chat/GUI external-link interception: clicking an http(s) link that points
|
||||
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
|
||||
* sidebar browser instead of a new browser tab. Gated by BOTH the
|
||||
* `browserInterceptLinks` pref and the browser tab's enable switch; a
|
||||
* Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
|
||||
* user can still force a real browser tab.
|
||||
*
|
||||
* Only the GUI's OWN document is watched — links inside the browser tab's
|
||||
* sandboxed iframe live in another document and never bubble here (and
|
||||
* their clicks must keep working inside the sidebar).
|
||||
*/
|
||||
|
||||
/** The pure decision: the URL to open in the sidebar, or null to let the
|
||||
* click fall through. Extracted so the policy is unit-testable without a
|
||||
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is). */
|
||||
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(anchorHref)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
// Same-origin links are GUI-internal navigation (settings pages, tool
|
||||
// docs) — never routed into the sidebar browser.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return null
|
||||
} catch {
|
||||
// Unparsable selfOrigin (never in practice): intercept defensively.
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Whether a left-click may be taken over (unmodified left click only). */
|
||||
export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean {
|
||||
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the document-level click capture that funnels external links
|
||||
* into the sidebar browser. Returns the disposer (HMR-safe).
|
||||
*/
|
||||
export function registerLinkInterception(opts: {
|
||||
takeoverEnabled: () => boolean
|
||||
/** Open the sidebar browser tab at `url` (the caller resolves the session). */
|
||||
openInSidebar: (url: string) => void
|
||||
/** The GUI's own origin (window.location.origin at registration). */
|
||||
selfOrigin: string
|
||||
}): () => void {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
if (!isPlainLeftClick(event)) return
|
||||
if (event.defaultPrevented) return
|
||||
if (!opts.takeoverEnabled()) return
|
||||
const target = event.target
|
||||
if (target === null || typeof (target as Element).closest !== 'function') return
|
||||
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
|
||||
if (anchor === null) return
|
||||
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
|
||||
if (url === null) return
|
||||
event.preventDefault()
|
||||
opts.openInSidebar(url)
|
||||
}
|
||||
document.addEventListener('click', onClick, true)
|
||||
return () => { document.removeEventListener('click', onClick, true) }
|
||||
}
|
||||
+119
-459
@@ -1,476 +1,136 @@
|
||||
/**
|
||||
* Minimal zh/en copy for the sidebar. The copy follows the DSH i18n system:
|
||||
* the client apply attaches the locale service (`ctx.locale`, provided by
|
||||
* `@deepseek-ai/dsh-client-locale`) through {@link attachLocale}, and
|
||||
* `t()`/`isZh()` resolve the active locale from it — the Host-backed
|
||||
* `locale.preference` wins over the raw browser language and switches live.
|
||||
* Without an attached service (standalone/test compositions) the browser
|
||||
* language is used, matching the previous behavior. The dictionaries are
|
||||
* also registered into the DSH locale registry under {@link LOCALE_NS}.
|
||||
* apes-sidebar i18n dictionaries, registered into the DSH locale runtime
|
||||
* under the `apesSidebar` namespace (follows the DSH i18n system: the
|
||||
* active locale is the Host-backed preference, not the raw browser language).
|
||||
*
|
||||
* zh + en are primary. A third language falls back to en (the better-sidebar
|
||||
* convention: every new zh key must also have an en entry).
|
||||
*
|
||||
* The module tracks the active locale in a small external store so `t()`
|
||||
* always resolves against the current locale, and React components re-render
|
||||
* on locale change via {@link useApesLocale} (useSyncExternalStore).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ApesLocaleService } from '../context-types.ts'
|
||||
|
||||
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
|
||||
export const zh = {
|
||||
explorer: '资源管理器',
|
||||
git: '源代码管理',
|
||||
terminal: '终端',
|
||||
editor: '编辑器',
|
||||
newTab: '新建标签页',
|
||||
openExplorer: '资源管理器',
|
||||
openGit: 'Git 面板',
|
||||
newTerminal: '新终端',
|
||||
terminalLimit: '终端数量已达上限 (3)',
|
||||
close: '关闭',
|
||||
collapse: '折叠侧边栏',
|
||||
expand: '展开侧边栏',
|
||||
collapseBottomPanel: '折叠底部面板',
|
||||
expandBottomPanel: '展开底部面板',
|
||||
terminalError: '终端连接失败',
|
||||
terminalConnectFailed: '终端多次连接失败',
|
||||
terminalRetry: '重试',
|
||||
preview: '预览',
|
||||
edit: '编辑',
|
||||
refresh: '刷新',
|
||||
save: '保存',
|
||||
saved: '已保存',
|
||||
unsaved: '未保存',
|
||||
saveFailed: '保存失败',
|
||||
truncation: '文件过大,仅显示前 512KB',
|
||||
binary: '二进制文件,无法预览',
|
||||
loading: '加载中…',
|
||||
error: '加载失败',
|
||||
retry: '重试',
|
||||
splitLeft: '向左分栏',
|
||||
splitRight: '向右分栏',
|
||||
splitUp: '向上分栏',
|
||||
splitDown: '向下分栏',
|
||||
notRepo: '当前目录不是 git 仓库',
|
||||
noChanges: '没有变更',
|
||||
stage: '暂存',
|
||||
unstage: '取消暂存',
|
||||
stageAll: '全部暂存',
|
||||
unstageAll: '全部取消暂存',
|
||||
commitPlaceholder: '提交信息 (Ctrl+Enter)',
|
||||
commit: '提交',
|
||||
commitError: '提交失败',
|
||||
branch: '分支',
|
||||
checkoutError: '切换分支失败',
|
||||
history: '历史',
|
||||
changes: '变更',
|
||||
staged: '已暂存',
|
||||
unstaged: '未暂存',
|
||||
cancel: '取消',
|
||||
diffEmpty: '没有文本差异',
|
||||
diffLoadError: '加载差异失败',
|
||||
diffBinary: '二进制',
|
||||
diffAdded: '新增',
|
||||
diffDeleted: '删除',
|
||||
diffRenamed: '重命名',
|
||||
diffExpand: '展开其余 {count} 行',
|
||||
diffCollapse: '收起',
|
||||
discard: '放弃更改',
|
||||
discardTitle: '放弃更改',
|
||||
discardDesc: '将丢弃「{path}」的工作区修改(不可恢复)。',
|
||||
viewCommitDiff: '查看提交差异',
|
||||
copyShortHash: '复制短哈希',
|
||||
copyFullHash: '复制完整哈希',
|
||||
copySubject: '复制提交信息',
|
||||
revertCommit: '还原此提交',
|
||||
revertTitle: '还原此提交',
|
||||
revertDesc: '将在当前分支创建一个反转「{subject}」的新提交。',
|
||||
cherryPickCommit: '捡取此提交',
|
||||
cherryPickTitle: '捡取此提交',
|
||||
cherryPickDesc: '将「{subject}」的更改应用到当前分支。',
|
||||
timeJustNow: '刚刚',
|
||||
timeMinutesAgo: '{n} 分钟前',
|
||||
timeHoursAgo: '{n} 小时前',
|
||||
timeYesterday: '昨天',
|
||||
loadMore: '加载更多',
|
||||
historyLoadError: '加载更多历史失败',
|
||||
produced: '本次产出',
|
||||
producedOpen: '在侧边栏中打开',
|
||||
disconnected: '终端连接断开,重连中…',
|
||||
exited: '终端进程已退出',
|
||||
noSession: '选择一个会话以使用侧边栏',
|
||||
pluginNotLoaded: '插件未加载,标签页暂不可用:',
|
||||
hiddenFiles: '隐藏文件',
|
||||
parent: '上级目录',
|
||||
copied: '已复制',
|
||||
copy: '复制',
|
||||
newFile: '新文件',
|
||||
openEditor: '打开编辑器',
|
||||
gitDetail: '查看变更详情',
|
||||
referenceFile: '@文件',
|
||||
addToConversation: '添加到对话',
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
downloadToView: '下载查看',
|
||||
officeTooLarge: '文件过大,无法预览',
|
||||
officeCorrupt: '文件损坏或格式无效',
|
||||
officeEncrypted: '不支持加密文件',
|
||||
officeLoadFailed: 'Office 预览组件加载失败',
|
||||
previousSlide: '上一页',
|
||||
nextSlide: '下一页',
|
||||
zoom: '缩放',
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
|
||||
settingsJobsTitle: '有新后台任务时自动展开后台任务页',
|
||||
settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
|
||||
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsGeneralTitle: '常规',
|
||||
settingsPopup: '功能设置',
|
||||
settingsViewerCatchAll: '兜底:任意文件',
|
||||
viewerImage: '图片',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word 文档',
|
||||
viewerXlsx: 'Excel 表格',
|
||||
viewerPptx: 'PPT 演示',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
viewerHtml: 'HTML',
|
||||
browser: '浏览器',
|
||||
browserPlaceholder: '输入网址,例如 example.com',
|
||||
browserGo: '前往',
|
||||
browserBack: '后退',
|
||||
browserForward: '前进',
|
||||
browserStart: '输入网址开始浏览(沙箱模式)',
|
||||
browserBlockedScheme: '已阻止:仅支持 http/https 链接',
|
||||
browserBlockedLoopback: '已阻止:不允许在浏览器中访问本机或内部地址',
|
||||
browserInvalid: '无效的网址',
|
||||
browserNoSandboxWarning: '沙箱已关闭:当前页面与界面同源,拥有完整会话权限(可在设置中恢复)',
|
||||
htmlNoSandboxWarning: '沙箱已关闭:此 HTML 与界面同源,可读取会话文件与内部接口(可在设置中恢复)',
|
||||
sandboxStatusOn: '沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用',
|
||||
sandboxUnlock: '临时解锁(不安全)',
|
||||
sandboxRestore: '恢复沙箱',
|
||||
settingsHtmlDefaultUnsafeTitle: 'HTML 预览默认以非沙箱模式打开(不安全)',
|
||||
settingsHtmlDefaultUnsafeDesc: '开启后,每次打开 HTML 文件时预览默认处于非沙箱状态(与界面同源,可读取会话文件与内部接口);可在状态行临时恢复沙箱',
|
||||
settingsHtmlSandboxTitle: '关闭 HTML 预览沙箱(不安全)',
|
||||
settingsHtmlSandboxDesc: '关闭后,预览的 HTML 将与界面同源运行,可读取会话文件、本地存储并调用内部接口。仅对完全可信的文件开启',
|
||||
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
|
||||
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
|
||||
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
|
||||
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行',
|
||||
browserOpenExternal: '在浏览器中打开',
|
||||
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
|
||||
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
|
||||
browserEmbedAnyway: '仍然加载',
|
||||
subagent: '任务管理',
|
||||
openSubagent: '任务管理',
|
||||
subagentMainAgent: '主代理',
|
||||
subagentEmpty: '暂无子代理',
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
subagentRunning: '运行中',
|
||||
subagentInactive: '空闲',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可续接',
|
||||
subagentCount: '{count} 个子代理',
|
||||
subagentCountRunning: '{count} 个子代理 · {running} 运行中',
|
||||
subagentDiagCorrupt: '目录损坏',
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
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: '终止失败',
|
||||
const NS = 'apesSidebar'
|
||||
|
||||
const zh: Record<string, string> = {
|
||||
'tab.tasks.title': '任务',
|
||||
'tab.agents.title': 'Agent',
|
||||
'tab.messages.title': '消息',
|
||||
'tab.apps.title': '应用',
|
||||
'tab.profile.title': '账户',
|
||||
'tasks.empty': '暂无会话',
|
||||
'tasks.running': '运行中',
|
||||
'tasks.current': '当前',
|
||||
'tasks.subagent': '子代理',
|
||||
'tasks.showInactive': '显示非运行会话',
|
||||
'agents.empty': '暂无子代理',
|
||||
'agents.hint': '子代理会话会在此按父会话分组展示。',
|
||||
'apps.empty': '暂无应用',
|
||||
'apps.hint': '工作区应用与外部入口将在此展示。',
|
||||
'messages.empty': '暂无消息',
|
||||
'messages.hint': '会话消息流与提醒将在此汇总。',
|
||||
'profile.model': '模型',
|
||||
'profile.provider': '提供商',
|
||||
'profile.unknown': '未知',
|
||||
'profile.hint': '账户与运行配置概览。',
|
||||
}
|
||||
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
export const en: Record<keyof typeof zh, string> = {
|
||||
explorer: 'Explorer',
|
||||
git: 'Source Control',
|
||||
terminal: 'Terminal',
|
||||
editor: 'Editor',
|
||||
newTab: 'New tab',
|
||||
openExplorer: 'Explorer',
|
||||
openGit: 'Git panel',
|
||||
newTerminal: 'New terminal',
|
||||
terminalLimit: 'Terminal limit reached (3)',
|
||||
close: 'Close',
|
||||
collapse: 'Collapse sidebar',
|
||||
expand: 'Expand sidebar',
|
||||
collapseBottomPanel: 'Collapse bottom panel',
|
||||
expandBottomPanel: 'Expand bottom panel',
|
||||
terminalError: 'Terminal connection failed',
|
||||
terminalConnectFailed: 'Terminal failed to connect repeatedly',
|
||||
terminalRetry: 'Retry',
|
||||
preview: 'Preview',
|
||||
edit: 'Edit',
|
||||
refresh: 'Refresh',
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
unsaved: 'Unsaved',
|
||||
saveFailed: 'Save failed',
|
||||
truncation: 'File too large — showing the first 512KB',
|
||||
binary: 'Binary file, preview unavailable',
|
||||
loading: 'Loading…',
|
||||
error: 'Failed to load',
|
||||
retry: 'Retry',
|
||||
splitLeft: 'Split left',
|
||||
splitRight: 'Split right',
|
||||
splitUp: 'Split up',
|
||||
splitDown: 'Split down',
|
||||
notRepo: 'This directory is not a git repository',
|
||||
noChanges: 'No changes',
|
||||
stage: 'Stage',
|
||||
unstage: 'Unstage',
|
||||
stageAll: 'Stage all',
|
||||
unstageAll: 'Unstage all',
|
||||
commitPlaceholder: 'Commit message (Ctrl+Enter)',
|
||||
commit: 'Commit',
|
||||
commitError: 'Commit failed',
|
||||
branch: 'Branch',
|
||||
checkoutError: 'Branch switch failed',
|
||||
history: 'History',
|
||||
changes: 'Changes',
|
||||
staged: 'Staged',
|
||||
unstaged: 'Unstaged',
|
||||
cancel: 'Cancel',
|
||||
diffEmpty: 'No text changes',
|
||||
diffLoadError: 'Failed to load diff',
|
||||
diffBinary: 'Binary',
|
||||
diffAdded: 'Added',
|
||||
diffDeleted: 'Deleted',
|
||||
diffRenamed: 'Renamed',
|
||||
diffExpand: 'Expand {count} more rows',
|
||||
diffCollapse: 'Collapse',
|
||||
discard: 'Discard changes',
|
||||
discardTitle: 'Discard changes',
|
||||
discardDesc: 'This discards the worktree changes of "{path}" (not recoverable).',
|
||||
viewCommitDiff: 'View commit diff',
|
||||
copyShortHash: 'Copy short hash',
|
||||
copyFullHash: 'Copy full hash',
|
||||
copySubject: 'Copy subject',
|
||||
revertCommit: 'Revert commit',
|
||||
revertTitle: 'Revert commit',
|
||||
revertDesc: 'Create a new commit on the current branch that reverts "{subject}".',
|
||||
cherryPickCommit: 'Cherry-pick commit',
|
||||
cherryPickTitle: 'Cherry-pick commit',
|
||||
cherryPickDesc: 'Apply the changes of "{subject}" to the current branch.',
|
||||
timeJustNow: 'just now',
|
||||
timeMinutesAgo: '{n} min ago',
|
||||
timeHoursAgo: '{n} h ago',
|
||||
timeYesterday: 'yesterday',
|
||||
loadMore: 'Load more',
|
||||
historyLoadError: 'Failed to load more history',
|
||||
produced: 'Produced',
|
||||
producedOpen: 'Open in sidebar',
|
||||
disconnected: 'Terminal disconnected, reconnecting…',
|
||||
exited: 'Terminal process exited',
|
||||
noSession: 'Select a conversation to use the sidebar',
|
||||
pluginNotLoaded: 'Plugin not loaded; tab unavailable:',
|
||||
hiddenFiles: 'Hidden files',
|
||||
parent: 'Parent directory',
|
||||
copied: 'Copied',
|
||||
copy: 'Copy',
|
||||
newFile: 'New file',
|
||||
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',
|
||||
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',
|
||||
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
||||
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
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',
|
||||
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)',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
settingsViewersTitle: 'File viewers',
|
||||
settingsGeneralTitle: 'General',
|
||||
settingsPopup: 'Feature settings',
|
||||
settingsViewerCatchAll: 'Catch-all: any file',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word',
|
||||
viewerXlsx: 'Excel',
|
||||
viewerPptx: 'PowerPoint',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
viewerHtml: 'HTML',
|
||||
browser: 'Browser',
|
||||
browserPlaceholder: 'Enter a URL, e.g. example.com',
|
||||
browserGo: 'Go',
|
||||
browserBack: 'Back',
|
||||
browserForward: 'Forward',
|
||||
browserStart: 'Enter a URL to start browsing (sandbox mode)',
|
||||
browserBlockedScheme: 'Blocked: only http/https URLs are allowed',
|
||||
browserBlockedLoopback: 'Blocked: local and internal addresses cannot be browsed here',
|
||||
browserInvalid: 'Invalid URL',
|
||||
browserNoSandboxWarning: 'Sandbox off: the current page runs with full GUI privileges (re-enable in settings)',
|
||||
htmlNoSandboxWarning: 'Sandbox off: this HTML runs with full GUI privileges (re-enable in settings)',
|
||||
sandboxStatusOn: 'Sandbox mode: on · pages cannot access the GUI\'s data or local files; logins and third-party cookies may not work',
|
||||
sandboxUnlock: 'Temporarily disable (unsafe)',
|
||||
sandboxRestore: 'Restore sandbox',
|
||||
settingsHtmlDefaultUnsafeTitle: 'Open HTML previews unsandboxed by default (unsafe)',
|
||||
settingsHtmlDefaultUnsafeDesc: 'When on, every newly opened HTML preview starts in the unsandboxed state (same origin as the GUI — it can read session files and internal APIs); the status row still offers a one-tap restore',
|
||||
settingsHtmlSandboxTitle: 'Disable HTML preview sandbox (unsafe)',
|
||||
settingsHtmlSandboxDesc: 'With the sandbox off, previewed HTML runs with the same origin as the GUI: it can read session files, local storage and call internal APIs. Only enable for fully trusted files',
|
||||
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)',
|
||||
settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites',
|
||||
settingsBrowserLinksTitle: 'Open chat external links in the sidebar',
|
||||
settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses',
|
||||
browserOpenExternal: 'Open in browser',
|
||||
browserEmbedBlocked: '{host} refused to be embedded',
|
||||
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
|
||||
browserEmbedAnyway: 'Load anyway',
|
||||
subagent: 'Tasks',
|
||||
openSubagent: 'Tasks',
|
||||
subagentMainAgent: 'Main agent',
|
||||
subagentEmpty: 'No subagents',
|
||||
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
|
||||
subagentRunning: 'Running',
|
||||
subagentInactive: 'Inactive',
|
||||
subagentModeOneShot: 'One-shot',
|
||||
subagentModeContinuable: 'Continuable',
|
||||
subagentCount: '{count} subagents',
|
||||
subagentCountRunning: '{count} subagents · {running} running',
|
||||
subagentDiagCorrupt: 'Corrupt',
|
||||
subagentDiagUnsupported: 'Unsupported',
|
||||
subagentDiagUnavailable: 'Unavailable',
|
||||
subagentThinking: 'Thinking…',
|
||||
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',
|
||||
const en: Record<string, string> = {
|
||||
'tab.tasks.title': 'Tasks',
|
||||
'tab.agents.title': 'Agents',
|
||||
'tab.messages.title': 'Messages',
|
||||
'tab.apps.title': 'Apps',
|
||||
'tab.profile.title': 'Profile',
|
||||
'tasks.empty': 'No sessions yet',
|
||||
'tasks.running': 'running',
|
||||
'tasks.current': 'current',
|
||||
'tasks.subagent': 'subagent',
|
||||
'tasks.showInactive': 'Show inactive sessions',
|
||||
'agents.empty': 'No subagents yet',
|
||||
'agents.hint': 'Subagent sessions appear here, grouped by parent.',
|
||||
'apps.empty': 'No apps yet',
|
||||
'apps.hint': 'Workspace apps and external entry points appear here.',
|
||||
'messages.empty': 'No messages yet',
|
||||
'messages.hint': 'Session message flow and reminders are aggregated here.',
|
||||
'profile.model': 'Model',
|
||||
'profile.provider': 'Provider',
|
||||
'profile.unknown': 'Unknown',
|
||||
'profile.hint': 'Account and runtime configuration overview.',
|
||||
}
|
||||
|
||||
const DICTS: Record<string, Record<string, string>> = { zh, en }
|
||||
|
||||
/** The currently resolved active locale code ('zh' default). */
|
||||
let activeLocale = 'zh'
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function notify(): void {
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
/** The active locale snapshot (uSES-safe: returns the same code string). */
|
||||
export function getActiveLocale(): string {
|
||||
return activeLocale
|
||||
}
|
||||
|
||||
export function subscribeLocale(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => {
|
||||
listeners.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dictionary namespace this plugin owns in the DSH locale registry
|
||||
* (`'sidebar'` is taken by DSH's own ui-sidebar, hence this distinct name).
|
||||
* Re-render the calling component whenever the active locale changes.
|
||||
* Returns the active locale code; components re-resolve `t(key)` against it.
|
||||
*/
|
||||
export const LOCALE_NS = 'betterSidebar'
|
||||
export function useApesLocale(): string {
|
||||
return useSyncExternalStore(subscribeLocale, getActiveLocale, getActiveLocale)
|
||||
}
|
||||
|
||||
/** The DSH locale service attached by the client apply (absent → browser detection). */
|
||||
let localeService: { getSnapshot(): { active: string } } | undefined
|
||||
|
||||
/**
|
||||
* Attach (or detach, with undefined) the DSH locale service. The sidebar
|
||||
* mounts its own React root outside the slot system's locale seat, so the
|
||||
* service rides this module-level holder: components keep calling the plain
|
||||
* `t()` function, and the Sidebar root's locale subscription re-renders the
|
||||
* whole tree on switches.
|
||||
*/
|
||||
export function attachLocale(service: { getSnapshot(): { active: string } } | undefined): void {
|
||||
localeService = service
|
||||
/** Translate a key in the active locale; falls back to en, then the key. */
|
||||
export function t(key: string): string {
|
||||
const dict = DICTS[activeLocale] ?? en
|
||||
return dict[key] ?? en[key] ?? key
|
||||
}
|
||||
|
||||
/**
|
||||
* The active locale id ('zh' | 'en'): the DSH locale service's snapshot when
|
||||
* attached, else the browser language.
|
||||
* Register the dictionaries into the locale runtime AND subscribe to its
|
||||
* active-locale changes, keeping {@link activeLocale} in sync. Returns the
|
||||
* combined disposer (locale registrations + the snapshot subscription).
|
||||
*/
|
||||
function activeLocale(): string {
|
||||
return localeService?.getSnapshot().active
|
||||
?? (typeof navigator !== 'undefined' ? navigator.language : '')
|
||||
?? 'en'
|
||||
}
|
||||
|
||||
/** Translate a copy key in the active locale (zh → zh, else en). */
|
||||
export type CopyKey = keyof typeof zh
|
||||
|
||||
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
|
||||
export function t(key: CopyKey, params?: Record<string, string | number>): string {
|
||||
const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
|
||||
let text = dict[key]
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
text = text.replaceAll(`{${name}}`, String(value))
|
||||
export function registerLocales(locale: ApesLocaleService | undefined): () => void {
|
||||
if (!locale) return () => undefined
|
||||
const disposes = [
|
||||
locale.register(NS, 'zh', zh),
|
||||
locale.register(NS, 'en', en),
|
||||
]
|
||||
let unsub: (() => void) | undefined
|
||||
try {
|
||||
// Seed the active locale from the runtime snapshot (uSES-safe read).
|
||||
activeLocale = locale.getSnapshot().active || 'zh'
|
||||
unsub = locale.subscribe(() => {
|
||||
const next = locale.getSnapshot().active || 'en'
|
||||
if (next !== activeLocale) {
|
||||
activeLocale = next
|
||||
notify()
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// Locale runtime unavailable (very early boot): stay on the default.
|
||||
}
|
||||
return () => {
|
||||
unsub?.()
|
||||
for (const d of disposes) {
|
||||
try {
|
||||
d()
|
||||
} catch {
|
||||
// Disposer already run (double-dispose) — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** Whether the active locale is Chinese (used for selectors). */
|
||||
export function isZh(): boolean {
|
||||
return activeLocale().toLowerCase().startsWith('zh')
|
||||
}
|
||||
|
||||
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = Math.floor((Date.now() - then) / 1000)
|
||||
if (seconds < 60) return t('timeJustNow')
|
||||
if (seconds < 3600) return t('timeMinutesAgo', { n: Math.floor(seconds / 60) })
|
||||
if (seconds < 86400) return t('timeHoursAgo', { n: Math.floor(seconds / 3600) })
|
||||
if (seconds < 172800) return t('timeYesterday')
|
||||
const date = new Date(then)
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Interception of the chat's file-open funnel. The client runtime's
|
||||
* `ctx.workspaces.openPath` is the SINGLE door every chat-side file open goes
|
||||
* through — ui-conversation's apply.ts resolves the path against the session
|
||||
* cwd and calls it for tool-row path links, the produced-files row, and
|
||||
* prose file mentions alike (verified against the DSH source:
|
||||
* `packages/client/ui-conversation/src/client/apply.ts` is the only
|
||||
* production caller). Wrapping that one method reroutes those opens into the
|
||||
* sidebar editor instead of the Host OS — no DSH modification needed.
|
||||
*
|
||||
* The wrapper is dependency-free by design (no React / ui-primitives), so
|
||||
* the takeover logic is unit-testable and the file stays importable from the
|
||||
* test runtime.
|
||||
*/
|
||||
|
||||
/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */
|
||||
export interface OpenPathService {
|
||||
openPath(path: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
|
||||
export interface OpenPathInterceptDeps {
|
||||
/**
|
||||
* Whether to take over this call: the `interceptOpenPath` pref AND the
|
||||
* editor tab's own enable switch must both be on (an editor that cannot
|
||||
* open must not swallow opens — they fall through to the Host).
|
||||
*/
|
||||
takeoverEnabled(): boolean
|
||||
/** The session whose scope the sidebar editor loads the file in (current session). */
|
||||
currentSessionId(): string | undefined
|
||||
/** Route the open into the sidebar editor (the established openSidebarFile). */
|
||||
openInSidebar(path: string, sessionId: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar
|
||||
* editor instead of the Host OS and resolve as success (the original's
|
||||
* callers ignore the result); anything that declines falls through to the
|
||||
* original method untouched.
|
||||
* @param workspaces - the client workspaces service to wrap.
|
||||
* @param deps - per-call takeover decisions.
|
||||
* @returns the disposer restoring the original method (HMR-safe).
|
||||
*/
|
||||
export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathInterceptDeps): () => void {
|
||||
// The RAW method reference (never a bound copy): restore must put back the
|
||||
// exact original so a chain of wrappers (other plugins wrapping the same
|
||||
// method) keeps working across disposals in any order.
|
||||
const original = workspaces.openPath
|
||||
workspaces.openPath = (path: string): Promise<void> => {
|
||||
if (deps.takeoverEnabled()) {
|
||||
const sessionId = deps.currentSessionId()
|
||||
if (sessionId !== undefined) {
|
||||
deps.openInSidebar(path, sessionId)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
return original.call(workspaces, path)
|
||||
}
|
||||
return () => {
|
||||
workspaces.openPath = original
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Path projection helpers shared by the explorer rows: a path relative to
|
||||
* the session cwd (for the @-reference button and "copy relative path").
|
||||
* The fs-tree joins with '/' even on Windows, so both separators normalize
|
||||
* to '/' before comparison.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The path relative to the session's working directory.
|
||||
* @param cwd - the explorer root (absolute).
|
||||
* @param path - an absolute entry path from the fs-tree.
|
||||
* @returns the relative path with '/' separators ('.' for the cwd itself),
|
||||
* or `path` unchanged when it lies outside the cwd.
|
||||
*
|
||||
* The prefix test is case-insensitive: Windows paths (and macOS's
|
||||
* case-insensitive volumes) may arrive with different casing than the cwd
|
||||
* row, and the containment decision must not depend on it. The returned
|
||||
* relative text keeps the caller's own casing.
|
||||
*/
|
||||
export function relativeTo(cwd: string, path: string): string {
|
||||
const base = cwd.replace(/[\\/]+$/, '')
|
||||
const norm = (value: string): string => value.replace(/\\/g, '/')
|
||||
const nBase = norm(base)
|
||||
const nPath = norm(path)
|
||||
if (nPath === nBase) return '.'
|
||||
if (nPath.toLowerCase().startsWith(`${nBase.toLowerCase()}/`)) return nPath.slice(nBase.length + 1)
|
||||
return path
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/** Pure PDF extension dispatch, separate from React for unit testing. */
|
||||
export function isPdfExt(ext: string): boolean {
|
||||
return ext === '.pdf'
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Client-side read of the user-facing "Side card" preferences. The host owns
|
||||
* the namespace through the settings seam (in-process); the DSH settings RPC
|
||||
* domain only serves allowlisted namespaces to configuration clients, so the
|
||||
* client reads and writes THIS namespace through the plugin's own fenced
|
||||
* /sidebar routes instead (api.settingsGet/settingsUpdate). Any failure
|
||||
* (route rejected, namespace absent, a field of the wrong type, a value out
|
||||
* of the contract range) falls back to the schema defaults — the side card
|
||||
* must keep working exactly as composed when the settings surface is missing.
|
||||
*/
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
|
||||
/** Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
* settings.get payload AND the settings.update response (both carry the
|
||||
* layered resolved value); any malformed field falls back to its default.
|
||||
* @param value - the raw resolved section from the settings wire.
|
||||
* @returns validated prefs (always well-formed).
|
||||
*/
|
||||
export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
const record = value as Record<string, unknown>
|
||||
return {
|
||||
openByDefault: typeof record.openByDefault === 'boolean'
|
||||
? record.openByDefault
|
||||
: SIDEBAR_PREFS_DEFAULTS.openByDefault,
|
||||
defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
|
||||
? clampWidthPercent(record.defaultWidthPercent)
|
||||
: SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
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,
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
|
||||
? record.htmlViewerNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
|
||||
htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean'
|
||||
? record.htmlViewerDefaultUnsafe
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe,
|
||||
browserNoSandbox: typeof record.browserNoSandbox === 'boolean'
|
||||
? record.browserNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserNoSandbox,
|
||||
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
|
||||
? record.browserInterceptLinks
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 /
|
||||
* drops the entry — an absent key means the feature stays enabled.
|
||||
*/
|
||||
function booleanMapOf(value: unknown): Record<string, boolean> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out: Record<string, boolean> = {}
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof item === 'boolean') out[key] = item
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
* @returns validated prefs, or the schema defaults when the route rejects,
|
||||
* the namespace is absent, or a stored value violates the contract.
|
||||
*/
|
||||
export async function loadPrefs(settings: SidebarSettingsClient): Promise<SidebarPrefs> {
|
||||
try {
|
||||
const view = await settings.settingsGet()
|
||||
return parsePrefs(view.value)
|
||||
} catch {
|
||||
// Transport/fence rejection or a malformed response: keep the defaults.
|
||||
return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of one turn's produced files from finalized conversation
|
||||
* nodes — a structural replica of ui-deliverables' `producedForClosing`
|
||||
* (the mutation tools' follow-along `locations`, by render intent: a diff
|
||||
* card or a generic edit card; reads/deletes/failures produce nothing).
|
||||
* Kept dependency-free so the takeover logic is unit-testable and the
|
||||
* replica is easy to diff against upstream when it drifts.
|
||||
*/
|
||||
|
||||
/** Paths a tool-result view reports as produced, by render intent. */
|
||||
export function producedPaths(view: unknown): readonly string[] {
|
||||
if (view === null || typeof view !== 'object') return []
|
||||
const record = view as { card?: unknown; kind?: unknown; locations?: unknown }
|
||||
const isEdit = record.card === 'diff' || (record.card === 'generic' && record.kind === 'edit')
|
||||
if (!isEdit) return []
|
||||
if (!Array.isArray(record.locations)) return []
|
||||
const paths: string[] = []
|
||||
for (const location of record.locations) {
|
||||
if (location !== null && typeof location === 'object' && typeof (location as { path?: unknown }).path === 'string') {
|
||||
paths.push((location as { path: string }).path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Files produced by the turn the assistant at `seq` closes. Accumulation
|
||||
* resets on turn boundaries (a user message, or a node reporting a different
|
||||
* turn number); paths keep first-seen order and appear once.
|
||||
* @param nodes - snapshot nodes in surface order (structural, unknown-safe).
|
||||
* @param seq - the closing assistant's seq (the render site's anchor).
|
||||
* @returns produced paths; empty when the turn wrote nothing.
|
||||
*/
|
||||
export function producedForClosing(nodes: readonly unknown[], seq: number): readonly string[] {
|
||||
let pending: string[] = []
|
||||
let seen = new Set<string>()
|
||||
let turn: number | undefined
|
||||
for (const node of nodes) {
|
||||
if (node === null || typeof node !== 'object') continue
|
||||
const record = node as { kind?: unknown; isError?: unknown; callView?: unknown; turn?: unknown; seq?: unknown }
|
||||
if (record.kind === 'tool-result') {
|
||||
if (record.isError === true) continue
|
||||
for (const path of producedPaths(record.callView)) {
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
pending.push(path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (record.kind === 'user') {
|
||||
turn = undefined
|
||||
pending = []
|
||||
seen = new Set()
|
||||
} else if (typeof record.turn === 'number') {
|
||||
if (turn !== undefined && record.turn !== turn) {
|
||||
pending = []
|
||||
seen = new Set()
|
||||
}
|
||||
turn = record.turn
|
||||
}
|
||||
if (record.kind === 'assistant' && record.seq === seq) return pending
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the turn-tail chain only when the closing turn produced files.
|
||||
* @param owner - the turn-tail owner currency ({nodes, seq}).
|
||||
* @returns produced paths as the matched value, or null to decline.
|
||||
*/
|
||||
export function selectProducedFiles(owner: unknown): readonly string[] | null {
|
||||
const record = owner as { nodes?: unknown; seq?: unknown } | null
|
||||
if (record === null || typeof record !== 'object') return null
|
||||
if (!Array.isArray(record.nodes) || typeof record.seq !== 'number') return null
|
||||
const paths = producedForClosing(record.nodes, record.seq)
|
||||
return paths.length === 0 ? null : paths
|
||||
}
|
||||
|
||||
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
|
||||
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
|
||||
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
|
||||
if (absolute) return path
|
||||
const base = cwd ?? ''
|
||||
if (base === '') return path
|
||||
const separator = base.includes('\\') ? '\\' : '/'
|
||||
return `${base.replace(/[\\/]+$/, '')}${separator}${path}`
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 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)),
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
/**
|
||||
* The BetterSidebar client service: a registry that external plugins use
|
||||
* to contribute sidebar tab types and file previewers. The service is
|
||||
* published to the cordis context as `ctx.betterSidebar` (see
|
||||
* {@link ../context-types.ts}); consumers declare it in `inject` and call
|
||||
* `registerTab` / `registerFileViewer`, both returning a disposer that
|
||||
* cordis auto-invokes on fiber disposal (HMR-safe).
|
||||
*
|
||||
* Design notes:
|
||||
* - The registry is synchronous-snapshot (Map + listener set) so React
|
||||
* can read it through `useSyncExternalStore` without tearing.
|
||||
* - `dedupeKey` unifies the three open-tab strategies the builtins used to
|
||||
* hardcode: single-instance (`() => type`), per-path (`tab => tab.path`),
|
||||
* and per-id (`tab => tab.id` for diff tabs whose id is change-derived).
|
||||
* `single: true` is sugar for `dedupeKey: () => id`.
|
||||
* - `createTab` lets a descriptor own tab instantiation (the terminal
|
||||
* builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
|
||||
* - `matchFileViewer` walks descriptors in priority order (desc, stable):
|
||||
* per descriptor it tries `detect` first (when `head` bytes are given),
|
||||
* then `exts`; `exts: []` is a catch-all that matches any path.
|
||||
*/
|
||||
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,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import type { SessionScope } from './api.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"). */
|
||||
export interface SidebarSettingToggle {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
/** Row title (i18n friendly: string or () => string). */
|
||||
title: string | (() => string)
|
||||
/** Row description (i18n friendly). */
|
||||
desc?: string | (() => string)
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
* Extra boolean toggles 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.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
/** Props every tab component receives (builtins and external alike). */
|
||||
export interface TabComponentProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
|
||||
visible: boolean
|
||||
/** The explorer's expanded directory set (ExplorerView). */
|
||||
expanded?: string[]
|
||||
onToggleDir?: (path: string) => void
|
||||
onReferenceFile?: (path: string) => void
|
||||
onOpenFile?: (path: string) => void
|
||||
onOpenDiff?: (tab: SidebarTab) => void
|
||||
onSubagentJump?: (childSessionId: string) => void
|
||||
}
|
||||
|
||||
/** Describes one kind of sidebar tab (builtins register themselves too). */
|
||||
export interface TabDescriptor {
|
||||
/** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
|
||||
id: string
|
||||
title: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** + menu sort order (ascending); default 100. */
|
||||
order?: number
|
||||
/** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
|
||||
hidden?: boolean
|
||||
/**
|
||||
* + menu disabled predicate (e.g. terminal at capacity). Receives the
|
||||
* session scope and the live sidebar state (counts, expansions).
|
||||
*/
|
||||
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
|
||||
/**
|
||||
* Single-instance sugar: `true` is shorthand for `dedupeKey: () => id`
|
||||
* (opening the tab focuses an existing one of the same type instead of
|
||||
* creating a duplicate). An explicit `dedupeKey` always wins when both
|
||||
* are given. Builtins: explorer/git/subagent use `single: true`.
|
||||
*/
|
||||
single?: boolean
|
||||
/**
|
||||
* If provided, opening a tab whose `dedupeKey(tab)` matches an existing
|
||||
* tab's key focuses the existing one instead of creating a new one.
|
||||
* Returning `undefined` means "no dedup — always open a new tab".
|
||||
* Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
|
||||
* (openDiffTab mints change-derived ids).
|
||||
*/
|
||||
dedupeKey?: (tab: SidebarTab) => string | undefined
|
||||
/**
|
||||
* Custom tab creation (minting the `SidebarTab` and any state patches).
|
||||
* Return `null` to refuse creation. The terminal builtin uses this to
|
||||
* mint `terminal:<n>` ids and bump `nextTerminal`.
|
||||
* When omitted, a default `{ id, type, title }` tab is created.
|
||||
*/
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered tab gets an enable/disable switch (icon + title + id), and
|
||||
* `settings.toggles` adds nested switches tied to SidebarPrefs fields
|
||||
* (e.g. the subagent tab's 'autoOpenSubagent').
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: TabComponentProps) => ReactNode
|
||||
}
|
||||
|
||||
/** How the host loads a file's bytes for one viewer. */
|
||||
export type FileFetchStrategy =
|
||||
| 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves)
|
||||
| 'fsRead' // text read through /sidebar/api fs.read
|
||||
| 'mediaUrl' // the viewer gets a media URL string
|
||||
| 'custom' // the viewer's load() fetches its own bytes
|
||||
| 'binary-download' // show a download button (no client-side renderer)
|
||||
|
||||
/** Props every file viewer component receives. */
|
||||
export interface FileViewerProps {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
/** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */
|
||||
viewerId: string
|
||||
/** fsRead text content (fetchStrategy='fsRead'). */
|
||||
content?: string
|
||||
truncated?: boolean
|
||||
/** mediaUrl for the path (fetchStrategy='mediaUrl'). */
|
||||
mediaUrl?: string
|
||||
/** custom load() return value (fetchStrategy='custom'). */
|
||||
customData?: unknown
|
||||
}
|
||||
|
||||
/** Describes one file previewer (builtins register themselves too). */
|
||||
export interface FileViewerDescriptor {
|
||||
/** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
|
||||
id: string
|
||||
/** Display name for the settings inventory (falls back to `id` when absent). */
|
||||
title?: string | (() => string)
|
||||
/** Icon shown in the settings inventory. */
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
/** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
|
||||
exts: readonly string[]
|
||||
/** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
|
||||
priority?: number
|
||||
fetchStrategy: FileFetchStrategy
|
||||
/**
|
||||
* Content sniff: when `head` bytes are available the descriptor's `detect`
|
||||
* 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>
|
||||
/**
|
||||
* Declarative settings shown in the Side card settings page: every
|
||||
* registered viewer gets an enable/disable switch (icon + title + exts).
|
||||
*/
|
||||
settings?: SidebarSettingsDeclaration
|
||||
component: (props: FileViewerProps) => ReactNode
|
||||
}
|
||||
|
||||
/** The registry service published as `ctx.betterSidebar`. */
|
||||
export interface BetterSidebarService {
|
||||
registerTab(descriptor: TabDescriptor): () => void
|
||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||
getTabs(): readonly TabDescriptor[]
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
/** Find a tab descriptor by id (undefined if not registered). */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/**
|
||||
* Whether a tab type is enabled in the side card prefs. An absent
|
||||
* `tabsEnabled[id]` entry means enabled — only an explicit `false`
|
||||
* disables the type (hidden from the + menu, `openTab` refuses, and
|
||||
* derived flows gate on it).
|
||||
*/
|
||||
isTabEnabled(id: string): boolean
|
||||
/** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
|
||||
isViewerEnabled(id: string): boolean
|
||||
/**
|
||||
* Find a file viewer for a path (priority desc; detect first, then exts).
|
||||
* Disabled viewers are skipped, so files fall through to the next match.
|
||||
*/
|
||||
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
|
||||
/**
|
||||
* Open a tab (used by external tabs and the + menu). `title` overrides
|
||||
* the descriptor's title when given (the editor tab shows the file name);
|
||||
* when the descriptor provides `createTab` it mints the tab itself and
|
||||
* `title`/`path`/`id` are ignored. `url` lands the tab with its `path`
|
||||
* pre-set to the URL (the browser tab's navigation seed; the caller
|
||||
* usually pairs it with a hostname `title`). A disabled tab type is a
|
||||
* no-op.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Extract the lowercase extension without leading dot from a path. */
|
||||
function extOfPath(path: string): string {
|
||||
const at = path.lastIndexOf('.')
|
||||
if (at === -1) return ''
|
||||
const base = path.slice(at + 1).toLowerCase()
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one BetterSidebar service bound to a store. The service owns the
|
||||
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
|
||||
* to the store's reducer. One instance per client plugin activation.
|
||||
*/
|
||||
export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
|
||||
const tabs = new Map<string, TabDescriptor>()
|
||||
const viewers = new Map<string, FileViewerDescriptor>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
const notify = (): void => {
|
||||
for (const fn of [...listeners]) fn()
|
||||
}
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
const registerTab = (descriptor: TabDescriptor): (() => void) => {
|
||||
if (tabs.has(descriptor.id)) {
|
||||
throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`)
|
||||
}
|
||||
tabs.set(descriptor.id, descriptor)
|
||||
notify()
|
||||
return () => {
|
||||
if (tabs.get(descriptor.id) === descriptor) {
|
||||
tabs.delete(descriptor.id)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => {
|
||||
if (viewers.has(descriptor.id)) {
|
||||
throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`)
|
||||
}
|
||||
viewers.set(descriptor.id, descriptor)
|
||||
notify()
|
||||
return () => {
|
||||
if (viewers.get(descriptor.id) === descriptor) {
|
||||
viewers.delete(descriptor.id)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
|
||||
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
|
||||
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
|
||||
|
||||
// The enable switches come from the user's side card prefs (the shared
|
||||
// store the service is bound to): an absent key means enabled.
|
||||
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
|
||||
const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
|
||||
|
||||
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
|
||||
const ext = extOfPath(path)
|
||||
// Single pass in priority order (descending; stable for equal
|
||||
// priorities — insertion order). Each descriptor gets first refusal in
|
||||
// its own turn: `detect` (when head bytes are available) beats its own
|
||||
// `exts`, and `exts: []` is a catch-all matching any path — so the
|
||||
// catch-all `code` viewer (-100) only sees paths no higher-priority
|
||||
// descriptor claimed. Disabled viewers are skipped entirely.
|
||||
for (const v of Array.from(viewers.values()).sort(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
|
||||
)) {
|
||||
if (!isViewerEnabled(v.id)) continue
|
||||
// Content sniff first (only when head bytes are available).
|
||||
if (head !== undefined && v.detect !== undefined) {
|
||||
if (v.detect(path, head)) return v
|
||||
// A catch-all with detect is SNIFF-ONLY: it must not blind-claim
|
||||
// paths it never sniffed (a magic-number viewer must not swallow
|
||||
// every file before the real viewers get their turn).
|
||||
if (v.exts.length === 0) continue
|
||||
} else if (v.exts.length === 0) {
|
||||
// Blind catch-all (no detect) claims anything; a sniff-only
|
||||
// catch-all (detect defined, no head yet) yields this round.
|
||||
if (v.detect === undefined) return v
|
||||
continue
|
||||
}
|
||||
if (v.exts.includes(ext)) return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): 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.
|
||||
if (!isTabEnabled(seed.type)) {
|
||||
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
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
let tab: SidebarTab
|
||||
let next: SidebarState
|
||||
if (descriptor.createTab !== undefined) {
|
||||
const result = descriptor.createTab(state)
|
||||
if (result === null) return state
|
||||
tab = result.tab
|
||||
next = applyDedupe(state, result.tab, descriptor)
|
||||
if (result.patch !== undefined) next = { ...next, ...result.patch }
|
||||
} else {
|
||||
tab = {
|
||||
id: seed.id ?? seed.type,
|
||||
type: seed.type,
|
||||
// A caller-provided title wins (the editor shows the file name);
|
||||
// otherwise the descriptor's (possibly i18n) title is the default.
|
||||
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
}
|
||||
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) {
|
||||
landed = patchTab(next, tab.id, {
|
||||
path: seed.url,
|
||||
...(seed.title !== undefined ? { title: seed.title } : {}),
|
||||
})
|
||||
} else {
|
||||
landed = next
|
||||
}
|
||||
// A CONTENT open (file / browser) must land in sight: when the panel
|
||||
// hosting the landing pane is collapsed, expand it. On narrow
|
||||
// viewports the two workbenches merge into one drawer, so the drawer
|
||||
// (panelOpen) is the only lever; on wide viewports the landing pane's
|
||||
// own panel opens — the bottom panel when the active pane lives in the
|
||||
// bottom tree, else the right panel. Type-only opens (+ menu,
|
||||
// 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.
|
||||
if (
|
||||
typeof window !== 'undefined'
|
||||
&& (seed.path !== undefined || seed.url !== undefined)
|
||||
) {
|
||||
if (isNarrowWidth(window.innerWidth)) {
|
||||
if (!landed.panelOpen) return togglePanel(landed)
|
||||
} else {
|
||||
const hostKey = treeOf(landed, landed.activePane ?? '')
|
||||
if (hostKey === 'bottomSplits') {
|
||||
if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
|
||||
} else if (!landed.panelOpen) {
|
||||
return togglePanel(landed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return landed
|
||||
})
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string): void => {
|
||||
store.reduce((state) => {
|
||||
const paneId = findPaneIdOf(state, tabId)
|
||||
if (paneId === '') return state
|
||||
return closeTabReducer(state, paneId, tabId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
registerTab,
|
||||
registerFileViewer,
|
||||
getTabs,
|
||||
getFileViewers,
|
||||
getTab,
|
||||
isTabEnabled,
|
||||
isViewerEnabled,
|
||||
matchFileViewer,
|
||||
openTab,
|
||||
closeTab,
|
||||
subscribe,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
|
||||
* same type exists, focus it; otherwise land the tab through
|
||||
* `openTabInActivePane` (the id safety net + active-pane landing are that
|
||||
* reducer's job — not re-implemented here).
|
||||
* `single: true` resolves to the id-key sugar when no explicit key is given.
|
||||
*/
|
||||
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
|
||||
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
|
||||
const key = dedupeKey?.(tab)
|
||||
if (key !== undefined) {
|
||||
// The scan covers BOTH trees: opening a single-instance tab from the
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
return openTabInActivePane(state, tab)
|
||||
}
|
||||
|
||||
/** Find which pane hosts a tab id ('' if none). Either tree is searched. */
|
||||
function findPaneIdOf(state: SidebarState, tabId: string): string {
|
||||
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
|
||||
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
|
||||
}
|
||||
return state.activePane ?? ''
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* The split-pane workbench: renders the recursive split tree. A split lays
|
||||
* children out row- or column-wise with draggable dividers (fractional
|
||||
* sizes); a leaf renders its tab strip plus the active tab's content.
|
||||
*
|
||||
* Splitting is VSCode-style DRAG-TO-EDGE, not buttons: while dragging a tab
|
||||
* over a pane, a drop overlay shows five zones — four edges (left/right/up/
|
||||
* down) that split the pane with the tab in a fresh leaf, and the center
|
||||
* that merges the tab into the pane. The tree and all operations live in
|
||||
* state.ts; this file is pure presentation over them.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||
import type { DropZone } from './state.ts'
|
||||
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Actions the workbench needs (bound to the store by the sidebar shell). */
|
||||
export interface WorkbenchActions {
|
||||
closeTab: (paneId: string, tabId: string) => void
|
||||
activateTab: (paneId: string, tabId: string) => void
|
||||
/** Make a pane the target of newly opened tabs (click focus). */
|
||||
focusPane: (paneId: string) => void
|
||||
/** VSCode drag gesture: edge → split the target pane, center → merge. */
|
||||
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => void
|
||||
/** Reorder within a pane (drop onto another tab inserts before it). */
|
||||
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
|
||||
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
|
||||
}
|
||||
|
||||
/** One divider: pointer-capture drag translating px deltas into fractions.
|
||||
* Deltas are incremental — each move reports the displacement since the
|
||||
* previous move — because the store adds every reported delta to the pane
|
||||
* sizes; a cumulative (since-pointer-down) delta would be re-added on each
|
||||
* move and the divider would run away from the cursor. */
|
||||
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
|
||||
const { dir, onResize } = props
|
||||
// `last` is the previous pointer position while dragging; `size` is the
|
||||
// container size at pointer-down (constant during the drag), used to
|
||||
// normalize the px delta into a fraction.
|
||||
const last = useRef({ x: 0, y: 0, size: 0 })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.divider, dir === 'row' ? css.dividerRow : css.dividerCol, dragging && css.dividerActive)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
const box = event.currentTarget.parentElement?.getBoundingClientRect()
|
||||
last.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
|
||||
}
|
||||
setDragging(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
|
||||
onResize(delta / Math.max(1, last.current.size))
|
||||
last.current.x = event.clientX
|
||||
last.current.y = event.clientY
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
setDragging(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */
|
||||
function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
|
||||
const rect = pane.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return 'center'
|
||||
const x = (event.clientX - rect.left) / rect.width
|
||||
const y = (event.clientY - rect.top) / rect.height
|
||||
if (x < 0.25) return 'left'
|
||||
if (x > 0.75) return 'right'
|
||||
if (y < 0.25) return 'up'
|
||||
if (y > 0.75) return 'down'
|
||||
return 'center'
|
||||
}
|
||||
|
||||
/** The icon of one openable type card (mirror of the + menu options). */
|
||||
/**
|
||||
* An empty pane's welcome cards: the openable types as cards, clicked to
|
||||
* open (instead of a bare "this pane is empty" message).
|
||||
*/
|
||||
function PaneEmptyCards(props: {
|
||||
newTabOptions: NewTabOption[]
|
||||
onNewTab: (optionId: string) => void
|
||||
}) {
|
||||
const { newTabOptions, onNewTab } = props
|
||||
return (
|
||||
<div className={css.paneEmptyCards}>
|
||||
{newTabOptions.map(option => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={css.paneCard}
|
||||
disabled={option.disabled === true}
|
||||
title={option.label}
|
||||
onClick={() => { onNewTab(option.id) }}
|
||||
>
|
||||
{option.icon ?? null}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A leaf: tab strip + active content + VSCode-style drop target for tabs. */
|
||||
function LeafView(props: {
|
||||
leaf: { id: string; tabs: SidebarTab[]; active: string | null }
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setDropZone(null) }
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('drop', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('drop', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
||||
onPointerDown={() => { actions.focusPane(leaf.id) }}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
const zone = zoneAt(event, event.currentTarget)
|
||||
setDropZone(zone)
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
// Only clear when the pointer left the pane entirely (not onto a child).
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setDropZone(null)
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
const zone = dropZone ?? zoneAt(event, event.currentTarget)
|
||||
setDropZone(null)
|
||||
const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
|
||||
if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone)
|
||||
}}
|
||||
>
|
||||
{dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
|
||||
{/*
|
||||
The tab strip renders even for an empty pane: the + menu must stay
|
||||
reachable when the pane has no tabs (fresh split, or the last tab was
|
||||
dragged out), so a new tab can always be created or dragged in.
|
||||
*/}
|
||||
<TabBar
|
||||
paneId={leaf.id}
|
||||
tabs={leaf.tabs}
|
||||
active={leaf.active}
|
||||
onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
|
||||
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
|
||||
onNewTab={onNewTab}
|
||||
newTabOptions={newTabOptions}
|
||||
getTabIcon={getTabIcon}
|
||||
onDropTab={(payload, before) => {
|
||||
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
|
||||
else actions.moveTabBefore(payload, leaf.id, before)
|
||||
}}
|
||||
/>
|
||||
{leaf.tabs.length > 0 ? (
|
||||
/*
|
||||
Every tab stays MOUNTED (inactive ones hidden), so switching tabs
|
||||
never tears down the content: a terminal keeps its pty connection
|
||||
and scrollback, an editor keeps its CodeMirror view and unsaved
|
||||
draft, explorer/git keep their loaded data. The unmount (and the
|
||||
terminal's close frame) happens only when a tab is truly closed.
|
||||
*/
|
||||
<div className={css.paneContent}>
|
||||
{leaf.tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
|
||||
>
|
||||
{renderTab(tab, tab.id === activeTab?.id, leaf.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Recursive node renderer. */
|
||||
function NodeView(props: {
|
||||
node: SplitNode
|
||||
state: SidebarState
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
if (node.kind === 'leaf') {
|
||||
return (
|
||||
<LeafView
|
||||
leaf={node}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const isRow = node.dir === 'row'
|
||||
return (
|
||||
<div className={clsx(css.split, isRow ? css.splitRow : css.splitCol)}>
|
||||
{node.children.map((child, index) => (
|
||||
<Fragment key={child.id}>
|
||||
{index > 0 && (
|
||||
<Divider
|
||||
dir={node.dir}
|
||||
onResize={(deltaFrac) => { actions.resizeSplit(node.id, index - 1, deltaFrac) }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={css.splitChild}
|
||||
style={{ flexGrow: node.sizes[index], flexBasis: 0, minWidth: 0, minHeight: 0 }}
|
||||
>
|
||||
<NodeView
|
||||
node={child}
|
||||
state={state}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The workbench: the split tree filling the sidebar body. `tree` selects
|
||||
* which tree renders (the right panel's by default; the bottom panel passes
|
||||
* `state.bottomSplits` — the actions route by pane id, so one action set
|
||||
* serves both). */
|
||||
export function Workbench(props: {
|
||||
state: SidebarState
|
||||
tree?: SplitNode
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
||||
}) {
|
||||
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
node={tree ?? state.splits}
|
||||
state={state}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={getTabIcon}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-1048
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the compact LIVE line shown on a running subagent card:
|
||||
* the last text output and the last tool call of the child's history tail
|
||||
* (`subagent.history` events, oldest → newest). Renders nothing itself — the
|
||||
* SubagentLiveLine component turns this into the card's status lines.
|
||||
* Kept framework-free so the parser is unit-testable in the node environment.
|
||||
*/
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
|
||||
/**
|
||||
* Extract the concatenated plain text of a content-block list (the durable
|
||||
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
||||
* `text`; anything else — tool_use, image, … — contributes nothing).
|
||||
* @param content - the raw `content` field of a message event.
|
||||
* @returns the joined text, or undefined when the message carries no text.
|
||||
*/
|
||||
export function contentText(content: unknown): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** The live status of one subagent card (both fields optional). */
|
||||
export interface LastActivity {
|
||||
/** The latest assembled assistant text output in the tail. */
|
||||
text?: string
|
||||
/** The latest tool call in the tail. */
|
||||
tool?: { name: string; args: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a history tail into the last text output + last tool call (each is
|
||||
* the LAST occurrence in event order). Lifecycle events and raw
|
||||
* `assistant/chunk` rows are ignored — the card shows what the subagent is
|
||||
* doing right now, not its plumbing.
|
||||
* @param entries - the tail page from `subagent.history` (oldest → newest).
|
||||
* @returns the last text and/or tool call; an empty object when the tail has neither.
|
||||
*/
|
||||
export function lastActivity(entries: SidebarHistoryEntry[]): LastActivity {
|
||||
let text: string | undefined
|
||||
let tool: { name: string; args: string } | undefined
|
||||
for (const entry of entries) {
|
||||
const { type, data } = entry.event
|
||||
if (type === 'assistant/message') {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const extracted = contentText(message?.content)
|
||||
if (extracted !== undefined) text = extracted
|
||||
} else if (type === 'tool/call') {
|
||||
tool = {
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text === undefined && tool === undefined) return {}
|
||||
return {
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Pure subagent-membership helpers over the sessions list feed (structural
|
||||
* mirror world — no runtime imports). Used by the sidebar's auto-activation
|
||||
* effect and the Subagent page:
|
||||
*
|
||||
* - {@link directSubagentCount}: direct durable children of one session,
|
||||
* - {@link detectNewDirectSubagent}: the 0 → N transition that means "a new
|
||||
* subagent just spawned under the current session" (the auto-open trigger),
|
||||
* - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage
|
||||
* totals (mirror of the official `indexSubagentDescendants` over the
|
||||
* plugin's own summary rows).
|
||||
*/
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentCatalog,
|
||||
} from '../context-types.ts'
|
||||
|
||||
/** Count the direct subagent children of one session (durable `origin` rows). */
|
||||
export function directSubagentCount(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string,
|
||||
): number {
|
||||
let count = 0
|
||||
for (const summary of Object.values(byId)) {
|
||||
if (summary.origin === 'subagent' && summary.parentId === sessionId) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* The main agent of the current session's tree: walk the durable parent
|
||||
* chain upward until the first non-subagent session. The Subagent page shows
|
||||
* THIS root's full topology regardless of how deep the current selection is
|
||||
* (a session whose row is still hydrating, or a broken chain, degrades to
|
||||
* the session itself).
|
||||
*/
|
||||
export function rootAncestor(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string | undefined,
|
||||
): string | undefined {
|
||||
if (sessionId === undefined) return undefined
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = byId[sessionId]
|
||||
while (current !== undefined && current.origin === 'subagent'
|
||||
&& current.parentId !== undefined && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
current = byId[current.parentId]
|
||||
}
|
||||
return current?.id ?? sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every catalog branch (an entry with `hasChildren`) reachable from
|
||||
* the root — the set of catalogs the always-expanded topology consumes.
|
||||
* Cycles fail soft.
|
||||
*/
|
||||
export function collectBranchIds(
|
||||
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>,
|
||||
rootId: string | undefined,
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const visit = (parentId: string): void => {
|
||||
if (seen.has(parentId)) return
|
||||
seen.add(parentId)
|
||||
for (const entry of catalogs[parentId]?.entries ?? []) {
|
||||
if (entry.kind === 'child' && entry.hasChildren) {
|
||||
out.push(entry.id)
|
||||
visit(entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rootId !== undefined) visit(rootId)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a new direct subagent appeared under `sessionId` between two
|
||||
* consecutive list snapshots (the count crossed 0 → >0). Switching to a
|
||||
* session that already has subagents yields `false` (its baseline starts at
|
||||
* the current count), so the auto-open never fights an existing layout.
|
||||
*/
|
||||
export function detectNewDirectSubagent(
|
||||
prev: SidebarSessionList,
|
||||
next: SidebarSessionList,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
return directSubagentCount(prev.byId, sessionId) === 0
|
||||
&& directSubagentCount(next.byId, sessionId) > 0
|
||||
}
|
||||
|
||||
/** Descendant totals of one session through an uninterrupted subagent-origin chain. */
|
||||
export interface SubagentDescendantTotals {
|
||||
count: number
|
||||
runningCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every subagent descendant under each ancestor it reaches through an
|
||||
* uninterrupted subagent-origin chain (same semantics as the official
|
||||
* `indexSubagentDescendants`; cycles fail soft).
|
||||
*/
|
||||
export function countSubagentDescendants(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string,
|
||||
): SubagentDescendantTotals {
|
||||
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
|
||||
for (const descendant of Object.values(byId)) {
|
||||
if (descendant.origin !== 'subagent') continue
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = descendant
|
||||
while (current?.origin === 'subagent' && current.parentId !== undefined
|
||||
&& !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
if (current.parentId === sessionId) {
|
||||
totals.count += 1
|
||||
if (descendant.running === true) totals.runningCount += 1
|
||||
break
|
||||
}
|
||||
current = byId[current.parentId]
|
||||
}
|
||||
}
|
||||
return totals
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* 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-jobs header list.
|
||||
*/
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarJobStatus,
|
||||
SidebarJobView,
|
||||
} from '../context-types.ts'
|
||||
import type { CopyKey } from './locales.ts'
|
||||
|
||||
/** One row of the jobs section: the job plus its owning session's title. */
|
||||
export interface TreeJob {
|
||||
ownerSessionId: string
|
||||
ownerTitle: string
|
||||
job: SidebarJobView
|
||||
}
|
||||
|
||||
/** Whether the registry still holds the job open (its duration ticks). */
|
||||
export function isJobLive(job: SidebarJobView): boolean {
|
||||
return job.status === 'running' || job.status === 'stopping'
|
||||
}
|
||||
|
||||
/**
|
||||
* Every session id of the topology tree rooted at `rootId` (the root plus
|
||||
* 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
|
||||
* jobs section never shows foreign work.
|
||||
*/
|
||||
export function treeSessionIds(
|
||||
byId: SidebarSessionList['byId'],
|
||||
rootId: string | undefined,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
if (rootId === undefined) return ids
|
||||
for (const summary of Object.values(byId)) {
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = summary
|
||||
let reachesRoot = false
|
||||
while (current !== undefined && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
if (current.id === rootId) {
|
||||
reachesRoot = true
|
||||
break
|
||||
}
|
||||
if (current.origin !== 'subagent' || current.parentId === undefined) break
|
||||
current = byId[current.parentId]
|
||||
}
|
||||
if (reachesRoot) ids.add(summary.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 detectNewJob(
|
||||
prev: SidebarSessionList,
|
||||
next: SidebarSessionList,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
const prevIds = new Set((prev.jobsBySession?.[sessionId] ?? []).map(job => job.id))
|
||||
return (next.jobsBySession?.[sessionId] ?? []).some(job => !prevIds.has(job.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 jobs feed) yields an empty list.
|
||||
*/
|
||||
export function collectTreeJobs(
|
||||
byId: SidebarSessionList['byId'],
|
||||
jobsBySession: Readonly<Record<string, readonly SidebarJobView[]>> | undefined,
|
||||
rootId: string | undefined,
|
||||
): TreeJob[] {
|
||||
const rows: TreeJob[] = []
|
||||
if (jobsBySession === undefined) return rows
|
||||
for (const sessionId of treeSessionIds(byId, rootId)) {
|
||||
const jobs = jobsBySession[sessionId]
|
||||
if (jobs === undefined || jobs.length === 0) continue
|
||||
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
|
||||
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-jobs ordering); a tie falls back to start order so the
|
||||
* sort never depends on the host's map iteration.
|
||||
*/
|
||||
export function orderJobs(rows: readonly TreeJob[]): TreeJob[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
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 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 jobDotState(status: SidebarJobStatus): JobDotState {
|
||||
switch (status) {
|
||||
case 'running': return 'ongoing'
|
||||
case 'stopping': return 'warning'
|
||||
case 'completed': return 'done'
|
||||
case 'killed': return 'warning'
|
||||
case 'failed': return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/** Human status word of one wire status (localized through the passed translator). */
|
||||
export function jobStatusLabel(
|
||||
status: SidebarJobStatus,
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
switch (status) {
|
||||
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-jobs duration wording). A background job that outlives an hour is
|
||||
* already exceptional, so hours is the widest unit.
|
||||
*/
|
||||
export function formatJobDuration(
|
||||
elapsedMs: number,
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
|
||||
const seconds = total % 60
|
||||
const minutes = Math.floor(total / 60) % 60
|
||||
const hours = Math.floor(total / 3_600)
|
||||
if (hours > 0) return t('jobDurationHours', { hours, minutes })
|
||||
if (minutes > 0) return t('jobDurationMinutes', { minutes, seconds })
|
||||
return t('jobDurationSeconds', { seconds })
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Live theme access for surfaces that cannot consume the token colors
|
||||
* directly — xterm's palette and CodeMirror's theme extensions need concrete
|
||||
* values, but the app's scheme flips at runtime (ui-layout's ThemePresenter
|
||||
* projects prefers-color-scheme and the user's choice onto
|
||||
* body[data-ds-dark-theme] and html { color-scheme }). This module reads the
|
||||
* resolved scheme and token values, and notifies subscribers on flips, so
|
||||
* the terminal and the editor re-theme in place instead of freezing in the
|
||||
* scheme they happened to be created under.
|
||||
*/
|
||||
|
||||
/** Whether the app shell resolved to the dark scheme.
|
||||
*
|
||||
* The presenter sets `html { color-scheme }` together with the body palette
|
||||
* attribute, so a set color-scheme means the decision is authoritative (an
|
||||
* absent attribute is then LIGHT even when the OS prefers dark — the user
|
||||
* chose light). Before the presenter has run, fall back to the OS media
|
||||
* query as the best guess.
|
||||
*/
|
||||
export function isDarkScheme(): boolean {
|
||||
if (typeof document === 'undefined') return true
|
||||
const decided = document.documentElement.style.colorScheme !== ''
|
||||
if (decided) return document.body.hasAttribute('data-ds-dark-theme')
|
||||
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
/** One token's computed value on <body> ('' while the theme has not applied). */
|
||||
export function tokenValue(name: string): string {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return getComputedStyle(document.body).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to color-scheme flips (the presenter toggles the body
|
||||
* attribute). The callback fires after the attribute changed; re-read the
|
||||
* scheme inside it.
|
||||
* @returns the disposer.
|
||||
*/
|
||||
export function subscribeColorScheme(callback: () => void): () => void {
|
||||
if (typeof document === 'undefined') return () => {}
|
||||
const observer = new MutationObserver(() => { callback() })
|
||||
observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
||||
return () => { observer.disconnect() }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Serializable configuration and defaults for the sidebar host half. Loader
|
||||
* schema validation normally fills defaults; {@link resolveSidebarConfig}
|
||||
* applies the same defaults for direct callers that bypass the Loader.
|
||||
* @module dsh-better-sidebar/config
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
/** Tunable sidebar host limits (every field optional; defaults fill in). */
|
||||
export interface SidebarConfig {
|
||||
/** Read cap of one text file (bytes); larger files return truncated. */
|
||||
readLimit?: number
|
||||
/** Media route cap (bytes); larger binaries are refused. */
|
||||
mediaLimit?: number
|
||||
/** Explorer row bound of one level. */
|
||||
listLimit?: number
|
||||
/** Terminals per session. */
|
||||
terminalsPerSession?: number
|
||||
/** How long a disconnected terminal process survives awaiting a reconnect. */
|
||||
reconnectGraceMs?: number
|
||||
}
|
||||
|
||||
/** Schemastery schema for the plugin configuration. */
|
||||
export const Config: z<SidebarConfig> = z.object({
|
||||
readLimit: z.number().step(1).min(1).default(512 * 1024),
|
||||
mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024),
|
||||
listLimit: z.number().step(1).min(1).default(1000),
|
||||
terminalsPerSession: z.number().step(1).min(1).default(3),
|
||||
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
|
||||
})
|
||||
|
||||
/** Fully defaulted sidebar host settings. */
|
||||
export interface ResolvedSidebarConfig {
|
||||
readLimit: number
|
||||
mediaLimit: number
|
||||
listLimit: number
|
||||
terminalsPerSession: number
|
||||
reconnectGraceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply direct-call defaults after Loader schema validation has normally run.
|
||||
*
|
||||
* @param config - Deployment-provided sidebar host settings.
|
||||
* @returns Complete settings consumed by the host half.
|
||||
*/
|
||||
export function resolveSidebarConfig(config: SidebarConfig | undefined): ResolvedSidebarConfig {
|
||||
return {
|
||||
readLimit: config?.readLimit ?? 512 * 1024,
|
||||
mediaLimit: config?.mediaLimit ?? 20 * 1024 * 1024,
|
||||
listLimit: config?.listLimit ?? 1000,
|
||||
terminalsPerSession: config?.terminalsPerSession ?? 3,
|
||||
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
|
||||
}
|
||||
}
|
||||
|
||||
// ── User-facing "Side card" preferences ─────────────────────────────────────
|
||||
|
||||
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
||||
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),
|
||||
autoOpenJobs: z.boolean().default(true),
|
||||
agentTerminalTools: z.boolean().default(false),
|
||||
bottomPanelAutoTerminal: z.boolean().default(true),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
||||
browserNoSandbox: z.boolean().default(false),
|
||||
browserInterceptLinks: z.boolean().default(true),
|
||||
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
|
||||
// external): an absent key means enabled, so old documents resolve to {}
|
||||
// (everything on) with no migration. Non-boolean values fail validation.
|
||||
tabsEnabled: z.dict(z.boolean()).default({}),
|
||||
viewersEnabled: z.dict(z.boolean()).default({}),
|
||||
})
|
||||
+24
-422
@@ -1,431 +1,33 @@
|
||||
/**
|
||||
* Structural types for the cordis services this plugin consumes, plus the
|
||||
* Context augmentation both halves share. A third-party plugin resolves
|
||||
* outside the DSH monorepo's single cordis instance, so the upstream
|
||||
* `declare module 'cordis'` augmentations do not reach this Context — and
|
||||
* 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:
|
||||
* - 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 SlotRegistry
|
||||
* - effect: the DSH-vendored cordis lifecycle helper
|
||||
* Drift from upstream is contained to this file.
|
||||
* Structural types for the cordis services apes-sidebar consumes.
|
||||
*
|
||||
* PHASE 1: the sidebar tab + session types now come from the REAL
|
||||
* `dsh-better-sidebar` package (type-only imports in `src/client/index.tsx`):
|
||||
* `TabDescriptor` / `TabComponentProps` / `BetterSidebarService` /
|
||||
* `SidebarSessionSummary` / `SidebarSessionList`. The Phase 0 placeholder
|
||||
* mirrors (`ApesSidebarTabDescriptor`, `ApesSidebarService`,
|
||||
* `ApesSessionSummary`, `ApesSessionList`, `ApesSessionsService`, and the
|
||||
* `ApesContext` intersection) were deleted — they are superseded by the
|
||||
* peer's authoritative types and would drift.
|
||||
*
|
||||
* What remains here is the ONE service face apes-sidebar still defines
|
||||
* itself: the locale runtime mirror (`ApesLocaleService`). `dsh-better-sidebar`
|
||||
* publishes its own `SidebarLocaleService`, but locales.ts pre-dates the type
|
||||
* import and stays on this local mirror to keep the i18n module independent
|
||||
* of the peer (it must compile even when the peer is absent).
|
||||
*
|
||||
* This file must stay FREE of Node.js types: it is part of the
|
||||
* CLIENT-reachable declaration graph, so a Node import here would leak into
|
||||
* browser-only consumer builds.
|
||||
*/
|
||||
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'
|
||||
|
||||
/** 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>
|
||||
}
|
||||
|
||||
/** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */
|
||||
export interface SidebarWebUpgradeRoute {
|
||||
path: string
|
||||
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** The webServer service face this plugin uses. */
|
||||
export interface SidebarWebServer {
|
||||
register(route: SidebarWebRoute): () => void
|
||||
registerUpgrade(route: SidebarWebUpgradeRoute): () => void
|
||||
}
|
||||
|
||||
/** A published session's header slice the sidebar reads (authoritative cwd). */
|
||||
export interface SidebarSessionHeader {
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
|
||||
export interface SidebarSessionStore {
|
||||
get(id: string): {
|
||||
header: SidebarSessionHeader
|
||||
/**
|
||||
* The live session's append-only event log (immutable snapshot; absent
|
||||
* on sessions the runtime has not hydrated). Read-only access — the
|
||||
* 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>
|
||||
}
|
||||
|
||||
/** Registration options the sidebar passes to `ctx.slots.register` (subset of the real options). */
|
||||
export interface SidebarSlotRegisterOptions {
|
||||
name: string
|
||||
key?: string
|
||||
id?: string
|
||||
order?: number
|
||||
label?: string | (() => string)
|
||||
/** Chain routing selector (returns the matched value, or null to pass on). */
|
||||
select?: (owner: unknown) => unknown
|
||||
priority?: number
|
||||
locale?: string
|
||||
registrant?: string
|
||||
/** Business-face factory; args depend on the slot scope. */
|
||||
inject?: (...args: any[]) => Record<string, unknown>
|
||||
children?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** The client slots service face (register returns the disposer). */
|
||||
export interface SidebarSlotsService {
|
||||
register(options: SidebarSlotRegisterOptions, component: unknown): () => void
|
||||
/**
|
||||
* Run a callback for each declaration lifetime of a slot (the runtime
|
||||
* 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
|
||||
}
|
||||
|
||||
/** The client session list row the sidebar reads (cwd for the explorer). */
|
||||
export interface SidebarSessionSummary {
|
||||
id: string
|
||||
cwd?: string
|
||||
displayTitle: string
|
||||
/** Coarse durable origin for navigation filtering (subagent children). */
|
||||
origin?: 'subagent'
|
||||
/** Durable direct parent session id (present on subagent children). */
|
||||
parentId?: string
|
||||
/** Whether the session's agent is currently running. */
|
||||
running?: boolean
|
||||
}
|
||||
|
||||
/** One healthy subagent catalog child row (structural mirror of the runtime). */
|
||||
export interface SidebarSubagentChildEntry {
|
||||
kind: 'child'
|
||||
id: string
|
||||
/** Whether the child Agent driver is running at the Host sampling boundary. */
|
||||
activity: 'running' | 'inactive'
|
||||
/** Whether a direct descendant has durable `origin: 'subagent'`. */
|
||||
hasChildren: boolean
|
||||
mode: 'one-shot' | 'continuable'
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** One unreadable catalog row (corrupt / unsupported / unavailable). */
|
||||
export interface SidebarSubagentDiagnosticEntry {
|
||||
kind: 'diagnostic'
|
||||
id: string
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/** The per-parent lazy catalog delivered through the sessions list feed. */
|
||||
export interface SidebarSubagentCatalog {
|
||||
entries: Array<SidebarSubagentChildEntry | SidebarSubagentDiagnosticEntry>
|
||||
parentAvailable: boolean
|
||||
state: 'loading' | 'ready' | 'error'
|
||||
error: { code?: string; message?: string } | null
|
||||
}
|
||||
|
||||
/** Durable parent/child address that selects subagent transport in the client. */
|
||||
export interface SidebarSubagentAddress {
|
||||
parentSessionId: string
|
||||
childSessionId: string
|
||||
mode: 'one-shot' | 'continuable'
|
||||
}
|
||||
|
||||
/** Minimal structural mirror of one session event (the subagent history tail). */
|
||||
export interface SidebarSessionEvent {
|
||||
type: string
|
||||
seq: number
|
||||
time: number
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** One history row: the durable event plus an optional tool presentation view. */
|
||||
export interface SidebarHistoryEntry {
|
||||
event: SidebarSessionEvent
|
||||
view?: unknown
|
||||
}
|
||||
|
||||
/** Lifecycle status set of one background job (closed wire union). */
|
||||
export type SidebarJobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* One background job as the client mirror sees it (wire `JobView` shape:
|
||||
* id/kind/label/status/detail?/startedAt/finishedAt?).
|
||||
* The client locale service face (mirror of dsh-client-locale's LocaleRuntime).
|
||||
* Only the slices apes-sidebar's i18n module touches: snapshot read, snapshot
|
||||
* subscription, and per-namespace dictionary registration.
|
||||
*/
|
||||
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: SidebarJobStatus
|
||||
/** Kind-specific status detail ('exit code: 3'), present once supplied. */
|
||||
detail?: string
|
||||
/** Epoch ms when the job was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the job settled; absent while live. */
|
||||
finishedAt?: number
|
||||
}
|
||||
|
||||
/** 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'
|
||||
}
|
||||
|
||||
/** The host agent registry face (structural mirror of the runtime `ctx.agents`). */
|
||||
export interface SidebarAgentsService {
|
||||
/** The live agent registered under a session id, or undefined when not live. */
|
||||
get(id: string): SidebarAgent | undefined
|
||||
}
|
||||
|
||||
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
|
||||
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** Unary response mirror (`RpcResponse<T>` on the wire). */
|
||||
export interface SidebarRpcResponse<T> {
|
||||
rpcId: unknown
|
||||
result: SidebarRpcResult<T>
|
||||
}
|
||||
|
||||
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
|
||||
export interface SidebarConnectionHandle {
|
||||
api: {
|
||||
subagents: {
|
||||
history(
|
||||
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The client session list snapshot the sidebar subscribes to. */
|
||||
export interface SidebarSessionList {
|
||||
current: string | undefined
|
||||
byId: Record<string, SidebarSessionSummary>
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
/**
|
||||
* 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 jobs mirror — the sidebar simply shows no job rows.
|
||||
*/
|
||||
jobsBySession?: Readonly<Record<string, readonly SidebarJobView[]>>
|
||||
}
|
||||
|
||||
/** The client sessions service face (only the list feed is needed). */
|
||||
export interface SidebarSessionsService {
|
||||
list: {
|
||||
getSnapshot(): SidebarSessionList
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
/**
|
||||
* Select a listed session as current (mirror of the runtime ISessions.open)
|
||||
* — used to jump back to the main agent from the topology root node.
|
||||
*/
|
||||
open?(id: string): void
|
||||
/**
|
||||
* Resolve an Agent-scoped context view for one session (mirror of the
|
||||
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
|
||||
* requires to reach that session's composer.
|
||||
*/
|
||||
scope(id: string): Context | undefined
|
||||
/**
|
||||
* Open a healthy catalog child through its exact direct-parent address
|
||||
* (mirror of the runtime ISessions.openSubagent).
|
||||
*/
|
||||
openSubagent?(address: SidebarSubagentAddress): void
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
*/
|
||||
subagentAddress?(id: string): SidebarSubagentAddress | undefined
|
||||
/**
|
||||
* Mark whether a catalog surface is consuming live membership updates.
|
||||
*/
|
||||
setSubagentCatalogOpen?(parentSessionId: string, open: boolean): void
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
*/
|
||||
refreshSubagents?(parentSessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The client locale service face (mirror of @deepseek-ai/dsh-client-locale's
|
||||
* 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
|
||||
* namespace registry under `betterSidebar`.
|
||||
*/
|
||||
export interface SidebarLocaleService {
|
||||
/** Current immutable locale snapshot (uSES-safe; `active` is 'zh' | 'en' today). */
|
||||
export interface ApesLocaleService {
|
||||
getSnapshot(): { active: string }
|
||||
/** Subscribe to snapshot changes (locale switch or dictionary registration). */
|
||||
subscribe(fn: () => void): () => void
|
||||
/** Register one locale's dictionary for a namespace; returns the disposer. */
|
||||
register(ns: string, locale: string, dict: Record<string, string>): () => void
|
||||
}
|
||||
|
||||
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
||||
export interface SidebarSessionInput {
|
||||
/** The live input store (draft read for append). */
|
||||
state: {
|
||||
getSnapshot(): { draft: string }
|
||||
}
|
||||
/** Replace the draft text (the input machine's single public write path). */
|
||||
setDraft(text: string): void
|
||||
}
|
||||
|
||||
/** The composer draft face the sidebar reaches through `ctx.get('conversation')`. */
|
||||
export interface SidebarConversation {
|
||||
input: {
|
||||
for(actx: Context): SidebarSessionInput
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client workspaces service face (mirror of the runtime IWorkspaces). Only
|
||||
* the chat's file-open funnel is touched: `openPath` hands an absolute path
|
||||
* to the Host OS's default application, and every chat-side file open
|
||||
* (tool rows, produced-files, prose mentions) funnels through it.
|
||||
*/
|
||||
export interface SidebarWorkspacesService {
|
||||
/** Open a filesystem path with the Host operating system's default application. */
|
||||
openPath(path: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
|
||||
* 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.
|
||||
*/
|
||||
export interface SidebarInvariantsService {
|
||||
/** Reserve one package's checks and install them in the service's child fiber. */
|
||||
register(
|
||||
packageName: string,
|
||||
installer: (ctx: Context, fail: (message: string) => never) => void | Promise<void>,
|
||||
): () => void
|
||||
}
|
||||
|
||||
/** The settings service face (mirror of @deepseek-ai/dsh-settings' SettingsProvider). */
|
||||
export interface SidebarSettingsService {
|
||||
/**
|
||||
* Register one namespace schema (the resolved value layers schema defaults,
|
||||
* then the composition base, then the user document).
|
||||
*/
|
||||
register<T>(
|
||||
ns: string,
|
||||
schema: unknown,
|
||||
options?: { base?: Partial<T>; applies?: 'live' | 'restart' },
|
||||
): {
|
||||
get(): T
|
||||
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
|
||||
update(patch: object): Promise<void>
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
/** Redacted descriptors of every registered namespace (secrets stripped). */
|
||||
describe(options?: { redactSecrets?: boolean }): Array<{
|
||||
ns: string
|
||||
value?: unknown
|
||||
base?: unknown
|
||||
user?: unknown
|
||||
applies: 'live' | 'restart'
|
||||
revision: number
|
||||
}>
|
||||
/** Service-level merge write with the revision guard (a stale writer is refused). */
|
||||
update(ns: string, patch: object, expectedRevision?: number): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface SidebarToolsService {
|
||||
/** Register one tool definition (raw JSON-Schema or defineTool-sugar form). */
|
||||
register(tool: unknown): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent face a tool sees on `exec.agent` (mirror of @deepseek-ai/dsh-agent's
|
||||
* Agent). Only the slices the terminal tools touch are restated: the live
|
||||
* session identity and its header cwd, both readonly.
|
||||
*/
|
||||
export interface SidebarAgent {
|
||||
/** The live session identity shared with the session log. */
|
||||
readonly id: string
|
||||
/** The live session this agent drives. */
|
||||
readonly session: {
|
||||
/** The session's header (validated cwd, lineage metadata). */
|
||||
readonly header: { readonly cwd?: string }
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
webServer: SidebarWebServer
|
||||
sessions: SidebarSessionStore & SidebarSessionsService
|
||||
connection: SidebarConnectionHandle
|
||||
loader: SidebarLoader
|
||||
slots: SidebarSlotsService
|
||||
workspaces: SidebarWorkspacesService
|
||||
settings: SidebarSettingsService
|
||||
invariants: SidebarInvariantsService
|
||||
tools: SidebarToolsService
|
||||
/**
|
||||
* The client locale service (`@deepseek-ai/dsh-client-locale`): the
|
||||
* sidebar's copy follows its active locale and registers its
|
||||
* dictionaries under the `betterSidebar` namespace. Client side only.
|
||||
*/
|
||||
locale: SidebarLocaleService
|
||||
/**
|
||||
* The host background-job registry (`ctx.get('jobs')`; optional — the
|
||||
* sidebar routes degrade to a 503 when the deployment lacks it).
|
||||
*/
|
||||
jobs: SidebarJobsService
|
||||
/**
|
||||
* The host live-agent registry (`ctx.get('agents')`; optional — used to
|
||||
* resolve the caller the jobs fence compares against).
|
||||
*/
|
||||
agents: SidebarAgentsService
|
||||
/**
|
||||
* The client-side sidebar registry: external plugins register tab types
|
||||
* and file previewers here. Provided by the client half (see
|
||||
* {@link ./client/index.tsx}); undefined on the host side.
|
||||
*/
|
||||
betterSidebar: BetterSidebarService
|
||||
/**
|
||||
* 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 job_output events the
|
||||
* session store's own log can lag behind (restart divergence). Returns
|
||||
* the disposer.
|
||||
*/
|
||||
on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void
|
||||
/**
|
||||
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
|
||||
* activation; its returned cleanup runs at disposal.
|
||||
*/
|
||||
effect(fn: () => void | (() => void), label?: string): void
|
||||
}
|
||||
}
|
||||
|
||||
export type { Context }
|
||||
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Single-level directory listing for the sidebar explorer. Streams the level
|
||||
* with opendir, sorts directories first then names (case-insensitive), and
|
||||
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
|
||||
* reported as files without probing their target — the explorer shows what
|
||||
* dirent says, keeping the read cheap for arbitrarily large levels.
|
||||
*/
|
||||
import { opendir } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** One explorer row. */
|
||||
export interface SidebarFsEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
/** One listed level. */
|
||||
export interface SidebarFsListing {
|
||||
path: string
|
||||
entries: SidebarFsEntry[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Directory-first, case-insensitive name ordering (VSCode explorer order). */
|
||||
export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
/**
|
||||
* List one directory level.
|
||||
* @param path - absolute directory path.
|
||||
* @param maxEntries - row bound of one level (extra rows flag `truncated`).
|
||||
* @returns the sorted listing.
|
||||
* @throws {SidebarError} fs-error when the level is unreadable or not a directory.
|
||||
*/
|
||||
export async function listDirectory(path: string, maxEntries = 1000): Promise<SidebarFsListing> {
|
||||
let level
|
||||
try {
|
||||
level = await opendir(path)
|
||||
} catch (error) {
|
||||
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
|
||||
}
|
||||
const rows: SidebarFsEntry[] = []
|
||||
let overflow = 0
|
||||
try {
|
||||
for await (const dirent of level) {
|
||||
if (rows.length >= maxEntries) {
|
||||
overflow += 1
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
name: dirent.name,
|
||||
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
||||
// would leak mixed separators into every row's path.
|
||||
path: join(path, dirent.name),
|
||||
isDir: dirent.isDirectory(),
|
||||
hidden: dirent.name.startsWith('.'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
|
||||
}
|
||||
rows.sort(compareEntries)
|
||||
return { path, entries: rows, truncated: overflow > 0 }
|
||||
}
|
||||
|
||||
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
|
||||
export function rootLabel(path: string): string {
|
||||
const base = basename(path)
|
||||
return base !== '' ? base : path
|
||||
}
|
||||
|
||||
/** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
|
||||
export function parentOf(path: string): string | undefined {
|
||||
const parent = dirname(path)
|
||||
return parent === path ? undefined : parent
|
||||
}
|
||||
|
||||
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
|
||||
export function requireAbsolute(path: string): string {
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
|
||||
}
|
||||
return resolve(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `target` lies under `base` (or equals it), tolerant of separator
|
||||
* style and — on Windows, where the filesystem is case-insensitive — of
|
||||
* letter case. The media route uses this instead of a raw `startsWith` so a
|
||||
* case-mismatched or mixed-separator path can never be misclassified
|
||||
* (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
|
||||
* @param platform - filesystem semantics; injectable so both branches are
|
||||
* unit-testable on any host.
|
||||
*/
|
||||
export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '')
|
||||
const b = norm(base)
|
||||
const t = norm(target)
|
||||
if (platform === 'win32') {
|
||||
const lb = b.toLowerCase()
|
||||
const lt = t.toLowerCase()
|
||||
return lt === lb || lt.startsWith(`${lb}/`)
|
||||
}
|
||||
return t === b || t.startsWith(`${b}/`)
|
||||
}
|
||||
|
||||
/** Message text of an unknown thrown value. */
|
||||
export function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
-236
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* Git operations for the sidebar source-control panel. Everything goes
|
||||
* through the system `git` binary spawned per request (no library, no state),
|
||||
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
|
||||
* so parsing never depends on locale or color config. All commands run with
|
||||
* `-C <cwd>` on the session's working directory and `--no-pager` /
|
||||
* `-c color.ui=false` so output stays machine-readable.
|
||||
*
|
||||
* Commits use the user's git global identity untouched (never sets
|
||||
* user.name/user.email).
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
/** A parsed `git status --porcelain=v1 -z` entry. */
|
||||
export interface GitStatusEntry {
|
||||
path: string
|
||||
/** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
|
||||
xy: string
|
||||
}
|
||||
|
||||
/** The source-control panel snapshot. */
|
||||
export interface GitStatusResult {
|
||||
isRepo: boolean
|
||||
branch?: string
|
||||
entries: GitStatusEntry[]
|
||||
}
|
||||
|
||||
/** One `git log` row. */
|
||||
export interface GitLogEntry {
|
||||
/** Short hash (7+ chars, display). */
|
||||
hash: string
|
||||
/** Full 40-char hash (advanced operations: revert / cherry-pick). */
|
||||
hashFull: string
|
||||
subject: string
|
||||
author: string
|
||||
/** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
|
||||
date: string
|
||||
/** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
|
||||
refs: string
|
||||
}
|
||||
|
||||
/** One git failure (stderr text as the message). */
|
||||
export class GitCommandError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code = 'git-error',
|
||||
readonly command: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
|
||||
export function parsePorcelainZ(output: string): GitStatusEntry[] {
|
||||
const tokens = output.split('\0')
|
||||
const entries: GitStatusEntry[] = []
|
||||
let index = 0
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index]!
|
||||
index += 1
|
||||
if (token === '') continue
|
||||
const xy = token.slice(0, 2)
|
||||
const rest = token.slice(3)
|
||||
entries.push({ path: rest, xy })
|
||||
// Rename/copy entries carry the ORIGIN path as the next NUL field; the
|
||||
// new path (the file as it exists now) is the display path.
|
||||
if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
|
||||
export function parseLogLines(output: string): GitLogEntry[] {
|
||||
const rows: GitLogEntry[] = []
|
||||
for (const line of output.split('\n')) {
|
||||
if (line === '') continue
|
||||
const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
|
||||
if (hash === undefined || subject === undefined) continue
|
||||
rows.push({
|
||||
hash,
|
||||
subject,
|
||||
author: author ?? '',
|
||||
date: date ?? '',
|
||||
hashFull: hashFull ?? hash,
|
||||
refs: refs ?? '',
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
|
||||
function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
|
||||
const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn('git', full, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
|
||||
}, timeoutMs)
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) {
|
||||
resolvePromise(stdout)
|
||||
} else {
|
||||
reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
|
||||
export async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
|
||||
return out.trim() === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
|
||||
export async function repoRoot(cwd: string): Promise<string> {
|
||||
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
|
||||
export async function currentBranch(cwd: string): Promise<string> {
|
||||
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
/** Working-tree status (untracked included). */
|
||||
export async function status(cwd: string): Promise<GitStatusResult> {
|
||||
const repo = await isGitRepo(cwd)
|
||||
if (!repo) return { isRepo: false, entries: [] }
|
||||
const [branch, raw] = await Promise.all([
|
||||
currentBranch(cwd).catch(() => 'HEAD'),
|
||||
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
|
||||
])
|
||||
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
|
||||
}
|
||||
|
||||
/** Diff text of the worktree (unstaged) or the index (staged). */
|
||||
export async function diff(cwd: string, path: string | undefined, staged: boolean): Promise<string> {
|
||||
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
|
||||
if (staged) args.push('--cached')
|
||||
if (path !== undefined) args.push('--', path)
|
||||
return runGit(cwd, args)
|
||||
}
|
||||
|
||||
/** Stage paths (all when path is undefined). */
|
||||
export async function stage(cwd: string, path: string | undefined): Promise<void> {
|
||||
await runGit(cwd, ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
|
||||
}
|
||||
|
||||
/** Unstage paths (all when path is undefined). */
|
||||
export async function unstage(cwd: string, path: string | undefined): Promise<void> {
|
||||
await runGit(cwd, ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
|
||||
}
|
||||
|
||||
/** Commit the staged changes with a message (global identity untouched). */
|
||||
export async function commit(cwd: string, message: string): Promise<void> {
|
||||
await runGit(cwd, ['commit', '-m', message])
|
||||
}
|
||||
|
||||
/** Branch names (current first). */
|
||||
export async function branches(cwd: string): Promise<{ current: string; names: string[] }> {
|
||||
const [current, raw] = await Promise.all([
|
||||
currentBranch(cwd).catch(() => 'HEAD'),
|
||||
runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
|
||||
])
|
||||
const names = raw.split('\n').filter(line => line !== '')
|
||||
return { current, names: names.includes(current) ? names : [current, ...names] }
|
||||
}
|
||||
|
||||
/** Switch to an existing branch. */
|
||||
export async function checkout(cwd: string, branch: string): Promise<void> {
|
||||
await runGit(cwd, ['checkout', branch])
|
||||
}
|
||||
|
||||
/** Recent commit history (newest first), lazily pageable via skip/count. */
|
||||
export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
|
||||
const raw = await runGit(cwd, [
|
||||
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
|
||||
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
|
||||
])
|
||||
return parseLogLines(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
|
||||
* revision has no such path (a new/untracked file has no HEAD side).
|
||||
*/
|
||||
export async function show(cwd: string, rev: string, path: string): Promise<string | null> {
|
||||
try {
|
||||
return await runGit(cwd, ['show', `${rev}:${path}`])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
||||
* Merge commits show their diff against the first parent (`-m --first-parent`
|
||||
* is a no-op for regular commits), so a history click always has content. */
|
||||
export async function commitDiff(cwd: string, hash: string): Promise<string> {
|
||||
return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
|
||||
}
|
||||
|
||||
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
|
||||
export async function discard(cwd: string, path: string): Promise<void> {
|
||||
await runGit(cwd, ['checkout', '--', path])
|
||||
}
|
||||
|
||||
/** Revert one commit onto the current branch with an auto-generated message. */
|
||||
export async function revert(cwd: string, hash: string): Promise<void> {
|
||||
await runGit(cwd, ['revert', '--no-edit', hash])
|
||||
}
|
||||
|
||||
/** Cherry-pick one commit onto the current branch. */
|
||||
export async function cherryPick(cwd: string, hash: string): Promise<void> {
|
||||
await runGit(cwd, ['cherry-pick', hash])
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Pure URL vocabulary of the /sidebar/html route (HTML previewer).
|
||||
*
|
||||
* Why path-encoded parameters instead of a query string: the previewed
|
||||
* page resolves its relative assets (./style.css, img/x.png) against the
|
||||
* document URL, and the WHATWG URL algorithm DROPS the query of a
|
||||
* path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css`
|
||||
* would lose the session scope and the route would reject the asset.
|
||||
* Encoding everything into the URL path keeps relative resolution inside
|
||||
* the same route with every request self-contained:
|
||||
*
|
||||
* /sidebar/html/<sessionId>/<absolute-path segments, encodeURIComponent'd>
|
||||
* /sidebar/html/S/Users/me/proj/index.html
|
||||
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
|
||||
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
|
||||
*
|
||||
* This module is intentionally dependency-free (no node imports, no wire
|
||||
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
|
||||
* the build-time purity gate; the host converts decode failures into
|
||||
* SidebarError responses at the route boundary.
|
||||
*/
|
||||
|
||||
/** One decoded route reference. */
|
||||
export interface HtmlRouteRef {
|
||||
sessionId: string
|
||||
/** Absolute file path (leading slash; Windows drives keep their colon). */
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Decode outcome: the reference, or a client-error description. */
|
||||
export type HtmlDecodeResult =
|
||||
| { ok: true; ref: HtmlRouteRef }
|
||||
| { ok: false; status: 400 | 404; message: string }
|
||||
|
||||
/** The route prefix both encoders/decoders agree on. */
|
||||
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
|
||||
|
||||
/** Build the route URL for one absolute file path (client + tests). */
|
||||
export function encodeHtmlUrl(sessionId: string, path: string): string {
|
||||
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
|
||||
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a route pathname into the session + absolute file path. Rejects
|
||||
* a wrong prefix (404), an empty or double-slash path, malformed percent
|
||||
* encoding, and a missing sessionId or file path (400). The caller still
|
||||
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
|
||||
* decoded `..` segment resolves outside the cwd and is refused there.
|
||||
*/
|
||||
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
||||
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
|
||||
return { ok: false, status: 404, message: 'not an html route' }
|
||||
}
|
||||
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
|
||||
if (rest === '' || rest.includes('//')) {
|
||||
return { ok: false, status: 400, message: 'invalid html route path' }
|
||||
}
|
||||
let segments: string[]
|
||||
try {
|
||||
segments = rest.split('/').map(segment => decodeURIComponent(segment))
|
||||
} catch {
|
||||
return { ok: false, status: 400, message: 'malformed URL encoding' }
|
||||
}
|
||||
const [sessionId, ...pathSegments] = segments
|
||||
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('/')}` } }
|
||||
}
|
||||
+47
-884
@@ -1,897 +1,60 @@
|
||||
/**
|
||||
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
|
||||
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
|
||||
* 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.
|
||||
* apes-sidebar host-half entry.
|
||||
*
|
||||
* All operations are conversation-scoped: requests carry a sessionId, the
|
||||
* session's authoritative cwd comes from the session store, and terminal
|
||||
* processes are keyed by session.
|
||||
* The host half is intentionally MINIMAL for Phase 0: it mounts a logger
|
||||
* line so the cordis patch's `insert` row is exercised end-to-end (the
|
||||
* scratch-profile mount smoke asserts this package's fiber activates).
|
||||
*
|
||||
* Phase 2 (Tutti capability layer) will add the host routes the tabs need:
|
||||
* - subagent topology / job output replay (mirror of better-sidebar's
|
||||
* subagent-live-route.ts + jobs-routes.ts)
|
||||
* - agent-programmable open-tab registry (mirror of better-sidebar's
|
||||
* agent-opens.ts)
|
||||
* All host routes follow the better-sidebar trust-fence + session-isolation
|
||||
* model (same-origin loopback fence, per-request cwd from the session store).
|
||||
*
|
||||
* The client half (the actual sidebar tabs) lives in src/client/index.tsx and
|
||||
* is loaded through the package.json `dsh.client.inject` declaration; it
|
||||
* registers tabs into `ctx.betterSidebar` (better-sidebar's registry).
|
||||
*
|
||||
* inject list: Phase 0 needs only `logger` (always present). Phase 2 will
|
||||
* add `webServer` + `sessions` when the host routes land.
|
||||
*/
|
||||
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 { WebSocket, WebSocketServer } from 'ws'
|
||||
import type { Context } from './context-types.ts'
|
||||
import {
|
||||
Config,
|
||||
PrefsSchema,
|
||||
resolveSidebarConfig,
|
||||
SIDEBAR_PREFS_NS,
|
||||
type ResolvedSidebarConfig,
|
||||
type SidebarConfig,
|
||||
type SidebarPrefs,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { decodeHtmlUrl } from './html-route.ts'
|
||||
import { extractFrameAncestors } from './browser-probe.ts'
|
||||
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
|
||||
import { registerBundleRoute } from './bundle-route.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
export { Config }
|
||||
export type { SidebarConfig, ResolvedSidebarConfig }
|
||||
// Re-export the Context augmentation (declare module 'cordis') so consumers
|
||||
// `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`.
|
||||
// Also re-export the service descriptor types so consumers can type their
|
||||
// registerTab / registerFileViewer arguments without reaching into /client.
|
||||
export type { Context } from './context-types.ts'
|
||||
export type {
|
||||
BetterSidebarService,
|
||||
TabDescriptor,
|
||||
TabComponentProps,
|
||||
FileViewerDescriptor,
|
||||
FileViewerProps,
|
||||
FileFetchStrategy,
|
||||
} from './client/service.ts'
|
||||
|
||||
/** 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 = ['webServer', 'sessions', 'loader', 'tools']
|
||||
|
||||
/** Content types for the media route, by extension. */
|
||||
const MEDIA_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.avif': 'image/avif',
|
||||
'.pdf': 'application/pdf',
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
}
|
||||
|
||||
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
||||
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 []
|
||||
}
|
||||
export const name = 'apes-sidebar'
|
||||
|
||||
/**
|
||||
* Resolve a session's authoritative working directory. The attached session
|
||||
* header wins; while the session is still hydrating from persistence (the
|
||||
* web client attaches the current conversation a moment after page load, so
|
||||
* the very first sidebar requests can arrive detached) the caller's own
|
||||
* list-summary cwd is used; the process cwd is the last resort (blank
|
||||
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
|
||||
* explorer/git/terminal work from first paint instead of surfacing
|
||||
* "session ... has no working directory".
|
||||
* The cordis services the host half injects.
|
||||
*
|
||||
* MUST stay empty: the host half uses `ctx.logger` only via optional-chaining
|
||||
* (`log?.info?.()` inside try/catch) — it is a soft convenience, not a hard
|
||||
* dependency. Declaring it in `inject` would make the entry wait on a
|
||||
* `logger` service that no DSH host provider registers, leaving the entry
|
||||
* `pending` forever and failing the plugin tree load (recovery mode).
|
||||
* Compare dsh-better-sidebar, whose inject list is services the host
|
||||
* actually provides (`webServer`/`sessions`/`webRuntime`/`tools`).
|
||||
*/
|
||||
function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
|
||||
const session = ctx.sessions.get(sessionId)
|
||||
const headerCwd = session?.header.cwd
|
||||
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
|
||||
if (clientCwd !== undefined && clientCwd !== '') {
|
||||
export const inject = [] as const
|
||||
|
||||
/**
|
||||
* The cordis effect entry: runs once per activation on the Node half.
|
||||
* Phase 0 — log a mount marker and validate the better-sidebar peer
|
||||
* version (warn-only, never blocks mount).
|
||||
*/
|
||||
export function apply(ctx: { logger?: { info: (msg: string) => void }; effect?: (fn: () => void | (() => void)) => () => void }): void {
|
||||
const log = ctx.logger
|
||||
const mount = () => {
|
||||
try {
|
||||
return requireAbsolute(clientCwd)
|
||||
log?.info?.('[apes-sidebar] host half mounted (Phase 0 skeleton)')
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
|
||||
// Never let a logging failure abort the fiber.
|
||||
}
|
||||
}
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path that a git command reported — `git status`/`git diff`
|
||||
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
|
||||
* session cwd (a session inside a subdirectory of a repository). Absolute
|
||||
* paths pass through; relative ones join the repo root (falling back to the
|
||||
* cwd when the root cannot be resolved, e.g. a bare directory).
|
||||
*/
|
||||
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
|
||||
if (isAbsolute(raw)) return requireAbsolute(raw)
|
||||
const root = await git.repoRoot(cwd).catch(() => cwd)
|
||||
return requireAbsolute(join(root, raw))
|
||||
}
|
||||
|
||||
/** How many leading bytes a binary read returns for client-side detect sniffing. */
|
||||
const READ_HEAD_LIMIT = 4096
|
||||
|
||||
/** Text read of a file with the size cap; binary detection via NUL probe.
|
||||
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
|
||||
* so the client can re-match viewers by content (`detect`). */
|
||||
async function readText(path: string, readLimit: number): Promise<{
|
||||
content: string
|
||||
truncated: boolean
|
||||
binary: boolean
|
||||
size: number
|
||||
head?: string
|
||||
}> {
|
||||
const info = await stat(path).catch((error: unknown) => {
|
||||
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
})
|
||||
if (info.isDirectory()) {
|
||||
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
|
||||
}
|
||||
const size = info.size
|
||||
const truncated = size > readLimit
|
||||
const handle = await open(path, 'r').catch((error: unknown) => {
|
||||
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
})
|
||||
try {
|
||||
const buffer = Buffer.alloc(Math.min(size, readLimit))
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
||||
const slice = buffer.subarray(0, bytesRead)
|
||||
const binary = slice.includes(0)
|
||||
const head = binary
|
||||
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
|
||||
: undefined
|
||||
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** One API method dispatch table entry. */
|
||||
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
||||
|
||||
/**
|
||||
* The live face of the side card settings namespace, bound to the settings
|
||||
* service when it is mounted. The DSH settings RPC domain only serves
|
||||
* allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
|
||||
* and writes THIS namespace through the plugin's own fenced /sidebar routes,
|
||||
* which call the seam in-process — no configuration-client gate involved.
|
||||
*/
|
||||
export interface SidebarSettingsFace {
|
||||
/** The current resolved value + revision (undefined while the settings service is absent). */
|
||||
get(): { value?: unknown; revision?: number }
|
||||
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
|
||||
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
|
||||
}
|
||||
|
||||
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
|
||||
function buildApi(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Record<string, ApiMethod> {
|
||||
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const record = payload as { cwd?: unknown } | null
|
||||
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
|
||||
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
||||
}
|
||||
// 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
|
||||
// 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)
|
||||
return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
|
||||
},
|
||||
'fs.tree': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { path?: unknown }
|
||||
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
|
||||
return listDirectory(target, resolved.listLimit)
|
||||
},
|
||||
'fs.read': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
// Relative paths are git-derived (status/diff report repo-root-relative
|
||||
// names; the untracked diff view reads the file through this route).
|
||||
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
|
||||
if (binary) return { kind: 'binary', size, truncated, head }
|
||||
return { kind: 'text', content, truncated }
|
||||
},
|
||||
'fs.write': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const path = requireAbsolute(requireString(payload, 'path'))
|
||||
const content = requireString(payload, 'content')
|
||||
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(tmp, content, 'utf8')
|
||||
await rename(tmp, path)
|
||||
} catch (error) {
|
||||
await rm(tmp, { force: true }).catch(() => {})
|
||||
throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
'git.status': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
return git.status(cwd)
|
||||
},
|
||||
'git.diff': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { path?: unknown; staged?: unknown }
|
||||
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
return { diff: await git.diff(cwd, path, record.staged === true) }
|
||||
},
|
||||
'git.stage': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { path?: unknown }
|
||||
const path = record.path === undefined ? undefined : requireString(payload, 'path')
|
||||
await git.stage(cwd, path)
|
||||
return { ok: true }
|
||||
},
|
||||
'git.unstage': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { path?: unknown }
|
||||
const path = record.path === undefined ? undefined : requireString(payload, 'path')
|
||||
await git.unstage(cwd, path)
|
||||
return { ok: true }
|
||||
},
|
||||
'git.commit': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const message = requireString(payload, 'message')
|
||||
await git.commit(cwd, message)
|
||||
return { ok: true }
|
||||
},
|
||||
'git.branch': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
return git.branches(cwd)
|
||||
},
|
||||
'git.checkout': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
await git.checkout(cwd, requireString(payload, 'branch'))
|
||||
return { ok: true }
|
||||
},
|
||||
'git.log': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const record = payload as { count?: unknown; skip?: unknown }
|
||||
const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
|
||||
? record.count
|
||||
: undefined
|
||||
const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
|
||||
? record.skip
|
||||
: undefined
|
||||
return git.log(cwd, count, skip)
|
||||
},
|
||||
'git.commit-diff': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
|
||||
},
|
||||
'git.discard': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
|
||||
return { ok: true }
|
||||
},
|
||||
'git.revert': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
await git.revert(cwd, requireString(payload, 'hash'))
|
||||
return { ok: true }
|
||||
},
|
||||
'git.cherry-pick': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
await git.cherryPick(cwd, requireString(payload, 'hash'))
|
||||
return { ok: true }
|
||||
},
|
||||
'git.show': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
const rev = requireString(payload, 'rev')
|
||||
return { content: await git.show(cwd, rev, path) }
|
||||
},
|
||||
// Release a terminal immediately. The WebSocket close frame already does
|
||||
// this while the socket is open; this route covers the tab-close that
|
||||
// happens while the socket is down (reconnect loop), so a closed tab can
|
||||
// never hold the per-session quota until the reconnect grace expires.
|
||||
'pty.close': (payload) => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const tab = requireString(payload, 'tab')
|
||||
ptyManager.close(`${sessionId}:${tab}`)
|
||||
return { ok: true }
|
||||
},
|
||||
// Release an agent terminal by uuid. The WS close frame already does
|
||||
// this while the socket is open; this route covers the tab-close that
|
||||
// happens while the socket is down (reconnect loop) so a closed agent
|
||||
// tab never leaves a zombie pty behind. Idempotent.
|
||||
'agent-pty.close': (payload) => {
|
||||
const uuid = requireString(payload, 'uuid')
|
||||
agentPtyRegistry.close(uuid)
|
||||
return { ok: true }
|
||||
},
|
||||
// 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
|
||||
// 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
|
||||
// is refused with settings-conflict so a concurrent change is never
|
||||
// silently overwritten (mirror of the settings seam's own guard).
|
||||
'settings.get': () => {
|
||||
const settings = getSettings()
|
||||
return settings?.get() ?? { value: undefined, revision: undefined }
|
||||
},
|
||||
'settings.update': async (payload) => {
|
||||
const settings = getSettings()
|
||||
if (settings === undefined) {
|
||||
throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
|
||||
}
|
||||
const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
|
||||
const patch = record?.patch
|
||||
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||
throw new SidebarError('bad-request', 'patch must be a plain object')
|
||||
}
|
||||
const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
|
||||
try {
|
||||
return await settings.update(patch as Record<string, unknown>, expectedRevision)
|
||||
} catch (error) {
|
||||
if (error instanceof SettingsConflictError) {
|
||||
throw new SidebarError('settings-conflict', error.message, 409)
|
||||
}
|
||||
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
|
||||
}
|
||||
},
|
||||
// Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an
|
||||
// iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the
|
||||
// signals the browser enforces when it refuses to embed a site. The
|
||||
// probe is display-only (headers back to the caller), restricted to
|
||||
// http(s) non-loopback URLs with a hard timeout, and gated by the same
|
||||
// trust fence as every other route — a cross-site page cannot reach it.
|
||||
'browser.probe': async (payload) => {
|
||||
const raw = requireString(payload, 'url')
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', 'invalid url', 400)
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new SidebarError('bad-request', 'only http/https urls can be probed', 400)
|
||||
}
|
||||
// Mirror the browser tab's address-bar policy: loopback stays unreachable
|
||||
// from the sidebar, so probing it would leak nothing the tab could use.
|
||||
if (isLoopbackHostname(parsed.hostname)) {
|
||||
throw new SidebarError('bad-request', 'local addresses are not probed', 400)
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 8000)
|
||||
try {
|
||||
let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
|
||||
// Some servers answer HEAD with 405/501; retry once as GET (the
|
||||
// body is discarded — only the headers matter).
|
||||
if (response.status === 405 || response.status === 501) {
|
||||
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
|
||||
}
|
||||
const csp = response.headers.get('content-security-policy')
|
||||
const frameAncestors = extractFrameAncestors(csp)
|
||||
const xFrameOptions = response.headers.get('x-frame-options')
|
||||
return {
|
||||
reachable: true,
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
...(xFrameOptions !== null ? { xFrameOptions } : {}),
|
||||
...(frameAncestors !== undefined ? { frameAncestors } : {}),
|
||||
}
|
||||
} catch {
|
||||
// DNS / TLS / connection / timeout: nothing to judge — the client
|
||||
// keeps the plain iframe.
|
||||
return { reachable: false }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin body: mount the fenced routes and the pty lifecycle.
|
||||
* @param ctx - host plugin context (webServer, sessions, loader).
|
||||
* @param config - deployment-provided limits; the Loader validates against
|
||||
* {@link Config} and fills defaults, direct callers get them from
|
||||
* {@link resolveSidebarConfig}.
|
||||
*/
|
||||
export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
|
||||
// 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)
|
||||
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}`,
|
||||
// uncapped, and torn down with the plugin. The model creates terminals here
|
||||
// through the terminal_create tool; the sidebar view attaches through the
|
||||
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
|
||||
const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
|
||||
|
||||
// ── User-facing "Side card" preferences ──────────────────────────────────
|
||||
// Register the namespace with the settings provider so the Settings page
|
||||
// (client half) can render and persist the new-conversation defaults. The
|
||||
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
|
||||
// configuration clients, so the client reaches this namespace through the
|
||||
// plugin's own fenced routes below ('settings.get'/'settings.update'),
|
||||
// which call the seam in-process. Deployments without a settings service
|
||||
// simply never fill the face and the client falls back to the defaults.
|
||||
let settingsFace: SidebarSettingsFace | undefined
|
||||
// The model-facing terminal tools are gated on the side-card setting
|
||||
// `agentTerminalTools` (default off): nothing is injected until the user
|
||||
// turns the feature on, and turning it off mid-session unregisters the
|
||||
// tools and releases the agent terminals they created.
|
||||
let toolsDisposers: (() => void) | null = null
|
||||
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
||||
if (scope.get().agentTerminalTools) {
|
||||
if (toolsDisposers === null) {
|
||||
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
|
||||
}
|
||||
} else if (toolsDisposers !== null) {
|
||||
toolsDisposers()
|
||||
toolsDisposers = null
|
||||
// The feature is off: release every agent terminal the model created
|
||||
// while it was on (they are only reachable through the tools). The
|
||||
// registry change fires the push, so the sidebar reconciles them away.
|
||||
agentPtyRegistry.disposeAll()
|
||||
}
|
||||
}
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
|
||||
// The structural settings mirror types `schema` as unknown, so the
|
||||
// generic is not inferred here; the real service resolves it from the
|
||||
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
|
||||
const scope = sctx.settings.register(ns, PrefsSchema) as {
|
||||
get(): SidebarPrefs
|
||||
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
|
||||
}
|
||||
const viewOf = (): { value?: unknown; revision?: number } => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
||||
return descriptor === undefined
|
||||
? { value: undefined, revision: undefined }
|
||||
: { value: descriptor.value, revision: descriptor.revision }
|
||||
}
|
||||
settingsFace = {
|
||||
get: viewOf,
|
||||
update: async (patch, expectedRevision) => {
|
||||
await sctx.settings.update(ns, patch, expectedRevision)
|
||||
return viewOf()
|
||||
},
|
||||
}
|
||||
// Register (or unregister) the terminal tools from the current setting,
|
||||
// and keep them in sync with every settings commit.
|
||||
syncToolsGate(scope)
|
||||
scope.watch(() => { syncToolsGate(scope) })
|
||||
})
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/api',
|
||||
handler: async (req, res) => {
|
||||
if (!fence(req)) {
|
||||
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
||||
return
|
||||
}
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
|
||||
if (method === undefined || method.includes('/')) {
|
||||
writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = await readJsonBody(req)
|
||||
const handler = api[method]
|
||||
if (handler === undefined) {
|
||||
throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
|
||||
}
|
||||
writeOk(res, await handler(payload))
|
||||
} catch (error) {
|
||||
writeError(res, error)
|
||||
}
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/api routes')
|
||||
|
||||
// ── Lazy chunk route (client bundle splits) ─────────────────────────────
|
||||
// Serves the client half's split bundles (lib/client-<name>.js) so the
|
||||
// heavy preview/terminal libraries load on first use, not at page start
|
||||
// (see bundle-route.ts / src/client/chunk-loader.ts).
|
||||
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
|
||||
|
||||
// ── Media route (images for the editor) ─────────────────────────────────
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/file',
|
||||
handler: async (req, res) => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
const raw = url.searchParams.get('path')
|
||||
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const path = requireAbsolute(raw)
|
||||
if (!isWithin(cwd, path)) {
|
||||
// Only files under the session cwd are served as media (the editor
|
||||
// opens images from the explorer; produced files go through read).
|
||||
// isWithin (not a raw startsWith) so case-mismatched Windows paths
|
||||
// and mixed separators cannot be misclassified.
|
||||
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
|
||||
}
|
||||
const info = await stat(path)
|
||||
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
||||
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
||||
}
|
||||
const type = mediaTypeForPath(path)
|
||||
const body = await readFile(path)
|
||||
// Raw bytes either way (binary-safe); ?download=1 switches the
|
||||
// disposition so the browser saves the file instead of showing it.
|
||||
const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
|
||||
if (url.searchParams.get('download') === '1') {
|
||||
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
|
||||
}
|
||||
res.writeHead(200, headers)
|
||||
res.end(body)
|
||||
} catch (error) {
|
||||
writeError(res, error)
|
||||
}
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/file media route')
|
||||
|
||||
// ── HTML preview route (sandboxed HTML + its relative assets) ───────────
|
||||
// Serves files under the session cwd for the built-in HTML previewer. The
|
||||
// URL is path-encoded (see html-route.ts) so the previewed page's relative
|
||||
// assets (./style.css, img/x.png) resolve back into this route with the
|
||||
// session scope intact — a query-encoded URL would drop the scope when the
|
||||
// browser resolves relatives. Every response carries the CSP `sandbox`
|
||||
// directive: inside the editor's iframe the sandbox ATTRIBUTE is the
|
||||
// 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.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/html',
|
||||
handler: async (req, res) => {
|
||||
if (!fence(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const decoded = decodeHtmlUrl(url.pathname)
|
||||
if (!decoded.ok) {
|
||||
writeError(res, new SidebarError('bad-request', decoded.message, decoded.status))
|
||||
return
|
||||
}
|
||||
const { sessionId, path } = decoded.ref
|
||||
// The session's authoritative cwd (client cwd cannot ride in the URL
|
||||
// — the path encoding has no query; a detached first request falls
|
||||
// back to the process cwd and is normally refused by isWithin, same
|
||||
// semantics as the media route's fallback).
|
||||
const cwd = sessionCwdOf(ctx, sessionId)
|
||||
const absolute = requireAbsolute(path)
|
||||
if (!isWithin(cwd, absolute)) {
|
||||
throw new SidebarError('fs-error', 'html path outside the session working directory', 403)
|
||||
}
|
||||
const info = await stat(absolute)
|
||||
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
||||
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
||||
}
|
||||
const type = mediaTypeForPath(absolute)
|
||||
const body = await readFile(absolute)
|
||||
res.writeHead(200, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-cache',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'referrer-policy': 'no-referrer',
|
||||
// The sandbox directive (no allow-same-origin → opaque origin) is
|
||||
// the previewer's security boundary even for top-level loads;
|
||||
// object-src 'none' blocks plugin embeds.
|
||||
'content-security-policy': "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'",
|
||||
})
|
||||
res.end(body)
|
||||
} catch (error) {
|
||||
writeError(res, error)
|
||||
}
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/html preview route')
|
||||
|
||||
// ── Terminal WebSocket ──────────────────────────────────────────────────
|
||||
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
|
||||
// agent-owned terminals (?uuid=...). The two paths attach to different
|
||||
// registries but share the wire protocol: input frames are raw text,
|
||||
// resize frames are JSON `{type:'resize',cols,rows}`, and a close frame
|
||||
// `{type:'close'}` releases the underlying pty (immediate for agent
|
||||
// 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.webServer.registerUpgrade({
|
||||
path: '/sidebar/ws/terminal',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
|
||||
})
|
||||
},
|
||||
}), 'dsh-better-sidebar: terminal WebSocket')
|
||||
|
||||
// ── Agent terminals push WebSocket ──────────────────────────────────────
|
||||
// Pushes the live list of agent terminals for one session to the sidebar
|
||||
// view: the client mirrors the list into tabs (id `agent:<uuid>`,
|
||||
// title from the agent's `terminal_create` call). The host fires on every
|
||||
// create / close / exit; the client reconciles by adding tabs for new
|
||||
// uuids and dropping tabs whose uuids disappeared (the user closing a tab
|
||||
// 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.webServer.registerUpgrade({
|
||||
path: '/sidebar/ws/agent-terminals',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
agentListWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
void attachAgentList(agentPtyRegistry, ws, req)
|
||||
})
|
||||
},
|
||||
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
|
||||
|
||||
ctx.effect(() => () => {
|
||||
toolsDisposers?.()
|
||||
ptyManager.disposeAll()
|
||||
agentPtyRegistry.disposeAll()
|
||||
wss.close()
|
||||
agentListWss.close()
|
||||
}, 'dsh-better-sidebar: teardown')
|
||||
}
|
||||
|
||||
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
||||
async function attachAgentList(
|
||||
registry: AgentPtyRegistry,
|
||||
ws: WebSocket,
|
||||
req: IncomingMessage,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
if (sessionId === null) {
|
||||
ws.close(1008, 'sessionId is required')
|
||||
return
|
||||
}
|
||||
const send = (): void => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(registry.list(sessionId)))
|
||||
}
|
||||
}
|
||||
send()
|
||||
const unsubscribe = registry.subscribe(send)
|
||||
ws.on('close', () => { unsubscribe() })
|
||||
ws.on('error', () => { unsubscribe() })
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
||||
* Two attach modes share the wire protocol:
|
||||
* - `?uuid=...` attaches to an agent-owned terminal (created by the
|
||||
* `terminal_create` tool). The close frame kills the pty immediately
|
||||
* (the agent's terminal closes when the user closes the sidebar tab); a
|
||||
* bare socket drop (refresh, tab switch) leaves the pty alive for the
|
||||
* reconnect grace, exactly like UI-tab terminals.
|
||||
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
|
||||
* created it from the + menu). The close frame schedules a 0-ms close
|
||||
* (the host's reconnect grace keeps the shell alive across a refresh).
|
||||
*/
|
||||
async function attachTerminal(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ws: WebSocket,
|
||||
req: IncomingMessage,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const uuid = url.searchParams.get('uuid')
|
||||
if (uuid !== null) {
|
||||
const handle = agentPtyRegistry.get(uuid)
|
||||
if (handle === undefined) {
|
||||
ws.close(1011, `agent terminal "${uuid}" not found`)
|
||||
return
|
||||
}
|
||||
pumpAgentTerminal(agentPtyRegistry, handle, ws)
|
||||
return
|
||||
}
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
const tabId = url.searchParams.get('tab')
|
||||
if (sessionId === null || tabId === null) {
|
||||
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
|
||||
return
|
||||
}
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
|
||||
// Replay the transcript, then follow live output.
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
const onData = (data: string): void => {
|
||||
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
|
||||
ws.send(data)
|
||||
}
|
||||
}
|
||||
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
|
||||
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
|
||||
}
|
||||
const dataSub = handle.pty.onData(onData)
|
||||
const exitSub = handle.pty.onExit(onExit)
|
||||
ws.on('message', (data) => {
|
||||
const text = data.toString('utf8')
|
||||
// Control frames are JSON with a known shape; anything else (including
|
||||
// JSON that is not a recognized control) is terminal input, verbatim.
|
||||
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: terminal input.
|
||||
}
|
||||
if (control !== null && control.type === 'close') {
|
||||
// The owning tab was closed: release the quota immediately.
|
||||
ptyManager.scheduleClose(handle.key, 0)
|
||||
return
|
||||
}
|
||||
if (handle.exited) return
|
||||
if (
|
||||
control !== null
|
||||
&& control.type === 'resize'
|
||||
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
||||
) {
|
||||
const dims = clampDims(control.cols, control.rows)
|
||||
handle.pty.resize(dims.cols, dims.rows)
|
||||
} else {
|
||||
handle.pty.write(text)
|
||||
}
|
||||
if (typeof ctx.effect === 'function') {
|
||||
ctx.effect(() => {
|
||||
mount()
|
||||
return () => undefined
|
||||
})
|
||||
ws.on('close', () => {
|
||||
dataSub.dispose()
|
||||
exitSub.dispose()
|
||||
// A bare socket drop (refresh, tab switch) leaves the process alive
|
||||
// for a grace period so a quick reconnect keeps it; the reconnect's
|
||||
// open() cancels the pending close.
|
||||
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
|
||||
})
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pump one agent terminal's pty to a connected view. The close frame kills
|
||||
* the pty immediately (the agent's terminal closes when the user closes the
|
||||
* sidebar tab); a bare socket drop leaves the pty alive — the agent owns
|
||||
* the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
|
||||
* plugin teardown kills it.
|
||||
*/
|
||||
function pumpAgentTerminal(
|
||||
registry: AgentPtyRegistry,
|
||||
handle: AgentTerminalHandle,
|
||||
ws: WebSocket,
|
||||
): void {
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
const onData = (data: string): void => {
|
||||
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
|
||||
ws.send(data)
|
||||
}
|
||||
}
|
||||
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
|
||||
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
|
||||
}
|
||||
const dataSub = handle.pty.onData(onData)
|
||||
const exitSub = handle.pty.onExit(onExit)
|
||||
ws.on('message', (data) => {
|
||||
if (handle.exited) return
|
||||
const text = data.toString('utf8')
|
||||
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: terminal input.
|
||||
}
|
||||
if (control !== null && control.type === 'close') {
|
||||
// The user closed the sidebar tab: kill the pty immediately. The
|
||||
// agent's next terminal_list / terminal_send will see it gone.
|
||||
registry.close(handle.uuid)
|
||||
return
|
||||
}
|
||||
if (
|
||||
control !== null
|
||||
&& control.type === 'resize'
|
||||
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
||||
) {
|
||||
const dims = clampDims(control.cols, control.rows)
|
||||
handle.pty.resize(dims.cols, dims.rows)
|
||||
} else if (control === null) {
|
||||
// Raw text input (a JSON-looking string the pty would have received
|
||||
// verbatim is reachable in theory but is exotic for an agent terminal;
|
||||
// preserve the UI-tab semantics and forward as input).
|
||||
handle.pty.write(text)
|
||||
}
|
||||
// An unrecognized JSON control frame is dropped (the UI-tab path also
|
||||
// treats non-resize JSON controls as input, but for an agent terminal
|
||||
// there is no realistic input that is also valid JSON).
|
||||
})
|
||||
ws.on('close', () => {
|
||||
dataSub.dispose()
|
||||
exitSub.dispose()
|
||||
// A bare socket drop (refresh, tab switch) leaves the agent's pty alive.
|
||||
// The agent owns the lifetime: only `terminal_close`, a `{type:'close'}`
|
||||
// frame, or plugin teardown kills it. A reconnecting view reattaches the
|
||||
// same shell and gets the full transcript replayed.
|
||||
})
|
||||
}
|
||||
|
||||
+79
-22
@@ -1,32 +1,89 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `dsh-better-sidebar`.
|
||||
* @module dsh-better-sidebar/invariant
|
||||
* The apes-sidebar invariant surface: a minimal self-check companion module
|
||||
* (mirror of better-sidebar's `lib/invariant.js`). Shipped as a separate
|
||||
* entry so consumers can `import { assert } from 'apes-sidebar/invariant'`
|
||||
* without pulling the host-half apply() graph.
|
||||
*
|
||||
* Kept dependency-free: this module must never import Node builtins or
|
||||
* other @deepseek-ai packages, because it sits in the client-reachable
|
||||
* declaration graph.
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from './context-types.ts'
|
||||
/** Thrown by {@link assert} when the condition is falsy. */
|
||||
export class InvariantError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'InvariantError'
|
||||
}
|
||||
}
|
||||
|
||||
const PACKAGE_NAME = 'dsh-better-sidebar'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'dsh-better-sidebar-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
/** Assert a condition; throw an {@link InvariantError} with `message` when falsy. */
|
||||
export function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new InvariantError(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* No runtime invariant: the sidebar owns no service state or event protocol
|
||||
* 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.
|
||||
* The invariant installer signature: register one package's checks in the
|
||||
* service's child fiber. `fail` throws (never returns) to abort the mount.
|
||||
*/
|
||||
const install: () => void = () => {}
|
||||
export type InvariantInstaller = (
|
||||
ctx: unknown,
|
||||
fail: (message: string) => never,
|
||||
) => void | Promise<void>
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
* Guard the better-sidebar peer dependency contract at host startup:
|
||||
* apes-sidebar requires `dsh-better-sidebar` >= 0.18.0. The check is a
|
||||
* no-op in the client bundle (it only runs on the Node half) and a warning
|
||||
* (not a hard fail) so a fresh DSH without the peer still mounts — the
|
||||
* tabs simply won't appear until the peer is present.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
export function assertBetterSidebarPeer(
|
||||
logger: { warn: (msg: string) => void } | undefined,
|
||||
): void {
|
||||
let pkg: { version?: string } | undefined
|
||||
try {
|
||||
// Dynamic import so the client bundle never statically pulls a Node
|
||||
// resolution path; the import is guarded and never awaited here.
|
||||
pkg = (globalThis as { __apesPeer__?: unknown }).__apesPeer__ as { version?: string } | undefined
|
||||
} catch {
|
||||
pkg = undefined
|
||||
}
|
||||
const version = pkg?.version
|
||||
if (version && compareSemver(version, '0.18.0') < 0) {
|
||||
logger?.warn(
|
||||
`[apes-sidebar] dsh-better-sidebar ${version} is older than the required 0.18.0 — `
|
||||
+ 'sidebar tabs will not register. Upgrade dsh-better-sidebar.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare two semver strings (numeric, prerelease-aware enough for rc tags). */
|
||||
export function compareSemver(a: string, b: string): number {
|
||||
const pa = parseSemver(a)
|
||||
const pb = parseSemver(b)
|
||||
if (pa.num[0] !== pb.num[0]) return pa.num[0] - pb.num[0]
|
||||
if (pa.num[1] !== pb.num[1]) return pa.num[1] - pb.num[1]
|
||||
if (pa.num[2] !== pb.num[2]) return pa.num[2] - pb.num[2]
|
||||
// Both release, or both prerelease: lexicographic fallback on the tag.
|
||||
const ta = pa.pre
|
||||
const tb = pb.pre
|
||||
if (ta === tb) return 0
|
||||
if (ta === '') return 1
|
||||
if (tb === '') return -1
|
||||
return ta < tb ? -1 : ta > tb ? 1 : 0
|
||||
}
|
||||
|
||||
interface ParsedSemver {
|
||||
num: [number, number, number]
|
||||
pre: string
|
||||
}
|
||||
|
||||
function parseSemver(version: string): ParsedSemver {
|
||||
const m = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
|
||||
if (!m) return { num: [0, 0, 0], pre: '' }
|
||||
return {
|
||||
num: [Number(m[1]), Number(m[2]), Number(m[3])],
|
||||
pre: m[4] ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user