diff --git a/AGENTS.md b/AGENTS.md index d07d80e..54c6b46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 1. `pnpm build && pnpm pack` 产出 tarball(与发布产物一致)。 2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。 -3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,并通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Explorer 打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`)——缺失的内置 tab 或 chunk 都会使门禁变红。 +3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,显式展开面板(`openByDefault` 默认关)后通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Files 文件窗口的内嵌树打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`,原地模式:seed 的 home tab 原地切换到文件)——缺失的内置 tab 或 chunk 都会使门禁变红。 本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.6`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。 @@ -131,7 +131,7 @@ interface TabDescriptor { title: string | (() => string) /** 图标:ReactNode 或 (size: number) => ReactNode */ icon?: ReactNode | ((size: number) => ReactNode) - /** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */ + /** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, terminal=40, browser=50 */ order?: number /** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */ hidden?: boolean @@ -145,7 +145,7 @@ interface TabDescriptor { /** * 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。 * 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。 - * 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。 + * 内置策略:git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。 */ dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播 /** @@ -168,20 +168,24 @@ interface TabDescriptor { * 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行 * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行, * 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于 - * 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number - * 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。 + * 布尔开关:`type: 'switch' | 'text' | 'number' | 'select'`,缺省 'switch'; + * text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀; + * select 行是下拉选择:options 声明 `{ value, title, desc?, icon? }`,任一 + * 选项带 icon 时下拉渲染大图标选项卡、收起态同样显示图标,否则单行文本; + * `multi: true`(缺省 false)多选,存储值为选中 value 的数组,按 options + * 顺序提交)。 * v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置): * `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段) * 与 `render`(自定义设置面板,替代行列表)。 */ settings?: { toggles?: readonly { - /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */ + /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */ key: string title: string | (() => string) desc?: string | (() => string) /** 行控件类型;缺省 'switch'(向后兼容)。 */ - type?: 'switch' | 'text' | 'number' + type?: 'switch' | 'text' | 'number' | 'select' /** number 行的提交钳制下限。 */ min?: number /** number 行的提交钳制上限。 */ @@ -190,6 +194,15 @@ interface TabDescriptor { placeholder?: string /** 输入框后的单位后缀(如 'px')。 */ unit?: string + /** select 行的选项(value 为提交值:string | number | boolean)。 */ + options?: readonly { + value: string | number | boolean + title: string | (() => string) + desc?: string | (() => string) + icon?: ReactNode | ((size: number) => ReactNode) + }[] + /** select 行是否多选(缺省 false;存储值为 value 数组)。 */ + multi?: boolean }[] /** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的, * 持久化在 `pluginSettings[]`——不需要宿主 PrefsSchema 字段。 */ @@ -197,11 +210,18 @@ interface TabDescriptor { key: string title: string | (() => string) desc?: string | (() => string) - type?: 'switch' | 'text' | 'number' + type?: 'switch' | 'text' | 'number' | 'select' min?: number max?: number placeholder?: string unit?: string + options?: readonly { + value: string | number | boolean + title: string | (() => string) + desc?: string | (() => string) + icon?: ReactNode | ((size: number) => ReactNode) + }[] + multi?: boolean }[] /** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。 * props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、 @@ -251,7 +271,7 @@ interface TabComponentProps { tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?) visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等) // 以下由内置 tab 使用,外部 tab 可忽略: - expanded?: string[] // explorer 的展开目录集 + expanded?: string[] // 文件树的展开目录集 onToggleDir?: (path: string) => void onReferenceFile?: (path: string) => void onOpenFile?: (path: string) => void @@ -330,8 +350,7 @@ ctx.effect(() => { | id | order | single | hidden | 用途 | |---|---|---|---|---| -| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) | -| `explorer` | 10 | 是 | 否 | 文件资源管理器 | +| `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:开(默认,合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开);关(独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab | | `git` | 20 | 是 | 否 | Git 面板 | | `subagent` | 30 | 是 | 否 | 子代理拓扑 | | `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) | @@ -386,6 +405,11 @@ interface FileViewerProps { truncated?: boolean // fetchStrategy='fsRead' 时 mediaUrl?: string // fetchStrategy='mediaUrl' 时 customData?: unknown // fetchStrategy='custom' 时(load() 的返回值) + // 以下三个为内置文本编辑器与 EditorHost 的内部协作字段(合并模式把 + // 预览/编辑/保存工具栏上移到路径输入框行);外部 viewer 忽略即可: + toolbar?: 'self' | 'host' + onToolbarState?: (state: EditorToolbarState) => void + onToolbarControls?: (controls: EditorToolbarControls | null) => void } ``` @@ -510,7 +534,7 @@ interface BetterSidebarService { readonly version: string /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | - * 'pluginSettings' | 'urlTarget'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ + * 'pluginSettings' | 'urlTarget' | 'settingSelect'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ readonly features: readonly string[] /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 * session 未激活时 state/sessionId 为 undefined。 */ @@ -523,7 +547,10 @@ interface BetterSidebarService { * 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */ activateTab(tabId: string, scope?: SessionScope): void /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名; - * id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */ + * id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件 + * 可并排打开。注意:path 派生 id 只对 openFile/openSidebarFile 的打开成立—— + * 文件窗口的原地切换(editorExplorer 开)经 updateTab 重写 path/title, + * tab id 保持稳定、不再与 path 对应) */ openFile(scope: SessionScope, path: string, title?: string): void } @@ -541,7 +568,7 @@ interface OpenTabSeed { } ``` -> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 +> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 --- @@ -668,12 +695,13 @@ function parseCsv(text: string): string[][] { /* ... */ } better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"): -- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts) +- **`src/client/builtins/`**:6 个内置 tab(editor/git/subagent/terminal/browser/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts) - **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现 - **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化) - **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试 - **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据) - **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护 +- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git`/symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`) - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录) 调试时直接读这些文件即可看到所有 API 的真实用法。 diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 0296ac6..984a1d7 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -1,22 +1,43 @@ /** - * The editor tab host: resolves a file's previewer through the sidebar - * registry (`matchFileViewer`), fetches bytes per the matched viewer's - * fetch strategy, and renders its component — or the shared download pane - * when nothing can render the file. The header shows the file title; the - * editable code/markdown viewers render their own toolbar below it. + * The editor tab host: the single FILES WINDOW. It resolves a file's + * previewer through the sidebar registry (`matchFileViewer`), fetches bytes + * per the matched viewer's fetch strategy, and renders its component — or + * the shared download pane when nothing can render the file. A tab without + * a path (the seeded "Files" home) renders an empty-state hint instead of + * the viewer loading flow; that path-less window IS the file explorer. + * + * The chrome depends on the `editorExplorer` mode (read reactively so + * toggling it re-renders without a reload): + * - merged (in-place): tree click / path-input Enter switch the CURRENT + * tab in place (updateTab rewrites path/title; the tab keeps its id and + * meta, so treeOpen/treeWidth survive the switch); + * - split: they open through `openSidebarFile` (a per-path dedupe tab), + * and a PATH-LESS window is the standalone explorer — it renders ONLY + * the tree panel (search + FileTree, full-window), no editor chrome. + * Editor tabs (with a path) keep the full chrome in both modes. + * The tree's context menu offers the explicit escapes in both modes: open + * in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh + * rightward split of the current pane). * * The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in * editor-load.ts); this component only wires it to the host APIs. */ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { createElement } from 'react' +import clsx from 'clsx' +import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' import { api, mediaUrl, type SessionScope } from './api.ts' import { BinaryDownload } from './binary-download.tsx' import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts' +import { baseName } from './FileTree.tsx' +import { openSidebarFile } from './intercept.tsx' +import { TreePanel } from './TreePanel.tsx' import { t } from './locales.ts' -import type { FileViewerDescriptor } from './service.ts' -import type { SidebarStore } from './state.ts' +import { relativeTo } from './paths.ts' +import { resolveSidebarPath } from './produced-files.ts' +import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts' +import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts' import css from './sidebar.module.css' type EditorLoad = @@ -25,11 +46,156 @@ type EditorLoad = | { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } | { status: 'binary' } -export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) { - const { ctx, store, scope, path, title } = props +/** The docked tree panel's width bounds (drag-resize clamps into them). */ +const TREE_WIDTH_DEFAULT = 240 +const TREE_WIDTH_MIN = 160 +const TREE_WIDTH_MAX = 480 + +/** The tab's persisted meta object (a malformed meta reads as empty). */ +function metaOf(tab: SidebarTab): Record { + return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta) + ? tab.meta as Record + : {} +} + +/** Read the persisted tree-panel flag of one editor tab: an explicit + * boolean meta wins; otherwise path-less tabs (the seeded home) default + * open and file tabs default closed. */ +function treeOpenOf(tab: SidebarTab): boolean { + const treeOpen = metaOf(tab).treeOpen + return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '') +} + +/** Read the persisted tree-panel width (clamped; default 240). */ +function treeWidthOf(tab: SidebarTab): number { + const width = metaOf(tab).treeWidth + return typeof width === 'number' && Number.isFinite(width) + ? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width))) + : TREE_WIDTH_DEFAULT +} + +/** Merge a patch into the tab's persisted meta (rides the layout). */ +function patchMeta(ctx: Context, tab: SidebarTab, patch: Record): void { + ctx.betterSidebar?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } }) +} + +/** Clamp one dock width into the contract range. */ +function clampTreeWidth(value: number): number { + return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value))) +} + +export function EditorHost(props: { + ctx: Context + store: SidebarStore + scope: SessionScope + tab: SidebarTab + expanded: string[] + onToggleDir: (path: string) => void + onReferenceFile: (path: string) => void +}) { + const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props + const path = tab.path ?? '' + const title = tab.title const [load, setLoad] = useState({ status: 'loading' }) + // Reactive prefs read: flipping editorExplorer re-renders this tab with no + // reload. The snapshot is the bare boolean so unrelated store churn never + // re-renders the editor. + const inPlace = useSyncExternalStore( + useCallback((callback: () => void) => store.subscribe(callback), [store]), + useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]), + ) + // A path-less tab shows the empty-state hint in merged mode — and in split + // mode it is the standalone explorer (tree-only, see the render below). + const showEmpty = path === '' + const treeOnly = showEmpty && !inPlace + + /** + * Open a file from THIS window (tree click / search row / path input): + * merged mode switches this tab in place (stable id, meta survives); + * split mode opens a per-path dedupe tab through openSidebarFile. + */ + const openFile = (absolute: string): void => { + if (inPlace) { + ctx.betterSidebar?.updateTab(tab.id, { path: absolute, title: baseName(absolute) }) + } else { + openSidebarFile(ctx, store, scope.sessionId, absolute) + } + } + + /** The context menu's explicit "new tab" escape (per-path dedupe). */ + const openFileNewTab = (absolute: string): void => { + openSidebarFile(ctx, store, scope.sessionId, absolute) + } + + /** + * The context menu's "open to the side": a fresh editor tab (uid id — the + * `'editor:' + path` convention would clash with the id safety net on a + * second side-open of the same file) in a rightward split of THIS pane. + */ + const openFileSide = (absolute: string): void => { + store.reduce((state) => { + const key = treeOf(state, tab.id) + const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key]) + const fresh: SidebarTab = { + id: mintTabId(), + type: 'editor', + title: baseName(absolute), + path: absolute, + meta: { treeOpen: false }, + } + const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false) + return { ...state, [key]: node, activePane: leafId } + }) + } + + // The viewer's toolbar, hoisted into THIS header: the text editor reports + // its state and registers its commands (both null/absent for viewers + // without a toolbar — image, pdf, binary download). + const [toolbar, setToolbar] = useState(null) + const controlsRef = useRef(null) + const onToolbarState = useCallback((next: EditorToolbarState) => { + setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next) + }, []) + const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => { + controlsRef.current = controls + }, []) + + // The docked panel's drag-resize: pointer capture on the handle itself + // (no window listeners — the captured pointer keeps tracking even off the + // handle). Local width while dragging, persisted into meta.treeWidth on + // release. The panel docks right, so dragging LEFT widens it. + const [dragWidth, setDragWidth] = useState(null) + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null) + const treeWidth = dragWidth ?? treeWidthOf(tab) + + const onResizeStart = (event: React.PointerEvent): void => { + event.preventDefault() + // jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents. + event.currentTarget.setPointerCapture?.(event.pointerId) + dragRef.current = { startX: event.clientX, startWidth: treeWidth } + } + const onResizeMove = (event: React.PointerEvent): void => { + const drag = dragRef.current + if (drag === null) return + setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))) + } + const onResizeEnd = (event: React.PointerEvent): void => { + const drag = dragRef.current + if (drag === null) return + dragRef.current = null + setDragWidth(null) + const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)) + if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth }) + } + useEffect(() => { + // A (re)load or a path-less tab clears any hoisted toolbar state — the + // fresh viewer re-registers its own. + setToolbar(null) + // The seeded home tab (no path) never loads a viewer — the empty-state + // hint renders until the user picks a file. + if (showEmpty) return let cancelled = false // Aborts the matched viewer's `load` when the editor tears down (tab // closed, path changed, session switched) or re-matches the viewer. @@ -81,24 +247,176 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se } apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf)) return () => { cancelled = true; controller.abort() } - }, [scope.sessionId, scope.cwd, path, ctx]) + }, [scope.sessionId, scope.cwd, path, ctx, showEmpty]) + + const treeOpen = treeOpenOf(tab) + /** Persist the panel flag on the tab (survives reloads with the layout). */ + const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) } + const saveLabel = toolbar === null ? '' + : toolbar.saveState === 'saving' ? t('loading') + : toolbar.saveState === 'saved' ? t('saved') + : toolbar.saveState === 'failed' ? t('saveFailed') : '' + + // Split mode: the path-less window IS the standalone explorer — the tree + // panel fills the whole tab (search + FileTree, full form), no editor + // chrome. File opens land in new per-path tabs through openFile above. + if (treeOnly) { + return ( +
+ +
+ ) + } return (
- {title} + + {toolbar?.modes === true && ( +
+ + +
+ )} + {toolbar?.dirty === true && } + {toolbar?.editable === true && ( + + )} + {saveLabel !== '' && ( + {saveLabel} + )} + +
+
+
+ {showEmpty &&
{t('editorEmptyHint')}
} + {!showEmpty && load.status === 'loading' &&
{t('loading')}
} + {!showEmpty && load.status === 'error' &&
{load.message}
} + {!showEmpty && load.status === 'binary' && } + {!showEmpty && load.status === 'ready' && createElement(load.viewer.component, { + ctx, store, scope, path, title, + viewerId: load.viewer.id, + content: load.content, + truncated: load.truncated, + mediaUrl: load.mediaUrl, + customData: load.customData, + // The viewer's toolbar always hoists into this host's header. + toolbar: 'host', + onToolbarState, + onToolbarControls, + })} +
+ {treeOpen && ( +
+
+ +
+ )}
- {load.status === 'loading' &&
{t('loading')}
} - {load.status === 'error' &&
{load.message}
} - {load.status === 'binary' && } - {load.status === 'ready' && createElement(load.viewer.component, { - ctx, store, scope, path, title, - viewerId: load.viewer.id, - content: load.content, - truncated: load.truncated, - mediaUrl: load.mediaUrl, - customData: load.customData, - })}
) } + +/** + * The header's path input: shows the current file relative to the session + * cwd (absolute when outside it). Enter resolves the typed path (relative + * input joins onto the cwd — the same resolution `openSidebarFile` uses) + * and opens it through the parent's mode-aware open (in-place switch or a + * per-path dedupe tab); Escape/blur restores the current value. The parent + * keys it by `path` so an in-place switch remounts and reseeds the draft. + */ +function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) { + const { path, cwd, onOpen } = props + const display = path === '' ? '' : relativeTo(cwd ?? '', path) + const [value, setValue] = useState(display) + + const commit = (): void => { + const input = value.trim() + if (input === '' || input === display) { + setValue(display) + return + } + onOpen(resolveSidebarPath(cwd, input)) + // Split mode: the open lands in a NEW/deduped editor tab — THIS tab's + // path stays, so the input falls back to its own display value. (Merged + // mode remounts this input on the new path; the reset is harmless.) + setValue(display) + } + + return ( + { setValue(event.target.value) }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + commit() + } else if (event.key === 'Escape') { + setValue(display) + } + }} + onBlur={() => { setValue(display) }} + /> + ) +} diff --git a/src/client/ExplorerView.tsx b/src/client/FileTree.tsx similarity index 68% rename from src/client/ExplorerView.tsx rename to src/client/FileTree.tsx index e21e8d1..7e85a94 100644 --- a/src/client/ExplorerView.tsx +++ b/src/client/FileTree.tsx @@ -1,20 +1,25 @@ /** - * The file explorer: a lazy VSCode-style tree rooted at the session's - * working directory. Levels load on expansion (one API call per directory), - * directories sort first, hidden entries render dimmed, and the expansion - * set lives in the per-session state. Clicking a file opens an editor tab. + * The controlled file tree behind the files window's tree panel (TreePanel + * wraps it with the search box): a lazy VSCode-style tree rooted at the + * session's working directory. Levels load on expansion (one API call per + * directory), directories sort first, hidden entries render dimmed. The + * expansion set lives in the per-session state (owned by the caller); the + * caller also owns the refresh affordance — a `refreshTick` bump wipes the + * level cache so the visible set reloads. * * Row actions: hovering a row reveals an @-reference button on the far * right (appends `@` to the composer draft), and right-click - * opens a context menu to copy the relative or absolute path (with a brief - * "copied" label replacing the button after a successful write); file rows - * also offer a download action (the host serves raw bytes, binary-safe). + * opens a context menu: file rows offer the caller's open escapes + * (new tab / to the side, only when the callbacks exist) and a download + * action (the host serves raw bytes, binary-safe); every row can copy the + * relative or absolute path (with a brief "copied" label replacing the + * button after a successful write). */ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16, - IconLinkOutline16, IconRefreshOutline16, Menu, writeClipboard, + IconLinkOutline16, Menu, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import { api, downloadUrl, type FsEntry } from './api.ts' import { relativeTo } from './paths.ts' @@ -27,7 +32,7 @@ interface LevelData { } /** Root label: the last path segment (mirror of the host rootLabel). */ -function baseName(path: string): string { +export function baseName(path: string): string { const trimmed = path.replace(/[\\/]+$/, '') const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) return at === -1 ? trimmed : trimmed.slice(at + 1) @@ -36,19 +41,24 @@ function baseName(path: string): string { /** How long the row's "copied" label stays after a successful write. */ const COPIED_MS = 1200 -export function ExplorerView(props: { +export function FileTree(props: { sessionId: string cwd: string | undefined expanded: string[] onToggle: (path: string) => void onOpenFile: (path: string) => void + /** Context-menu "open in a new tab" (file rows; absent → no entry). */ + onOpenFileNewTab?: (path: string) => void + /** Context-menu "open to the side" (file rows; absent → no entry). */ + onOpenFileSide?: (path: string) => void /** Insert `@` into the composer draft. */ onReferenceFile: (path: string) => void + /** Bump to wipe the level cache and reload the visible set. */ + refreshTick: number }) { - const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props + const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick } = props const [data, setData] = useState>({}) const dataRef = useRef(data) - const [refreshTick, setRefreshTick] = useState(0) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */ @@ -69,9 +79,19 @@ export function ExplorerView(props: { }) }, [sessionId, cwd, storeLevel]) + // The caller's refresh tick wipes the cache (declared BEFORE the load + // effect so the reload below sees the empty cache). + const lastTick = useRef(refreshTick) + useEffect(() => { + if (lastTick.current === refreshTick) return + lastTick.current = refreshTick + dataRef.current = {} + setData({}) + }, [refreshTick]) + useEffect(() => { // Load the visible set; already-loaded levels (kept in the cache) are - // not refetched. Only the refresh button wipes the cache. + // not refetched. Only the refresh tick wipes the cache. const root = cwd if (root === undefined) return loadDir(root) @@ -197,64 +217,53 @@ export function ExplorerView(props: { } return ( -
-
- {root === undefined ? t('noSession') : baseName(root)} - -
-
- {root === undefined ? ( -
{t('noSession')}
- ) : ( - <> -
{ openRowMenu(event, root, true) }} - > - - {baseName(root)} - {copiedPath === root - ? {t('copied')} - : ( - - )} -
- {data[root] !== undefined && renderLevel(root, 1)} - - )} -
+
+ {root === undefined ? ( +
{t('noSession')}
+ ) : ( + <> +
{ openRowMenu(event, root, true) }} + > + + {baseName(root)} + {copiedPath === root + ? {t('copied')} + : ( + + )} +
+ {data[root] !== undefined && renderLevel(root, 1)} + + )} {/* The one shared context menu, positioned at the right-click cursor - (portal so the explorer's overflow clip cannot crop it). + (portal so the tree's overflow clip cannot crop it). */} { setRowMenu(null) }} items={[ + // The open escapes head the FILE menu (dirs only get copy). + ...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined + ? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: }] + : []), + ...(rowMenu?.isDir === false && onOpenFileSide !== undefined + ? [{ id: 'open-side', label: t('openFileSide'), icon: }] + : []), // Download applies to files only (the host route refuses directories). ...(rowMenu?.isDir === false ? [{ id: 'download', label: t('download'), icon: }] @@ -266,6 +275,14 @@ export function ExplorerView(props: { const target = rowMenu if (target === null) return setRowMenu(null) + if (id === 'open-new-tab') { + onOpenFileNewTab?.(target.path) + return + } + if (id === 'open-side') { + onOpenFileSide?.(target.path) + return + } if (id === 'download') { downloadFile(target.path) return diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 3212fc5..ae47045 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -403,6 +403,59 @@ width: 76px; } +/* Select rows: the closed anchor is a compact button (selected option's + icon when the options carry icons, plus title and a chevron); the dropdown + itself is the primitives Menu (portal). */ +.selectAnchor { + display: flex; + align-items: center; + gap: 6px; + max-width: 220px; + padding: 4px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-layer-3); + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; + cursor: pointer; +} + +.selectAnchor:hover { + border-color: var(--dsw-alias-label-dimmed); +} + +.selectAnchorIcon { + display: inline-flex; + flex: none; +} + +.selectAnchorText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* One big-icon option card inside the select dropdown. */ +.selectOption { + display: flex; + align-items: center; + gap: 10px; + min-width: 200px; +} + +.selectOptionIcon { + display: inline-flex; + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.selectOptionText { + display: flex; + flex-direction: column; + min-width: 0; +} + .suffix { font-size: 14px; line-height: 22px; diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 72df028..1da35d3 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -41,9 +41,11 @@ import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' import { IconCheckOutline16, + IconChevronDownOutline14, IconPlusOutline16, IconSettingsOutline16, Input, + Menu, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import clsx from 'clsx' @@ -209,6 +211,10 @@ export function FeatureSettingsRows(props: { * display (clamped for numbers, the current pref when the input is * invalid). Optional: rows with no handler keep their draft. */ onCommit?: (toggle: SidebarSettingToggle, raw: string) => string + /** Commit one select row: the picked option's value (single) or the array + * of picked values (`multi: true`). Optional: rows with no handler are + * display-only. */ + onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void /** Explicit value source (v0.12.0+): when given, rows read their values * from it instead of the `prefs` face — plugin-owned rows read their * own blob, so a plugin key can never collide with (or silently read) @@ -216,12 +222,23 @@ export function FeatureSettingsRows(props: { * the latter collides with the inherited Object.prototype.valueOf.) */ valueSource?: (key: string) => unknown }) { - const { toggles, prefs, onToggle, onCommit, valueSource } = props + const { toggles, prefs, onToggle, onCommit, onSelectValue, valueSource } = props const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record)[key]) return (
{toggles.map(toggle => { const title = textOf(toggle.title) + if (toggle.type === 'select') { + return ( + + ) + } if ((toggle.type ?? 'switch') === 'switch') { return (
@@ -302,6 +319,99 @@ function TypedRow(props: { ) } +/** + * One select row: a dropdown over the toggle's declared `options`. When any + * option carries an icon, the dropdown renders big-icon option cards (icon + + * title + desc) and the closed anchor shows the selected option's icon as + * well; without icons both are a single line of text. Single-pick commits the + * option's value and closes; `multi` toggles membership, commits the picked + * values as an array (in options order), and stays open. + */ +function SelectRow(props: { + toggle: SidebarSettingToggle + title: string + value: unknown + onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void +}) { + const { toggle, title, value, onSelectValue } = props + const options = toggle.options ?? [] + const multi = toggle.multi === true + const [open, setOpen] = useState(false) + const hasIcons = options.some(option => option.icon !== undefined) + const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value] + const selected = options.filter(option => picked.includes(option.value)) + + /** Commit one picked option (toggle semantics under multi). */ + const pick = (index: number): void => { + const option = options[index] + if (option === undefined) return + if (!multi) { + onSelectValue?.(toggle, option.value) + setOpen(false) + return + } + const current = Array.isArray(value) ? [...value] : [] + const at = current.indexOf(option.value) + if (at >= 0) current.splice(at, 1) + else current.push(option.value) + // Stable wire order: follow the declared options order, not pick order. + onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value)) + } + + const anchor = ( + + ) + + return ( +
+ + {title} + {textOf(toggle.desc) !== '' && {textOf(toggle.desc)}} + + + ({ + id: String(index), + label: hasIcons + ? ( + + {iconOf(option.icon, 24)} + + {textOf(option.title)} + {textOf(option.desc) !== '' && {textOf(option.desc)}} + + + ) + : textOf(option.title), + }))} + selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined} + selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined} + onSelect={(id) => { pick(Number(id)) }} + onClose={() => { setOpen(false) }} + portal + /> + +
+ ) +} + /** * The secondary settings popup body of one feature (tab or viewer): * - `settings.render` (custom panel) when declared — rendered with the @@ -318,12 +428,14 @@ export function SettingsBody(props: { service: BetterSidebarService onToggle: (toggle: SidebarSettingToggle, next: boolean) => void onCommit: (toggle: SidebarSettingToggle, raw: string) => string + onSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string + onPluginSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void onPluginWrite: (key: string, value: unknown) => void onClose: () => void }) { - const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props + const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props const render = feature.settings?.render if (render !== undefined) { return ( @@ -356,6 +468,7 @@ export function SettingsBody(props: { prefs={prefs} onToggle={onToggle} onCommit={onCommit} + onSelectValue={onSelectValue} /> )} {pluginToggles.length > 0 && ( @@ -364,6 +477,7 @@ export function SettingsBody(props: { prefs={prefs} onToggle={onPluginToggle} onCommit={onPluginCommit} + onSelectValue={onPluginSelectValue} valueSource={(key) => pluginBlob[key]} /> )} @@ -493,6 +607,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { applyPref({ [toggle.key]: next }) } + /** Commit one declaratively-declared select row (the option's value, or an + * array of values under `multi`). */ + const onSelectSetting = (toggle: SidebarSettingToggle, next: unknown): void => { + applyPref({ [toggle.key]: next }) + } + /** * Commit one declaratively-declared text/number row. Numbers are parsed * and clamped to the toggle's declared min/max (an unparsable input falls @@ -807,8 +927,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { prefs={prefs} onToggle={onToggleSetting} onCommit={onCommitSetting} + onSelectValue={onSelectSetting} onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }} onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)} + onPluginSelectValue={(toggle, next) => { applyPluginSetting(settingsFor.id, toggle.key, next) }} onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }} onClose={() => { setSettingsFor(null) }} store={store} diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 2e3377e..50f893c 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -7,7 +7,11 @@ * so this component never fetches or dispatches — it only edits. * * The toolbar (mode toggle / dirty dot / save / status) renders as its own - * row below the host's title bar, VSCode-style. + * row below the host's title bar, VSCode-style — unless the host passes + * `toolbar: 'host'` (the merged editor-explorer mode), in which case this + * component skips the row and reports state + registers commands through + * the FileViewerProps toolbar callbacks so the host's path-input header + * renders the controls instead. */ import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' @@ -24,7 +28,7 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { appendToDraft } from './conversation-draft.ts' import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts' import { t } from './locales.ts' -import type { FileViewerProps } from './service.ts' +import type { EditorToolbarState, FileViewerProps } from './service.ts' import css from './sidebar.module.css' /** Previewable files (rendered output vs source editing). */ @@ -278,8 +282,31 @@ export function TextEditor(props: FileViewerProps) { const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true) const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock + // Host-toolbar mode (the merged editor header renders the controls): skip + // the own toolbar row, report the state after every relevant render (the + // JSON key guards redundant calls), and register the commands on mount. + const hostToolbar = props.toolbar === 'host' + const lastToolbarRef = useRef('') + useEffect(() => { + if (!hostToolbar) return + const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState } + const key = JSON.stringify(state) + if (lastToolbarRef.current === key) return + lastToolbarRef.current = key + props.onToolbarState?.(state) + }) + useEffect(() => { + if (!hostToolbar) return + // `save` reads live refs only, and `setMode` is the stable state setter — + // registering this render's closures is safe for the mount's lifetime. + props.onToolbarControls?.({ setMode, save }) + return () => { props.onToolbarControls?.(null) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hostToolbar]) + return ( <> + {!hostToolbar && (
{(markdown || html) && (
@@ -313,6 +340,7 @@ export function TextEditor(props: FileViewerProps) { )} {saveLabel !== '' && {saveLabel}}
+ )} {editable && ( <> {truncated === true && mode === 'edit' &&
{t('truncation')}
} diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx new file mode 100644 index 0000000..0f7830b --- /dev/null +++ b/src/client/TreePanel.tsx @@ -0,0 +1,121 @@ +/** + * The files window's tree surface: a global file-name search box on top + * (300ms debounce; an in-flight search is aborted by the next keystroke) + * over either the shared controlled FileTree (empty query) or the flat + * result list (relative paths; click opens through the caller's mode-aware + * open). Owns its refresh tick: the icon next to the search input clears + * the tree cache. EditorHost docks it as the tab's right panel (wrapped in + * a drag-resize handle) and provides the file context-menu open escapes. + */ +import { useEffect, useState } from 'react' +import clsx from 'clsx' +import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { api } from './api.ts' +import { FileTree } from './FileTree.tsx' +import { t } from './locales.ts' +import { resolveSidebarPath } from './produced-files.ts' +import css from './sidebar.module.css' + +export function TreePanel(props: { + sessionId: string + cwd: string | undefined + expanded: string[] + onToggle: (path: string) => void + onOpenFile: (path: string) => void + /** File context-menu "open in a new tab" (passed through to FileTree). */ + onOpenFileNewTab?: (path: string) => void + /** File context-menu "open to the side" (passed through to FileTree). */ + onOpenFileSide?: (path: string) => void + onReferenceFile: (path: string) => void + /** Full-window presentation: the panel fills its host instead of docking + * at a fixed width. */ + full?: boolean +}) { + const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, full } = props + const [query, setQuery] = useState('') + const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null) + const [error, setError] = useState(null) + const [refreshTick, setRefreshTick] = useState(0) + + const needle = query.trim() + useEffect(() => { + if (needle === '') { + setResults(null) + setError(null) + return + } + const controller = new AbortController() + const timer = window.setTimeout(() => { + api.fsSearch({ sessionId, cwd }, needle, controller.signal).then((found) => { + setResults(found) + setError(null) + }).catch((failure: unknown) => { + if (controller.signal.aborted) return + setResults(null) + setError(failure instanceof Error ? failure.message : String(failure)) + }) + }, 300) + return () => { + window.clearTimeout(timer) + controller.abort() + } + }, [sessionId, cwd, needle]) + + return ( +
+
+ { setQuery(event.target.value) }} + /> + +
+ {needle === '' ? ( + + ) : ( +
+ {error !== null &&
{error}
} + {error === null && results === null &&
{t('loading')}
} + {error === null && results !== null && results.matches.length === 0 && ( +
{t('editorSearchNoResults')}
+ )} + {error === null && results !== null && results.matches.map(rel => ( + + ))} + {error === null && results?.truncated === true && ( +
{t('editorSearchTruncated')}
+ )} +
+ )} +
+ ) +} diff --git a/src/client/api.ts b/src/client/api.ts index 7dbdda1..e0ae279 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -135,6 +135,10 @@ export const api = { call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal), fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) => call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal), + /** Global recursive file-name search rooted at the session cwd (the editor + * side panel's search box); matches are cwd-relative '/'-separated paths. */ + fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) => + call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal), fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) => call('fs.read', scopePayload(scope, { path }), signal), fsWrite: (scope: SessionScope, path: string, content: string) => diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 049d3e7..e48212d 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -1,17 +1,17 @@ /** - * The 7 built-in tab descriptors: the plugin registers its own pages - * (explorer / git / terminal / browser / subagent / editor / diff) through + * The 6 built-in tab descriptors: the plugin registers its own pages + * (editor / git / terminal / browser / subagent / diff) through * the same {@link BetterSidebarService} external plugins use — eating its * own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) * and mints `terminal:` ids through `createTab`; the browser mints - * `browser:` the same way (no quota). + * `browser:` the same way (no quota). The editor IS the files window + * (the old standalone explorer merged into it). */ -import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../../context-types.ts' import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts' import { t } from '../locales.ts' import { openSidebarFile } from '../intercept.tsx' -import { ExplorerView } from '../ExplorerView.tsx' import { EditorHost } from '../EditorHost.tsx' import { lazyChunkComponent } from '../lazy-chunk.tsx' import { GitView } from '../GitView.tsx' @@ -59,33 +59,52 @@ function uiTerminalCount(state: SidebarState): number { .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length } -/** The 7 built-in tab descriptors. */ +/** The 6 built-in tab descriptors. */ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { return [ { id: 'editor', - title: () => t('editor'), - icon: (size: number) => , - order: -1, - hidden: true, - dedupeKey: (tab) => tab.path, - component: ({ ctx, store, scope, tab }) => ( - - ), - }, - { - id: 'explorer', - title: () => t('explorer'), + // The single files window: an editor tab with no path IS the file + // explorer (empty hint + docked tree); with a path it previews/edits + // the file. Visible in the + menu in the explorer's old slot. + title: () => t('files'), icon: (size: number) => , order: 10, - single: true, - component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( - tab.path, + // Declarative settings: the file-open behavior picker (in-place switch + // vs per-path windows) renders as an iconed select row under the + // editor card's gear in the Side card settings page. + settings: { + toggles: [{ + key: 'editorExplorer', + type: 'select', + title: () => t('editorExplorer'), + desc: () => t('editorExplorerDesc'), + options: [ + { + value: true, + icon: (size: number) => , + title: () => t('editorExplorerMerged'), + desc: () => t('editorExplorerMergedDesc'), + }, + { + value: false, + icon: (size: number) => , + title: () => t('editorExplorerSplit'), + desc: () => t('editorExplorerSplitDesc'), + }, + ], + }], + }, + component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => ( + { /* no-op */ })} - onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }} + onToggleDir={onToggleDir ?? (() => { /* no-op */ })} onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} /> ), diff --git a/src/client/locales.ts b/src/client/locales.ts index c4221d6..58ab930 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -11,10 +11,25 @@ /** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */ export const zh = { + files: '文件', explorer: '资源管理器', git: '源代码管理', terminal: '终端', editor: '编辑器', + editorExplorer: '文件打开方式', + editorExplorerDesc: '控制文件打开方式', + editorExplorerMerged: '合并', + editorExplorerMergedDesc: '文件在同一窗口内原地切换;新窗口默认展开文件树', + editorExplorerSplit: '独立', + editorExplorerSplitDesc: '无路径窗口即资源管理器(仅文件树);文件各自新开窗口(带文件树,默认收起)', + editorTreeToggle: '文件树面板', + editorPathPlaceholder: '输入文件路径(相对会话目录或绝对路径),Enter 打开', + editorSearchPlaceholder: '按文件名搜索…', + editorSearchNoResults: '无匹配文件', + editorSearchTruncated: '结果过多,仅显示部分匹配', + editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览', + openFileNewTab: '在新 Tab 中打开', + openFileSide: '在侧边打开', newTab: '新建标签页', openExplorer: '资源管理器', brokenSymlink: '失效的软链接', @@ -238,10 +253,25 @@ export const zh = { /** The en dictionary (key-set-equal to zh, enforced by the type annotation). */ export const en: Record = { + files: 'Files', explorer: 'Explorer', git: 'Source Control', terminal: 'Terminal', editor: 'Editor', + editorExplorer: 'File open behavior', + editorExplorerDesc: 'Controls how files open', + editorExplorerMerged: 'Merged', + editorExplorerMergedDesc: 'Files switch in place in the same window; new windows start with the tree open', + editorExplorerSplit: 'Separate', + editorExplorerSplitDesc: 'Path-less windows are the standalone explorer (tree only); each file opens its own window (tree docked, closed by default)', + editorTreeToggle: 'File tree panel', + editorPathPlaceholder: 'File path (relative to the session directory or absolute), Enter to open', + editorSearchPlaceholder: 'Search files by name…', + editorSearchNoResults: 'No matching files', + editorSearchTruncated: 'Too many results — showing a partial list', + editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing', + openFileNewTab: 'Open in New Tab', + openFileSide: 'Open to the Side', newTab: 'New tab', openExplorer: 'Explorer', brokenSymlink: 'Broken symlink', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 3bae810..48e630b 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -60,6 +60,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { interceptOpenPath: typeof record.interceptOpenPath === 'boolean' ? record.interceptOpenPath : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath, + editorExplorer: typeof record.editorExplorer === 'boolean' + ? record.editorExplorer + : SIDEBAR_PREFS_DEFAULTS.editorExplorer, titleBarCompat: typeof record.titleBarCompat === 'boolean' ? record.titleBarCompat : SIDEBAR_PREFS_DEFAULTS.titleBarCompat, diff --git a/src/client/service.ts b/src/client/service.ts index bc66f51..69693f1 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -48,14 +48,32 @@ export type { SessionScope } from './api.ts' export type { SidebarPrefs } from '../prefs-shared.ts' /** The row control a declarative setting renders as in the settings popup. */ -export type SidebarSettingToggleType = 'switch' | 'text' | 'number' +export type SidebarSettingToggleType = 'switch' | 'text' | 'number' | 'select' + +/** One option of a `type: 'select'` setting row. */ +export interface SidebarSettingSelectOption { + /** The value written to the setting key when this option is picked + * (JSON-serializable: string / number / boolean). */ + value: string | number | boolean + /** Option title (i18n friendly: string or () => string). */ + title: string | (() => string) + /** Option description (i18n friendly); rendered under the title in the + * icon dropdown. */ + desc?: string | (() => string) + /** Option icon: when ANY option declares one, the dropdown renders + * big-icon option cards and the closed control shows the selected + * option's icon too; without icons both are a single line of text. */ + icon?: ReactNode | ((size: number) => ReactNode) +} /** One declarative setting of a tab/viewer, rendered as a nested row in the * Side card settings page (e.g. the Subagent page's "auto-open when a * subagent appears" switch, or the terminal's custom font rows). `type` * selects the control: 'switch' (default) renders the custom switch, * 'text' a free-form input committed on blur/Enter, 'number' a numeric - * input clamped to `min`/`max`. */ + * input clamped to `min`/`max`, 'select' a dropdown over the declared + * `options` (single-pick writes the option's value; `multi: true` writes + * the array of picked values and defaults to false). */ export interface SidebarSettingToggle { /** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */ key: string @@ -73,6 +91,11 @@ export interface SidebarSettingToggle { placeholder?: string /** Unit suffix rendered after the input (e.g. 'px' for a size row). */ unit?: string + /** Options of a `type: 'select'` row. */ + options?: readonly SidebarSettingSelectOption[] + /** Whether a `type: 'select'` row allows picking several options (the + * stored value is then an array of option values); defaults to false. */ + multi?: boolean } /** Props of a descriptor's custom settings panel (`settings.render`). */ @@ -240,6 +263,33 @@ export interface FileViewerProps { mediaUrl?: string /** custom load() return value (fetchStrategy='custom'). */ customData?: unknown + /** Internal (built-in text editor): 'host' asks the viewer to skip its own + * toolbar row — the editor host's merged-mode header renders it instead, + * fed through the two callbacks below. Viewers that ignore these fields + * render exactly as before. */ + toolbar?: 'self' | 'host' + /** Internal: the viewer reports its toolbar state (mode/dirty/save). */ + onToolbarState?: (state: EditorToolbarState) => void + /** Internal: the viewer registers its toolbar commands on mount (null on + * unmount). */ + onToolbarControls?: (controls: EditorToolbarControls | null) => void +} + +/** The toolbar state a text editor reports to the host's merged-mode header. */ +export interface EditorToolbarState { + /** Whether the preview/edit mode toggle applies (markdown/html). */ + modes: boolean + mode: 'preview' | 'edit' + dirty: boolean + /** Whether saving applies (text content loaded). */ + editable: boolean + saveState: 'idle' | 'saving' | 'saved' | 'failed' +} + +/** The commands the host's merged-mode header sends back to the viewer. */ +export interface EditorToolbarControls { + setMode(mode: 'preview' | 'edit'): void + save(): void } /** Describes one file previewer (builtins register themselves too). */ @@ -434,6 +484,7 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.3' * - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence) * - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render * - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims) + * - 'settingSelect': SidebarSettingToggle type 'select' (options/multi) */ export const SIDEBAR_FEATURES = [ 'badge', @@ -445,6 +496,7 @@ export const SIDEBAR_FEATURES = [ 'tabMeta', 'pluginSettings', 'urlTarget', + 'settingSelect', ] as const /** Run one plugin callback; a throw is logged and never breaks the caller. */ diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 7b6680e..3cfbb6e 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -666,6 +666,8 @@ flex: 1; min-height: 0; overflow-y: auto; + /* Deep trees truncate their labels instead of scrolling horizontally. */ + overflow-x: hidden; padding: 2px 6px 8px; } @@ -677,7 +679,12 @@ display: flex; align-items: center; gap: 6px; + /* border-box keeps the inline indent padding (depth * 22 + 6) INSIDE the + row's 100% width — without it deep rows overflow and force a horizontal + scrollbar on the tree body. */ + box-sizing: border-box; width: 100%; + max-width: 100%; height: 34px; padding: 0 8px; border: none; @@ -716,8 +723,12 @@ } .explorerName { + /* min-width: 0 lets the flex item shrink below its content so the label + truncates with an ellipsis instead of widening the row. */ + min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } /* The hover-revealed @-reference button at the row's far right (and the @@ -799,6 +810,139 @@ white-space: nowrap; } +/* Merged mode (the editorExplorer pref): the header's path input replaces + the plain title, and a toggleable file tree docks at the tab's right edge. */ +.editorPathInput { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxs-12); +} + +.editorPathInput:focus { + outline: none; + border-color: var(--dsw-alias-border-l2); +} + +/* The tree-panel toggle in the header: active = panel open (the selected + fill of the tab strip). */ +.editorTreeToggleActive { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-active); +} + +.editorBody { + flex: 1; + min-height: 0; + display: flex; +} + +.editorMain { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* The docked file tree: the wrapper owns the (drag-adjustable) width and + the hairline separation; the resize handle is a wider invisible strip on + its left edge with a hover affordance. */ +.editorTreeDock { + position: relative; + flex: none; + min-height: 0; + display: flex; + border-left: 1px solid var(--dsw-alias-border-l1); +} + +.editorTreeResize { + position: absolute; + top: 0; + bottom: 0; + /* Inside the dock (left edge): pointer capture on the handle keeps the + drag alive even when the pointer leaves the strip. */ + left: 0; + width: 6px; + cursor: col-resize; + touch-action: none; + z-index: 3; +} + +.editorTreeResize:hover { + background: var(--dsw-alias-border-l2); +} + +.editorTreePanel { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* The tree panel's full-window form (the `full` prop): fill the tab body. */ +.editorTreePanelFull { + flex: 1; +} + +.editorTreeSearch { + flex: none; + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-bottom: 1px solid var(--dsw-alias-border-l1); +} + +.editorSearchInput { + flex: 1; + min-width: 0; + height: 26px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxs-12); +} + +.editorSearchInput:focus { + outline: none; + border-color: var(--dsw-alias-border-l2); +} + +.editorSearchHint { + padding: 8px 12px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-tertiary); +} + +.editorSearchResult { + display: block; + width: 100%; + padding: 4px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxs-12); + text-align: left; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editorSearchResult:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + .editorStatus { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); diff --git a/src/client/state.ts b/src/client/state.ts index 8656ddc..5730bcf 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -13,8 +13,8 @@ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts' import { isNarrowWidth } from './breakpoints.ts' /** - * Tab type identifier. Builtins register their ids (explorer / git / editor - * / terminal / subagent / diff) through the sidebar service; external + * Tab type identifier. Builtins register their ids (editor / git / terminal + * / subagent / browser / diff) through the sidebar service; external * plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so * the registry stays open. */ @@ -25,7 +25,7 @@ export type SidebarDiffRef = | { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean } | { kind: 'commit'; hash: string; hashFull: string; subject: string } -/** One open tab. `path` carries the file (editor) or is absent (explorer/git); +/** One open tab. `path` carries the file (editor) or is absent (git/terminal); * `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries * plugin-owned JSON-serializable state, preserved across reloads. */ export interface SidebarTab { @@ -105,6 +105,13 @@ function uid(prefix: string): string { return `${prefix}:${nextIdCounter}` } +/** Mint a fresh uid-based tab id. The `'editor:' + path` convention only + * covers openSidebarFile opens (per-path dedupe); opens that must not + * dedupe (the tree's "open to the side") mint through here. */ +export function mintTabId(): string { + return uid('tab') +} + /** * The largest numeric suffix across a raw persisted state's counter ids * (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and @@ -139,17 +146,24 @@ function maxCounterId(parsed: unknown): number { return max } -/** A fresh default state: one explorer tab in one pane, open per the caller's +/** The default tab a fresh session seeds. */ +export type DefaultSeed = 'editor-home' | 'none' + +/** A fresh default state: one seeded tab in one pane, open per the caller's * preference. `width` is the caller's preferred panel width (default * PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default * true); the store seeds new sessions from the user's side card prefs. - * `seedExplorer` places the default explorer tab — the store passes false - * when the user disabled the explorer tab type in settings, so a fresh - * session starts with an empty pane instead of a tab they turned off. */ -export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState { + * `seed` picks the seeded tab: 'editor-home' places the EMPTY files window + * (an editor tab with no path whose tree panel starts open, + * `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the + * file explorer page — and 'none' starts with an empty pane (the store + * passes it when the user disabled the editor tab type in settings). */ +export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState { const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null } - if (seedExplorer) { - leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }] + if (seed === 'editor-home') { + // No path: the editor host renders its empty-state hint and the docked + // tree panel (treeOpen defaults open for path-less tabs; meta pins it). + leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }] leaf.active = leaf.tabs[0]!.id } // The bottom panel starts closed with an empty pane (its welcome cards @@ -764,19 +778,21 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { // New sessions seed from the user's side card prefs: the width is the // chosen percent of the window (clamped to the panel floor and the // viewport so a huge percent can never crush the app shell), the panel - // starts open only when the preference says so, and the default explorer - // tab is skipped when the user disabled the explorer tab type. On a - // NARROW viewport a brand-new session starts collapsed instead — the - // panel is a full-screen drawer there, and auto-opening it on first - // paint would cover the conversation before the user asked. Only the - // first seeding is affected: once the user expands the drawer, - // `panelOpen: true` persists like any other state. + // starts open only when the preference says so, and the seed tab is the + // empty files window (tree panel open) in BOTH editorExplorer modes — a + // disabled editor type seeds nothing. On a NARROW viewport a brand-new + // session starts collapsed instead — the panel is a full-screen drawer + // there, and auto-opening it on first paint would cover the conversation + // before the user asked. Only the first seeding is affected: once the + // user expands the drawer, `panelOpen: true` persists like any other + // state. const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined const width = viewport === undefined ? PANEL_DEFAULT : defaultWidthFor(viewport, prefs.defaultWidthPercent) const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport)) - return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false) + const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home' + return makeDefaultState(width, openByDefault, seed) } /** @@ -891,6 +907,21 @@ function sanitizeNode(node: unknown, seen: Set, reid: Map at view time and recovers if its plugin loads later. if (typeof candidate.type !== 'string') return undefined + // The standalone explorer tab type merged INTO the editor (the single + // files window): a persisted explorer tab reopens as an editor home + // tab — no path, tree panel open (an existing meta object survives). + if (candidate.type === 'explorer') { + const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta) + ? candidate.meta as Record + : undefined + tabs.push({ + id: candidate.id, + type: 'editor', + title: 'Files', + meta: { treeOpen: true, ...meta }, + }) + continue + } // `meta` is plugin-owned JSON-serializable state (v0.12.0+): the // persisted value already went through JSON.parse, so it is inherently // serializable — carry it through verbatim (absent on older states). diff --git a/src/config.ts b/src/config.ts index bc1f0ff..f7b3a66 100644 --- a/src/config.ts +++ b/src/config.ts @@ -99,7 +99,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve /** Schemastery schema for the user-facing preferences (validated by the settings service). */ export const PrefsSchema: z = z.object({ - openByDefault: z.boolean().default(true), + openByDefault: z.boolean().default(false), defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), autoOpenSubagent: z.boolean().default(true), autoOpenJobs: z.boolean().default(true), @@ -108,6 +108,7 @@ export const PrefsSchema: z = z.object({ terminalFontFamily: z.string().default(''), terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT), interceptOpenPath: z.boolean().default(true), + editorExplorer: z.boolean().default(true), titleBarCompat: z.boolean().default(false), titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT), htmlViewerNoSandbox: z.boolean().default(false), diff --git a/src/fs-search.ts b/src/fs-search.ts new file mode 100644 index 0000000..597bbb0 --- /dev/null +++ b/src/fs-search.ts @@ -0,0 +1,86 @@ +/** + * Recursive file-name search for the editor's merged-mode side panel. + * Streams the tree with opendir and matches the query as a case-insensitive + * substring of each entry's NAME (paths stay relative to the search root — + * the client resolves them against the session cwd). No .gitignore semantics + * (this is a name lookup, not a code search), but `.git` directories are + * skipped outright (VCS internals are never useful results) and symlink + * directories are NOT descended (cycle safety). + * + * Two performance budgets bound the walk: `maxMatches` (the client renders + * the flat list) and `maxVisited` (a runaway tree — a home directory root, + * a node_modules forest — must not stall the host). Exceeding either stops + * early with `truncated: true`. + */ +import { opendir } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' + +/** One search: the relative paths of the matching entries (dirs included so + * the client can hint where matches live) plus the truncation flag. */ +export interface FsSearchResult { + matches: string[] + truncated: boolean +} + +/** Search budgets (both injectable for tests). */ +export interface FsSearchOptions { + /** Row cap of the result list (default 200). */ + maxMatches?: number + /** Total entries visited before the walk gives up (default 100_000). */ + maxVisited?: number +} + +const DEFAULT_MAX_MATCHES = 200 +const DEFAULT_MAX_VISITED = 100_000 + +/** + * Search `root` recursively for entries whose name contains `query` + * (case-insensitive). + * @param root - absolute search root. + * @param query - the name substring; empty matches nothing. + * @param opts - budget overrides (tests). + * @returns the matching paths RELATIVE to `root` ('/'-separated), sorted, + * plus whether a budget cut the walk short. An unreadable level is skipped + * (permission errors never fail the whole search). + */ +export async function searchFiles(root: string, query: string, opts: FsSearchOptions = {}): Promise { + const needle = query.trim().toLowerCase() + if (needle === '') return { matches: [], truncated: false } + const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES + const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED + + const matches: string[] = [] + let visited = 0 + let truncated = false + + const walk = async (dir: string): Promise => { + if (truncated) return + const level = await opendir(dir).catch(() => undefined) + if (level === undefined) return + for await (const dirent of level) { + visited += 1 + if (visited > maxVisited) { + truncated = true + return + } + // .git is VCS-internal noise: never matched, never descended. + if (dirent.isDirectory() && dirent.name === '.git') continue + if (dirent.name.toLowerCase().includes(needle)) { + matches.push(join(relative(root, dir), dirent.name)) + if (matches.length >= maxMatches) { + truncated = true + return + } + } + // Descend real directories only: a symlinked directory may point back + // up the tree (cycle). + if (dirent.isDirectory() && !dirent.isSymbolicLink()) { + await walk(join(dir, dirent.name)) + if (truncated) return + } + } + } + await walk(root) + // '/' separators on every platform: the client joins onto the cwd itself. + return { matches: matches.sort().map(path => path.split(sep).join('/')), truncated } +} diff --git a/src/index.ts b/src/index.ts index a6cf980..714410e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import { type SidebarPrefs, } from './config.ts' import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' +import { searchFiles } from './fs-search.ts' import { decodeHtmlUrl } from './html-route.ts' import { extractFrameAncestors } from './browser-probe.ts' import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts' @@ -213,6 +214,14 @@ function buildApi( const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path')) return listDirectory(target, resolved.listLimit) }, + 'fs.search': async (payload) => { + // The editor side panel's global name search: rooted at the session + // cwd (not caller-targetable — the walk is unbounded by design and + // must never escape the workspace), budgeted inside searchFiles. + const { cwd } = cwdOf(payload) + const query = requireString(payload, 'query') + return searchFiles(cwd, query) + }, 'fs.read': async (payload) => { const { cwd } = cwdOf(payload) // Relative paths are git-derived (status/diff report repo-root-relative diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 42986a4..5d6c6a2 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -60,6 +60,15 @@ export interface SidebarPrefs { * own enable switch gates it too (both must be on for the takeover). */ interceptOpenPath: boolean + /** + * Whether the editor tab runs in merged mode: a path input replaces the + * plain header and a toggleable file-tree panel (with a global name + * search) docks at the tab's right edge. On by default; also makes brand + * new sessions seed an empty editor tab (tree panel open) instead of the + * explorer tab. The switch lives under the editor card's gear in the + * Side card settings; off restores the pre-merge editor exactly. + */ + editorExplorer: boolean /** * Position compatibility mode: reserves space at the top for the native * Windows title bar (drawn at the window's top-right corner over the web @@ -156,7 +165,7 @@ export interface SidebarPrefs { /** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */ export const WIDTH_PERCENT_MIN = 20 export const WIDTH_PERCENT_MAX = 60 -export const WIDTH_PERCENT_DEFAULT = 30 +export const WIDTH_PERCENT_DEFAULT = 35 /** Range contract of {@link SidebarPrefs.terminalFontSize}. */ export const TERMINAL_FONT_SIZE_MIN = 9 @@ -170,7 +179,7 @@ export const TITLE_BAR_STRIP_DEFAULT = 40 /** Fallback prefs used whenever the settings document is unreachable or malformed. */ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { - openByDefault: true, + openByDefault: false, defaultWidthPercent: WIDTH_PERCENT_DEFAULT, autoOpenSubagent: true, autoOpenJobs: true, @@ -179,6 +188,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { terminalFontFamily: '', terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, interceptOpenPath: true, + editorExplorer: true, titleBarCompat: false, titleBarStripPx: TITLE_BAR_STRIP_DEFAULT, htmlViewerNoSandbox: false, diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 20ed655..80908a3 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -1,5 +1,5 @@ /** - * Built-in registration tests: the plugin registers 7 tabs and 6 file + * Built-in registration tests: the plugin registers 6 tabs and 6 file * viewers through the same service external plugins use (dogfooding); * the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer, * and the html sandbox settings pin the registry's behavior. (Office @@ -24,21 +24,24 @@ function setup(): { service: ReturnType; stor } describe('built-in tab registrations', () => { - it('registers the 7 built-in tabs', () => { + it('registers the 6 built-in tabs', () => { const { service } = setup() expect(service.getTabs().map(t => t.id).sort()).toEqual( - ['browser', 'diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'], + ['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'], ) }) - it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => { + it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => { const { service } = setup() - expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor']) + expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff']) + const editor = service.getTab('editor') + expect(editor?.hidden).toBe(false) + expect(editor?.order).toBe(10) }) it('single-instance tabs use the single sugar', () => { const { service } = setup() - for (const id of ['explorer', 'git', 'subagent']) { + for (const id of ['git', 'subagent']) { expect(service.getTab(id)?.single).toBe(true) } }) @@ -49,6 +52,19 @@ describe('built-in tab registrations', () => { expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs']) }) + it('the editor tab declares its merged-mode (embedded file tree) setting', () => { + const { service } = setup() + const toggles = service.getTab('editor')?.settings?.toggles ?? [] + expect(toggles.map(t => t.key)).toEqual(['editorExplorer']) + expect(toggles[0]?.title).toBeDefined() + expect(toggles[0]?.desc).toBeDefined() + // The merged mode is an iconed select (merged vs separate), not a switch. + expect(toggles[0]?.type).toBe('select') + const options = toggles[0]?.options ?? [] + expect(options.map(o => o.value)).toEqual([true, false]) + expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true) + }) + it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => { const { service } = setup() const toggles = service.getTab('terminal')?.settings?.toggles ?? [] diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts index 517f529..5b2c54a 100644 --- a/tests/e2e/drag-layout.e2e.ts +++ b/tests/e2e/drag-layout.e2e.ts @@ -83,28 +83,8 @@ interface FrameSample { test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => { await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) - // The seeded session opens the right panel by default: the layout push - // variable becomes live once the panel mounts (session activation lags - // the shell render). - await expect - .poll(async () => ( - await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) - ), { timeout: 90_000 }) - .not.toBe('') - - // The width drag strip is the panel's left-edge hit strip. There is no - // dedicated hook (the skinning contract is token-driven), so locate it - // semantically: the only `cursor: col-resize` element in the sidebar. - const stripBox = await page.evaluate(() => { - const host = document.querySelector('[data-dsh-better-sidebar]') - if (host === null) return null - const found = [...host.querySelectorAll('*')] - .find(el => getComputedStyle(el).cursor === 'col-resize') - if (found === undefined) return null - const r = found.getBoundingClientRect() - return { x: r.x, y: r.y, width: r.width, height: r.height } - }) - expect(stripBox, 'the width drag strip must be present (cursor: col-resize)').not.toBeNull() + const sidebar = page.locator('[data-dsh-better-sidebar]') + await expect(sidebar).toBeAttached({ timeout: 90_000 }) // Dismiss whatever onboarding takeover is present (same dance as the mount // lane), so the pointer can reach the strip without a masking overlay. @@ -131,12 +111,72 @@ test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', as if (!dismissed) break } + // openByDefault defaults OFF: a fresh session's panel starts collapsed, and + // the collapsed layout push still writes `--dsh-sidebar-width: 0px` — so the + // geometry checks below are meaningless until the panel is expanded through + // the toggle cluster. + const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' }) + await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1) + await expandButton.click() + + // The layout push variable becomes live (non-zero) once the panel opens + // (session activation lags the shell render). + await expect + .poll(async () => { + const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) + return value !== '' && value !== '0px' + }, { timeout: 90_000 }) + .toBe(true) + + // The width drag strip is the panel's left-edge hit strip. There is no + // dedicated hook (the skinning contract is token-driven), so locate it + // semantically among the sidebar's `cursor: col-resize` elements — the files + // window's tree-dock handle matches too, but the panel strip is always the + // LEFTMOST one (the dock sits at the panel's right edge). + const locateStrip = `(() => { + const host = document.querySelector('[data-dsh-better-sidebar]') + if (host === null) return null + const boxes = [...host.querySelectorAll('*')] + .filter(el => getComputedStyle(el).cursor === 'col-resize') + .map(el => el.getBoundingClientRect()) + .filter(r => r.width > 0 && r.height > 0) + .sort((a, b) => a.x - b.x) + const r = boxes[0] + if (r === undefined) return null + const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) + return { + x: r.x, y: r.y, width: r.width, height: r.height, + varWidth: Number.isNaN(varWidth) ? 0 : varWidth, + innerWidth: window.innerWidth, + } + })()` + type StripBox = { x: number; y: number; width: number; height: number; varWidth: number; innerWidth: number } + // The panel SLIDES IN from the right on expand: locating the strip before + // the open transition settles captures a mid-animation box at the viewport + // edge, and the drag lands off-panel. Wait until the strip sits at the + // pushed layout edge (its right edge ≈ innerWidth - the push variable). + await expect + .poll(async () => { + const box = await page.evaluate(locateStrip) + if (box === null) return false + return Math.abs((box.x + box.width) - (box.innerWidth - box.varWidth)) <= 8 + }, { timeout: 30_000 }) + .toBe(true) + const stripBox = await page.evaluate(locateStrip) + expect(stripBox, 'the width drag strip must be present (cursor: col-resize)').not.toBeNull() + // Instrument a per-frame sampler BEFORE the drag begins. await page.evaluate(() => { type Sample = FrameSample const samples: Sample[] = [] - const strip = [...document.querySelectorAll('*')] - .find(el => getComputedStyle(el).cursor === 'col-resize') + // Same rule as the strip locator above: the LEFTMOST col-resize element + // inside the sidebar host (the tree-dock handle is further right). + const host = document.querySelector('[data-dsh-better-sidebar]') + const strip = host === null + ? null + : [...host.querySelectorAll('*')] + .filter(el => getComputedStyle(el).cursor === 'col-resize') + .sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x)[0] // The conversation column: the grid item the layout push squeezes (the // same nth-child(2) layout.css targets for the vertical push; its right // edge is where the width push lands). diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts index c0412e1..172063f 100644 --- a/tests/e2e/mount.e2e.ts +++ b/tests/e2e/mount.e2e.ts @@ -13,10 +13,13 @@ * plugin's `[data-dsh-better-sidebar]` host mount; * 3. asserts the plugin's crash markers never appear (no RenderBoundary / * fail() strips, no `pageerror`, no plugin-prefixed console errors); - * 4. sweeps every built-in tab (Explorer / Source Control / Tasks / - * Terminal / Browser) — including the lazily-fetched terminal chunk — - * and then opens a seeded file through the Explorer to force the - * lazily-fetched editor chunk (client-editor.js) to load as well. + * 4. expands the collapsed panel (openByDefault defaults off), sweeps every + * built-in tab (Files / Source Control / Tasks / Terminal / Browser) — + * including the lazily-fetched terminal chunk — and then opens a seeded + * file through the Files window's tree (in-place mode: the seeded home + * tab itself switches to the file, no new tab), while a response wait + * armed before goto proves the lazily-fetched editor chunk + * (client-editor.js) loaded. * * Deterministic by construction: every wait is on a DOM/network marker, the * suite is serial (one server instance), and any crash trips the very next @@ -35,8 +38,8 @@ if (!BASE_URL) { /** Workspace the sidebar renders against (created by the lane's seeding). */ const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace') -/** A file seeded into the workspace, opened through the Explorer to force the - * lazily-packed editor chunk (client-editor.js) to load. */ +/** A file seeded into the workspace, opened through the Files window's tree to + * exercise the file-open path (editor chunk = client-editor.js). */ const SEEDED_FILE = 'hello.txt' /** @@ -47,7 +50,7 @@ const SEEDED_FILE = 'hello.txt' const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/] /** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */ -const BUILTIN_TABS = ['Explorer', 'Source Control', 'Tasks', 'Terminal', 'Browser'] +const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser'] let api: APIRequestContext @@ -91,6 +94,15 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async // Load the shell. The app renders into #root; the plugin appends its own // [data-dsh-better-sidebar] host once its client half activates. + // + // The editor chunk (client-editor.js) loads as soon as ANY files-window tab + // renders — the seeded home tab mounts the moment the panel expands, long + // before the tree click below — so the response wait must be armed BEFORE + // goto, or it misses the fetch and times out. + const editorChunk = page.waitForResponse( + (response) => response.url().includes('/sidebar/bundle/editor.js'), + { timeout: 120_000 }, + ) await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') @@ -134,6 +146,12 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async const tabBar = sidebar.locator('[title]') await expect(tabBar.first()).toBeAttached({ timeout: 90_000 }) + // openByDefault defaults OFF: a fresh session's panel starts collapsed. + // Expand it through the toggle cluster before the layout push can apply. + const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' }) + await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1) + await expandButton.click() + // The skinning contract is token-driven (AGENTS.md §8): the panels consume // `--dsw-alias-bg-layer-1`, so switching a skin re-skins the sidebar with // no per-skin code. The layout push variable must be live once the panel @@ -179,22 +197,36 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async await assertNoCrash() } - // The editor tab is hidden from the + menu — its CodeMirror chunk - // (client-editor.js) only loads when a file is opened. Exercise that path - // explicitly: reopen Explorer, open the seeded file, and require the chunk - // round-trip, so a missing/corrupt editor chunk fails the lane. - await newTabButton.click() - const explorerItem = page.getByRole('menuitem', { name: 'Explorer' }).first() - await expect(explorerItem, 'Explorer must be re-openable for the editor-chunk probe').toHaveCount(1) - await explorerItem.click() - const editorChunk = page.waitForResponse( - (response) => response.url().includes('/sidebar/bundle/editor.js'), - { timeout: 30_000 }, - ) - const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]`) - await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the Explorer tree`).toHaveCount(1, { timeout: 30_000 }) - await fileRow.click() + // The editor chunk (client-editor.js) only loads when a files-window tab + // renders. Exercise the file-open path explicitly through the Files window's + // own tree: the seeded home tab ("Files") is already open with its tree + // panel pinned — activate it from the tab strip, open the seeded file, and + // require the chunk round-trip (armed before goto), so a missing/corrupt + // editor chunk fails the lane. + // Tab-strip tabs carry `draggable="true"`; the always-mounted (hidden) + // bottom panel's empty-pane welcome cards repeat the + menu labels with + // `title="Files"`, so a bare `[title="Files"]` match is ambiguous. + const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first() + await expect(filesTab, 'the seeded files-window home tab must be in the tab strip').toHaveCount(1) + await filesTab.click() + // Inactive tabs stay mounted (display:none); only the ACTIVE files + // window's embedded tree is visible — match the visible row. + const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`) + await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 }) + // Click near the row's LEFT edge: hovering reveals an @-reference button at + // the row's right end, and a center click on a narrow dock lands on it + // (referencing the file into the composer instead of opening it). + await fileRow.click({ position: { x: 8, y: 8 } }) await editorChunk + // In-place mode (editorExplorer default): the SAME tab switches to the + // file — its title is rewritten, no new tab appears. One "Files" tab + // remains: the second files window the sweep opened via the + menu. + await expect( + sidebar.locator('[title="Files"][draggable="true"]'), + 'in-place mode rewrites the activated home tab instead of opening a new one', + ).toHaveCount(1) + const pathInput = sidebar.locator('input[placeholder^="File path"]:visible') + await expect(pathInput, 'the files window header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`)) await page.waitForTimeout(1_500) await assertNoCrash() diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx new file mode 100644 index 0000000..f01bb01 --- /dev/null +++ b/tests/editor-host.spec.tsx @@ -0,0 +1,286 @@ +/** + * EditorHost (the files window): in merged (in-place) mode a path-less tab + * renders the empty-state hint with the tree dock open, and the header's + * tree toggle persists its flag through ctx.betterSidebar.updateTab + * (meta.treeOpen rides the tab's persisted layout). The editorExplorer pref + * controls FILE-OPEN behavior — in-place rewrites the current tab via + * updateTab, split opens a per-path dedupe tab via openSidebarFile — and in + * split mode a PATH-LESS window becomes the standalone explorer (tree panel + * only, no editor chrome); file tabs keep the full chrome in both modes. + */ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { createElement, useEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import type { Context } from '../src/context-types.ts' +import { EditorHost } from '../src/client/EditorHost.tsx' +import { createBetterSidebarService } from '../src/client/service.ts' +import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +/** A store with the seeded editor-home tab (default prefs: in-place mode). */ +function setup(): { + store: ReturnType + ctx: Context + homeTab: () => SidebarTab +} { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + // The openTab path needs a registered editor descriptor (dedupe by path). + service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null }) + store.setSession('editor-home-session') + const homeTab = (): SidebarTab => + allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + .find(tab => tab.type === 'editor' && tab.path === undefined)! + // openSidebarFile reads the session cwd from ctx.sessions. + const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' } + const ctx = { + betterSidebar: service, + sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } }, + } as unknown as Context + return { store, ctx, homeTab } +} + +/** Mount the host for one tab; returns the container and an unmount helper. */ +function mountHost(ctx: Context, store: ReturnType, tab: () => SidebarTab): { + container: HTMLDivElement + rerender: () => void + unmount: () => void +} { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + const render = (): void => { + root.render(createElement(EditorHost, { + ctx, + store, + scope: { sessionId: 'editor-home-session' }, + tab: tab(), + expanded: [], + onToggleDir: () => {}, + onReferenceFile: () => {}, + })) + } + act(render) + return { + container, + // The real app re-renders the host with the fresh tab on every store + // change (Sidebar subscribes); mirror that after mutating the store. + rerender: () => { act(render) }, + unmount: () => { + act(() => { root.unmount() }) + container.remove() + }, + } +} + +/** Type into the controlled path input (native setter) and press Enter. */ +function typeAndCommit(input: HTMLInputElement, value: string): void { + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + act(() => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) +} + +describe('EditorHost (files window)', () => { + it('a path-less tab renders the empty-state hint with the tree panel open', () => { + const { store, ctx, homeTab } = setup() + const { container, unmount } = mountHost(ctx, store, homeTab) + try { + const html = container.innerHTML + // The empty-state hint renders instead of the viewer loading flow. + expect(html).toContain('Pick a file from the tree panel') + expect(html).not.toContain('Loading…') + // The header carries the path input and the pressed tree toggle; the + // docked panel (search box) is open by default for path-less tabs. + expect(container.querySelector('input')).not.toBeNull() + const toggle = container.querySelector('button[aria-pressed]') + expect(toggle?.getAttribute('aria-pressed')).toBe('true') + // No cwd: the embedded tree renders its no-session placeholder + // instead of touching the network. + expect(html).toContain('Select a conversation') + } finally { + unmount() + } + }) + + it('the tree toggle persists meta.treeOpen through updateTab', () => { + const { store, ctx, homeTab } = setup() + expect(homeTab().meta).toEqual({ treeOpen: true }) + const { container, rerender, unmount } = mountHost(ctx, store, homeTab) + try { + act(() => { + container.querySelector('button[aria-pressed]')! + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(homeTab().meta).toEqual({ treeOpen: false }) + // The store change re-renders the host with the fresh tab (Sidebar's + // subscription in the real app); the second click flips it back. + rerender() + expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('false') + act(() => { + container.querySelector('button[aria-pressed]')! + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(homeTab().meta).toEqual({ treeOpen: true }) + } finally { + unmount() + } + }) + + it('in-place mode: the path input Enter switches the CURRENT tab (stable id, meta kept)', () => { + const { store, ctx, homeTab } = setup() + const { container, unmount } = mountHost(ctx, store, homeTab) + try { + const before = homeTab() + typeAndCommit(container.querySelector('input')!, '/tmp/a.ts') + // The same tab id now carries the file (homeTab's path-less finder no + // longer matches — look the tab up by id). + const after = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + .find(tab => tab.id === before.id)! + expect(after.id).toBe(before.id) + expect(after.path).toBe('/tmp/a.ts') + expect(after.title).toBe('a.ts') + expect(after.meta).toEqual({ treeOpen: true }) + // No new tab landed. + expect(allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)).toHaveLength(1) + } finally { + unmount() + } + }) + + it('split mode: a file tab\'s path input Enter opens a NEW per-path tab; the source tab keeps its path', () => { + const { store, ctx } = setup() + store.setPrefs({ ...store.getPrefs(), editorExplorer: false }) + ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' }) + const fileTab = (): SidebarTab => + allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + .find(tab => tab.path === '/tmp/a.ts')! + const { container, unmount } = mountHost(ctx, store, fileTab) + try { + typeAndCommit(container.querySelector('input[placeholder^="File path"]')!, '/tmp/b.ts') + const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + // home + a.ts + b.ts + expect(tabs).toHaveLength(3) + expect(fileTab().path).toBe('/tmp/a.ts') + const opened = tabs.find(tab => tab.path === '/tmp/b.ts')! + expect(opened.type).toBe('editor') + expect(opened.title).toBe('b.ts') + expect(opened.id).toBe('editor:/tmp/b.ts') + } finally { + unmount() + } + }) + + it('split mode: the path-less window is the standalone explorer (tree only, no chrome)', () => { + const { store, ctx, homeTab } = setup() + store.setPrefs({ ...store.getPrefs(), editorExplorer: false }) + const { container, unmount } = mountHost(ctx, store, homeTab) + try { + // No editor chrome: no path input, no tree toggle, no resize handle. + expect(container.querySelector('input[placeholder^="File path"]')).toBeNull() + expect(container.querySelector('button[aria-pressed]')).toBeNull() + expect(container.querySelector('[role="separator"]')).toBeNull() + // The tree panel fills the whole window — its search box is the only + // input, and (no cwd) the tree shows its no-session placeholder. + expect(container.querySelector('input[placeholder^="Search files"]')).not.toBeNull() + expect(container.innerHTML).toContain('Select a conversation') + } finally { + unmount() + } + }) + + it('split mode: a file tab keeps the full chrome (path input + tree toggle + dock)', () => { + const { store, ctx } = setup() + store.setPrefs({ ...store.getPrefs(), editorExplorer: false }) + ctx.betterSidebar.openTab({ + type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true }, + }) + const fileTab = (): SidebarTab => + allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + .find(tab => tab.path === '/tmp/a.ts')! + const { container, unmount } = mountHost(ctx, store, fileTab) + try { + expect(container.querySelector('input[placeholder^="File path"]')).not.toBeNull() + expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('true') + expect(container.querySelector('[role="separator"]')).not.toBeNull() + } finally { + unmount() + } + }) + + it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', () => { + const { store, ctx, homeTab } = setup() + const { container, unmount } = mountHost(ctx, store, homeTab) + try { + const handle = container.querySelector('[role="separator"]')! + expect(handle).not.toBeNull() + // The dock starts at the default width. + const dock = handle.parentElement! + expect(dock.style.width).toBe('240px') + // Drag the left edge LEFT by 100px → the right-docked panel widens. + // Pointer capture keeps move/up on the handle (jsdom: MouseEvent with + // pointer* type names; setPointerCapture is absent and skipped). + act(() => { + handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300 })) + handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200 })) + }) + expect(dock.style.width).toBe('340px') + // Release: the drag state clears and the width persists on the tab. + act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200 })) }) + expect(homeTab().meta).toEqual({ treeOpen: true, treeWidth: 340 }) + } finally { + unmount() + } + }) + + it('the header hosts the viewer toolbar (mode toggle / dirty dot / save)', () => { + const { store, ctx } = setup() + const service = ctx.betterSidebar + const calls: string[] = [] + // A viewer with a hoisted toolbar (the TextEditor contract): register + // commands and report the state once on mount. + service.registerFileViewer({ + id: 'test:fake', + exts: ['fake'], + fetchStrategy: 'none', + component: (viewerProps) => { + useEffect(() => { + viewerProps.onToolbarControls?.({ + setMode: (next) => { calls.push(`mode:${next}`) }, + save: () => { calls.push('save') }, + }) + viewerProps.onToolbarState?.({ modes: true, mode: 'preview', dirty: true, editable: true, saveState: 'idle' }) + return () => { viewerProps.onToolbarControls?.(null) } + }, []) + return null + }, + }) + service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' }) + const fileTab = (): SidebarTab => + allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + .find(tab => tab.path === '/tmp/x.fake')! + const { container, unmount } = mountHost(ctx, store, fileTab) + try { + // Mode toggle + dirty dot + save button sit in the header row. + const header = container.querySelector('input')!.parentElement! + const buttons = [...header.querySelectorAll('button')] + expect(buttons.map(b => b.textContent)).toContain('Preview') + expect(buttons.map(b => b.textContent)).toContain('Edit') + expect(header.querySelector('button[aria-label="Save"]')).not.toBeNull() + expect(header.querySelector('[title="Unsaved"]')).not.toBeNull() + // The header commands reach the viewer's registered controls. + act(() => { buttons.find(b => b.textContent === 'Edit')!.click() }) + act(() => { header.querySelector('button[aria-label="Save"]')!.click() }) + expect(calls).toEqual(['mode:edit', 'save']) + } finally { + unmount() + } + }) +}) diff --git a/tests/fs-search.spec.ts b/tests/fs-search.spec.ts new file mode 100644 index 0000000..9bb0c6b --- /dev/null +++ b/tests/fs-search.spec.ts @@ -0,0 +1,151 @@ +/** + * fs-search: the host's recursive file-name search behind the editor side + * panel's search box. Matches are case-insensitive name substrings, reported + * RELATIVE to the root ('/'-separated); `.git` directories are skipped, + * symlinked directories are never descended (cycle safety), and the + * maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`. + */ +import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { searchFiles } from '../src/fs-search.ts' + +/** + * Symlink creation needs extra privileges on Windows; the symlink case skips + * there rather than fails (mirror of the fs-tree symlink spec). + */ +const canSymlink = (() => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-probe-')) + try { + symlinkSync('target', join(dir, 'link')) + return true + } catch { + return false + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() + +/** A scratch tree: nested matches, a .git dir, and unrelated noise. */ +function makeFixture(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-')) + mkdirSync(join(dir, 'src')) + mkdirSync(join(dir, 'docs')) + mkdirSync(join(dir, '.git')) + mkdirSync(join(dir, '.git', 'objects')) + writeFileSync(join(dir, 'README.md'), 'readme') + writeFileSync(join(dir, 'src', 'Index.TS'), 'code') + writeFileSync(join(dir, 'src', 'util.ts'), 'code') + writeFileSync(join(dir, 'docs', 'guide.md'), 'doc') + writeFileSync(join(dir, '.git', 'config'), 'git-internal') + writeFileSync(join(dir, '.git', 'objects', 'readme-pack'), 'git-internal') + return dir +} + +describe('fs-search', () => { + it('matches name substrings and reports root-relative /-separated paths', async () => { + const dir = makeFixture() + try { + const result = await searchFiles(dir, 'util') + expect(result).toEqual({ matches: ['src/util.ts'], truncated: false }) + // A multi-level match list is sorted and relative (never absolute). + const md = await searchFiles(dir, '.md') + expect(md.truncated).toBe(false) + expect(md.matches).toEqual(['README.md', 'docs/guide.md']) + for (const match of md.matches) { + expect(match.startsWith(dir)).toBe(false) + expect(match).not.toContain('\\') + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('matches case-insensitively on the entry name', async () => { + const dir = makeFixture() + try { + expect((await searchFiles(dir, 'index.ts')).matches).toEqual(['src/Index.TS']) + expect((await searchFiles(dir, 'INDEX.TS')).matches).toEqual(['src/Index.TS']) + // Directory names match too (the client can hint where matches live). + expect((await searchFiles(dir, 'SRC')).matches).toEqual(['src']) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('never descends into .git directories', async () => { + const dir = makeFixture() + try { + // 'readme' would hit .git/objects/readme-pack if the walk entered .git. + expect((await searchFiles(dir, 'readme')).matches).toEqual(['README.md']) + expect((await searchFiles(dir, 'config')).matches).toEqual([]) + expect((await searchFiles(dir, '.git')).matches).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('an empty (or whitespace) query matches nothing without walking', async () => { + const dir = makeFixture() + try { + expect(await searchFiles(dir, '')).toEqual({ matches: [], truncated: false }) + expect(await searchFiles(dir, ' ')).toEqual({ matches: [], truncated: false }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it.skipIf(!canSymlink)('does not descend into symlinked directories (cycle safety)', async () => { + const dir = makeFixture() + try { + // A link back to the root would loop forever if descended; a link to + // src would duplicate its matches. Neither must be entered. + symlinkSync(dir, join(dir, 'loop')) + symlinkSync(join(dir, 'src'), join(dir, 'src-link')) + const result = await searchFiles(dir, 'util') + expect(result).toEqual({ matches: ['src/util.ts'], truncated: false }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('stops with truncated: true when the match budget is exceeded', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-cap-')) + try { + for (let index = 0; index < 5; index += 1) { + writeFileSync(join(dir, `match-${index}.txt`), 'x') + } + const result = await searchFiles(dir, 'match', { maxMatches: 2 }) + expect(result.truncated).toBe(true) + expect(result.matches.length).toBe(2) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('stops with truncated: true when the visited budget is exceeded', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-visited-')) + try { + for (let index = 0; index < 5; index += 1) { + writeFileSync(join(dir, `file-${index}.txt`), 'x') + } + // The walk visits more entries than the budget allows and gives up. + const result = await searchFiles(dir, 'nomatch', { maxVisited: 3 }) + expect(result.truncated).toBe(true) + expect(result.matches).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('an unreadable root yields no matches instead of throwing', async () => { + const dir = makeFixture() + try { + const missing = join(dir, 'does-not-exist') + expect(await searchFiles(missing, 'x')).toEqual({ matches: [], truncated: false }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 1b44d41..b897278 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -49,8 +49,8 @@ describe('dsh-better-sidebar plugin export shape', () => { const resolved = (PrefsSchema as unknown as { (input: Record | undefined): Record })(undefined) - expect(resolved.openByDefault).toBe(true) - expect(resolved.defaultWidthPercent).toBe(30) + expect(resolved.openByDefault).toBe(false) + expect(resolved.defaultWidthPercent).toBe(35) expect(resolved.autoOpenSubagent).toBe(true) // A new background job auto-opens the Jobs page too. expect(resolved.autoOpenJobs).toBe(true) @@ -68,12 +68,14 @@ describe('dsh-better-sidebar plugin export shape', () => { // The enable-switch maps resolve to {} (everything on) for old documents. expect(resolved.tabsEnabled).toEqual({}) expect(resolved.viewersEnabled).toEqual({}) + // The merged editor-explorer mode defaults ON. + expect(resolved.editorExplorer).toBe(true) // A stored overridden value resolves through (the range contract is // enforced by the settings service on write); the new pref keeps its // default when the stored document predates it. const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) }) }) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index 75ea78b..bfaa18f 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -36,6 +36,7 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, @@ -53,7 +54,7 @@ describe('side card preferences', () => { it('falls back per-field when a stored field is malformed', async () => { expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' }))) .toEqual({ - openByDefault: true, + openByDefault: false, defaultWidthPercent: 33, autoOpenSubagent: true, autoOpenJobs: true, @@ -62,6 +63,7 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, @@ -88,6 +90,7 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, @@ -126,6 +129,16 @@ describe('side card preferences', () => { expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true) }) + it('defaults editorExplorer to true; only an explicit false restores the plain editor', async () => { + // Absent or malformed → on (the merged editor-explorer is the default). + expect((await loadPrefs(wire({}))).editorExplorer).toBe(true) + expect((await loadPrefs(wire({ editorExplorer: 'yes' }))).editorExplorer).toBe(true) + expect((await loadPrefs(wire({ editorExplorer: 0 }))).editorExplorer).toBe(true) + // Explicit booleans survive verbatim. + expect((await loadPrefs(wire({ editorExplorer: false }))).editorExplorer).toBe(false) + expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true) + }) + it('defaults titleBarCompat to false; only an explicit true turns the position-compat mode on', async () => { // Absent or malformed → off (the normal layout is the default). expect((await loadPrefs(wire({}))).titleBarCompat).toBe(false) @@ -198,17 +211,17 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false) expect(snapshot.state?.width).toBe(400) - // The default prefs keep the panel open. + // The default prefs keep the panel closed (openByDefault defaults off). const openStore = createSidebarStore() openStore.setSession('another-fresh') - expect(openStore.getSnapshot().state?.panelOpen).toBe(true) + expect(openStore.getSnapshot().state?.panelOpen).toBe(false) }) it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => { @@ -222,8 +235,8 @@ describe('side card preferences', () => { } try { const store = createSidebarStore() - // Default prefs say openByDefault: true — the narrow viewport overrides - // it for the FIRST seeding only (a later user expansion persists). + // The narrow viewport keeps a fresh session collapsed for the FIRST + // seeding only (a later user expansion persists). store.setSession('narrow-fresh') expect(store.getSnapshot().state?.panelOpen).toBe(false) // The width seeding still follows the window (clamped to the floor). @@ -234,20 +247,37 @@ describe('side card preferences', () => { } }) - it('skips the default explorer tab when the explorer type is disabled', () => { + it('skips the default seed tab when the editor (files window) type is disabled', () => { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} }) - store.setSession('no-explorer') + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} }) + store.setSession('no-editor') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) expect(tabs).toHaveLength(0) expect(state.splits.kind).toBe('leaf') - // Re-enabling seeds the explorer tab again. - const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) - openStore.setSession('with-explorer') - const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) - expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) + // Re-enabling seeds the files window (editor home tab) again — in BOTH + // editorExplorer modes. + for (const editorExplorer of [true, false]) { + const openStore = createSidebarStore() + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + openStore.setSession(`with-editor-${editorExplorer}`) + const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + expect(openTabs.map(tab => tab.type)).toEqual(['editor']) + } + }) + + it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => { + for (const editorExplorer of [true, false]) { + const store = createSidebarStore() + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setSession(`fresh-${editorExplorer}`) + const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) + expect(tabs).toHaveLength(1) + expect(tabs[0]!.type).toBe('editor') + expect(tabs[0]!.title).toBe('Files') + expect(tabs[0]!.path).toBeUndefined() + expect(tabs[0]!.meta).toEqual({ treeOpen: true }) + } }) it('derives the default width from the window percent with clamps', () => { @@ -260,8 +290,8 @@ describe('side card preferences', () => { expect(makeDefaultState().panelOpen).toBe(true) expect(makeDefaultState(400, false).panelOpen).toBe(false) expect(makeDefaultState(400, false).width).toBe(400) - // The seedExplorer flag controls the default explorer tab. - expect(makeDefaultState(400, true, false).splits.kind).toBe('leaf') - expect((makeDefaultState(400, true, false).splits as { tabs: unknown[] }).tabs).toHaveLength(0) + // The 'none' seed starts with an empty pane (no default tab). + expect(makeDefaultState(400, true, 'none').splits.kind).toBe('leaf') + expect((makeDefaultState(400, true, 'none').splits as { tabs: unknown[] }).tabs).toHaveLength(0) }) }) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index 0ae19dc..aab61ab 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -21,7 +21,7 @@ if (g.localStorage === undefined) { } import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts' -import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts' +import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, openTabInActivePane, sanitizeState } from '../src/client/state.ts' describe('BetterSidebar service', () => { it('registerTab adds to the registry and dispose removes it', () => { @@ -108,8 +108,9 @@ describe('enable switches (declarative settings)', () => { store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } }) store.setSession('s1') service.openTab({ type: 'explorer', title: 'Explorer' }) + // The seeded files-window home tab stays; no EXPLORER tab landed. const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs) - expect(tabs).toHaveLength(0) + expect(tabs.filter(t => t.type === 'explorer')).toHaveLength(0) }) it('matchFileViewer skips a disabled viewer (files fall through)', () => { @@ -352,7 +353,8 @@ describe('service.openTab dedupe', () => { store.setSession('s1') service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }) const state = store.getSnapshot().state! - const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor') + // Find by path: the seeded files-window home tab is an editor tab too. + const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path === '/p/main.ts') expect(tab?.title).toBe('main.ts') }) @@ -643,7 +645,7 @@ describe('service.openTab auto-expand for content opens', () => { service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }) const state = store.getSnapshot().state! expect(state.panelOpen).toBe(true) - expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1) + expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor' && t.path === '/p/main.ts')).toHaveLength(1) }) }) @@ -671,7 +673,7 @@ describe('state subscription (v0.12.0)', () => { const snapshot = service.getSnapshot() expect(snapshot.sessionId).toBe('s1') expect(snapshot.state).toBeDefined() - expect(snapshot.prefs.openByDefault).toBe(true) + expect(snapshot.prefs.openByDefault).toBe(false) }) it('subscribeState fires on state changes but NOT on registry changes', () => { @@ -739,7 +741,7 @@ describe('targeted openTab (v0.12.0)', () => { service.registerTab({ id: 'notes', title: 'Notes', component: () => null }) store.setSession('s1') service.openTab({ type: 'notes', title: 'Notes', id: 'notes:1' }, { sessionId: 's2' }) - // The UI snapshot still shows s1, untouched (its default explorer tab + // The UI snapshot still shows s1, untouched (its default files-window tab // is the only one — no notes tab landed there). const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('s1') @@ -783,7 +785,8 @@ describe('openFile (v0.12.0)', () => { store.setSession('s1') service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/src/main.ts') const state = store.getSnapshot().state! - const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor') + // Find by path: the seeded files-window home tab is an editor tab too. + const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor' && t.path !== undefined) expect(tab?.title).toBe('main.ts') expect(tab?.path).toBe('/p/src/main.ts') // Windows separators are handled too. @@ -892,8 +895,9 @@ describe('tab meta (v0.12.0)', () => { }) it('older persisted tabs (no meta) sanitize unchanged', () => { - const state = makeDefaultState(400, true, true) - const sanitized = sanitizeState(JSON.parse(JSON.stringify(state))) + const state = makeDefaultState(400, true, 'none') + const withTab = openTabInActivePane(state, { id: 'tab:old', type: 'git', title: 'Git' }) + const sanitized = sanitizeState(JSON.parse(JSON.stringify(withTab))) const tabs = allLeaves(sanitized!.splits).flatMap(l => l.tabs) expect(tabs[0]?.meta).toBeUndefined() }) diff --git a/tests/side-card-section-rows.spec.tsx b/tests/side-card-section-rows.spec.tsx index 08dac8e..0706b1a 100644 --- a/tests/side-card-section-rows.spec.tsx +++ b/tests/side-card-section-rows.spec.tsx @@ -1,9 +1,11 @@ /** - * Interactive tests for the settings popup's text/number rows: the draft is - * local state committed on blur through the parent's onCommit; the parent's - * canonical return is adopted (clamped numbers, stored value for invalid - * input), and the row remounts when the committed pref value changes (a - * failed commit reverts prefs → the stored value reappears). + * Interactive tests for the settings popup's text/number/select rows: the + * draft is local state committed on blur through the parent's onCommit; the + * parent's canonical return is adopted (clamped numbers, stored value for + * invalid input), and the row remounts when the committed pref value changes + * (a failed commit reverts prefs → the stored value reappears). Select rows + * commit the picked option's value (single) or the picked values in options + * order (multi) through onSelectValue. * * Rendered with createRoot + act() in jsdom (the SSR specs stay in * side-card-section.spec.tsx; this file exercises the event paths). @@ -173,3 +175,99 @@ describe('FeatureSettingsRows typed rows (interactive)', () => { unmount() }) }) + +/** Open one select row's dropdown: click the anchor, the Menu portals its + * list into document.body (role="menuitem" rows). */ +function openSelect(container: HTMLDivElement): HTMLElement[] { + const anchor = container.querySelector('button[aria-haspopup="listbox"]')! + act(() => { anchor.click() }) + return [...document.querySelectorAll('[role="menuitem"]')] +} + +describe('FeatureSettingsRows select rows (interactive)', () => { + const options: NonNullable = [ + { value: true, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Merged', desc: () => 'Docked tree' }, + { value: false, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Split', desc: () => 'Two tabs' }, + ] + + it('commits the picked option value and closes (iconed single select)', () => { + const commits: Array<[string, unknown]> = [] + const toggle: SidebarSettingToggle = { + key: 'editorExplorer', + type: 'select', + title: () => 'Editor explorer', + options, + } + const { container, unmount } = mount(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs, + onToggle: () => {}, + onSelectValue: (t, next) => { commits.push([t.key, next]) }, + })) + // The closed anchor shows the selected option's title (icon variant). + expect(container.textContent).toContain('Merged') + const items = openSelect(container) + // Big-icon cards: title + desc per option. + expect(items.map(item => item.textContent)).toEqual(['MergedDocked tree', 'SplitTwo tabs']) + act(() => { items[1]!.click() }) + expect(commits).toEqual([['editorExplorer', false]]) + // Single-pick closes the dropdown. + expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(0) + unmount() + }) + + it('toggles membership and commits the picked values in options order (multi)', () => { + const commits: Array<[string, unknown]> = [] + const plain: NonNullable = [ + { value: 'a', title: () => 'Alpha' }, + { value: 'b', title: () => 'Beta' }, + { value: 'c', title: () => 'Gamma' }, + ] + const toggle: SidebarSettingToggle = { + key: 'pluginKey', + type: 'select', + multi: true, + title: () => 'Pick several', + options: plain, + } + const rows = (value: unknown) => createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs, + onToggle: () => {}, + onSelectValue: (t, next) => { commits.push([t.key, next]) }, + valueSource: () => value, + }) + const { container, rerender, unmount } = mount(rows(['c', 'a'])) + // The anchor shows the picked titles; options follow the declared order. + const items = openSelect(container) + expect(items.map(item => item.textContent)).toEqual(['Alpha', 'Beta', 'Gamma']) + // Deselect 'a' → ['c'] is normalized to options order… (['c'] here). + act(() => { items[0]!.click() }) + expect(commits).toEqual([['pluginKey', ['c']]]) + // The menu stays open under multi; re-render with the committed value. + rerender(rows(['c'])) + const again = [...document.querySelectorAll('[role="menuitem"]')] + expect(again).toHaveLength(3) + // Select 'b' → committed in OPTIONS order, not pick order. + act(() => { again[1]!.click() }) + expect(commits[1]).toEqual(['pluginKey', ['b', 'c']]) + unmount() + }) + + it('shows an em dash when nothing is selected', () => { + const toggle: SidebarSettingToggle = { + key: 'editorExplorer', + type: 'select', + title: () => 'Editor explorer', + options, + } + const { container, unmount } = mount(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs, + onToggle: () => {}, + valueSource: () => undefined, + })) + expect(container.querySelector('button[aria-haspopup="listbox"]')!.textContent).toContain('—') + unmount() + }) +}) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 78391ea..ecdb9df 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -80,13 +80,14 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('>explorer<') expect(html).toContain('data-icon="subagent"') expect(html).toContain('>Subagents<') - // Default prefs: openByDefault + interceptOpenPath switches checked, and - // both tabs + the image viewer cards pressed (3 aria-pressed cards). + // Default prefs: only the interceptOpenPath switch is checked (openByDefault + // now defaults off), and both tabs + the image viewer cards pressed + // (3 aria-pressed cards). // The nested auto-open toggle is NOT an inline card (it lives in the popup). expect(pressedCount(html, 'true')).toBe(3) expect(pressedCount(html, 'false')).toBe(0) - // The general toggles are custom switches (real checkboxes, checked). - expect(html.match(/checked=""/g)?.length).toBe(2) + // The general toggles are custom switches (real checkboxes, one checked). + expect(html.match(/checked=""/g)?.length).toBe(1) expect(html).not.toContain('Auto-open Subagents') }) @@ -140,9 +141,9 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('>Subagents<') expect(html).toContain('>Image<') expect(pressedCount(html, 'false')).toBe(2) - // The explorer card stays pressed; the general switches stay checked. + // The explorer card stays pressed; the one default-on general switch stays checked. expect(pressedCount(html, 'true')).toBe(1) - expect(html.match(/checked=""/g)?.length).toBe(2) + expect(html.match(/checked=""/g)?.length).toBe(1) }) it('hides the gear of a disabled feature (its related settings are dormant)', () => { @@ -158,11 +159,11 @@ describe('SideCardSection declarative inventory', () => { // The general row renders its title and description. expect(html).toContain('Position compatibility mode') expect(html).toContain('Reserve space for the native Windows title bar') - // Three general rows now: openByDefault + interceptOpenPath checked, - // the new titleBarCompat row UNCHECKED (default off) — the checked - // checkbox count stays at 2 while the total checkbox count is 3. + // Three general rows now: only interceptOpenPath is checked by default + // (openByDefault defaults off, the titleBarCompat row starts UNCHECKED) — + // the checked checkbox count is 1 while the total checkbox count is 3. expect(html.match(/type="checkbox"/g)?.length).toBe(3) - expect(html.match(/checked=""/g)?.length).toBe(2) + expect(html.match(/checked=""/g)?.length).toBe(1) // The row's gear (customize the shift distance) is dormant while the // mode is off — the feature-card convention. expect(html).not.toContain('Position compatibility mode Feature settings') @@ -170,7 +171,7 @@ describe('SideCardSection declarative inventory', () => { // When the pref is on, the new switch is checked and the gear appears. store.setPrefs({ ...store.getPrefs(), titleBarCompat: true }) html = renderSection(store, service) - expect(html.match(/checked=""/g)?.length).toBe(3) + expect(html.match(/checked=""/g)?.length).toBe(2) expect(html).toContain('aria-label="Position compatibility mode Feature settings"') }) }) diff --git a/tests/sidebar-crash.spec.tsx b/tests/sidebar-crash.spec.tsx index a6c93f4..095ef86 100644 --- a/tests/sidebar-crash.spec.tsx +++ b/tests/sidebar-crash.spec.tsx @@ -53,7 +53,8 @@ function mountSidebar(): MountedSidebar { document.body.append(container) const store = createSidebarStore() const service = createBetterSidebarService(store) - // Fresh-session seed: the panel starts OPEN (openByDefault default true). + // Fresh-session seed: open the panel explicitly (openByDefault defaults off). + store.setPrefs({ ...store.getPrefs(), openByDefault: true }) store.setSession('s1') // useSyncExternalStore requires STABLE snapshots across calls (the real DSH // services return stable objects) — a fresh object per call loops forever. diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index d5a1cf2..bc66178 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -566,8 +566,8 @@ describe('side card settings routes', () => { expect(read.ok).toBe(true) expect(read.value).toEqual({ value: { - openByDefault: true, - defaultWidthPercent: 30, + openByDefault: false, + defaultWidthPercent: 35, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, @@ -575,6 +575,7 @@ describe('side card settings routes', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + editorExplorer: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, @@ -592,11 +593,11 @@ describe('side card settings routes', () => { revision: 0, }) - const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } }) + const written = await invoke(route, 'settings.update', { patch: { openByDefault: true } }) expect(written.ok).toBe(true) const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number } - expect(view.value.openByDefault).toBe(false) - expect(view.value.defaultWidthPercent).toBe(30) + expect(view.value.openByDefault).toBe(true) + expect(view.value.defaultWidthPercent).toBe(35) expect(view.revision).toBe(1) }) diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 7678d70..50df04c 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -10,6 +10,59 @@ import { describe('sidebar state', () => { const state = (): SidebarState => makeDefaultState() + it('makeDefaultState seeds per the seed enum (editor-home / none)', () => { + // Default and explicit 'editor-home' seed an EMPTY editor tab (the files + // window) with the tree panel pinned open. + for (const s of [makeDefaultState(), makeDefaultState(400, true, 'editor-home')]) { + const leaf = s.splits as { tabs: SidebarTab[]; active: string | null } + expect(leaf.tabs).toHaveLength(1) + expect(leaf.tabs[0]!.type).toBe('editor') + expect(leaf.tabs[0]!.title).toBe('Files') + expect(leaf.tabs[0]!.path).toBeUndefined() + expect(leaf.tabs[0]!.meta).toEqual({ treeOpen: true }) + expect(leaf.active).toBe(leaf.tabs[0]!.id) + } + // The seeded home tab survives the persist round-trip (meta intact). + const restored = sanitizeState(JSON.parse(JSON.stringify(makeDefaultState()))) + const restoredLeaf = restored!.splits as { tabs: SidebarTab[] } + expect(restoredLeaf.tabs[0]!.meta).toEqual({ treeOpen: true }) + // 'none' seeds an empty pane. + const bare = makeDefaultState(400, true, 'none') + const bareLeaf = bare.splits as { tabs: SidebarTab[]; active: string | null } + expect(bareLeaf.tabs).toHaveLength(0) + expect(bareLeaf.active).toBeNull() + }) + + it('sanitizeState migrates persisted explorer tabs to editor home tabs (both trees)', () => { + const valid = sanitizeState({ + panelOpen: true, + width: 400, + nextTerminal: 1, + activePane: 'pane:1', + expanded: [], + splits: { + kind: 'leaf', + id: 'pane:1', + active: 'ex-right', + tabs: [{ id: 'ex-right', type: 'explorer', title: 'Explorer', meta: { treeWidth: 300 } }], + }, + bottomSplits: { + kind: 'leaf', + id: 'pane:b', + active: 'ex-bottom', + tabs: [{ id: 'ex-bottom', type: 'explorer', title: 'Explorer' }], + }, + }) + const right = (valid?.splits as { tabs: SidebarTab[] }).tabs + expect(right).toHaveLength(1) + // Migrated: editor home tab (no path), tree pinned open, prior meta kept. + expect(right[0]).toMatchObject({ id: 'ex-right', type: 'editor', title: 'Files', meta: { treeOpen: true, treeWidth: 300 } }) + expect(right[0]!.path).toBeUndefined() + const bottom = (valid?.bottomSplits as { tabs: SidebarTab[] }).tabs + expect(bottom).toHaveLength(1) + expect(bottom[0]).toMatchObject({ id: 'ex-bottom', type: 'editor', title: 'Files', meta: { treeOpen: true } }) + }) + it('opens tabs into the active pane and dedupes by id (safety net)', () => { let s = state() const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } @@ -411,8 +464,8 @@ describe('sidebar state', () => { const tab = { id: 'git', type: 'git' as const, title: 'Git' } s = openTabInActivePane(s, tab) expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git') - // The right tree is untouched. - expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['explorer']) + // The right tree is untouched (its seeded files-window home tab stays). + expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['editor']) expect(s.activePane).toBe(bottomPane) // The id safety net works across trees: reopening the same id focuses it. const after = openTabInActivePane(s, tab) @@ -677,8 +730,8 @@ describe('persisted state sanitization', () => { it('falls back from a stale active pane instead of dropping the open', () => { let s = makeDefaultState() const paneA = allLeaves(s.splits)[0]!.id - const explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id - s = closeTab(s, paneA, explorerTab) + const seededTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'editor')!.id + s = closeTab(s, paneA, seededTab) s = openTabInActivePane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false) s = { ...s, splits: split.node, activePane: paneA }