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:
@@ -141,3 +141,4 @@ dist
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
lib/
|
||||
|
||||
@@ -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 无右侧插件位),不修改核心布局。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1360
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
Vendored
+5
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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[]
|
||||
Reference in New Issue
Block a user