Просмотр исходного кода

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

imccyu 6 дней назад
Родитель
Сommit
b24ebc8cce

+ 72 - 0
packages/client/ui-sidebar-files/README.md

@@ -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.

+ 72 - 0
packages/client/ui-sidebar-files/README.zh.md

@@ -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 的中止信号忘掉;没有第二个观测源可与之比对。

+ 76 - 0
packages/client/ui-sidebar-files/package.json

@@ -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"
+  ]
+}

+ 128 - 0
packages/client/ui-sidebar-files/src/client/FilesBody.module.css

@@ -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);
+}

+ 192 - 0
packages/client/ui-sidebar-files/src/client/FilesBody.tsx

@@ -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>
+  )
+}

+ 37 - 0
packages/client/ui-sidebar-files/src/client/definition.ts

@@ -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,
+    }],
+  }
+}

+ 149 - 0
packages/client/ui-sidebar-files/src/client/face.ts

@@ -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)
+      },
+    }
+  }
+}

+ 52 - 0
packages/client/ui-sidebar-files/src/client/index.ts

@@ -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')
+}

+ 56 - 0
packages/client/ui-sidebar-files/src/client/locales.ts

@@ -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>

+ 165 - 0
packages/client/ui-sidebar-files/src/client/store.ts

@@ -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 - 0
packages/client/ui-sidebar-files/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 4 - 0
packages/client/ui-sidebar-files/src/index.ts

@@ -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 {}

+ 48 - 0
packages/client/ui-sidebar-files/tsconfig.json

@@ -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"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-sidebar-files/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-files', ['lib/types/index.js'])