feat(sidebar-files): add lazy workspace file tree tabs

This commit is contained in:
imccyu committed 2026-09-07 21:29:40 +08:00
1 parent b67e0a838c
commit b24ebc8cce
14 files changed
+1060

No files matched your search

@@ -0,0 +1,72 @@
---
description: "The right Sidebar's file-tree tab type for the dsh web client: the session workspace root listed one level at a time over the wire, opening files into the Sidebar by resource address."
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-sidebar-files
English | [中文](README.zh.md)
## Summary
The right Sidebar's navigator tab type: the session's workspace root as a tree, listed one level at a time over the wire, opening files into the Sidebar. It is a page type reached from the guide and claims no address; it opens files by address for the `dsh-resource://file` viewers to claim — nothing in `ui-sidebar-right` knows this package.
## Table of Contents
- [What it registers](#what-it-registers)
- [The tree](#the-tree)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="what-it-registers"></a>
## What it registers
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, titled from the `sidebarFiles` namespace) that opens the type.
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: the tree, with its one control, reload, at the right of its header row.
Six source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `locales.ts` (what it says), and `index.ts` (the wiring).
<a id="the-tree"></a>
## The tree
The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and labelled by `workspaceTitleOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
| Entry type | Row |
|---|---|
| `directory` | Toggles; the level is fetched the first time it opens and kept while collapsed. |
| `file` | Opens `dsh-resource://file/session/<sessionId>/<encoded path relative to the root>`, built by `fileAddressFor` from `@deepseek-ai/dsh-util-workspace-path` from the entry's absolute path and the tree's root, through `useTabInfo().tab.actions.openResource`, landing in the tab's own pane. |
| `other` | Shown greyed and not clickable, so the directory is reported whole. |
A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload drops every listed level and asks again for the expanded ones; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree.
State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), and `expanded`. The owner's `signal` ends a bucket: on abort the tab is forgotten and a listing that settles afterwards writes nothing.
<a id="model-experience"></a>
## Model Experience
None, as this package draws a workspace file tree in the browser and registers nothing model-facing.
#### KV Cache effect
None; directory listings travel over the Remote and assemble no model request.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, current-file highlight, or filesystem watching; a level changes only through reload.
- **One root.** The tree is rooted at the session's working directory; there is no way to browse above it, and the Host refuses paths outside the workspace root anyway.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
**Runtime invariant:** No companion is published. The tree's only runtime state is one Slot store per tab, written by the body that owns it and forgotten on the tab's abort signal; there is no second observation of it to compare against.
@@ -0,0 +1,72 @@
---
description: "dsh Web 客户端右侧 Sidebar 的文件树 tab 类型:逐层经线上列出会话工作区根目录,按资源地址把文件打开到 Sidebar。"
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-sidebar-files
[English](README.md) | 中文
## 概述
右侧 Sidebar 的导航器 tab 类型:把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里。它是从引导页进入的页类型,不认领任何地址;它按地址打开文件,交给 `dsh-resource://file` 的查看器认领:`ui-sidebar-right` 里没有任何东西认识本包。
## 目录
- [注册了什么](#what-it-registers)
- [树](#the-tree)
- [模型体验](#model-experience)
- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="what-it-registers"></a>
## 注册了什么
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:树本身,以及它唯一的控件、位于标题行右端的重新读取。
`src/client/` 下六个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`locales.ts`(它说什么)、`index.ts`(接线)。
<a id="the-tree"></a>
## 树
根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标签由 `@deepseek-ai/dsh-util-workspace-path` 的 `workspaceTitleOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
| 条目类型 | 行 |
|---|---|
| `directory` | 切换展开与折叠;该层在首次打开时拉取,折叠期间保留。 |
| `file` | 经 `useTabInfo().tab.actions.openResource` 打开 `dsh-resource://file/session/<sessionId>/<encoded path relative to the root>`,地址由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 从条目的绝对路径与树的根生成,落在该 tab 自己的 pane 里。 |
| `other` | 灰显且不可点击,使目录被完整报告。 |
被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
状态住在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)与 `expanded`。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表什么也不写。
<a id="model-experience"></a>
## 模型体验
无,因为本包在浏览器里绘制工作区文件树,不注册任何面向模型的内容。
#### KV Cache 影响
无;目录列表经 Remote 传输,不会组装模型请求。
## 已知限制与暂缓事项
<a id="known-limitations-and-deferred-work"></a>
- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单、当前文件高亮或文件系统监听;一层只会因重新读取而变化。
- **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作上下文——点击展开</summary>
无。
</details>
**运行时不变量:** 不发布 companion。树唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。
@@ -0,0 +1,76 @@
{
"name": "@deepseek-ai/dsh-client-ui-sidebar-files",
"description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar",
"version": "0.1.3-alpha.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-sidebar-files"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-workspace-files",
"@deepseek-ai/dsh-client-ui-sidebar-right",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-workspace-files": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0"
},
"files": [
"lib/index.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}
@@ -0,0 +1,128 @@
.root {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: auto;
padding: 4px 0 8px;
color: var(--dsw-alias-label-primary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: 1.5;
}
.header {
display: flex;
flex: 0 0 auto;
gap: 6px;
align-items: center;
padding: 4px 10px;
color: var(--dsw-alias-label-secondary);
font-weight: 500;
}
.level {
margin: 0;
padding: 0;
list-style: none;
}
/* Every nested level indents by one step; the root level sits under the header. */
.level .level {
padding-left: 14px;
}
.item {
margin: 0;
padding: 0;
}
.row {
display: flex;
gap: 6px;
align-items: center;
width: 100%;
min-width: 0;
padding: 3px 10px;
color: inherit;
font: inherit;
text-align: left;
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.icon {
flex: 0 0 auto;
color: var(--dsw-alias-label-secondary);
}
/* The document glyph is drawn 24×28; it rides the row at icon height. */
.fileIcon {
flex: 0 0 auto;
width: 14px;
height: 16px;
}
.name {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Neither a file nor a directory: shown so the directory is reported whole,
greyed so nobody tries to open it. */
.other {
color: var(--dsw-alias-label-tertiary);
cursor: default;
}
.other:hover {
background: transparent;
}
.note {
margin: 0;
padding: 3px 10px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
}
.status {
display: flex;
flex-direction: column;
padding: 12px 10px;
}
.statusLine {
margin: 0;
color: var(--dsw-alias-label-secondary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: 1.6;
}
/* The header's reload control, pushed to the row's right edge. */
.tool {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-left: auto;
padding: 0;
color: var(--dsw-alias-label-secondary);
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.tool:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -0,0 +1,192 @@
/**
* The file tree's body: the session's workspace root, listed one level at a time.
*
* Everything the tree keeps lives in its store, keyed by tab; everything it asks
* for goes through its injected face. The component itself only decides what to
* draw for each absolute path and what a click means: a directory toggles, a
* file opens through the owner's `tabActions` for a `file:` viewer to claim, and
* anything else is shown but refuses to open. The header row carries the one
* control: reload, which drops every listed level and asks again for the
* expanded ones.
*/
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import {
DocumentFileIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { fileAddressFor, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types'
import { childPath } from './face.ts'
import type { FilesInjected } from './face.ts'
import type {} from './locales.ts'
import type { FilesTabState, createFilesStore } from './store.ts'
import css from './FilesBody.module.css'
/** The body's composed props: the tab it draws, its store, its face, and its copy. */
export type FilesBodyProps =
& PropsRuntime<'sidebar.right.pane.tab'>
& PropsStore<ReturnType<typeof createFilesStore>>
& FilesInjected
& PropsLocale<'sidebarFiles'>
/** Natural, case-insensitive name order, so `file2` precedes `file10`. */
const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
/**
* Order one level's entries for display: directories first, then everything
* else, each group by name. The endpoint's order is a listing fact; this is the
* reader's.
* @param entries - the listing as the endpoint returned it.
* @returns a new array, directories first, then by name within each group.
*/
export function orderEntries(entries: readonly WorkspaceDirectoryEntry[]): WorkspaceDirectoryEntry[] {
return [...entries].sort((left, right) => {
const group = Number(right.type === 'directory') - Number(left.type === 'directory')
return group !== 0 ? group : byName.compare(left.name, right.name)
})
}
/**
* Say why a directory could not be listed, in terms of the directory.
* @param t - namespace-bound translate.
* @param failure - the settled Remote failure.
* @returns the line to show under the directory.
*/
export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailure): string {
switch (failure.code) {
case 'workspace-file/not-found': return t('error.notFound')
case 'workspace-file/outside-workspace': return t('error.outsideWorkspace')
case 'workspace-file/not-directory': return t('error.notDirectory')
// Carrier and unclassified host failures reach the reader as themselves:
// this tree knows nothing useful to add to a transport-level message.
default: return t('error.unavailable', { message: failure.message })
}
}
/** What every level shares: the tab's tree and the two gestures. */
interface TreeContext {
readonly state: FilesTabState
readonly onToggle: (path: string) => void
readonly onOpen: (path: string) => void
readonly t: TranslateNS<'sidebarFiles'>
}
/** One entry's row, and its children when it is an expanded directory. */
function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirectoryEntry; tree: TreeContext }): ReactNode {
const path = childPath(parent, entry.name)
if (entry.type === 'directory') {
const expanded = tree.state.expanded.includes(path)
return (
<li className={css.item} data-files-entry="directory" data-files-path={path}>
<button type="button" className={css.row} aria-expanded={expanded} onClick={() => { tree.onToggle(path) }}>
{expanded ? <IconFolderOpen16 className={css.icon} /> : <IconFolderClose16 className={css.icon} />}
<span className={css.name}>{entry.name}</span>
</button>
{expanded && <ul className={css.level}><Level path={path} tree={tree} /></ul>}
</li>
)
}
if (entry.type === 'file') {
return (
<li className={css.item} data-files-entry="file" data-files-path={path}>
<button type="button" className={css.row} onClick={() => { tree.onOpen(path) }}>
<DocumentFileIcon className={css.fileIcon} />
<span className={css.name}>{entry.name}</span>
</button>
</li>
)
}
return (
<li className={css.item} data-files-entry="other" data-files-path={path}>
<span className={clsx(css.row, css.other)} aria-disabled="true" title={tree.t('entry.other')}>
<span className={css.name}>{entry.name}</span>
</span>
</li>
)
}
/** One directory's rows: its state while listing, its entries once listed. */
function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode {
const { state, t } = tree
const level = state.levels[path]
if (level === undefined || level.kind === 'loading') {
return <li className={css.note} data-files-row="loading">{t('loading')}</li>
}
if (level.kind === 'failed') {
return (
<li className={css.note} data-files-row="failed" data-files-code={level.failure.code}>
{failureLine(t, level.failure)}
</li>
)
}
const entries = orderEntries(level.level.entries)
return (
<>
{entries.length === 0 && <li className={css.note} data-files-row="empty">{t('empty')}</li>}
{entries.map(entry => <Entry key={entry.name} parent={path} entry={entry} tree={tree} />)}
{level.level.truncated && <li className={css.note} data-files-row="truncated">{t('truncated')}</li>}
</>
)
}
/** The file tree's body: the workspace root and whatever the reader has opened under it. */
export function FilesBody({
useTabInfo, sessionId, useSessions, useStore, actions, start, load, toggle, t,
}: FilesBodyProps): ReactNode {
const { tab } = useTabInfo()
const { signal, actions: tabActions } = tab
const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd)
const state = useStore(store => store.byTab[tab.id])
useEffect(() => {
// A bucket gone because the record aborted must not be re-seeded by a
// component that has not unmounted yet.
if (state !== undefined || cwd === undefined || signal.aborted) return
start(tab.id, cwd, signal)
}, [state, cwd, tab.id, signal, start])
if (cwd === undefined) {
return (
<div className={css.status} data-files-state="no-workspace">
<p className={css.statusLine}>{t('noWorkspace')}</p>
</div>
)
}
if (state === undefined) return null
const tree: TreeContext = {
state,
onToggle: (path) => { toggle(tab.id, path, state.levels[path] !== undefined, signal) },
// Every row is under the tree's root, so its address is session-relative.
onOpen: (path) => { tabActions.openResource(fileAddressFor(sessionId, state.root, path)) },
t,
}
// Reload drops every level and asks again for the expanded ones; a collapsed
// level is fetched again the next time it opens.
const reload = (): void => {
actions.reset(tab.id)
for (const path of state.expanded) load(tab.id, path, signal)
}
// A separator-only root has no final segment; the root itself is the label then.
const title = workspaceTitleOf(state.root) || state.root
return (
<div className={css.root} data-files-state="tree" data-files-root={state.root}>
<div className={css.header}>
<IconFolderOpen16 className={css.icon} />
<span className={css.name}>{title}</span>
<button
type="button"
className={css.tool}
aria-label={t('reload')}
title={t('reload')}
data-files-reload
onClick={reload}
>
<IconRefreshOutline16 />
</button>
</div>
<ul className={css.level}><Level path={state.root} tree={tree} /></ul>
</div>
)
}
@@ -0,0 +1,37 @@
/**
* Stage one of this package's registration: what the `files` tab type IS.
*
* The type is a page, not a viewer: it claims no address. The guide page offers
* it as an entry box, and the tree opens files through `tabActions.openResource`
* for the `dsh-resource://file` viewers to claim.
*/
import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import type {} from './locales.ts'
import { IconFolderClose16 } from '@deepseek-ai/dsh-client-ui-primitives'
/** The tab kind this package owns. */
export const FILES_KIND = 'files'
/** This implementation's identity in the tab system, and the key its body registers under. */
export const FILES_ID = '@deepseek-ai/dsh-client-ui-sidebar-files'
/**
* The files type's registry definition.
* @param t - namespace-bound translate, read fresh on every label call.
* @returns the definition to register.
*/
export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTabDefinition {
return {
id: FILES_ID,
kind: FILES_KIND,
priority: 'builtin',
title: () => t('type.label'),
guide: [{
order: 10,
title: () => t('guide.title'),
description: () => t('guide.description'),
icon: IconFolderClose16,
}],
}
}
@@ -0,0 +1,149 @@
/**
* The tree's asynchronous half: listing directories into the store.
*
* The component never awaits anything. It calls `start` / `load` / `toggle`, and
* this face performs the listing and writes the outcome through the store's own
* actions — the Slot-standard `inject` shape, so the session id is resolved by
* the framework and the write set stays the store's.
*
* The listing itself is bound here to the Client Remote face: the tree keys
* every level by absolute path and hands the endpoint that same absolute path;
* the endpoint answers with the directory's workspace-relative path as well,
* which the tree has no use for and drops.
*
* One level has one listing in force: asking for a level again — the reload
* gesture, a directory reopened after a reset — retires the listing still in
* flight for it, whose settlement then writes nothing. Cleanup rides the owner's
* `signal`: a request is not made for a record that already ended, and when the
* record goes away the bucket and the tab's listing bookkeeping are forgotten,
* so no later settlement writes to it.
*/
import type { ClientRemote, RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
import type { BoundActions } from '@deepseek-ai/dsh-client-store'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { DirLevel, createFilesStore } from './store.ts'
/**
* One directory listing, bound to a Remote face.
*
* The session travels with the call because the endpoint resolves the workspace
* root from it: the same path means different directories in different sessions.
* A Remote call does not reject — the result carries the failure.
*/
export type ListWorkspaceDirectory = (
sessionId: SessionId,
path: string,
signal: AbortSignal,
) => Promise<RemoteResult<DirLevel>>
/**
* The slice of the Client Remote face this package calls: the `workspaceFiles`
* namespace's `list`, exactly as the Host's generated client declares it.
*/
export type WorkspaceFilesListRemote = {
readonly workspaceFiles: Pick<ClientRemote['workspaceFiles'], 'list'>
}
/**
* Bind the listing to one Remote face, keeping only what the tree stores.
* @param remote - the Client Remote face carrying the `workspaceFiles` namespace.
* @returns the listing the tree's face performs.
*/
export function createList(remote: WorkspaceFilesListRemote): ListWorkspaceDirectory {
return async (sessionId, path, signal) => {
const result = await remote.workspaceFiles.list(sessionId, path, signal)
if (!result.ok) return result
return { ok: true, value: { entries: result.value.entries, truncated: result.value.truncated } }
}
}
/**
* The absolute path of one child entry.
*
* Joined with `/` whatever the parent's separators: the Host resolves mixed
* separators, and the tree only needs a stable key.
* @param parent - absolute path of the listed directory.
* @param name - the entry's basename.
* @returns the child's absolute path.
*/
export function childPath(parent: string, name: string): string {
return `${parent.replace(/[/\\]+$/, '')}/${name}`
}
/** The tree's injected business face, as the body receives it. */
export interface FilesInjected {
/**
* Seed this tab's tree and list its root.
* @param tabId - the tab being drawn.
* @param root - absolute path of the workspace root.
* @param signal - the tab record's lifetime.
*/
readonly start: (tabId: TabId, root: string, signal: AbortSignal) => void
/**
* List one directory into the store.
* @param tabId - the tab being drawn.
* @param path - absolute directory path.
* @param signal - the tab record's lifetime.
*/
readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void
/**
* Open or collapse one directory, listing it the first time it opens.
* @param tabId - the tab being drawn.
* @param path - absolute directory path.
* @param loaded - whether this level already has state.
* @param signal - the tab record's lifetime.
*/
readonly toggle: (tabId: TabId, path: string, loaded: boolean, signal: AbortSignal) => void
}
/**
* Bind the tree's face to one directory listing.
* @param list - the bound `workspaceFiles.list` call.
* @returns the Slot `inject` factory: session and bound actions in, face out.
*/
export function filesFace(
list: ListWorkspaceDirectory,
): (sessionId: SessionId, actions: BoundActions<ReturnType<typeof createFilesStore>>) => FilesInjected {
return (
sessionId: SessionId,
actions: BoundActions<ReturnType<typeof createFilesStore>>,
): FilesInjected => {
/** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */
const generations = new Map<TabId, Map<string, number>>()
const nextGeneration = (tabId: TabId, path: string): number => {
const byPath = generations.get(tabId) ?? new Map<string, number>()
generations.set(tabId, byPath)
const generation = (byPath.get(path) ?? 0) + 1
byPath.set(path, generation)
return generation
}
const load = (tabId: TabId, path: string, signal: AbortSignal): void => {
if (signal.aborted) return
const generation = nextGeneration(tabId, path)
actions.loading(tabId, path)
void list(sessionId, path, signal).then((result) => {
// A newer listing of this level was asked for since, or the record is
// gone and its bookkeeping with it: nothing left for this one to write.
if (generations.get(tabId)?.get(path) !== generation) return
if (result.ok) actions.loaded(tabId, path, result.value)
else actions.failed(tabId, path, result.error)
})
}
return {
start(tabId, root, signal) {
actions.start(tabId, root)
signal.addEventListener('abort', () => {
generations.delete(tabId)
actions.forget(tabId)
}, { once: true })
load(tabId, root, signal)
},
load,
toggle(tabId, path, loaded, signal) {
actions.toggled(tabId, path)
if (!loaded) load(tabId, path, signal)
},
}
}
}
@@ -0,0 +1,52 @@
/**
* Browser half: register `files` as a right-Sidebar tab type.
*
* The public two-stage path, unmodified: the type into `ctx.sidebarRightTabs`,
* the body into the keyed `sidebar.right.pane.tab` seat under the type's `id`.
*
* The file split is this package's layering: what the type IS
* (`definition.ts`), what it keeps (`store.ts`), how it lists (`face.ts`), what
* it draws (`FilesBody.tsx`), what it says (`locales.ts`), and this module,
* which only wires them together.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import { FILES_ID, filesDefinition } from './definition.ts'
import { createList, filesFace } from './face.ts'
import { FilesBody } from './FilesBody.tsx'
import { en, zh } from './locales.ts'
import { createFilesStore } from './store.ts'
export type { SidebarFilesKey } from './locales.ts'
export type { DirLevel, FilesState, FilesTabState, LevelState } from './store.ts'
export type { FilesInjected, ListWorkspaceDirectory, WorkspaceFilesListRemote } from './face.ts'
export type { FilesBodyProps } from './FilesBody.tsx'
/** This package's copy namespace. */
const NS = 'sidebarFiles'
/**
* Required browser services: the tab registry, the keyed seat, the Remote
* carrier and its namespace, and copy.
*/
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
/**
* Client plugin body: register the type, its dictionaries, then its body.
* @param ctx - client root context carrying the registry, the slots, and the Remote face.
*/
export function apply(ctx: ClientContext): void {
const t = ctx.locale.bind(NS)
ctx.effect(() => ctx.sidebarRightTabs.register(filesDefinition(t)), 'ui-sidebar-files: files type')
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-files: dictionaries')
const store = createFilesStore()
const inject = filesFace(createList(ctx.remote))
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
{ name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject },
FilesBody,
)), 'ui-sidebar-files: files tab body')
}
@@ -0,0 +1,56 @@
/**
* `sidebarFiles` namespace dictionaries, and the namespace's declaration.
*
* The failure lines name what the tree could not list, one code each, because a
* directory that is gone, one outside the workspace, and a path that is not a
* directory each suggest a different next step.
*
* The namespace merge lives with its key set so that any module naming
* `TranslateNS<'sidebarFiles'>` or `PropsLocale<'sidebarFiles'>` needs only this
* file, whichever entry a program loads first.
*/
import type {} from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** File-tree type name, guide entry, row states, and failure lines. */
sidebarFiles: SidebarFilesKey
}
}
/** Simplified Chinese dictionary and key-set source of truth. */
export const zh = {
'type.label': '文件',
'guide.title': '文件',
'guide.description': '浏览这个会话工作区里的文件,点开就能查看。',
loading: '正在读取…',
empty: '空目录',
truncated: '条目太多,只显示了一部分。',
noWorkspace: '这个会话没有工作区目录。',
reload: '重新读取',
'entry.other': '这不是文件或目录,没法打开。',
'error.notFound': '这个目录不在了。可能已被移动或删除。',
'error.outsideWorkspace': '这个目录在工作区之外,侧栏不会读取它。',
'error.notDirectory': '这不是一个目录。',
'error.unavailable': '读取失败:{message}',
} satisfies Record<string, string>
/** Files dictionary key union. */
export type SidebarFilesKey = keyof typeof zh
/** English dictionary, checked against the Chinese key set. */
export const en = {
'type.label': 'Files',
'guide.title': 'Files',
'guide.description': 'Browse the files in this session\'s workspace and open any of them.',
loading: 'Reading…',
empty: 'Empty directory',
truncated: 'Too many entries; showing only some of them.',
noWorkspace: 'This session has no workspace directory.',
reload: 'Reload',
'entry.other': 'Not a file or a directory, so it cannot be opened.',
'error.notFound': 'That directory is gone. It may have been moved or deleted.',
'error.outsideWorkspace': 'That directory is outside the workspace, so the sidebar will not read it.',
'error.notDirectory': 'That is not a directory.',
'error.unavailable': 'Read failed: {message}',
} satisfies Record<SidebarFilesKey, string>
@@ -0,0 +1,165 @@
/**
* The file tree's view state: which directories are expanded, and what each
* loaded level contains.
*
* The tree is not one resource. A directory listing per level, expanded lazily,
* is state the type owns — so it lives in a Slot-standard exclusive store
* (one instance per session), bucketed by tab id because two tabs of this kind
* in one session expand independently.
*
* Writers run between `start` and `forget`: the owner's `signal` is what ends a
* bucket's life, and the face stops dispatching once it aborts.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types'
/**
* One directory's contents, as one expanded level of the tree.
*
* The endpoint's listing also names the directory as a workspace-relative path;
* the tree keys every level by absolute path instead, so the adapter drops it.
*/
export interface DirLevel {
/** The directory's entries, in the endpoint's order. */
readonly entries: readonly WorkspaceDirectoryEntry[]
/** The listing hit the endpoint's entry cap, so entries are missing. */
readonly truncated: boolean
}
/** What one directory level is doing right now. */
export type LevelState =
| { readonly kind: 'loading' }
| { readonly kind: 'ready'; readonly level: DirLevel }
| { readonly kind: 'failed'; readonly failure: RemoteFailure }
/**
* One tab's tree: its root, the levels it has asked for, and what is open.
*
* Every path here is absolute: the root is the session's working directory as
* the Host reports it, and a child is the parent joined with the entry name.
*/
export interface FilesTabState {
/** Absolute path of the workspace root this tree is rooted at. */
root: string
/** Level state by absolute directory path; a path absent here was never asked for. */
levels: Record<string, LevelState>
/** Expanded absolute directory paths, root included. */
expanded: string[]
}
/** Every tab's tree, keyed by tab id. */
export interface FilesState {
byTab: Record<TabId, FilesTabState>
}
/**
* One tab's bucket, which every writer after `start` relies on: the face only
* dispatches while the record's signal is live, and `forget` runs on its abort.
* @param state - the draft.
* @param tabId - the tab being written.
* @returns the tab's tree.
*/
function bucket(state: FilesState, tabId: TabId): FilesTabState {
const tree = state.byTab[tabId]
if (tree === undefined) throw new Error(`ui-sidebar-files: no tree for tab "${tabId}"`)
return tree
}
/** The tree store's write set; every action names the tab it writes. */
type FilesActions = {
start: (draft: FilesState, tabId: TabId, root: string) => void
loading: (draft: FilesState, tabId: TabId, path: string) => void
loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void
failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void
toggled: (draft: FilesState, tabId: TabId, path: string) => void
reset: (draft: FilesState, tabId: TabId) => void
forget: (draft: FilesState, tabId: TabId) => void
}
/**
* Declare the file tree's store.
*
* A factory rather than a shared handle: the registration declares it as an
* exclusive store, so the framework mints one instance per session.
* @returns the store handle to declare on the registration.
*/
export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions> {
return defineStore({
init: (): FilesState => ({ byTab: {} }),
actions: {
/**
* Seed one tab's tree at its workspace root, with the root expanded.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param root - absolute path of the workspace root.
*/
start: (d, tabId: TabId, root: string) => {
d.byTab[tabId] = { root, levels: {}, expanded: [root] }
},
/**
* Mark one directory as being listed.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param path - absolute directory path.
*/
loading: (d, tabId: TabId, path: string) => {
bucket(d, tabId).levels[path] = { kind: 'loading' }
},
/**
* Record one directory's contents.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param path - absolute directory path.
* @param level - the listing to show under it.
*/
loaded: (d, tabId: TabId, path: string, level: DirLevel) => {
bucket(d, tabId).levels[path] = { kind: 'ready', level }
},
/**
* Record why one directory could not be listed.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param path - absolute directory path.
* @param failure - the settled Remote failure.
*/
failed: (d, tabId: TabId, path: string, failure: RemoteFailure) => {
bucket(d, tabId).levels[path] = { kind: 'failed', failure }
},
/**
* Open a collapsed directory, or collapse an open one.
*
* A collapsed level keeps what it loaded, so reopening it draws at once.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param path - absolute directory path.
*/
toggled: (d, tabId: TabId, path: string) => {
const state = bucket(d, tabId)
const at = state.expanded.indexOf(path)
if (at >= 0) state.expanded.splice(at, 1)
else state.expanded.push(path)
},
/**
* Drop every loaded level, keeping what is expanded.
*
* This is the reload gesture's first half: the expanded set says which
* levels to fetch again.
* @param d - draft state.
* @param tabId - the tab being drawn.
*/
reset: (d, tabId: TabId) => {
bucket(d, tabId).levels = {}
},
/**
* Forget one tab's tree, for a tab record that is gone.
* @param d - draft state.
* @param tabId - the tab that went away.
*/
forget: (d, tabId: TabId) => {
d.byTab = Object.fromEntries(Object.entries(d.byTab).filter(([id]) => id !== tabId))
},
},
})
}
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
@@ -0,0 +1,4 @@
/** Pure host half; the whole tab type lives in the browser export. */
/** Host plugin body: the file tree contributes nothing to the host tree. */
export function apply(): void {}
@@ -0,0 +1,48 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../../core/session"
},
{
"path": "../locale"
},
{
"path": "../store"
},
{
"path": "../ui-dockkit"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-session"
},
{
"path": "../ui-sidebar-right"
},
{
"path": "../ui-slots"
},
{
"path": "../../util/workspace-path"
},
{
"path": "../../api/workspace-files/tsconfig.client.json"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-files', ['lib/types/index.js'])