feat(sidebar-files): add lazy workspace file tree tabs
This commit is contained in:
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))
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'])
|
||||
Reference in new issue
Block a user