dsh-better-sidebar: VSCode-like right sidebar plugin for the DSH web GUI

Host half (src/index.ts): /sidebar/api JSON API (session cwd, fs tree/read/
write, git status/diff/stage/commit/branch/log/show), /sidebar/file media
route, /sidebar/ws/terminal WebSocket over node-pty; routes share the /api
trust fence (Host-header loopback or connection.trustedHosts).

Client half (src/client): portal-mounted right panel with a per-session
split-pane workbench (recursive row/col splits, draggable dividers, tab
drag-move), tab bar with max-width 160px + plus-menu (explorer/git/terminal
multi-open rules), lazy file explorer rooted at the session cwd, editable
text editor (dirty dot, Ctrl/Cmd+S), image + Markdown preview, xterm
terminal with reconnect, and a VSCode-style git panel. Per-session state
persists to localStorage (dsh-sidebar:v1:<sessionId>). Intercepts the chat
turn-tail produced-files row (priority -1 chain entry) to open files in the
sidebar. UI styled with the dsh --dsw-* theme tokens and ui-primitives.

Build: tsdown emits the host lib plus the __ModuleLoader__ client bundle
(externals = module table words; xterm/css inlined). Tests: 19 unit + smoke
specs (pure state/tree/git parsers + real git/fs integration).
This commit is contained in:
Menghuan1918
2026-08-08 01:02:24 +08:00
parent 4f4e462f98
commit 63e2988ef1
31 changed files with 5807 additions and 0 deletions
+1
View File
@@ -141,3 +141,4 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
lib/
+75
View File
@@ -0,0 +1,75 @@
# dsh-better-sidebar
A DeepSeek Harness web plugin providing a **VSCode-like right sidebar** with a
file explorer, file editor/preview, an interactive terminal, and a source
control panel — **isolated per conversation session**.
- 右侧侧边栏(文件夹预览 / 文件编辑与预览 / 终端 / Git)——按会话记忆与隔离
- Explorer: 懒加载目录树,根目录 = 当前会话工作目录
- Editor: 文本编辑(脏点 + Ctrl/Cmd+S 保存)、图片查看、Markdown 预览
- Terminal: xterm.js + node-pty(每会话最多 3 个,进程跨刷新存活)
- Git: status / diff / stage / commit / branch / history(基础集)
- 分栏工作台: 上下左右递归分栏、分隔线拖拽、Tab 拖拽移动、面板宽度拖拽
- Tab 栏: 最大宽度 160px、横向滚动、`+` 菜单新建 Tab
- 拦截: 对话"产出文件"行点击改为在侧边栏打开
- 样式完全复用 `--dsw-*` 主题变量与 `@deepseek-ai/dsh-client-ui-primitives`
## 架构
一个 npm 包,双半结构(与 DSH monorepo 内 client 包同构):
| 半 | 入口 | 职责 |
|---|---|---|
| host | `src/index.ts` → `lib/index.js` | cordis 插件:`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
| client | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂):portal 侧边栏 + 各视图 + turnTail 拦截 |
- 所有 API 携带 `sessionId`;cwd 从会话存储取权威值;终端按 `${sessionId}:${tabId}` 键控。
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)。
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`):面板几何、分栏树、Tabs、树展开。
- client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm 等全部内联。
## 安装(web profile)
```sh
# 1. 构建
pnpm install && pnpm build
# 2. 全局 web profile 引用
# ~/.dsh/profiles/web/package.json dependencies:
# "dsh-better-sidebar": "link:/Users/menghuan/Code/DSH-better-sidebar"
# ~/.dsh/profiles/web/cordis.patch.yml 追加:
# - id: better-sidebar
# name: 'dsh-better-sidebar'
# 3. 安装依赖并重启 GUI
(cd ~/.dsh/profiles/web && pnpm install)
pm2 restart dsh-web # 或你的 dsh web 启动方式
```
## 开发
```sh
pnpm typecheck # tsc --noEmit
pnpm test # vitest(单元 + 冒烟:真实 git/fs 交互)
pnpm build # tsc + tsdown → lib/index.js + lib/client.js
pnpm watch # tsdown --watch(client bundle 热重建)
```
改完 host/client 后重新 `pnpm build`,再重启 `dsh-web` 生效。
## 依赖说明
- `node-pty` / `ws` / `xterm` / `@xterm/addon-fit` / `clsx`:npm 常规依赖(node-pty 使用随包预编译二进制,无源码编译)。
- `@deepseek-ai/*` 包仅以 `link:` 指向 `~/.dsh/source/current`(DSH 源码 checkout,构建产物已存在),仅用于类型;运行时只经模块表消费 `dsh-client-ui-primitives`。
## 安全边界
- 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效)。
- `fs.write` 仅文本写入、原子替换(临时文件 + rename);`/sidebar/file` 仅限会话 cwd 内的媒体文件。
- 提交/分支操作仅调用 git CLI,绝不设置 git 身份。
## 已知限制(v1 非目标)
- 文本编辑无语法高亮;Git 无 push/pull/fetch;无文件 watcher(手动刷新)。
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管。
- 侧边栏为悬浮面板(核心 AppFrame 无右侧插件位),不修改核心布局。
+57
View File
@@ -0,0 +1,57 @@
{
"name": "dsh-better-sidebar",
"version": "0.1.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
"private": true,
"type": "module",
"main": "lib/index.js",
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"dshClient": {
"platform": "web"
},
"files": [
"lib"
],
"scripts": {
"build": "tsc --noEmit && tsdown",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
"watch": "tsdown --watch",
"test": "vitest run"
},
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"clsx": "^2.1.1",
"node-pty": "^1.1.0",
"ws": "^8.18.0",
"xterm": "^5.3.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime",
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives",
"@deepseek-ai/dsh-client-ui-slots": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots",
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"cordis": "^4.0.0-rc.7",
"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"
}
}
+1360
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
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
+132
View File
@@ -0,0 +1,132 @@
/**
* The file editor tab: text editing with a dirty dot and Ctrl/Cmd+S save,
* image viewing through the media route, Markdown preview through the shared
* MarkdownText component, and a plain notice for binary files. Reads cap at
* the host's 512KB bound (a banner marks truncation).
*/
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { api, mediaUrl } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
const MD_EXT = ['.md', '.markdown']
function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; kind: 'text' | 'image' | 'md'; content: string; truncated: boolean }
| { status: 'binary' }
export function EditorView(props: { sessionId: string; path: string; title: string }) {
const { sessionId, path, title } = props
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
const [draft, setDraft] = useState<string | null>(null)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
useEffect(() => {
let cancelled = false
setLoad({ status: 'loading' })
setDraft(null)
setSaveState('idle')
api.fsRead(sessionId, path).then((result) => {
if (cancelled) return
const ext = extOf(path)
if (result.kind === 'binary') {
setLoad({ status: 'binary' })
return
}
if (IMAGE_EXT.includes(ext)) {
setLoad({ status: 'ready', kind: 'image', content: '', truncated: false })
return
}
setLoad({
status: 'ready',
kind: MD_EXT.includes(ext) ? 'md' : 'text',
content: result.content,
truncated: result.truncated,
})
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [sessionId, path])
const save = (): void => {
if (load.status !== 'ready' || load.kind !== 'text' || draft === null || saveState === 'saving') return
setSaveState('saving')
api.fsWrite(sessionId, path, draft).then(() => {
setSaveState('saved')
setDraft(null)
}).catch(() => {
setSaveState('failed')
})
}
const dirty = load.status === 'ready' && load.kind === 'text' && draft !== null
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={path}>{title}</span>
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
{load.status === 'ready' && load.kind === 'text' && (
<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>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'binary' && <div className={css.editorPlaceholder}>{t('binary')}</div>}
{load.status === 'ready' && load.kind === 'text' && (
<>
{load.truncated && <div className={css.editorBanner}>{t('truncation')}</div>}
<textarea
className={css.editorTextarea}
spellCheck={false}
value={draft ?? load.content}
onChange={(event) => {
setDraft(event.target.value)
setSaveState('idle')
}}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault()
save()
}
}}
/>
</>
)}
{load.status === 'ready' && load.kind === 'image' && (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={mediaUrl(sessionId, path)} alt={title} />
</div>
)}
{load.status === 'ready' && load.kind === 'md' && (
<div className={css.editorMd}>
<MarkdownText text={load.content} />
</div>
)}
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 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.
*/
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCodeOutline16, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, type FsEntry } from './api.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)
}
export function ExplorerView(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
const [refreshTick, setRefreshTick] = useState(0)
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, dir).then((listing) => {
storeLevel(dir, { entries: listing.entries })
}).catch((error: unknown) => {
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
})
}, [sessionId, 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])
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 * 14 + 8 }}>{t('loading')}</div>
}
if (level.error !== undefined) {
return (
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 14 + 8 }}>
{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}>
<button
type="button"
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 14 + 8 }}
onClick={() => { onToggle(entry.path) }}
>
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span>
</button>
{isOpen && renderLevel(entry.path, depth + 1)}
</div>
)
}
return (
<button
key={entry.path}
type="button"
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 14 + 8 }}
title={entry.path}
onClick={() => { onOpenFile(entry.path) }}
>
<IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span>
</button>
)
})
}
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: 8 }}>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
</div>
</div>
)
}
+271
View File
@@ -0,0 +1,271 @@
/**
* The source-control panel: status list (staged vs unstaged), per-file diff
* through the shared DiffBlock (old = HEAD/index content, new = worktree or
* index content), stage/unstage, commit with a message box, branch list with
* switch, and recent history. Refresh is manual + on mount/focus (no file
* watcher — KISS).
*/
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
DiffBlock, IconBranchOutline16, IconRefreshOutline16, IconTrashOutline16,
Input,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult } from './api.ts'
import { api } from './api.ts'
import { t } from './locales.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 '?'
}
export function GitView(props: { sessionId: string }) {
const { sessionId } = 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)
// Selected diff: path + side (staged) + the two text sides for DiffBlock.
const [diffPath, setDiffPath] = useState<string | null>(null)
const [diffStaged, setDiffStaged] = useState(false)
const [diffOld, setDiffOld] = useState<string | null>(null)
const [diffNew, setDiffNew] = useState('')
const refresh = useCallback(async (): Promise<void> => {
setLoading(true)
setError(null)
try {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(sessionId),
api.gitBranch(sessionId).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(sessionId).catch(() => [] as GitLogEntry[]),
])
setStatus(statusResult)
setBranchNames(branchResult.names)
setLogEntries(logResult)
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setLoading(false)
}
}, [sessionId])
useEffect(() => { void refresh() }, [refresh])
/** Load the diff sides for one entry: HEAD content vs worktree/index content. */
const selectDiff = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setDiffPath(entry.path)
setDiffStaged(staged)
setDiffOld(null)
setDiffNew('')
const oldPromise = api.gitShow(sessionId, 'HEAD', entry.path)
.then(result => result.content)
.catch(() => null)
const newPromise = staged
? api.gitShow(sessionId, ':', entry.path).then(result => result.content ?? '').catch(() => '')
: api.fsRead(sessionId, entry.path)
.then(result => (result.kind === 'text' ? result.content : ''))
.catch(() => '')
const [oldText, newText] = await Promise.all([oldPromise, newPromise])
setDiffOld(oldText)
setDiffNew(newText)
}
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(sessionId, entry.path)
else await api.gitStage(sessionId, entry.path)
setDiffPath(null)
await refresh()
} finally {
setBusy(false)
}
}
const stageAll = async (staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(sessionId)
else await api.gitStage(sessionId)
setDiffPath(null)
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(sessionId, message)
setCommitMsg('')
setDiffPath(null)
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(sessionId, branch)
setDiffPath(null)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
} finally {
setBusy(false)
}
}
const stagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) !== '?')
const unstagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) === '?')
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
const selected = diffPath === entry.path && diffStaged === staged
return (
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={clsx(css.gitRow, selected && css.gitRowSelected)}>
<button
type="button"
className={css.gitRowMain}
title={entry.path}
onClick={() => { void selectDiff(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>
{diffPath !== null && (
<div className={css.gitDiff}>
<DiffBlock
diffs={[{ path: diffPath, oldText: diffOld, newText: diffNew }]}
maxLines={300}
/>
</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.hash} className={css.gitLogRow} title={`${entry.author} · ${entry.date}`}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
</div>
))}
</div>
</>
)}
</div>
)
}
+245
View File
@@ -0,0 +1,245 @@
/**
* The sidebar shell: a fixed-position right panel portalled onto
* document.body (the core AppFrame owns the left sidebar / center / details
* columns and has no right-side hole for plugins), plus the edge toggle
* button. The panel width drags from its left edge; 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 four views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import {
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, openTab, resizeSplit,
sidebarStore, splitPane, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type SidebarState, type SidebarTab, type SplitNode,
} from './state.ts'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { ExplorerView } from './ExplorerView.tsx'
import { EditorView } from './EditorView.tsx'
import { TerminalView } from './TerminalView.tsx'
import { GitView } from './GitView.tsx'
import { t } from './locales.ts'
import { openSidebarFile } from './intercept.tsx'
import css from './sidebar.module.css'
/** 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
ctx: Context
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, ctx } = props
switch (tab.type) {
case 'explorer':
return (
<ExplorerView
sessionId={sessionId}
cwd={cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={(path) => { openSidebarFile(ctx, sessionId, path) }}
/>
)
case 'git':
return <GitView sessionId={sessionId} />
case 'terminal':
return <TerminalView sessionId={sessionId} tabId={tab.id} />
case 'editor':
return <EditorView sessionId={sessionId} path={tab.path ?? ''} title={tab.title} />
}
}
/** The + menu options for the current state (terminal cap applied). */
function buildNewTabOptions(state: SidebarState): NewTabOption[] {
const terminalCount = allLeaves(state.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal').length
return [
{ id: 'explorer', label: t('openExplorer') },
{ id: 'git', label: t('openGit') },
{
id: 'terminal',
label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
disabled: terminalCount >= TERMINAL_LIMIT,
},
]
}
export function Sidebar(props: { ctx: Context }) {
const { ctx } = props
// 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) => sidebarStore.subscribe(callback), []),
useCallback(() => sidebarStore.getSnapshot(), []),
)
useEffect(() => { sidebarStore.setSession(current) }, [current])
const state = snapshot.state
const sessionId = snapshot.sessionId
const cwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// Panel width drag (left edge strip).
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => { sidebarStore.reduce(s => closeTab(s, paneId, tabId)) },
activateTab: (paneId, tabId) => {
sidebarStore.reduce(s => ({
...s,
activePane: paneId,
splits: mapLeaf(s.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}))
},
focusPane: (paneId) => { sidebarStore.reduce(s => ({ ...s, activePane: paneId })) },
splitPane: (dir) => { sidebarStore.reduce(s => splitPane(s, dir)) },
moveTab: (payload: TabDragPayload, toPane: string, before: string | null) => {
sidebarStore.reduce((s) => {
let index = -1
if (before !== null) {
const source = leafWithTab(s.splits, before)
if (source !== undefined && source.id === toPane) {
index = source.tabs.findIndex(tab => tab.id === before)
}
}
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
})
},
resizeSplit: (splitId, index, deltaFrac) => {
sidebarStore.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
},
}), [])
if (state === undefined || sessionId === undefined) {
return (
<div className={css.toggleRail}>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelLeftOutline16 size={16} />
</button>
</Tooltip>
</div>
)
}
const onNewTab = (optionId: string): void => {
sidebarStore.reduce((s) => {
if (optionId === 'explorer') {
return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') })
}
if (optionId === 'git') {
return openTab(s, { id: 'git', type: 'git', title: t('git') })
}
if (optionId === 'terminal') {
const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length
if (count >= TERMINAL_LIMIT) return s
const tab: SidebarTab = {
id: `terminal:${s.nextTerminal}`,
type: 'terminal',
title: `${t('terminal')} ${s.nextTerminal}`,
}
return { ...openTab(s, tab), nextTerminal: s.nextTerminal + 1 }
}
return s
})
}
const renderTab = (tab: SidebarTab) => (
<TabContent
key={tab.id}
tab={tab}
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
onToggleDir={(path) => { sidebarStore.reduce(s => toggleExpanded(s, path)) }}
ctx={ctx}
/>
)
return (
<>
{!state.panelOpen && (
<div className={css.toggleRail}>
<Tooltip label={t('openExplorer')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={t('openExplorer')}
onClick={() => { sidebarStore.reduce(togglePanel) }}
>
<IconPanelLeftOutline16 size={16} />
</button>
</Tooltip>
</div>
)}
{state.panelOpen && (
<div className={css.panel} style={{ width: state.width }} data-dragging={draggingWidth || undefined}>
<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
sidebarStore.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
setDraggingWidth(false)
}}
/>
<div className={css.panelHeader}>
<span className={css.panelTitle}>
{t('explorer')}
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('close')}
title={t('close')}
onClick={() => { sidebarStore.reduce(togglePanel) }}
>
<IconCloseOutline16 />
</button>
</div>
<div className={css.panelBody}>
<Workbench
state={state}
newTabOptions={buildNewTabOptions(state)}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
/>
</div>
</div>
)}
</>
)
}
+166
View File
@@ -0,0 +1,166 @@
/**
* 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 { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconChevronDownOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
IconChevronUpOutline14, IconCloseFill14, IconPlusOutline16, Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
import { t, type CopyKey } from './locales.ts'
import css from './sidebar.module.css'
/** One + menu option. */
export interface NewTabOption {
id: string
label: string
disabled?: boolean
}
/** 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
}
}
const SPLIT_DIRS: Array<{ dir: 'left' | 'right' | 'up' | 'down'; label: CopyKey; icon: (p: { size?: number }) => ReactNode }> = [
{ dir: 'left', label: 'splitLeft', icon: IconChevronLeftOutline14 },
{ dir: 'right', label: 'splitRight', icon: IconChevronRightOutline14 },
{ dir: 'up', label: 'splitUp', icon: IconChevronUpOutline14 },
{ dir: 'down', label: 'splitDown', icon: IconChevronDownOutline14 },
]
export function TabBar(props: {
paneId: string
tabs: SidebarTab[]
active: string | null
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
onSplit: (dir: 'row' | 'col') => void
onNewTab: (optionId: string) => void
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
}) {
const {
paneId, tabs, active, onActivate, onClose, onSplit, onNewTab, newTabOptions, onDropTab,
} = props
const [menuOpen, setMenuOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
return (
<div
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
onDragOver={(event) => {
event.preventDefault()
setDragOver(true)
}}
onDragLeave={() => { setDragOver(false) }}
onDrop={(event) => {
event.preventDefault()
setDragOver(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) => {
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
event.dataTransfer.effectAllowed = 'move'
}}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
onDrop={(event) => {
event.preventDefault()
event.stopPropagation()
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
const payload = parseDrag(raw)
if (payload !== null) onDropTab(payload, tab.id)
}}
onClick={() => { onActivate(tab.id) }}
>
<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>
))}
</div>
<div className={css.tabBarActions}>
{SPLIT_DIRS.map(({ dir, label, icon: Icon }) => (
<button
key={dir}
type="button"
className={css.tabBarButton}
aria-label={t(label)}
title={t(label)}
onClick={() => { onSplit(dir === 'left' || dir === 'right' ? 'row' : 'col') }}
>
<Icon />
</button>
))}
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={newTabOptions.map(option => ({
id: option.id,
label: option.label,
...(option.disabled === true ? { disabled: true } : {}),
}))}
onSelect={(id) => {
onNewTab(id)
setMenuOpen(false)
}}
portal
align="end"
anchor={(
<button
type="button"
className={css.tabBarButton}
aria-label={t('newTab')}
title={t('newTab')}
onClick={() => { setMenuOpen(v => !v) }}
>
<IconPlusOutline16 />
</button>
)}
/>
</div>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 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". Disconnects (page refresh, host restart) reconnect with a
* delay — the pty process survives the socket.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export function TerminalView(props: { sessionId: string; tabId: string }) {
const { sessionId, tabId } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
useEffect(() => {
const host = hostRef.current
if (host === null) return
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
allowTransparency: true,
convertEol: false,
scrollback: 4000,
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
fit.fit()
let socket: WebSocket | null = null
let closed = false
let retry: number | undefined
const wsUrl = (): string => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const params = new URLSearchParams({ sessionId, tab: tabId })
return `${proto}//${location.host}/sidebar/ws/terminal?${params.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
socket = new WebSocket(wsUrl())
socket.onopen = () => {
setConnected(true)
sendResize()
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') term.write(event.data)
}
socket.onclose = () => {
setConnected(false)
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket?.close()
}
}
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()
inputSub.dispose()
socket?.close()
term.dispose()
}
}, [sessionId, tabId])
return (
<div className={css.terminalWrap}>
{!connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} />
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
* `/sidebar/api/<method>` with the sessionId; failures surface as
* {@link SidebarApiError} with the wire error code.
*/
/** 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 {
hash: string
subject: string
author: string
date: string
}
/** Text read result. */
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
/** Binary read result (no content; images load through the media route). */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: 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
}
/** The sidebar API surface (sessionId is threaded through every call). */
export const api = {
sessionCwd: (sessionId: string, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', { sessionId }, signal),
fsTree: (sessionId: string, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', { sessionId, path }, signal),
fsRead: (sessionId: string, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', { sessionId, path }, signal),
fsWrite: (sessionId: string, path: string, content: string) =>
call<{ ok: true }>('fs.write', { sessionId, path, content }),
gitStatus: (sessionId: string, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', { sessionId }, signal),
gitDiff: (sessionId: string, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', { sessionId, ...(path !== undefined ? { path } : {}), staged }, signal),
gitShow: (sessionId: string, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', { sessionId, rev, path }, signal),
gitStage: (sessionId: string, path?: string) =>
call<{ ok: true }>('git.stage', { sessionId, ...(path !== undefined ? { path } : {}) }),
gitUnstage: (sessionId: string, path?: string) =>
call<{ ok: true }>('git.unstage', { sessionId, ...(path !== undefined ? { path } : {}) }),
gitCommit: (sessionId: string, message: string) =>
call<{ ok: true }>('git.commit', { sessionId, message }),
gitBranch: (sessionId: string, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', { sessionId }, signal),
gitCheckout: (sessionId: string, branch: string) =>
call<{ ok: true }>('git.checkout', { sessionId, branch }),
gitLog: (sessionId: string, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', { sessionId }, signal),
}
/** Absolute URL of the media route for one path (images only). */
export function mediaUrl(sessionId: string, path: string): string {
return `/sidebar/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`
}
+5
View File
@@ -0,0 +1,5 @@
/** CSS Modules class-map declaration (tsdown compiles .module.css at build). */
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Client half of dsh-better-sidebar: mounts the right sidebar portal and
* registers the turn-tail interception. 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).
*/
import { createRoot } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
/** Services required before mounting (provided by the client runtime). */
export const inject = ['slots', 'sessions']
/**
* Client plugin body.
* @param ctx - the client cordis context (slots, sessions).
*/
export function apply(ctx: Context): void {
ctx.effect(() => {
const host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
const root = createRoot(host)
root.render(<Sidebar ctx={ctx} />)
return () => {
root.unmount()
host.remove()
}
}, 'dsh-better-sidebar: sidebar mount')
ctx.effect(
() => registerTurnTailInterception(ctx),
'dsh-better-sidebar: turn-tail interception',
)
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 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 { openTab } from './state.ts'
import { sidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.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, 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)
sidebarStore.reduce(state => openTab(state, {
id: `editor:${absolute}`,
type: 'editor',
title,
path: 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): () => void {
return ctx.slots.register({
name: 'conversation.chat.turnTail',
select: selectProducedFiles,
priority: -1,
registrant: 'dsh-better-sidebar',
inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, sessionId, path) },
}),
}, SidebarProducedFiles)
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Minimal zh/en copy for the sidebar. The plugin renders outside the slot
* system's locale seat, so it reads the browser language directly (the DSH
* locale preference and the browser language agree in practice; both default
* from navigator.language anyway).
*/
const zh = {
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
editor: '编辑器',
newTab: '新建标签页',
openExplorer: '资源管理器',
openGit: 'Git 面板',
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
close: '关闭',
refresh: '刷新',
save: '保存',
saved: '已保存',
unsaved: '未保存',
saveFailed: '保存失败',
truncation: '文件过大,仅显示前 512KB',
binary: '二进制文件,无法预览',
loading: '加载中…',
error: '加载失败',
retry: '重试',
emptyPane: '此分栏为空 — 从资源管理器或 + 菜单打开内容',
splitLeft: '向左分栏',
splitRight: '向右分栏',
splitUp: '向上分栏',
splitDown: '向下分栏',
notRepo: '当前目录不是 git 仓库',
noChanges: '没有变更',
stage: '暂存',
unstage: '取消暂存',
stageAll: '全部暂存',
unstageAll: '全部取消暂存',
commitPlaceholder: '提交信息 (Ctrl+Enter)',
commit: '提交',
commitError: '提交失败',
branch: '分支',
checkoutError: '切换分支失败',
history: '历史',
changes: '变更',
staged: '已暂存',
unstaged: '未暂存',
produced: '本次产出',
producedOpen: '在侧边栏中打开',
disconnected: '终端连接断开,重连中…',
exited: '终端进程已退出',
noSession: '选择一个会话以使用侧边栏',
hiddenFiles: '隐藏文件',
parent: '上级目录',
copied: '已复制',
copy: '复制',
newFile: '新文件',
openEditor: '打开编辑器',
gitDetail: '查看变更详情',
}
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',
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',
emptyPane: 'This pane is empty — open something from the explorer or the + menu',
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',
produced: 'Produced',
producedOpen: 'Open in sidebar',
disconnected: 'Terminal disconnected, reconnecting…',
exited: 'Terminal process exited',
noSession: 'Select a conversation to use the sidebar',
hiddenFiles: 'Hidden files',
parent: 'Parent directory',
copied: 'Copied',
copy: 'Copy',
newFile: 'New file',
openEditor: 'Open editor',
gitDetail: 'View change details',
}
/** Translate a copy key in the browser's language (zh → zh, else en). */
export type CopyKey = keyof typeof zh
export function t(key: CopyKey): string {
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
return dict[key]
}
/** Whether the browser language is Chinese (used for selectors). */
export function isZh(): boolean {
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 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}`
}
+783
View File
@@ -0,0 +1,783 @@
/**
* Sidebar styles: everything rides the DSH theme tokens (--dsw-alias-*,
* --dsw-static-*, --ds-transition-*) so the plugin matches the host UI in
* both light and dark themes, plus a few absolute z-indexes for the
* document-level overlay (below the app's modal layer).
*/
/* ── Shell ─────────────────────────────────────────────────────────────── */
.toggleRail {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
z-index: 55;
display: flex;
align-items: center;
}
.toggleButton {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 56px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-right: none;
border-radius: 10px 0 0 10px;
background: var(--dsw-alias-button-floating-fill);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
transition:
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.toggleButton:hover:not(:disabled) {
background: var(--dsw-alias-button-floating-hover);
color: var(--dsw-alias-label-primary);
}
.toggleButton:disabled {
opacity: 0.4;
cursor: default;
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 50;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-layer-1);
border-left: 1px solid var(--dsw-alias-border-l2);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.08);
}
.panelResize {
position: absolute;
left: -4px;
top: 0;
bottom: 0;
width: 8px;
cursor: col-resize;
z-index: 2;
touch-action: none;
}
.panelResizeActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 6px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
flex: none;
}
.panelTitle {
font-size: 12px;
font-weight: 600;
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panelBody {
flex: 1;
min-height: 0;
display: flex;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
flex: none;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
opacity: 0.4;
cursor: default;
}
/* ── Workbench / split panes ───────────────────────────────────────────── */
.workbench {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.split {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.splitRow {
flex-direction: row;
}
.splitCol {
flex-direction: column;
}
.splitChild {
position: relative;
display: flex;
overflow: hidden;
}
.divider {
flex: none;
z-index: 3;
touch-action: none;
background: transparent;
transition: background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.dividerRow {
width: 5px;
margin: 0 -2px;
cursor: col-resize;
}
.dividerCol {
height: 5px;
margin: -2px 0;
cursor: row-resize;
}
.divider:hover,
.dividerActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
.pane {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.paneDrop {
outline: 1px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
.paneContent {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.paneEmpty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
}
/* ── Tab strip ─────────────────────────────────────────────────────────── */
.tabBar {
flex: none;
display: flex;
align-items: stretch;
height: 34px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-1);
}
.tabBarDrop {
outline: 1px dashed var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
.tabList {
flex: 1;
min-width: 0;
display: flex;
overflow-x: auto;
scrollbar-width: none;
}
.tabList::-webkit-scrollbar {
display: none;
}
.tab {
flex: none;
max-width: 160px;
min-width: 64px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 4px 0 10px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
border-right: 1px solid var(--dsw-alias-border-l1);
background: transparent;
cursor: pointer;
user-select: none;
}
.tab:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.tabActive {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-base);
box-shadow: inset 0 -2px 0 var(--dsw-alias-brand-primary);
}
.tabTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tabClose {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
flex: none;
}
.tabClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.tabBarActions {
flex: none;
display: flex;
align-items: center;
gap: 2px;
padding: 0 6px;
}
.tabBarButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.tabBarButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* ── Explorer ──────────────────────────────────────────────────────────── */
.explorer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.explorerHeader {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 6px 12px;
}
.explorerRoot {
font-size: 12px;
font-weight: 600;
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorerBody {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: 8px;
}
.explorerRow {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 24px;
padding: 0 8px;
border: none;
background: transparent;
font-size: 12px;
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.explorerRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.explorerDir {
font-weight: 500;
}
.explorerHidden {
opacity: 0.45;
}
.explorerName {
overflow: hidden;
text-overflow: ellipsis;
}
.explorerError {
color: var(--dsw-static-red-500, #ef4444);
cursor: default;
}
.explorerEmpty {
padding: 16px;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
/* ── Editor ────────────────────────────────────────────────────────────── */
.editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.editorHeader {
flex: none;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.editorTitle {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 600;
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editorStatus {
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
}
.editorStatusError {
color: var(--dsw-static-red-500, #ef4444);
}
.dirtyDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--dsw-static-amber-500, #f59e0b);
flex: none;
}
.editorPlaceholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
padding: 16px;
text-align: center;
}
.editorError {
padding: 12px 16px;
font-size: 12px;
color: var(--dsw-static-red-500, #ef4444);
}
.editorBanner {
flex: none;
padding: 4px 12px;
font-size: 11px;
color: var(--dsw-static-amber-600, #dd8629);
background: var(--dsw-static-amber-100, #fef5e7);
}
.editorTextarea {
flex: 1;
min-height: 0;
width: 100%;
padding: 10px 12px;
border: none;
outline: none;
resize: none;
background: transparent;
color: var(--dsw-alias-label-primary);
font-family: var(--dsw-font-mono, 'SF Mono', Menlo, Consolas, monospace);
font-size: 12px;
line-height: 1.55;
tab-size: 2;
white-space: pre;
}
.editorImageWrap {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
padding: 12px;
}
.editorImage {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.editorMd {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 14px;
font-size: 13px;
}
/* ── Terminal ──────────────────────────────────────────────────────────── */
.terminalWrap {
flex: 1;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.terminal {
flex: 1;
min-height: 0;
padding: 6px 4px 6px 8px;
}
.terminal :global(.xterm) {
height: 100%;
}
.terminalBanner {
flex: none;
padding: 3px 10px;
font-size: 11px;
color: var(--dsw-static-amber-600, #dd8629);
background: var(--dsw-static-amber-100, #fef5e7);
}
/* ── Git ───────────────────────────────────────────────────────────────── */
.git {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.gitHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 6px 12px;
}
.gitBranchSelect {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font-size: 12px;
}
.gitSection {
border-top: 1px solid var(--dsw-alias-border-l1);
}
.gitSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
}
.gitLink {
border: none;
background: transparent;
font-size: 11px;
color: var(--dsw-alias-brand-primary);
cursor: pointer;
padding: 0;
}
.gitLink:hover:not(:disabled) {
text-decoration: underline;
}
.gitLink:disabled {
opacity: 0.4;
cursor: default;
}
.gitRow {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px 2px 12px;
}
.gitRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitRowSelected {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitRowMain {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 3px 0;
cursor: pointer;
text-align: left;
}
.gitBadge {
flex: none;
width: 20px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.gitName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--dsw-alias-label-primary);
}
.gitEmpty {
padding: 4px 12px 8px;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
}
.gitPlaceholder {
padding: 16px;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.gitError {
padding: 8px 12px;
font-size: 12px;
color: var(--dsw-static-red-500, #ef4444);
white-space: pre-wrap;
}
.gitDiff {
border-top: 1px solid var(--dsw-alias-border-l1);
padding: 8px;
}
.gitCommit {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
align-items: center;
}
.gitCommitInput {
flex: 1;
min-width: 0;
}
.gitCommitButton {
flex: none;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 6px;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-inverted, #fff);
font-size: 12px;
cursor: pointer;
}
.gitCommitButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.gitCommitButton:disabled {
opacity: 0.45;
cursor: default;
}
.gitLogRow {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 12px;
font-size: 12px;
}
.gitLogHash {
flex: none;
font-family: var(--dsw-font-mono, 'SF Mono', Menlo, Consolas, monospace);
color: var(--dsw-alias-label-tertiary);
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-primary);
}
/* ── Intercepted produced-files row ────────────────────────────────────── */
.producedRow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 4px 0;
}
.producedLabel {
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
}
.producedChip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 200px;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-secondary);
font-size: 12px;
cursor: pointer;
overflow: hidden;
}
.producedChip:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.producedChip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.producedMore {
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* 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 and
* accepts tab drops. The tree and all operations live in state.ts — this
* file is pure presentation over them.
*/
import { Fragment, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import { t } from './locales.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
splitPane: (dir: 'row' | 'col') => void
moveTab: (payload: TabDragPayload, toPane: string, before: string | null) => void
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
}
/** One divider: pointer-capture drag translating px deltas into fractions. */
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
const { dir, onResize } = props
const start = 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()
start.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 - start.current.x : event.clientY - start.current.y
const size = Math.max(1, start.current.size)
onResize(delta / size)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
setDragging(false)
}}
/>
)
}
/** A leaf: tab strip + active content + 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) => ReactNode
}) {
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
const [dropTarget, setDropTarget] = useState(false)
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
return (
<div
className={clsx(css.pane, dropTarget && css.paneDrop)}
onPointerDown={() => { actions.focusPane(leaf.id) }}
onDragOver={(event) => {
event.preventDefault()
setDropTarget(true)
}}
onDragLeave={() => { setDropTarget(false) }}
onDrop={(event) => {
event.preventDefault()
setDropTarget(false)
const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
if (payload !== null) actions.moveTab(payload, leaf.id, null)
}}
>
{leaf.tabs.length > 0 ? (
<>
<TabBar
paneId={leaf.id}
tabs={leaf.tabs}
active={leaf.active}
onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
onSplit={(dir) => { actions.splitPane(dir) }}
onNewTab={onNewTab}
newTabOptions={newTabOptions}
onDropTab={(payload, before) => { actions.moveTab(payload, leaf.id, before) }}
/>
<div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</div>
</>
) : (
<div className={css.paneEmpty}>{t('emptyPane')}</div>
)}
</div>
)
}
/** Recursive node renderer. */
function NodeView(props: {
node: SplitNode
state: SidebarState
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab) => ReactNode
}) {
const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
if (node.kind === 'leaf') {
return (
<LeafView
leaf={node}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
/>
)
}
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}
/>
</div>
</Fragment>
))}
</div>
)
}
/** The workbench: the split tree filling the sidebar body. */
export function Workbench(props: {
state: SidebarState
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab) => ReactNode
}) {
const { state, newTabOptions, actions, onNewTab, renderTab } = props
return (
<div className={css.workbench}>
<NodeView
node={state.splits}
state={state}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
/>
</div>
)
}
+377
View File
@@ -0,0 +1,377 @@
/**
* Per-session sidebar state: the panel geometry, the split-pane workbench
* tree, open tabs, and the explorer expansion set. One state instance per
* conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
* a reload restores the exact layout of the session it belongs to — switching
* conversations swaps the whole state (memory + isolation).
*
* The split tree is a recursive structure: a leaf holds a tab group, a split
* divides the space row- or column-wise with fractional sizes. All tree
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
*/
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal'
/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */
export interface SidebarTab {
id: string
type: TabType
title: string
path?: string
}
/** A tab group. */
export interface SidebarLeaf {
kind: 'leaf'
id: string
tabs: SidebarTab[]
active: string | null
}
/** A recursive split between child panes (fractional sizes summing to 1). */
export interface SidebarSplit {
kind: 'split'
id: string
dir: 'row' | 'col'
sizes: number[]
children: SplitNode[]
}
export type SplitNode = SidebarLeaf | SidebarSplit
/** The full per-session state. */
export interface SidebarState {
panelOpen: boolean
width: number
/** The pane receiving newly opened tabs (last pane the user touched). */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
/** Explorer expansion set (absolute directory paths). */
expanded: string[]
splits: SplitNode
}
export const PANEL_MIN = 280
export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
export const TERMINAL_LIMIT = 3
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
function uid(prefix: string): string {
nextIdCounter += 1
return `${prefix}:${nextIdCounter}`
}
/** A fresh default state: open, default width, one explorer tab in one pane. */
export function makeDefaultState(): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
leaf.active = leaf.tabs[0]!.id
return {
panelOpen: true,
width: PANEL_DEFAULT,
activePane: leaf.id,
nextTerminal: 1,
expanded: [],
splits: leaf,
}
}
/** Walk the tree and apply `visit` to the leaf with the given id. */
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
if (node.kind === 'leaf') {
if (node.id === paneId) {
const copy: SidebarLeaf = { ...node, tabs: [...node.tabs] }
visit(copy)
return copy
}
return node
}
const split = node
return {
...split,
sizes: [...split.sizes],
children: split.children.map(child => mapLeaf(child, paneId, visit)),
}
}
/** The first leaf of the tree (fallback pane when activePane is gone). */
export function firstLeaf(node: SplitNode): SidebarLeaf {
if (node.kind === 'leaf') return node
return firstLeaf(node.children[0]!)
}
/** Find the leaf containing a tab id, if any. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') {
return node.tabs.some(tab => tab.id === tabId) ? node : undefined
}
for (const child of node.children) {
const found = leafWithTab(child, tabId)
if (found !== undefined) return found
}
return undefined
}
/** All leaves of the tree, depth-first. */
export function allLeaves(node: SplitNode): SidebarLeaf[] {
if (node.kind === 'leaf') return [node]
return node.children.flatMap(allLeaves)
}
/** Replace a leaf with a split of it plus a fresh empty leaf. */
export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
return mapLeaf(node, paneId, (leaf) => {
const target: SidebarLeaf = { ...leaf }
const split: SidebarSplit = {
kind: 'split',
id: uid('split'),
dir,
sizes: [0.5, 0.5],
children: [target, fresh],
}
Object.assign(leaf, split)
})
}
/**
* Remove a leaf from the tree. A split left with one child promotes that
* child; removing the last leaf yields an empty leaf.
*/
export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
if (node.kind === 'leaf') return node.id === paneId ? { ...node, tabs: [], active: null } : node
const children = node.children.filter(child => !(child.kind === 'leaf' && child.id === paneId))
if (children.length === node.children.length) {
return {
...node,
sizes: [...node.sizes],
children: node.children.map(child => removeLeafAt(child, paneId)),
}
}
if (children.length === 1) return children[0]!
return { ...node, sizes: [...node.sizes], children }
}
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
let emptied = false
const splits = mapLeaf(state.splits, paneId, (leaf) => {
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
return { ...state, splits: emptied ? removeLeafAt(splits, paneId) : splits }
}
/** Activate a tab in its pane. */
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
return {
...state,
activePane: paneId,
splits: mapLeaf(state.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}
}
/** Whether a tab type is single-instance per session (explorer/git). */
function isSingle(type: TabType): boolean {
return type === 'explorer' || type === 'git'
}
/**
* Open a tab (or focus its existing instance). Single-instance types focus
* the existing tab wherever it lives; editors dedupe by path. The tab lands
* in the active pane (or the first pane when the active one is gone).
*/
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
const targetId = state.activePane ?? firstLeaf(state.splits).id
if (isSingle(tab.type)) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
if (tab.type === 'editor' && tab.path !== undefined) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.type === 'editor' && candidate.path === tab.path)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
return {
...state,
activePane: targetId,
splits: mapLeaf(state.splits, targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
/** Move a tab from one pane to another (insert at index; -1 appends). */
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
let moved: SidebarTab | undefined
let emptied = false
let splits = mapLeaf(state.splits, fromPane, (leaf) => {
const found = leaf.tabs.find(tab => tab.id === tabId)
if (found === undefined) return
moved = found
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (moved === undefined) return state
if (emptied) splits = removeLeafAt(splits, fromPane)
splits = mapLeaf(splits, toPane, (leaf) => {
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
leaf.active = moved!.id
})
return { ...state, splits, activePane: toPane }
}
/** Split the active pane (or the pane containing the active tab). */
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
const paneId = state.activePane ?? firstLeaf(state.splits).id
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
}
/** Toggle the panel open/closed (opening restores the previous layout). */
export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
}
/** Set the panel width (clamped to the contract range). */
export function setWidth(state: SidebarState, width: number): SidebarState {
return { ...state, width: Math.min(PANEL_MAX, Math.max(PANEL_MIN, Math.round(width))) }
}
/** Toggle a directory in the explorer expansion set. */
export function toggleExpanded(state: SidebarState, path: string): SidebarState {
const expanded = state.expanded.includes(path)
? state.expanded.filter(item => item !== path)
: [...state.expanded, path]
return { ...state, expanded }
}
/** Adjust one split divider: `i` is the left/top child index, delta in fractions. */
export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode {
if (node.kind === 'leaf') return node
if (node.id === splitId) {
const sizes = [...node.sizes]
const left = Math.min(0.92, Math.max(0.08, sizes[index]! + delta))
const right = Math.min(0.92, Math.max(0.08, sizes[index + 1]! - delta))
sizes[index] = left
sizes[index + 1] = right
return { ...node, sizes }
}
return {
...node,
sizes: [...node.sizes],
children: node.children.map(child => resizeSplit(child, splitId, index, delta)),
}
}
// ── The per-session store ──────────────────────────────────────────────────
const STORAGE_PREFIX = 'dsh-sidebar:v1'
/** Immutable snapshot handed to React (replaced only on real changes). */
export interface SidebarSnapshot {
sessionId: string | undefined
state: SidebarState | undefined
}
function loadState(sessionId: string): SidebarState {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
if (raw !== null) {
const parsed = JSON.parse(raw) as SidebarState
if (parsed !== null && typeof parsed === 'object' && 'splits' in parsed && 'width' in parsed) {
return parsed
}
}
} catch {
// Corrupt or unavailable storage: fall through to the default.
}
return makeDefaultState()
}
/** The session-scoped store: one state per conversation, localStorage-backed. */
export class SidebarStore {
private readonly bySession = new Map<string, SidebarState>()
private snapshot: SidebarSnapshot = { sessionId: undefined, state: undefined }
private readonly listeners = new Set<() => void>()
private persistTimer: number | undefined
/** Select a session (or none); loads its persisted state. */
setSession(sessionId: string | undefined): void {
if (this.snapshot.sessionId === sessionId) return
if (sessionId === undefined) {
this.snapshot = { sessionId: undefined, state: undefined }
} else {
let state = this.bySession.get(sessionId)
if (state === undefined) {
state = loadState(sessionId)
this.bySession.set(sessionId, state)
}
this.snapshot = { sessionId, state }
}
this.notify()
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
getSnapshot(): SidebarSnapshot {
return this.snapshot
}
/** Mutate the current session's state (no-op without a session). */
update(mutator: (draft: SidebarState) => void): void {
const sessionId = this.snapshot.sessionId
const state = this.snapshot.state
if (sessionId === undefined || state === undefined) return
const draft = structuredClone(state)
mutator(draft)
this.bySession.set(sessionId, draft)
this.snapshot = { sessionId, state: draft }
this.schedulePersist(sessionId, draft)
this.notify()
}
/** Apply a pure reducer (returns the next state). */
reduce(reducer: (state: SidebarState) => SidebarState): void {
const sessionId = this.snapshot.sessionId
const state = this.snapshot.state
if (sessionId === undefined || state === undefined) return
const next = reducer(state)
this.bySession.set(sessionId, next)
this.snapshot = { sessionId, state: next }
this.schedulePersist(sessionId, next)
this.notify()
}
private schedulePersist(sessionId: string, state: SidebarState): void {
window.clearTimeout(this.persistTimer)
this.persistTimer = window.setTimeout(() => {
try {
localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
} catch {
// Storage full or unavailable: layout memory is best-effort.
}
}, 200)
}
private notify(): void {
for (const listener of [...this.listeners]) listener()
}
}
/** Module-level store instance (one per page; the plugin mounts once). */
export const sidebarStore = new SidebarStore()
+116
View File
@@ -0,0 +1,116 @@
/**
* 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:
* - httpServer: @deepseek-ai/dsh-host-webserver
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
* side the runtime ISessions list feed
* - loader: @cordisjs/plugin-loader (entry options)
* - slots: the client runtime SlotsService
* - effect: the DSH-vendored cordis lifecycle helper
* Drift from upstream is contained to this file.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Duplex } from 'node:stream'
import type { Context } from 'cordis'
/** 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 httpServer service face this plugin uses. */
export interface SidebarHttpServer {
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 } | 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
}
/** The client session list row the sidebar reads (cwd for the explorer). */
export interface SidebarSessionSummary {
cwd?: string
displayTitle: string
}
/** The client session list snapshot the sidebar subscribes to. */
export interface SidebarSessionList {
current: string | undefined
byId: Record<string, SidebarSessionSummary>
}
/** The client sessions service face (only the list feed is needed). */
export interface SidebarSessionsService {
list: {
getSnapshot(): SidebarSessionList
subscribe(fn: () => void): () => void
}
}
declare module 'cordis' {
interface Context {
httpServer: SidebarHttpServer
sessions: SidebarSessionStore & SidebarSessionsService
loader: SidebarLoader
slots: SidebarSlotsService
/**
* 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 }
+92
View File
@@ -0,0 +1,92 @@
/**
* 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, 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,
path: `${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)
}
/** Message text of an unknown thrown value. */
export function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
+192
View File
@@ -0,0 +1,192 @@
/**
* 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 {
hash: string
subject: string
author: string
date: 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` 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] = line.split('\x1f')
if (hash === undefined || subject === undefined) continue
rows.push({ hash, subject, author: author ?? '', date: date ?? '' })
}
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 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). */
export async function log(cwd: string, count = 30): Promise<GitLogEntry[]> {
const raw = await runGit(cwd, ['log', `-n ${count}`, '--pretty=format:%h%x1f%s%x1f%an%x1f%ai'])
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
}
}
+354
View File
@@ -0,0 +1,354 @@
/**
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
* read/write, git), the /sidebar/file media route (images), 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.
*
* 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.
*/
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, extname } from 'node:path'
import type { IncomingMessage } from 'node:http'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts'
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { isTrustedApiRequest } from './trust-fence.ts'
import * as git from './git.ts'
import { defaultShell, PtyManager } from './pty-manager.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
/** Services required before mounting: the webserver routes and the session store. */
export const inject = ['httpServer', 'sessions']
/** Read cap of one text file (bytes); larger files return truncated. */
const READ_LIMIT = 512 * 1024
/** Media route cap (bytes); larger binaries are refused. */
const MEDIA_LIMIT = 20 * 1024 * 1024
/** Explorer row bound of one level. */
const LIST_LIMIT = 1000
/** Terminals per session. */
const TERMINALS_PER_SESSION = 3
/** 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',
}
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
function trustedHostsOf(ctx: Context): string[] {
for (const entry of ctx.loader.entries()) {
if (entry.options.name === 'connection') {
const config = entry.options.config as { trustedHosts?: string[] } | undefined
return config?.trustedHosts ?? []
}
}
return []
}
/** Resolve a session's authoritative working directory or throw not-found. */
function sessionCwdOf(ctx: Context, sessionId: string): string {
const session = ctx.sessions.get(sessionId)
const cwd = session?.header.cwd
if (cwd === undefined) {
throw new SidebarError('not-found', `session "${sessionId}" has no working directory`, 404)
}
return cwd
}
/** Text read of a file with the size cap; binary detection via NUL probe. */
async function readText(path: string): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
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 > READ_LIMIT
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, READ_LIMIT))
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
const slice = buffer.subarray(0, bytesRead)
const binary = slice.includes(0)
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size }
} finally {
await handle.close()
}
}
/** One API method dispatch table entry. */
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
/** Build the API method table bound to the plugin context and pty manager. */
function buildApi(ctx: Context, ptyManager: PtyManager): Record<string, ApiMethod> {
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
const sessionId = requireString(payload, 'sessionId')
return { sessionId, cwd: sessionCwdOf(ctx, sessionId) }
}
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, LIST_LIMIT)
},
'fs.read': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const { content, truncated, binary, size } = await readText(path)
if (binary) return { kind: 'binary', size, truncated }
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 : requireAbsolute(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)
return git.log(cwd)
},
'git.show': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const rev = requireString(payload, 'rev')
return { content: await git.show(cwd, rev, path) }
},
}
}
/**
* Plugin body: mount the fenced routes and the pty lifecycle.
* @param ctx - host plugin context (httpServer, sessions, loader).
*/
export function apply(ctx: Context): void {
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), TERMINALS_PER_SESSION)
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager)
ctx.effect(() => ctx.httpServer.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')
// ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.httpServer.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)
const path = requireAbsolute(raw)
if (!path.startsWith(cwd)) {
// Only files under the session cwd are served as media (the editor
// opens images from the explorer; produced files go through read).
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
}
const info = await stat(path)
if (!info.isFile() || info.size > MEDIA_LIMIT) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
}
const type = MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
const body = await readFile(path)
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-cache' })
res.end(body)
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── Terminal WebSocket ──────────────────────────────────────────────────
const wss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.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, ws, req) })
},
}), 'dsh-better-sidebar: terminal WebSocket')
ctx.effect(() => () => {
ptyManager.disposeAll()
wss.close()
}, 'dsh-better-sidebar: teardown')
}
/** Wire one terminal socket to its pty: replay transcript, pump both ways. */
async function attachTerminal(
ctx: Context,
ptyManager: PtyManager,
ws: WebSocket,
req: IncomingMessage,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
const tabId = url.searchParams.get('tab')
if (sessionId === null || tabId === null) {
ws.close(1008, 'sessionId and tab are required')
return
}
const cwd = sessionCwdOf(ctx, sessionId)
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')
if (handle.exited) return
// 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 && typeof control === 'object'
&& control.type === 'resize'
&& typeof control.cols === 'number' && typeof control.rows === 'number'
) {
handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows)))
} else {
handle.pty.write(text)
}
})
ws.on('close', () => {
dataSub.dispose()
exitSub.dispose()
})
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* PTY session table for the sidebar terminals. One node-pty process per
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
* (page refresh, tab switch) and reconnect to the same process by key.
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
* connection replays history before live data. Sessions die only when the
* tab is closed or the plugin tears down.
*/
import * as nodePty from 'node-pty'
import { SidebarError } from './wire.ts'
/** Per-terminal transcript bound (bytes kept for replay). */
const TRANSCRIPT_LIMIT = 1 << 20
/** One live terminal. */
export interface SidebarPty {
/** `${sessionId}:${tabId}` registry key. */
key: string
sessionId: string
tabId: string
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
exitCode?: number | null
}
/**
* The terminal registry. `maxPerSession` bounds concurrent processes per
* conversation (the client caps tabs at the same number).
*/
export class PtyManager {
private readonly sessions = new Map<string, SidebarPty>()
constructor(
private readonly shell: string,
private readonly maxPerSession: number,
) {}
/** All live terminal keys of one session. */
keysOf(sessionId: string): string[] {
const keys: string[] = []
for (const handle of this.sessions.values()) {
if (handle.sessionId === sessionId) keys.push(handle.key)
}
return keys
}
/**
* Open (or reuse) the terminal for a session/tab key.
* @param sessionId - conversation id.
* @param tabId - client tab id.
* @param cwd - initial working directory (the session's cwd).
* @param cols - initial terminal width.
* @param rows - initial terminal height.
* @returns the live handle.
* @throws {SidebarError} pty-error when the per-session cap is reached.
*/
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
const key = `${sessionId}:${tabId}`
const existing = this.sessions.get(key)
if (existing !== undefined) return existing
if (this.keysOf(sessionId).length >= this.maxPerSession) {
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
}
const handle: SidebarPty = {
key,
sessionId,
tabId,
pty: nodePty.spawn(this.shell, [], {
name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)),
cwd,
env: { ...process.env },
}),
transcript: '',
exited: false,
}
handle.pty.onData((data) => {
handle.transcript += data
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
}
})
handle.pty.onExit(({ exitCode }) => {
handle.exited = true
handle.exitCode = exitCode
})
this.sessions.set(key, handle)
return handle
}
/** Resolve a live handle by key, or undefined. */
get(key: string): SidebarPty | undefined {
return this.sessions.get(key)
}
/** Close a terminal and drop its state (the owning tab was closed). */
close(key: string): void {
const handle = this.sessions.get(key)
if (handle === undefined) return
this.sessions.delete(key)
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
}
/** Close every terminal (plugin teardown). */
disposeAll(): void {
for (const key of [...this.sessions.keys()]) this.close(key)
}
}
/** The interactive shell for this platform. */
export function defaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe'
return process.env.SHELL ?? '/bin/bash'
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Browser-trust fence for the sidebar routes, behaviorally identical to the
* /api gateway's fence in @deepseek-ai/dsh-client-connection
* (src/api-request-trust.ts + src/loopback-hostname.ts, BSD-3-Clause,
* copied here because the package does not export these helpers and the
* plugin must not depend on its internals). Host-header loopback or a
* configured trusted authority passes; cross-site browser markers refuse.
* This is a DNS-rebinding / cross-site defense, not authentication.
*/
import type { IncomingHttpHeaders } from 'node:http'
/** The request facts the fence reads (structural subset of IncomingMessage). */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {
return new URL(`http://${authority}`)
} catch {
return undefined
}
}
/** Whether a normalized URL hostname names the local loopback authority. */
export function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Canonical authority form: hostname, or hostname:port when a port was written. */
function canonicalAuthority(entry: string, entryUrl: URL): string {
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
}
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
return trustedHosts.some((entry) => {
const entryUrl = parseAuthority(entry)
if (entryUrl === undefined) return false
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
? entryUrl.hostname === hostUrl.hostname
: entryUrl.host === hostUrl.host
})
}
/**
* Decide whether one sidebar request may reach the plugin routes.
* @param request - node HTTP request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves.
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
*/
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
const host = header(request.headers, 'host')
if (host === undefined) return false
const hostUrl = parseAuthority(host)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
const origin = header(request.headers, 'origin')
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Wire helpers for the /sidebar JSON API: bounded body reading, response
* writing, and the shared error envelope. Every API method returns
* `{ok: true, value}` on success and `{ok: false, error: {code, message}}`
* (HTTP 4xx/5xx matching the code) on failure.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Machine-readable error codes of the sidebar API. */
export type SidebarErrorCode =
| 'bad-request'
| 'not-found'
| 'forbidden'
| 'method-error'
| 'fs-error'
| 'git-error'
| 'pty-error'
| 'internal'
/** One API failure with its wire code and HTTP status. */
export class SidebarError extends Error {
constructor(
readonly code: SidebarErrorCode,
message: string,
readonly status = 400,
) {
super(message)
}
}
/** Body size bound of one JSON request (defense against unbounded reads). */
const MAX_BODY_BYTES = 1 << 20
/** Success envelope of one API method. */
export interface SidebarOk<T> { ok: true; value: T }
/** Failure envelope of one API method. */
export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } }
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
let total = 0
for await (const chunk of req) {
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
total += buffer.length
if (total > MAX_BODY_BYTES) {
throw new SidebarError('bad-request', 'request body too large')
}
chunks.push(buffer)
}
const text = Buffer.concat(chunks).toString('utf8')
if (text.trim() === '') return {}
try {
return JSON.parse(text) as unknown
} catch {
throw new SidebarError('bad-request', 'request body is not valid JSON')
}
}
/** Write a JSON response with the given status. */
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(payload)
}
/** Write the success envelope. */
export function writeOk(res: ServerResponse, value: unknown): void {
writeJson(res, 200, { ok: true, value })
}
/** Write the failure envelope for any thrown value (unknown → internal 500). */
export function writeError(res: ServerResponse, error: unknown): void {
if (error instanceof SidebarError) {
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
return
}
const message = error instanceof Error ? error.message : String(error)
writeJson(res, 500, { ok: false, error: { code: 'internal', message } })
}
/** Narrow an unknown payload value to a string, else throw bad-request. */
export function requireString(payload: unknown, key: string): string {
const record = payload as Record<string, unknown> | null
const value = record?.[key]
if (typeof value !== 'string' || value === '') {
throw new SidebarError('bad-request', `missing or invalid "${key}"`)
}
return value
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Smoke spec: mounts the host plugin against a minimal fake context and
* exercises the real integrations — route registration, git against the
* actual repository, and a real directory listing. Runs with `pnpm test`.
*/
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts'
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
interface FakeContext {
loader: { entries: () => never[] }
httpServer: {
register: (route: SidebarWebRoute) => () => void
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
}
sessions: { get: (id: string) => undefined }
effect: (fn: () => void | (() => void), label?: string) => void
}
describe('host plugin smoke', () => {
it('mounts the fenced routes', () => {
const routes: SidebarWebRoute[] = []
const upgrades: SidebarWebUpgradeRoute[] = []
const effects: Array<() => void | (() => void)> = []
const ctx: FakeContext = {
loader: { entries: () => [] },
httpServer: {
register: (route) => { routes.push(route); return () => {} },
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
},
sessions: { get: () => undefined },
// The DSH-vendored cordis runs the registration effect immediately and
// keeps its cleanup for disposal.
effect: (fn) => {
const cleanup = fn()
if (typeof cleanup === 'function') effects.push(cleanup)
},
}
apply(ctx as never)
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal'])
// Teardown runs without throwing (pty manager has nothing open).
for (const cleanup of effects) cleanup()
})
it('runs git status/log/branches against this repository', async () => {
const cwd = process.cwd()
const status = await git.status(cwd)
expect(status.isRepo).toBe(true)
expect(typeof status.branch).toBe('string')
expect(Array.isArray(status.entries)).toBe(true)
const log = await git.log(cwd)
expect(log.length).toBeGreaterThan(0)
expect(log[0]!.hash).toMatch(/^[0-9a-f]{7,}$/)
const branches = await git.branches(cwd)
expect(branches.names).toContain(branches.current)
})
it('lists the repository root level', async () => {
const listing = await listDirectory(process.cwd(), 1000)
expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true)
expect(listing.entries.some(entry => entry.name === 'package.json' && !entry.isDir)).toBe(true)
expect(listing.truncated).toBe(false)
})
})
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest'
import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import {
activateTab, closeTab, makeDefaultState, moveTab, openTab, resizeSplit,
splitPane, toggleExpanded, type SidebarState, type SplitNode,
} from '../src/client/state.ts'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
const rows = [
{ name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false },
{ name: 'A', path: '/x/A', isDir: true, hidden: false },
{ name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false },
{ name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true },
]
expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt'])
})
it('derives root labels and parents', () => {
expect(rootLabel('/Users/me/code')).toBe('code')
expect(rootLabel('/')).toBe('/')
expect(parentOf('/Users/me/code')).toBe('/Users/me')
expect(parentOf('/')).toBeUndefined()
})
it('accepts absolute paths and rejects relative ones', () => {
expect(requireAbsolute('/a/b')).toBe('/a/b')
expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/)
})
})
describe('git parsing', () => {
it('parses porcelain -z entries including renames', () => {
const output = ['M src/a.ts', ' M src/b.ts', '?? src/c.ts', 'R src/new.ts', 'src/old.ts', ''].join('\0')
const entries = parsePorcelainZ(output)
expect(entries).toEqual([
{ path: 'src/a.ts', xy: 'M ' },
{ path: 'src/b.ts', xy: ' M' },
{ path: 'src/c.ts', xy: '??' },
{ path: 'src/new.ts', xy: 'R ' },
])
})
it('parses log rows with unit separators', () => {
const rows = parseLogLines('abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\n')
expect(rows).toEqual([
{ hash: 'abc1234', subject: 'First subject', author: 'Alice', date: '2024-01-01 10:00:00 +0800' },
])
})
})
describe('sidebar state', () => {
const state = (): SidebarState => makeDefaultState()
it('opens tabs into the active pane and dedupes single-instance types', () => {
let s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTab(s, gitTab)
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// Reopening git focuses the existing tab instead of duplicating.
const after = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
})
it('dedupes editors by path', () => {
let s = state()
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = openTab(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
const after = openTab(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
})
it('splits panes and moves tabs between them', () => {
let s = state()
s = splitPane(s, 'row')
expect(s.splits.kind).toBe('split')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
expect(split.children).toHaveLength(2)
const explorerId = (split.children[0] as { id: string }).id
const otherId = (split.children[1] as { id: string }).id
expect((split.children[1] as { tabs: unknown[] }).tabs).toHaveLength(0)
const explorerTab = ((split.children[0] as { tabs: { id: string }[] }).tabs[0]!).id
s = moveTab(s, explorerId, explorerTab, otherId)
// The source pane emptied and was removed; the target leaf is promoted.
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { id: string }).id).toBe(otherId)
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([explorerTab])
})
it('closing the last tab removes the pane (promotes the sibling)', () => {
let s = state()
s = splitPane(s, 'col')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string }
const explorerId = paneA.tabs[0]!.id
// paneA gets a terminal; the explorer moves to paneB; closing the
// terminal empties paneA, which is removed, promoting paneB.
s = openTab(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
s = moveTab(s, paneA.id, explorerId, paneB.id)
s = activateTab(s, paneA.id, 't')
s = closeTab(s, paneA.id, 't')
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { id: string }).id).toBe(paneB.id)
})
it('resizes splits within the clamp range', () => {
let s = state()
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const id = split.id
s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) }
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
expect(after.sizes[0]).toBeCloseTo(0.7)
expect(after.sizes[1]).toBeCloseTo(0.3)
})
it('tracks explorer expansion and tab activation', () => {
let s = state()
s = toggleExpanded(s, '/p/a')
s = toggleExpanded(s, '/p/b')
expect(s.expanded).toEqual(['/p/a', '/p/b'])
s = toggleExpanded(s, '/p/a')
expect(s.expanded).toEqual(['/p/b'])
const leaf = s.splits as { id: string; tabs: { id: string }[]; active: string | null }
const tabId = leaf.tabs[0]!.id
const after = activateTab(s, leaf.id, tabId)
expect((after.splits as { active: string | null }).active).toBe(tabId)
})
})
describe('produced-files derivation', () => {
const diffResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
})
const editResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'edit', locations: [{ path }] },
})
it('collects diff/edit locations of the closing turn, first-seen order', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('a.ts'),
editResult('b.ts'),
diffResult('a.ts'),
{ kind: 'assistant', seq: 2, turn: 1 },
]
expect(producedForClosing(nodes, 2)).toEqual(['a.ts', 'b.ts'])
})
it('resets on user messages and turn changes', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('old.ts'),
{ kind: 'user' },
{ kind: 'assistant', seq: 2, turn: 2 },
diffResult('new.ts'),
{ kind: 'assistant', seq: 3, turn: 2 },
]
expect(producedForClosing(nodes, 3)).toEqual(['new.ts'])
})
it('ignores reads, deletes, errors, and unknown cards', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
{ kind: 'tool-result', isError: true, callView: { card: 'diff', locations: [{ path: 'x.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'read', locations: [{ path: 'r.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'delete', locations: [{ path: 'd.ts' }] } },
]
expect(producedForClosing(nodes, 1)).toEqual([])
})
it('selector claims only when files exist', () => {
expect(selectProducedFiles({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toBeNull()
expect(selectProducedFiles({ nodes: [diffResult('a.ts'), { kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toEqual(['a.ts'])
expect(selectProducedFiles(null)).toBeNull()
})
it('resolves relative paths against the session cwd', () => {
expect(resolveSidebarPath('/work/proj', 'src/a.ts')).toBe('/work/proj/src/a.ts')
expect(resolveSidebarPath('/work/proj', '/abs/x.ts')).toBe('/abs/x.ts')
expect(resolveSidebarPath(undefined, 'a.ts')).toBe('a.ts')
})
})
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "tests", "tsdown.config.ts"]
}
+145
View File
@@ -0,0 +1,145 @@
/**
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js, ESM
* node) plus the browser client bundle (lib/client.js, CJS closure factory).
*
* The client bundle replicates the DSH client-bundle contract:
* - externals resolve through the loader module table at runtime (platform
* seed words from apps/web's seed.ts, plus the runtime/client exemption),
* - everything else is inlined into the bundle (xterm, clsx, ...),
* - CSS Modules compile to hashed class maps and inject <style data-plugin>
* tags at factory execution,
* - the artifact registers itself via window.__ModuleLoader__.load({id,
* factory}) with the (require) => exports CJS closure shape.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
const require = createRequire(import.meta.url)
const PLUGIN_ID = 'dsh-better-sidebar'
/** Module specifiers the web shell shares into the frozen module table (apps/web/src/seed.ts). */
const CLIENT_EXTERNALS = [
'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',
]
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
/** The style-injection prologue shared by module css and plain css loads. */
function injectTag(fileId: string, cssText: string): string {
const tagId = `${PLUGIN_ID}/${basename(fileId)}`
return [
`const css = ${JSON.stringify(cssText)};`,
`const tagId = ${JSON.stringify(tagId)};`,
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
` const tag = document.createElement('style');`,
` tag.dataset.plugin = ${JSON.stringify(PLUGIN_ID)};`,
` tag.dataset.pluginCss = tagId;`,
` tag.textContent = css;`,
` document.head.appendChild(tag);`,
`}`,
].join('\n')
}
/** Rebase a physical lib-relative source onto the repository-shaped URL tree. */
function browserSourcePath(source: string, sourcemapPath: string): string {
if (!source.startsWith('.')) return source
const physicalSource = resolvePath(dirname(sourcemapPath), source)
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
return `../../../${repositoryPath}`
}
export default [
{
entry: { index: 'src/index.ts' },
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: true,
},
{
entry: { client: 'src/client/index.tsx' },
outDir: 'lib',
format: 'cjs',
platform: 'browser',
dts: false,
sourcemap: true,
clean: false,
external: [...CLIENT_EXTERNALS],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
},
// External wins for module-table entries; every other dependency inlines.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{
name: 'dsh-css-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null
// Relative/absolute paths resolve against the importer; bare
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
} else {
abs = require.resolve(source)
}
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
// CSS Modules (x.module.css) become hashed class maps; plain css
// (xterm's stylesheet) is inlined verbatim.
if (fileId.endsWith('.module.css')) {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}],
outputOptions: {
entryFileNames: 'client.js',
sourcemapPathTransform: browserSourcePath,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PLUGIN_ID)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
},
},
] satisfies UserConfig[]