From b24ebc8cce461e6df86413ac9cff8df0f0966e89 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:23:17 +0800 Subject: [PATCH] feat(sidebar-files): add lazy workspace file tree tabs --- packages/client/ui-sidebar-files/README.md | 72 +++++++ packages/client/ui-sidebar-files/README.zh.md | 72 +++++++ packages/client/ui-sidebar-files/package.json | 76 +++++++ .../src/client/FilesBody.module.css | 128 ++++++++++++ .../ui-sidebar-files/src/client/FilesBody.tsx | 192 ++++++++++++++++++ .../ui-sidebar-files/src/client/definition.ts | 37 ++++ .../ui-sidebar-files/src/client/face.ts | 149 ++++++++++++++ .../ui-sidebar-files/src/client/index.ts | 52 +++++ .../ui-sidebar-files/src/client/locales.ts | 56 +++++ .../ui-sidebar-files/src/client/store.ts | 165 +++++++++++++++ .../ui-sidebar-files/src/css-modules.d.ts | 6 + packages/client/ui-sidebar-files/src/index.ts | 4 + .../client/ui-sidebar-files/tsconfig.json | 48 +++++ .../client/ui-sidebar-files/tsdown.config.ts | 3 + 14 files changed, 1060 insertions(+) create mode 100644 packages/client/ui-sidebar-files/README.md create mode 100644 packages/client/ui-sidebar-files/README.zh.md create mode 100644 packages/client/ui-sidebar-files/package.json create mode 100644 packages/client/ui-sidebar-files/src/client/FilesBody.module.css create mode 100644 packages/client/ui-sidebar-files/src/client/FilesBody.tsx create mode 100644 packages/client/ui-sidebar-files/src/client/definition.ts create mode 100644 packages/client/ui-sidebar-files/src/client/face.ts create mode 100644 packages/client/ui-sidebar-files/src/client/index.ts create mode 100644 packages/client/ui-sidebar-files/src/client/locales.ts create mode 100644 packages/client/ui-sidebar-files/src/client/store.ts create mode 100644 packages/client/ui-sidebar-files/src/css-modules.d.ts create mode 100644 packages/client/ui-sidebar-files/src/index.ts create mode 100644 packages/client/ui-sidebar-files/tsconfig.json create mode 100644 packages/client/ui-sidebar-files/tsdown.config.ts diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md new file mode 100644 index 0000000000..6e9e305b15 --- /dev/null +++ b/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) + +----- + + +## 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). + + +## 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//`, 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. + + +## 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 + + +- **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. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**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. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md new file mode 100644 index 0000000000..95a1544418 --- /dev/null +++ b/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) + +----- + + +## 注册了什么 + +- **类型**:`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`(接线)。 + + +## 树 + +根是会话的工作目录,读自 `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//`,地址由 `@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,其后才结算的列表什么也不写。 + + +## 模型体验 + +无,因为本包在浏览器里绘制工作区文件树,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;目录列表经 Remote 传输,不会组装模型请求。 + +## 已知限制与暂缓事项 + + +- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单、当前文件高亮或文件系统监听;一层只会因重新读取而变化。 +- **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**运行时不变量:** 不发布 companion。树唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。 diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json new file mode 100644 index 0000000000..0ba064054e --- /dev/null +++ b/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" + ] +} diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css new file mode 100644 index 0000000000..4ae79aa58a --- /dev/null +++ b/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); +} diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx new file mode 100644 index 0000000000..46727638b7 --- /dev/null +++ b/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> + & 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 ( +
  • + + {expanded &&
    } +
  • + ) + } + if (entry.type === 'file') { + return ( +
  • + +
  • + ) + } + return ( +
  • + + {entry.name} + +
  • + ) +} + +/** 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
  • {t('loading')}
  • + } + if (level.kind === 'failed') { + return ( +
  • + {failureLine(t, level.failure)} +
  • + ) + } + const entries = orderEntries(level.level.entries) + return ( + <> + {entries.length === 0 &&
  • {t('empty')}
  • } + {entries.map(entry => )} + {level.level.truncated &&
  • {t('truncated')}
  • } + + ) +} + +/** 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 ( +
    +

    {t('noWorkspace')}

    +
    + ) + } + 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 ( +
    +
    + + {title} + +
    +
    +
    + ) +} diff --git a/packages/client/ui-sidebar-files/src/client/definition.ts b/packages/client/ui-sidebar-files/src/client/definition.ts new file mode 100644 index 0000000000..f967a472a0 --- /dev/null +++ b/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, + }], + } +} diff --git a/packages/client/ui-sidebar-files/src/client/face.ts b/packages/client/ui-sidebar-files/src/client/face.ts new file mode 100644 index 0000000000..a170e897ce --- /dev/null +++ b/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> + +/** + * 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 +} + +/** + * 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>) => FilesInjected { + return ( + sessionId: SessionId, + actions: BoundActions>, + ): FilesInjected => { + /** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */ + const generations = new Map>() + const nextGeneration = (tabId: TabId, path: string): number => { + const byPath = generations.get(tabId) ?? new Map() + 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) + }, + } + } +} diff --git a/packages/client/ui-sidebar-files/src/client/index.ts b/packages/client/ui-sidebar-files/src/client/index.ts new file mode 100644 index 0000000000..0f04544729 --- /dev/null +++ b/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') +} diff --git a/packages/client/ui-sidebar-files/src/client/locales.ts b/packages/client/ui-sidebar-files/src/client/locales.ts new file mode 100644 index 0000000000..1976add093 --- /dev/null +++ b/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 + +/** 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 diff --git a/packages/client/ui-sidebar-files/src/client/store.ts b/packages/client/ui-sidebar-files/src/client/store.ts new file mode 100644 index 0000000000..619ed0ba30 --- /dev/null +++ b/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 + /** Expanded absolute directory paths, root included. */ + expanded: string[] +} + +/** Every tab's tree, keyed by tab id. */ +export interface FilesState { + byTab: Record +} + +/** + * 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 { + 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)) + }, + }, + }) +} diff --git a/packages/client/ui-sidebar-files/src/css-modules.d.ts b/packages/client/ui-sidebar-files/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-sidebar-files/src/index.ts b/packages/client/ui-sidebar-files/src/index.ts new file mode 100644 index 0000000000..154525a7cb --- /dev/null +++ b/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 {} diff --git a/packages/client/ui-sidebar-files/tsconfig.json b/packages/client/ui-sidebar-files/tsconfig.json new file mode 100644 index 0000000000..97ba9441ec --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/client/ui-sidebar-files/tsdown.config.ts b/packages/client/ui-sidebar-files/tsdown.config.ts new file mode 100644 index 0000000000..15e759c557 --- /dev/null +++ b/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'])