diff --git a/AGENTS.md b/AGENTS.md index 65f5e1f..66fa6d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## 2. CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`) -「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add file:`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,经「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk),再经文件树打开 seed 文件强制加载 editor chunk(`client-editor.js`)。 +「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add `)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开插件 tab 类型(含终端懒加载 chunk),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。 本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.1`(`alpha` dist-tag;peer 下限 `^0.1.5-alpha.1`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。 @@ -38,7 +38,7 @@ 7. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。 8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。 9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。 -10. **聊天文件打开漏斗已改为原生右侧栏**:0.1.5 客户端不再有任何 `remote.session.openWorkspacePath` 调用——聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点)。插件此前的 `openpath-intercept`(defineProperty 遮蔽 `remote.session` 方法)随之删除:0.1.5 上它是死代码,且会劫持宿主的「在外部应用打开」语义。文件当前落到原生栏的内置 `text` 预览;阶段二由插件注册 `dsh-resource://file/**` 类型(默认 `extension` 带,压过 `text` 的 `fallback`)接管。 +10. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点),`remote.session.openWorkspacePath` 在 0.1.5 客户端已无调用者,插件的 openpath 拦截随之删除。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-better-sidebar:`);`editor` 类型同时认领 `dsh-resource://file/**`(压过内置 `text` 的 `fallback`),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`(不在 `ISidebarRight` 接口里,需结构化探测),目标会话未挂载时排队到上屏重放(`src/client/native/surface.ts`)。原生 tab 的插件侧状态(合成 `SidebarTab`、树展开集合、实例编号)在 `src/client/native/tab-adapter.tsx`;原生布局只在内存,不做持久化。指南 §0 列了全部行为差异。 11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折(`inboxProjectionDefinition`,0.1.5 起 `@deepseek-ai/dsh-agent` 只导出 `Inbox` 接口,实现是该投影)重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折)守护。0.1.5 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法;`Session.create` 的 header `version` 必须用 `SESSION_FORMAT_VERSION`(0.1.5 是 `3` 字面量类型),勿钉 `0`。 --- diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 2e373dc..0580714 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -8,6 +8,27 @@ --- +## 0. 承载面:DSH 原生右侧栏(v0.19.0-alpha.0 起) + +从 v0.19.0-alpha.0 起,插件**不再自绘右侧面板**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`: + +- `registerTab` / `registerFileViewer` 签名不变; +- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台); +- `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效)。 + +行为差异(写在这里以免踩坑): + +| 事项 | 说明 | +|---|---| +| 生命周期回调 | 原生面只有「一次打开」,不区分新建/聚焦,因此只触发 `onOpen`(`onActivate` 仅在插件自己的底部工作台里触发) | +| 去重 | 原生按 `(kind, 地址)` 去重:有 `createTab` 的类型每次新开一个 tab(terminal / browser / sidechat / diff),其余聚焦已有 tab;`dedupeKey` 的自定义语义不参与原生面 | +| 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 | +| 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 | +| 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `text` 预览),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位 | +| 终端上限 | 终端 tab 的数量上限只统计插件自己底部工作台里的终端;原生栏里的终端不计入 | + +--- + ## 1. 总览:你能扩展什么 better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**: diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index f20ce7a..fcbc034 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -33,7 +33,7 @@ import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' -import { appendToDraft, insertFileReference } from './conversation-draft.ts' +import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts' import { BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs, @@ -56,7 +56,6 @@ import { useHostFeeds } from './sidebar/use-host-feeds.ts' import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts' import { FreeWindowLayer, useFloatDragout } from './sidebar/free-windows.tsx' import type { TabDragPayload } from './TabBar.tsx' -import { relativeTo } from './paths.ts' import { t } from './locales.ts' import { api } from './api.ts' import css from './sidebar.module.css' @@ -102,8 +101,23 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem return tag } -export function Sidebar(props: { ctx: Context; store: SidebarStore }) { +export function Sidebar(props: { ctx: Context; store: SidebarStore; nativeSurface?: boolean }) { const { ctx, store } = props + // With DSH's native right Sidebar owning the right column, this panel keeps + // only its bottom workbench: the right panel and its toggle are not offered. + const nativeSurface = props.nativeSurface === true + + // Keep the retired right panel collapsed: nothing routes content into it any + // more, so an open state (restored from a pre-0.1.5 layout, or written by a + // stale flow) would reserve width for an empty column. + useEffect(() => { + if (!nativeSurface) return + const enforce = (): void => { + store.reduce(state => (state.panelOpen ? { ...state, panelOpen: false } : state)) + } + enforce() + return store.subscribe(enforce) + }, [nativeSurface, store]) // Copy freshness: re-render the whole tree when the DSH locale switches. // The module-level t() reads the active locale at call time, so a root @@ -391,7 +405,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Land the tab in the bottom panel's first pane; the once-flag is set // atomically so later expansions never repeat the auto-open. store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true })) - ctx.get('betterSidebar')?.openTab({ type: 'terminal' }) + ctx.get('betterSidebar')?.openTab({ type: 'terminal', target: 'bottom' }) }, [state, store, ctx, narrow]) // Panel drags: the right panel's width (left edge strip), the bottom @@ -752,14 +766,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { */ const referenceInChat = useCallback((path: string, isDir: boolean): void => { if (sessionId === undefined) return - const rel = relativeTo(cwd ?? '', path) - if (isDir) { - appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`) - return - } - if (!insertFileReference(ctx, sessionId, rel)) { - appendToDraft(ctx, sessionId, `@${rel}`) - } + referenceInChatShared(ctx, sessionId, cwd, path, isDir) }, [ctx, sessionId, cwd]) if (state === undefined || sessionId === undefined) { @@ -797,14 +804,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { viewportHeight: layoutViewportHeight, }).height - const onNewTab = (optionId: string): void => { + const onNewTab = (optionId: string, target: 'right' | 'bottom' = 'right'): void => { const service = ctx.get('betterSidebar') const descriptor = service?.getTab(optionId) if (service === undefined || descriptor === undefined) return const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title // The session scope rides along: lifecycle callbacks receive it (and - // the open stays in the current session, as before). - service.openTab({ type: optionId, title }, { sessionId, cwd }) + // the open stays in the current session, as before). The bottom panel's + // own + menu passes 'bottom' so the tab lands in THIS workbench, not in + // the native right Sidebar. + service.openTab({ type: optionId, title, target }, { sessionId, cwd }) } /** @@ -915,16 +924,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { )} - - - + {!nativeSurface && ( + + + + )} {/* The right panel stays mounted while collapsed (hidden off-screen) so @@ -1147,7 +1158,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { tree={state.bottomSplits} newTabOptions={newTabOptions} actions={actions} - onNewTab={onNewTab} + onNewTab={(optionId) => { onNewTab(optionId, 'bottom') }} renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')} getTabIcon={tabIconOf} getTabBadge={tabBadgeOf} diff --git a/src/client/index.tsx b/src/client/index.tsx index 9f016ab..48e8ae5 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -18,6 +18,9 @@ import { registerBuiltins } from './builtins/index.ts' import { Sidebar } from './Sidebar.tsx' import { RenderBoundary } from './RenderBoundary.tsx' import { registerTurnTailInterception } from './intercept.tsx' +import { createNativeTabRecords } from './native/tab-adapter.tsx' +import { registerNativeSurface } from './native/index.ts' +import { createNativeSurface } from './native/surface.ts' import { registerLinkInterception } from './link-intercept.ts' import { registerImeGuard } from './ime-guard.ts' import { registerSettingsNavIcon } from './settings-nav-icon.ts' @@ -137,6 +140,21 @@ export function apply(ctx: Context): void { // are ready by the time the sidebar renders. const service = createBetterSidebarService(sidebarStore) ctx.provide('betterSidebar', service) + // The native right-Sidebar surface: the plugin's content is registered as + // DSH tab types (one per descriptor) and every open routes there, so the + // right column belongs to the host and only the bottom workbench stays + // plugin-owned. Both halves live for this fiber's lifetime. + const nativeRecords = createNativeTabRecords() + const nativeSurface = createNativeSurface(ctx, nativeRecords) + service.setSurface(nativeSurface) + ctx.effect( + () => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }), + 'dsh-better-sidebar: native right-Sidebar registrations', + ) + ctx.effect( + () => () => { nativeSurface.dispose(); service.setSurface(undefined) }, + 'dsh-better-sidebar: native right-Sidebar surface', + ) // Terminal tab titles use the host's effective shell name (e.g. bash/zsh) // instead of "Terminal 1". Start with a safe fallback and replace it as // soon as the host shell info resolves. Tabs created before the response @@ -284,7 +302,7 @@ export function apply(ctx: Context): void { host.setAttribute('data-dsh-better-sidebar', '') document.body.appendChild(host) root = createRoot(host) - root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore }))) + root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore, nativeSurface: true }))) mounted = true guardAnchor() scheduleHostCheck() diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index ffa4f95..e8f1c6c 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -18,6 +18,7 @@ /** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const ar: Record = { files: 'الملفات', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد', changesRead: 'قراءة', changesMdReading: 'قراءة', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 1baa471..881dcb0 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -3,6 +3,7 @@ */ export const de: Record = { files: 'Dateien', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung', changesRead: 'Lesen', changesMdReading: 'Lesen', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index d47ab59..1904063 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -10,6 +10,7 @@ /** fr dictionary for the `betterSidebar` namespace. */ export const fr: Record = { files: 'Fichiers', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Aucune opération de fichier dans cette session', changesRead: 'Lecture', changesMdReading: 'Lecture', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index ce19341..bee6300 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -17,6 +17,7 @@ /** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const hi: Record = { files: 'फ़ाइलें', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं', changesRead: 'पढ़ना', changesMdReading: 'पठन', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 952f8ba..2a4389e 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -15,6 +15,7 @@ export const id: Record = { files: 'Berkas', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Belum ada operasi file di sesi ini', changesRead: 'Baca', changesMdReading: 'Baca', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 9d6052a..fb10153 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -8,6 +8,7 @@ export const it: Record = { files: 'File', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione', changesRead: 'Lettura', changesMdReading: 'Lettura', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 1b780a2..57dcf52 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -17,6 +17,7 @@ /** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const ja: Record = { files: 'ファイル', + nativeGuideDesc: 'DSH のサイドバーで開く', changesSessionEmpty: 'このセッションにはファイル操作がまだありません', changesRead: '読み取り', changesMdReading: '閲覧', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 82a4938..5b26f05 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -9,6 +9,7 @@ export const ko: Record = { files: '파일', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다', changesRead: '읽기', changesMdReading: '읽기', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index a3da95c..89242b2 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -15,6 +15,7 @@ */ export const nl: Record = { files: 'Bestanden', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie', changesRead: 'Lezen', changesMdReading: 'Lezen', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 855ea69..4e60ed2 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -19,6 +19,7 @@ /** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const pl: Record = { files: 'Pliki', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Brak operacji na plikach w tej sesji', changesRead: 'Odczyt', changesMdReading: 'Czytanie', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 7146bff..9bfc9fa 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -1,5 +1,6 @@ export const pt: Record = { files: 'Arquivos', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão', changesRead: 'Leitura', changesMdReading: 'Leitura', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 5a6155b..b8c51bf 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -13,6 +13,7 @@ export const ru: Record = { files: 'Файлы', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'В этой сессии пока нет файловых операций', changesRead: 'Чтение', changesMdReading: 'Чтение', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 0bdc0d0..5cb9780 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -1,5 +1,6 @@ export const sv: Record = { files: 'Filer', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu', changesRead: 'Läs', changesMdReading: 'Läsning', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 094f961..24f7a77 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -17,6 +17,7 @@ /** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const th: Record = { files: 'ไฟล์', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้', changesRead: 'อ่าน', changesMdReading: 'อ่าน', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index df40c75..ad1baa3 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -17,6 +17,7 @@ /** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const tr: Record = { files: 'Dosyalar', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok', changesRead: 'Okuma', changesMdReading: 'Okuma', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 35ec66d..f59f075 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -17,6 +17,7 @@ /** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const vi: Record = { files: 'Tệp', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này', changesRead: 'Đọc', changesMdReading: 'Đọc', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 248016f..e99f0d5 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -32,6 +32,7 @@ /** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const zhHK: Record = { files: '檔案', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: '本工作階段還沒有檔案操作', changesRead: '讀取', changesMdReading: '閱讀', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index d67b91c..ac5b2a0 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -32,6 +32,7 @@ /** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const zhMO: Record = { files: '檔案', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: '本工作階段還沒有檔案操作', changesRead: '讀取', changesMdReading: '閱讀', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 0fb0422..a80b965 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -32,6 +32,7 @@ /** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const zhTW: Record = { files: '檔案', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: '本作業階段還沒有檔案操作', changesRead: '讀取', changesMdReading: '閱讀', diff --git a/src/client/locales.ts b/src/client/locales.ts index ef3a2ee..2e31c77 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -24,6 +24,7 @@ /** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */ export const zh = { files: '文件', + nativeGuideDesc: '在 DSH 原生右侧栏打开', changesSessionEmpty: '本会话还没有文件操作', changesRead: '读取', changesMdReading: '阅读', @@ -472,6 +473,7 @@ export const zh = { /** The en dictionary (key-set-equal to zh, enforced by the type annotation). */ export const en: Record = { files: 'Files', + nativeGuideDesc: 'Open in the DSH sidebar', changesSessionEmpty: 'No file operations in this session yet', changesRead: 'Read', changesMdReading: 'Reading', diff --git a/src/client/native/index.ts b/src/client/native/index.ts new file mode 100644 index 0000000..fecd898 --- /dev/null +++ b/src/client/native/index.ts @@ -0,0 +1,229 @@ +/** + * The plugin's content, registered into DSH's native right Sidebar. + * + * Every tab the plugin can draw becomes a native tab TYPE plus a BODY: + * + * - the `editor` type is both a page (the files window) and a resource + * viewer — it claims `dsh-resource://file/**` at the default `extension` + * band, which outranks the built-in `text` preview (`fallback`) and the + * built-in `files` tree (`builtin`), so every file the product opens lands + * in the plugin's editor; + * - the built-in `files` page kind is TAKEN OVER by an `extension` + * registration of the same kind, so `openTab('files')` draws the plugin's + * explorer instead of the built-in tree; the built-in resumes when this + * plugin unregisters; + * - every other descriptor (changes / subagent / side chat / terminal / + * browser / diff) becomes a page type of its own. + * + * The registrations follow the plugin's registry lifecycle: a descriptor + * registered later (an external plugin) gets its native type too, and a type + * the user disabled in the side-card settings is unregistered — so it is + * absent from the native guide and `openTab` refuses it. + */ +import type { Context } from '../../context-types.ts' +import { t } from '../locales.ts' +import { parseFileAddress } from '../resource-address.ts' +import type { BetterSidebarService, TabDescriptor } from '../service.ts' +import type { SidebarStore } from '../state.ts' +import { + NativeTabBody, + NativeTabTitle, + type NativeBodyInjected, + type NativeTabInfo, + type NativeTabParams, + type NativeTabRecords, +} from './tab-adapter.tsx' + +/** The native tab-type registry face (`ctx.sidebarRightTabs`). */ +interface NativeTabRegistry { + register(definition: { + id: string + kind: string + patterns?: readonly string[] + priority?: 'extension' | 'builtin' | 'fallback' + canOpen?: (address: string) => boolean + title: (address: string) => string + guide?: readonly { order: number; title: () => string; description: () => string; icon?: unknown }[] + }): () => void +} + +/** The kind the plugin's files window owns (page + file viewer). */ +const EDITOR_KIND = 'editor' + +/** The built-in page kind this plugin takes over. */ +const FILES_KIND = 'files' + +/** The plugin's implementation id for a descriptor (unique across kinds). */ +function nativeId(descriptorId: string): string { + return `dsh-better-sidebar:${descriptorId}` +} + +/** The descriptor's title text, evaluated fresh for the current locale. */ +function titleOf(descriptor: TabDescriptor): string { + return typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title +} + +/** One descriptor's live native registrations. */ +interface Registration { + readonly dispose: () => void +} + +/** Everything the registrations need. */ +export interface NativeSurfaceDeps { + readonly ctx: Context + readonly store: SidebarStore + readonly service: BetterSidebarService + /** The shared native tab record registry (the surface writes it too). */ + readonly records: NativeTabRecords +} + +/** + * Register the plugin's tabs into the native right Sidebar and keep them in + * step with the plugin's own registry and settings. + * @param deps - client context, the plugin store/service, and the records. + * @returns a disposer unregistering everything. + */ +export function registerNativeSurface(deps: NativeSurfaceDeps): () => void { + const { ctx, store, service, records } = deps + const disposers: Array<() => void> = [] + // The native seat declares `sidebar.right.pane.tab` when its client half + // activates, which may be after this plugin; the slot registry's `inject` + // re-runs on every declaration lifetime, and the tab-type registry + // (`ctx.sidebarRightTabs`) is provided by that same package, so one wait + // covers both. + const disposeSeat = ctx.slots.inject('sidebar.right.pane.tab', () => { + const tabs = ctx.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined + if (tabs === undefined) return () => {} + const live = new Map() + + const fileParamsOf = (info: NativeTabInfo): NativeTabParams | undefined => { + const address = parseFileAddress(info.tab.contentId) + return address === undefined ? undefined : { path: address.path } + } + const fileSessionIdOf = (info: NativeTabInfo): string | undefined => { + const address = parseFileAddress(info.tab.contentId) + return address !== undefined && address.scope === 'session' ? address.sessionId : undefined + } + + /** Register the body + chip-title slots for one native implementation id. */ + const registerSlots = ( + id: string, + injected: Omit, + params: Pick, + ): Array<() => void> => [ + ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: id, + inject: (sessionId: string) => ({ ...injected, ...params, sessionId }), + }, NativeTabBody)), + ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab.title', + key: id, + inject: () => ({ records }), + }, NativeTabTitle)), + ] + + /** One descriptor's native type + body + title, as one disposable. */ + const registerDescriptor = (descriptor: TabDescriptor): (() => void) => { + const id = nativeId(descriptor.id) + const isEditor = descriptor.id === EDITOR_KIND + const icon = descriptor.icon + const disposeType = tabs.register({ + id, + kind: descriptor.id, + ...(isEditor + ? { + patterns: ['dsh-resource://file/**'], + canOpen: (address: string) => parseFileAddress(address) !== undefined, + } + : {}), + // An external implementation outranks the product's own viewers, which + // is what lets the plugin's editor take over file addresses. + priority: 'extension', + title: () => titleOf(descriptor), + ...(descriptor.hidden === true + ? {} + : { + guide: [{ + order: descriptor.order ?? 100, + title: () => titleOf(descriptor), + description: () => t('nativeGuideDesc'), + ...(typeof icon === 'function' + ? { icon: (props: { size?: number }) => icon(props.size ?? 16) } + : {}), + }], + }), + }) + const slots = registerSlots( + id, + { ctx, store, service, records, descriptorId: descriptor.id }, + isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {}, + ) + return () => { + for (const dispose of slots.reverse()) dispose() + disposeType() + } + } + + /** One `files`-kind takeover: the plugin's explorer under the built-in kind. */ + const registerFilesKind = (): (() => void) => { + const id = 'dsh-better-sidebar:files' + const disposeType = tabs.register({ + id, + kind: FILES_KIND, + priority: 'extension', + title: () => t('files'), + guide: [{ + order: 10, + title: () => t('files'), + description: () => t('nativeGuideDesc'), + }], + }) + const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {}) + return () => { + for (const dispose of slots.reverse()) dispose() + disposeType() + } + } + + /** Bring the live registrations in line with the registry + the settings. */ + const sync = (): void => { + const wanted = new Map () => void>() + for (const descriptor of service.getTabs()) { + if (!service.isTabEnabled(descriptor.id)) continue + wanted.set(descriptor.id, () => registerDescriptor(descriptor)) + } + for (const [descriptorId, registration] of live) { + if (wanted.has(descriptorId)) continue + registration.dispose() + live.delete(descriptorId) + } + for (const [descriptorId, create] of wanted) { + if (live.has(descriptorId)) continue + live.set(descriptorId, { dispose: create() }) + } + // The built-in files kind follows the editor type's switch: with the + // editor disabled the plugin has no explorer to put there. + const wantsFiles = service.isTabEnabled(EDITOR_KIND) + const hasFiles = live.has(FILES_KIND) + if (wantsFiles && !hasFiles) live.set(FILES_KIND, { dispose: registerFilesKind() }) + if (!wantsFiles && hasFiles) { + live.get(FILES_KIND)?.dispose() + live.delete(FILES_KIND) + } + } + + disposers.push(service.subscribe(sync), store.subscribe(sync)) + sync() + return () => { + for (const registration of live.values()) registration.dispose() + live.clear() + } + }) + return () => { + disposeSeat() + for (const dispose of disposers.reverse()) dispose() + } +} + +export type { NativeTabRecords } diff --git a/src/client/native/surface.ts b/src/client/native/surface.ts new file mode 100644 index 0000000..6dc0861 --- /dev/null +++ b/src/client/native/surface.ts @@ -0,0 +1,149 @@ +/** + * The plugin's write face over DSH's native right Sidebar (`ctx.sidebarRight`). + * + * The service speaks in the plugin's own vocabulary (tab type, seed, session + * scope); this module turns those into the native surface's vocabulary + * (kind + navigation params, or a `dsh-resource://` address) and forwards + * tab-record operations to the plugin's native record registry. + * + * Two native limits shape the implementation: + * + * - the surface exists only while a session's panel is mounted, and the + * service's public face (`ISidebarRight`) writes only into THAT session. + * The controller also carries `openTabIn` / `openResourceIn` / + * `closeIn`, which act on any session whose store the runtime has minted; + * both are probed at call time, and an open for a session that has no + * store yet is QUEUED and replayed when that session comes on screen; + * - layout state is memory-only, so a queued open is not durable either. + */ +import type { Context } from '../../context-types.ts' +import { fileAddressFor } from '../resource-address.ts' +import type { NativeTabParams, SidebarSurface } from '../service.ts' +import type { NativeTabRecords } from './tab-adapter.tsx' + +/** One open the surface could not place yet. */ +type Pending = + | { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean } + | { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean } + +/** The controller face this module uses (a structural slice of `ISidebarRight`). */ +interface NativeController { + openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void + openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void + close(tabId: string): void + /** Not part of `ISidebarRight`: the concrete controller's per-session writes. */ + openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void + openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void + closeIn?(sessionId: string, tabId: string): void +} + +/** The plugin's write face over the native surface. */ +export interface NativeSurface extends SidebarSurface { + /** Replay opens that were queued for a session that had no mounted surface. */ + flushPending(): void + /** Stop observing the session list. */ + dispose(): void +} + +/** The active session id, as the client list reports it. */ +function activeSessionId(ctx: Context): string | undefined { + try { + return ctx.sessions.list.getSnapshot().current + } catch { + return undefined + } +} + +/** + * Bind the plugin's write face to the native controller. + * @param ctx - the client context (session list + `ctx.sidebarRight`). + * @param records - the plugin's native tab record registry. + * @returns the surface, plus a disposer unbinding its session subscription. + */ +export function createNativeSurface(ctx: Context, records: NativeTabRecords): NativeSurface { + const pending: Pending[] = [] + const controller = (): NativeController | undefined => + ctx.get('sidebarRight') as unknown as NativeController | undefined + + const place = (entry: Pending): boolean => { + const api = controller() + if (api === undefined) return false + const active = activeSessionId(ctx) + const onScreen = active !== undefined && active === entry.sessionId + if (entry.kind === 'tab') { + const options = { params: entry.params, revealIfOpened: entry.revealIfOpened } + if (onScreen) { + api.openTab(entry.tabKind, options) + return true + } + if (api.openTabIn !== undefined) { + api.openTabIn(entry.sessionId, entry.tabKind, options) + return true + } + return false + } + const options = { + ...(entry.line === undefined ? {} : { params: { line: entry.line } }), + revealIfOpened: entry.revealIfOpened, + } + if (onScreen) { + api.openResource(entry.address, options) + return true + } + if (api.openResourceIn !== undefined) { + api.openResourceIn(entry.sessionId, entry.address, options) + return true + } + return false + } + + const flushPending = (): void => { + if (pending.length === 0) return + for (let index = pending.length - 1; index >= 0; index--) { + const entry = pending[index] + if (entry !== undefined && place(entry)) pending.splice(index, 1) + } + } + + const enqueue = (entry: Pending): void => { + if (!place(entry)) pending.push(entry) + } + + const unsubscribe = ctx.sessions.list.subscribe(flushPending) + return { + openTab({ sessionId, kind, params, revealIfOpened }) { + enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened }) + }, + openResource({ sessionId, address, line, revealIfOpened }) { + enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened }) + }, + fileAddress(sessionId, cwd, path) { + return fileAddressFor(sessionId, cwd, path) + }, + close(sessionId, tabId) { + const record = records.get(tabId) + if (record === undefined) return undefined + records.drop(tabId) + const api = controller() + if (api !== undefined) { + if (sessionId === activeSessionId(ctx)) api.close(tabId) + else if (api.closeIn !== undefined) api.closeIn(sessionId, tabId) + } + return { type: record.tab.type, title: record.tab.title } + }, + update(tabId, patch) { + if (!records.has(tabId)) return false + records.update(tabId, patch) + return true + }, + activate(tabId) { + // The native surface has no cross-pane activation face the plugin needs: + // a tab is focused by opening its (kind, address) again, which the + // native open already de-duplicates. + return records.has(tabId) + }, + has: tabId => records.has(tabId), + flushPending, + dispose: () => { unsubscribe() }, + } +} diff --git a/src/client/native/tab-adapter.tsx b/src/client/native/tab-adapter.tsx new file mode 100644 index 0000000..840ff76 --- /dev/null +++ b/src/client/native/tab-adapter.tsx @@ -0,0 +1,338 @@ +/** + * The plugin's side of a native right-Sidebar tab. + * + * A plugin tab descriptor is one registration in the plugin's own registry + * (`ctx.betterSidebar`); this module adapts the native tab record to that + * descriptor's component contract. The native surface carries the tab's + * record (id, kind, title, content identity, navigation params, lifetime + * signal); the plugin's components expect a `SidebarTab` plus the explorer + * state the plugin's own layout used to hold — both live here. + * + * The adapter owns three things the native layout does not carry: + * + * - a synthetic plugin `SidebarTab` per native tab id, minted from the native + * record + `navigation.params` and kept live across navigations, so a + * component that retitles itself (`updateTab`) or rewrites its path (the + * editor's in-place file switch) keeps working; + * - the explorer's per-tab expansion/reveal sets; + * - the per-kind instance counter behind titles like "Terminal 2". + * + * Nothing here is a singleton: the registry is created once per client + * activation and handed to every registration. + */ +import { createElement, useEffect, useMemo, useSyncExternalStore } from 'react' +import type { ComponentType, ReactNode } from 'react' +import type { Context } from '../../context-types.ts' +import type { SessionScope } from '../api.ts' +import { RenderBoundary } from '../RenderBoundary.tsx' +import { OrphanedTab } from '../OrphanedTab.tsx' +import { referenceInChat } from '../reference-in-chat.ts' +import type { BetterSidebarService } from '../service.ts' +import type { SidebarStore, SidebarTab, TabType } from '../state.ts' +import css from '../sidebar.module.css' + +/** + * The plugin-side seed a native open carries in `navigation.params`. + * JSON-shaped by convention (the native surface does not validate it). + */ +export interface NativeTabParams { + /** Overrides the descriptor's title for this instance. */ + readonly title?: string + /** A file path (the editor window's content seed). */ + readonly path?: string + /** A URL the tab navigates to on mount (the browser tab's seed). */ + readonly url?: string + /** A diff reference (the diff tab's content seed). */ + readonly diff?: SidebarTab['diff'] + /** JSON-serializable custom state carried on the synthetic record. */ + readonly meta?: unknown + /** A line to land on (file addresses carry it as a navigation parameter). */ + readonly line?: number +} + +/** The native tab information this adapter reads (structural mirror of `useTabInfo`). */ +export interface NativeTabInfo { + readonly tab: { + readonly id: string + readonly kind: string + readonly title: string + readonly contentId: string + readonly visible: boolean + readonly navigation: { + readonly address: string + readonly params: NativeTabParams | undefined + readonly revision: number + } + readonly signal: AbortSignal + } +} + +/** One native tab's plugin-side view state. */ +interface View { + tab: SidebarTab + scope: SessionScope + expanded: string[] + revealed: string[] + /** Bumped on every mutation; the components subscribe to it. */ + version: number +} + +/** The plugin-side record registry for native tabs. */ +export interface NativeTabRecords { + /** + * The synthetic record for a native tab, minted on first sight and kept + * across navigations (a navigation refreshes the seed fields, never the + * identity or a plugin-side title/meta mutation). + * @param input - the native record and the session it lives in. + * @returns the current view state. + */ + ensure(input: { + id: string + kind: string + title: string + params: NativeTabParams | undefined + scope: SessionScope + /** + * The descriptor's own factory, called ONCE for a record that arrives + * without seed fields (a native guide open, which knows nothing about the + * plugin's per-instance minting): it supplies the title and the meta a + * view needs — the side chat's thread bootstrap, the terminal's name. + */ + mint?: () => { title?: string; meta?: unknown } | undefined + }): View + /** One record by native tab id. */ + get(id: string): View | undefined + /** Whether this id belongs to a native tab (vs the plugin's own layout). */ + has(id: string): boolean + /** Merge a patch into the synthetic record (the `updateTab` path). */ + update(id: string, patch: { title?: string; path?: string; meta?: unknown }): void + /** Forget a record (the native tab closed). */ + drop(id: string): void + /** Toggle one directory in a record's expansion set. */ + toggleExpanded(id: string, path: string): void + /** Mint the next instance number of a kind (titles like "Terminal 2"). */ + nextInstance(kind: string): number + /** A per-record version for `useSyncExternalStore`. */ + versionOf(id: string): number + /** Subscribe to record changes (title/path/meta/expanded). */ + subscribe(listener: () => void): () => void +} + +/** Create the record registry for one client activation. */ +export function createNativeTabRecords(): NativeTabRecords { + const views = new Map() + const instances = new Map() + const listeners = new Set<() => void>() + const notify = (): void => { for (const listener of listeners) listener() } + const put = (id: string, view: View): void => { + views.set(id, { ...view, version: view.version + 1 }) + notify() + } + return { + ensure({ id, kind, title, params, scope, mint }) { + const existing = views.get(id) + if (existing === undefined) { + const seeded = params?.title === undefined && params?.meta === undefined ? mint?.() : undefined + const meta = params?.meta ?? seeded?.meta + const minted: View = { + tab: { + id, + type: kind as TabType, + title: params?.title ?? seeded?.title ?? title, + ...(params?.path === undefined ? {} : { path: params.path }), + ...(params?.diff === undefined ? {} : { diff: params.diff }), + ...(meta === undefined ? {} : { meta }), + }, + scope, + expanded: [], + revealed: [], + version: 0, + } + views.set(id, minted) + return minted + } + // A navigation may carry new seed fields (the editor's in-place switch, + // a browser tab pointed at another URL); the record's identity and any + // plugin-side mutation (title/meta from updateTab) stay. + const patch: Partial = {} + if (params?.path !== undefined && params.path !== existing.tab.path) patch.path = params.path + if (params?.diff !== undefined) patch.diff = params.diff + if (params?.url !== undefined) { + const meta = typeof existing.tab.meta === 'object' && existing.tab.meta !== null + ? existing.tab.meta as Record + : {} + patch.meta = { ...meta, url: params.url } + } + if (existing.scope.cwd !== scope.cwd) { + views.set(id, { ...existing, scope, tab: { ...existing.tab, ...patch } }) + return views.get(id)! + } + if (Object.keys(patch).length === 0) return existing + const next: View = { ...existing, tab: { ...existing.tab, ...patch } } + views.set(id, next) + return next + }, + get: id => views.get(id), + has: id => views.has(id), + update(id, patch) { + const entry = views.get(id) + if (entry === undefined) return + put(id, { ...entry, tab: { ...entry.tab, ...patch } }) + }, + drop(id) { + if (views.delete(id)) notify() + }, + toggleExpanded(id, path) { + const entry = views.get(id) + if (entry === undefined) return + const expanded = entry.expanded.includes(path) + ? entry.expanded.filter(candidate => candidate !== path) + : [...entry.expanded, path] + put(id, { ...entry, expanded }) + }, + nextInstance(kind) { + const next = (instances.get(kind) ?? 0) + 1 + instances.set(kind, next) + return next + }, + versionOf: id => views.get(id)?.version ?? 0, + subscribe(listener) { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + } +} + +/** What a body registration injects (the plugin's business face). */ +export interface NativeBodyInjected { + readonly sessionId: string + readonly ctx: Context + readonly store: SidebarStore + readonly service: BetterSidebarService + readonly records: NativeTabRecords + /** The descriptor id this body draws (one registration per descriptor). */ + readonly descriptorId: string + /** + * Extra seed fields derived from the native record — a file address carries + * its path there, not in `navigation.params`. + */ + readonly paramsOf?: (info: NativeTabInfo) => NativeTabParams | undefined + /** + * The session the body acts in, when the record names one (a + * `session`-scoped file address names its own session); absent falls back to + * the session the slot is scoped to. + */ + readonly sessionIdOf?: (info: NativeTabInfo) => string | undefined +} + +/** The props the slot framework adds to every tab body and title. */ +export interface NativeBodyFrameworkProps { + readonly useTabInfo: () => NativeTabInfo +} + +/** Subscribe a component to its own record's mutations. */ +function useRecordVersion(records: NativeTabRecords, id: string): number { + return useSyncExternalStore( + listener => records.subscribe(listener), + () => records.versionOf(id), + ) +} + +/** The current session's workspace root, live from the client session list. */ +function useSessionCwd(ctx: Context, sessionId: string): string | undefined { + return useSyncExternalStore( + useMemo(() => (listener: () => void) => ctx.sessions.list.subscribe(listener), [ctx]), + () => ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd, + ) +} + +/** + * One plugin tab rendered inside the native right Sidebar: the descriptor's + * own component with the plugin's props, over a synthetic record minted from + * the native tab and dropped when the record ends. + */ +export function NativeTabBody(props: NativeBodyInjected & NativeBodyFrameworkProps): ReactNode { + const { ctx, store, service, records, descriptorId, useTabInfo } = props + const info = useTabInfo() + const nativeTab = info.tab + const version = useRecordVersion(records, nativeTab.id) + const sessionId = props.sessionIdOf?.(info) ?? props.sessionId + const cwd = useSessionCwd(ctx, sessionId) + const scope = useMemo((): SessionScope => ({ sessionId, cwd }), [sessionId, cwd]) + // `version` is not read: it only forces this render when the record changed. + void version + const derived = props.paramsOf?.(info) + const params = derived === undefined && nativeTab.navigation.params === undefined + ? undefined + : { ...derived, ...nativeTab.navigation.params } + const descriptor = service.getTab(descriptorId) + const view = records.ensure({ + id: nativeTab.id, + kind: nativeTab.kind, + title: nativeTab.title, + params, + scope, + mint: () => { + const state = store.getSnapshot().state + if (descriptor?.createTab === undefined || state === undefined) return undefined + const minted = descriptor.createTab(state) + return minted === null ? undefined : { title: minted.tab.title, meta: minted.tab.meta } + }, + }) + useEffect(() => () => { records.drop(nativeTab.id) }, [records, nativeTab.id]) + if (descriptor === undefined) { + return createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible }) + } + return createElement( + RenderBoundary, + { className: css.tabBoundaryError }, + createElement(descriptor.component, { + ctx, + store, + scope, + tab: view.tab, + visible: nativeTab.visible, + expanded: view.expanded, + revealed: view.revealed, + onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) }, + onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) }, + onOpenDiff: (tab: SidebarTab) => { + service.openTab({ + type: 'diff', + title: tab.title, + id: tab.id, + ...(tab.diff === undefined ? {} : { diff: tab.diff }), + }, scope) + }, + onSubagentJump: (childSessionId: string) => { + service.openTab({ type: 'subagent', meta: { childSessionId } }, scope) + }, + }), + ) +} + +/** What a title registration injects. */ +export interface NativeTitleInjected { + readonly records: NativeTabRecords +} + +/** + * A live tab chip: the synthetic record's title (the editor rewrites it on an + * in-place file switch, the side chat on the thread's first prompt). Without + * this registration the chip would keep the title captured when the tab + * opened. + */ +export function NativeTabTitle(props: NativeTitleInjected & NativeBodyFrameworkProps): ReactNode { + const { records, useTabInfo } = props + const nativeTab = useTabInfo().tab + return useSyncExternalStore( + listener => records.subscribe(listener), + () => records.get(nativeTab.id)?.tab.title ?? nativeTab.title, + ) +} + +/** The component pair one descriptor contributes to the native surface. */ +export interface NativeTabComponents { + readonly body: ComponentType + readonly title: ComponentType +} diff --git a/src/client/reference-in-chat.ts b/src/client/reference-in-chat.ts new file mode 100644 index 0000000..1043457 --- /dev/null +++ b/src/client/reference-in-chat.ts @@ -0,0 +1,42 @@ +/** + * The explorer's `@`-reference button: insert one file or folder reference + * into the conversation draft of a session. + * + * Directories append the folder mention (`@dir/`) as plain text so DSH's + * folder decoration and completion keep working; files insert a structured + * chip like the native `@` picker, so the whole reference stays one link + * instead of decorating only the leading folder. The session-scope context and + * the conversation input service are resolved at click time; a missing service + * or scope degrades to a logged no-op, never a crash. + * + * Shared by the plugin's own panel and the native right-Sidebar tab body, so + * both surfaces behave identically. + */ +import type { Context } from '../context-types.ts' +import { appendToDraft, insertFileReference } from './conversation-draft.ts' +import { relativeTo } from './paths.ts' + +/** + * Reference one path in the session's conversation draft. + * @param ctx - the client context. + * @param sessionId - the session whose draft receives the reference. + * @param cwd - that session's workspace root (for the relative spelling). + * @param path - the absolute or workspace-relative path to reference. + * @param isDir - whether the path is a directory (folder mention vs file chip). + */ +export function referenceInChat( + ctx: Context, + sessionId: string, + cwd: string | undefined, + path: string, + isDir: boolean, +): void { + const rel = relativeTo(cwd ?? '', path) + if (isDir) { + appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`) + return + } + if (!insertFileReference(ctx, sessionId, rel)) { + appendToDraft(ctx, sessionId, `@${rel}`) + } +} diff --git a/src/client/resource-address.ts b/src/client/resource-address.ts new file mode 100644 index 0000000..e1c2a91 --- /dev/null +++ b/src/client/resource-address.ts @@ -0,0 +1,138 @@ +/** + * The DSH resource-address grammar for files (`dsh-resource://file/…`). + * + * DSH's right Sidebar opens content by ADDRESS, and a file's address IS the + * tab's content identity: the same address is the same tab. Two scopes exist: + * + * - `dsh-resource://file/session//` names a file by its path + * relative to that session's workspace root (`src/a.ts`, no leading `/`); + * - `dsh-resource://file/absolute/` names a file by its absolute path + * with the leading `/` dropped (`absolute/home/me/x.txt`; Windows + * `absolute/C:/x/y.txt`; a UNC path keeps an empty first segment, + * `absolute//server/share/x.txt`). It carries no session. + * + * Every id and path segment is component-encoded, so a name carrying `#`, `?` + * or a space survives the round trip; `:` stays literal so a drive letter + * reads as written. + * + * The plugin parses these addresses itself instead of importing + * `@deepseek-ai/dsh-util-workspace-path`: the client bundle's purity gate + * forbids value-importing an unlisted `@deepseek-ai/*` package. This module + * mirrors that package's implementation (`packages/util/workspace-path` in + * DSH 0.1.5) and is pinned by tests/resource-address.spec.ts. + */ + +/** The scheme and type every file address opens with. */ +export const FILE_ADDRESS_PREFIX = 'dsh-resource://file/' + +/** + * A file resource address, in one of two scopes. + */ +export type FileAddress = + | { + readonly scope: 'session' + /** The session whose workspace root the path is relative to. */ + readonly sessionId: string + /** Workspace-relative `/`-separated path, no leading `/`; empty for the root itself. */ + readonly path: string + } + | { + readonly scope: 'absolute' + /** Absolute `/`-separated path: `/a/b` on POSIX, `C:/a/b` for a drive, `//server/share/a` for UNC. */ + readonly path: string + } + +/** Component-encode one id or path segment, keeping `:` literal for drive letters. */ +function encodeSegment(segment: string): string { + return encodeURIComponent(segment).replace(/%3A/gi, ':') +} + +/** Encode a `/`-separated path segment by segment. */ +function encodePath(path: string): string { + return path.split('/').map(encodeSegment).join('/') +} + +/** Whether a decoded first path segment is a Windows drive (`C:`). */ +function isDriveSegment(segment: string | undefined): boolean { + return segment !== undefined && /^[A-Za-z]:$/.test(segment) +} + +/** + * Build the address of a file inside one session's workspace. + * @param sessionId - the session whose workspace root the path is relative to. + * @param path - workspace-relative path; backslashes normalize to `/`, a leading `./` or `/` is dropped. + * @returns the `dsh-resource://file/session//` address. + */ +export function sessionFileAddress(sessionId: string, path: string): string { + const relative = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '') + return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(relative)}` +} + +/** + * Build the address of a file by its absolute path. + * @param path - absolute path; backslashes normalize to `/` and the leading `/` + * is dropped, except that a UNC path keeps one empty first segment. + * @returns the `dsh-resource://file/absolute/` address. + */ +export function absoluteFileAddress(path: string): string { + const normalized = path.replace(/\\/g, '/') + const unc = normalized.startsWith('//') + const absolute = normalized.replace(/^\/+/, '') + return `${FILE_ADDRESS_PREFIX}absolute/${unc ? '/' : ''}${encodePath(absolute)}` +} + +/** + * Read a file address back into its parts. + * @param address - a candidate address. + * @returns the parts, or `undefined` when the string is not a + * `dsh-resource://file/` URI in a known scope with a valid path. + */ +export function parseFileAddress(address: string): FileAddress | undefined { + try { + const url = new URL(address) + if (url.protocol !== 'dsh-resource:' || url.host !== 'file') return undefined + const [, scope, ...rest] = url.pathname.split('/') + if (scope === 'session') { + const [id, ...segments] = rest + if (id === undefined || id === '' || segments.length === 0) return undefined + return { scope, sessionId: decodeURIComponent(id), path: segments.map(decodeURIComponent).join('/') } + } + if (scope === 'absolute') { + // An empty first segment with more behind it is a UNC path's `//`; alone it is no path. + const unc = rest[0] === '' && rest.length > 1 + const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent) + if (segments.length === 0 || segments[0] === '') return undefined + if (unc) return { scope, path: `//${segments.join('/')}` } + return { scope, path: isDriveSegment(segments[0]) ? segments.join('/') : `/${segments.join('/')}` } + } + return undefined + } catch { + // `new URL` throws on a non-URL and `decodeURIComponent` on a malformed + // escape; both mean "not a file address". + return undefined + } +} + +/** Whether a path is absolute in either spelling the host accepts. */ +function isAbsolutePath(path: string): boolean { + return path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\') +} + +/** + * The address for a path as a caller holds it: a relative path, or an absolute + * path inside the session's workspace, becomes a `session`-scoped address; an + * absolute path outside it, or one whose workspace root is unknown, becomes an + * `absolute`-scoped address. (Mirror of `fileAddressFor`.) + * @param sessionId - the session the path is read in. + * @param cwd - that session's workspace root, when known. + * @param path - absolute or workspace-relative path, either separator spelling. + * @returns the `dsh-resource://file/…` address. + */ +export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string { + const normalized = path.replace(/\\/g, '/') + if (!isAbsolutePath(normalized)) return sessionFileAddress(sessionId, normalized) + const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '') + if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '') + if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1)) + return absoluteFileAddress(normalized) +} diff --git a/src/client/service.ts b/src/client/service.ts index 140c5c2..d92b381 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -23,8 +23,8 @@ import type { ReactNode } from 'react' import type { Context } from '../context-types.ts' import { activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab, - leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf, - type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, + leafWithTab, openTabInActivePane, openTabInBottomPane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf, + type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType, } from './state.ts' import { isNarrowWidth } from './breakpoints.ts' import { extOf } from './paths.ts' @@ -339,6 +339,57 @@ export interface OpenTabSeed { url?: string /** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */ meta?: unknown + /** + * Where the open lands. `'right'` (the default) is DSH's right Sidebar — + * the plugin's content is registered there as native tab types; `'bottom'` + * is the plugin's own bottom workbench. Only the plugin's own flows pass + * `'bottom'` (the bottom panel's + menu, the auto-terminal). + */ + target?: 'right' | 'bottom' +} + +/** + * The plugin-side seed a native right-Sidebar tab carries in its navigation + * params (the native surface passes them back on every navigation). + */ +export interface NativeTabParams { + /** Overrides the descriptor's title for this instance. */ + title?: string + /** A file path (the editor window's content seed). */ + path?: string + /** A URL the tab navigates to on mount (the browser tab's seed). */ + url?: string + /** A diff reference (the diff tab's content seed). */ + diff?: SidebarTab['diff'] + /** JSON-serializable custom state carried on the synthetic record. */ + meta?: unknown +} + +/** + * The plugin's write face over DSH's native right Sidebar. + * + * Installed by the client half ({@link ./native/surface.ts}) so the service — + * and therefore every consumer of `ctx.betterSidebar` — keeps speaking the + * plugin's own vocabulary while the content lands natively. Without it the + * service writes into the plugin's own layout (the pre-0.1.5 behavior, which + * the bottom workbench still uses). + * @internal Not part of the consumer contract. + */ +export interface SidebarSurface { + /** Open a page type in one session's native surface. */ + openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean }): void + /** Open a resource address in one session's native surface. */ + openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean }): void + /** The file address of one path (the native surface owns the grammar). */ + fileAddress(sessionId: string, cwd: string | undefined, path: string): string + /** Close one native tab; the closed record's type/title, or undefined when the id is not native. */ + close(sessionId: string, tabId: string): { type: string; title: string } | undefined + /** Patch a native tab's plugin-side record; false when it is not native. */ + update(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): boolean + /** Focus a native tab; false when it is not native. */ + activate(tabId: string): boolean + /** Whether a tab id belongs to the native surface. */ + has(tabId: string): boolean } /** @@ -426,6 +477,11 @@ export interface BetterSidebarService { activateTab(tabId: string, scope?: SessionScope): void /** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */ openFile(scope: SessionScope, path: string, title?: string): void + /** + * Install (or clear) the native right-Sidebar write face. + * @internal Called once by the client half; not part of the consumer API. + */ + setSurface(surface: SidebarSurface | undefined): void } /** The file name of a path (both separators). */ @@ -516,6 +572,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const tabs = new Map() const viewers = new Map() const listeners = new Set<() => void>() + /** The native right-Sidebar write face, installed by the client half. */ + let surface: SidebarSurface | undefined const notify = (): void => { for (const fn of [...listeners]) fn() @@ -608,6 +666,61 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (targetSessionId === undefined) return const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId } + // ── Native right Sidebar ────────────────────────────────────────────── + // With the native surface installed, every open except an explicit + // bottom-panel one lands there: a file path becomes a resource address + // (the native registry routes it to the plugin's file type), a path-less + // editor open becomes the `files` page kind, and everything else becomes + // a page open carrying the seed as navigation params. + if (surface !== undefined && seed.target !== 'bottom') { + const state = store.getSnapshot().state + // The descriptor's own factory mints what a view needs beyond the seed: + // the side chat's thread bootstrap / reattach meta, the terminal's + // per-instance title. A `null` return refuses the open (terminal cap). + const minted = descriptor.createTab === undefined || state === undefined + ? undefined + : descriptor.createTab(state) + if (minted === null) return + const title = seed.title ?? minted?.tab.title + ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title) + // Multi-instance kinds (terminal / browser / side chat / diff) mint a + // fresh tab per open; single-instance kinds focus the existing one. + const revealIfOpened = descriptor.createTab === undefined + const synthetic: SidebarTab = { + id: seed.id ?? minted?.tab.id ?? seed.type, + type: seed.type, + title, + ...(seed.path === undefined ? {} : { path: seed.path }), + ...(seed.diff === undefined ? {} : { diff: seed.diff }), + ...(seed.meta === undefined && minted?.tab.meta === undefined ? {} : { meta: seed.meta ?? minted?.tab.meta }), + } + if (seed.path !== undefined) { + surface.openResource({ + sessionId: targetSessionId, + address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path), + revealIfOpened: true, + }) + } else if (seed.type === 'editor') { + // The path-less editor window IS the file explorer. + surface.openTab({ sessionId: targetSessionId, kind: 'files', params: {}, revealIfOpened: true }) + } else { + surface.openTab({ + sessionId: targetSessionId, + kind: seed.type, + params: { + title, + ...(seed.url === undefined ? {} : { url: seed.url }), + ...(seed.diff === undefined ? {} : { diff: seed.diff }), + ...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }), + }, + revealIfOpened, + }) + } + // The native surface reports one open event, not create-vs-focus, so a + // lifecycle consumer hears onOpen (documented in the guide). + safeCall(() => descriptor.onOpen?.(synthetic, callbackScope)) + return + } // Whether this open targets a session that is NOT the one on screen: a // targeted open must not auto-expand panels the user cannot see (the // expansion is about landing "in sight" for the CURRENT viewer). @@ -617,6 +730,10 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe // dedupe/id-safety-net focus is an ACTIVATION, not an open). let created: SidebarTab | undefined let activated: SidebarTab | undefined + // A bottom-targeted open lands in the bottom workbench's own pane; the + // right tree it would otherwise follow is no longer rendered (DSH's + // native sidebar owns the right column). + const land = seed.target === 'bottom' ? openTabInBottomPane : openTabInActivePane const reducer = (state: SidebarState): SidebarState => { // Let the descriptor mint the tab (terminal's nextTerminal bump, etc.). let tab: SidebarTab @@ -625,7 +742,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const result = descriptor.createTab(state) if (result === null) return state tab = result.tab - next = applyDedupe(state, result.tab, descriptor) + next = applyDedupe(state, result.tab, descriptor, land) if (result.patch !== undefined) next = { ...next, ...result.patch } } else { tab = { @@ -638,7 +755,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe ...(seed.diff !== undefined ? { diff: seed.diff } : {}), ...(seed.meta !== undefined ? { meta: seed.meta } : {}), } - next = applyDedupe(state, tab, descriptor) + next = applyDedupe(state, tab, descriptor, land) } // Classify the landing against the INPUT state FIRST: a FOCUS fires // onActivate with the tab that is active NOW; a real creation fires @@ -735,6 +852,20 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe } const closeTab = (tabId: string, scope?: SessionScope): void => { + const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId + if (surface !== undefined && sessionId !== undefined) { + const closedNative = surface.close(sessionId, tabId) + if (closedNative !== undefined) { + const descriptor = tabs.get(closedNative.type) + if (descriptor !== undefined) { + safeCall(() => descriptor.onClose?.( + { id: tabId, type: closedNative.type as TabType, title: closedNative.title }, + scope ?? { sessionId }, + )) + } + return + } + } let closed: SidebarTab | undefined store.reduce((state) => { // Unknown tab ids are a strict no-op: no state churn, no notify, no @@ -769,6 +900,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe /** Patch an open tab's display fields (a missing tab id is a no-op). */ const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => { + if (surface?.update(tabId, patch) === true) return store.reduce((state) => patchTab(state, tabId, { ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.path !== undefined ? { path: patch.path } : {}), @@ -778,6 +910,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe /** Activate an open tab (the tab-bar activation path; fires onActivate). */ const activateTab = (tabId: string, scope?: SessionScope): void => { + if (surface?.activate(tabId) === true) return let activated: SidebarTab | undefined store.reduce((state) => { // Unknown tab ids are a strict no-op (no state churn / notify). @@ -829,6 +962,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe updateTab, activateTab, openFile, + setSurface: (next: SidebarSurface | undefined) => { surface = next }, } } @@ -839,7 +973,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe * reducer's job — not re-implemented here). * `single: true` resolves to the id-key sugar when no explicit key is given. */ -function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState { +function applyDedupe( + state: SidebarState, + tab: SidebarTab, + descriptor: TabDescriptor, + land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInActivePane, +): SidebarState { const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined) const key = dedupeKey?.(tab) if (key !== undefined) { @@ -854,7 +993,7 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key) if (floated !== undefined) return raiseFloat(state, floated.id) } - return openTabInActivePane(state, tab) + return land(state, tab) } /** Find which pane hosts a tab id ('' if none). Either tree is searched. */ diff --git a/src/client/state.ts b/src/client/state.ts index 172415c..0927f58 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -660,6 +660,37 @@ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): Sideb } } +/** + * Land a tab in the BOTTOM workbench's first pane (the bottom panel's own + * opens — its + menu, the auto-terminal): the plugin's right tree is no longer + * rendered on 0.1.5 (DSH's native sidebar is the right column), so an open + * that must land in the bottom dock cannot follow the active pane, which may + * point at the retired tree. + * @param state - the session state. + * @param tab - the tab to land. + * @returns the next state, with the bottom panel open. + */ +export function openTabInBottomPane(state: SidebarState, tab: SidebarTab): SidebarState { + const targetId = firstLeaf(state.bottomSplits).id + // Id-based safety net: if a tab with the same id exists, focus it — in a + // pane (activate) or in a free window (raise, no panel switch). + for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) { + const existing = leaf.tabs.find(candidate => candidate.id === tab.id) + if (existing !== undefined) return activateTab(state, leaf.id, existing.id) + } + const floated = floatWithTab(state, tab.id) + if (floated !== undefined) return raiseFloat(state, floated.id) + return { + ...state, + bottomOpen: true, + activePane: targetId, + bottomSplits: mapLeaf(state.bottomSplits, targetId, (leaf) => { + leaf.tabs = [...leaf.tabs, tab] + leaf.active = tab.id + }), + } +} + /** Move a tab from one pane to another (insert at index; -1 appends). * The panes may live in DIFFERENT trees — dragging a tab between the two * panels removes it from its own tree and lands it in the other one. */ diff --git a/tests/e2e/desktop-layout.e2e.ts b/tests/e2e/desktop-layout.e2e.ts deleted file mode 100644 index 87cd006..0000000 --- a/tests/e2e/desktop-layout.e2e.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** Desktop layout regression: opening the right panel must not make Harness enter narrow mode. */ -import { mkdirSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { expect, test, type APIRequestContext, type Page } from '@playwright/test' -import { PAGE_URL, createHostApi, hostRpc } from './host' - -const WORKSPACE_PATH = process.env.DSH_E2E_DESKTOP_WORKSPACE - ?? (process.env.DSH_E2E_WORKSPACE === undefined - ? join(homedir(), 'dsh-e2e-desktop-layout-workspace') - : `${process.env.DSH_E2E_WORKSPACE}-desktop-layout`) - -let api: APIRequestContext - -async function dismissOnboarding(page: Page): Promise { - await expect - .poll(() => page.getByRole('button', { name: /^(Continue|Configure later|继续|稍后配置)$/ }).count(), { timeout: 60_000 }) - .toBeGreaterThan(0) - for (let round = 0; round < 8; round++) { - let dismissed = false - for (const name of [/^(Continue|继续)$/, /^(Configure later|稍后配置)$/]) { - const button = page.getByRole('button', { name }).first() - if ((await button.count()) === 0) continue - await button.click() - dismissed = true - await expect(button).toBeHidden() - } - if (!dismissed) return - } - throw new Error('onboarding takeovers did not settle after visible dismissal') -} - -test.beforeAll(async () => { - mkdirSync(WORKSPACE_PATH, { recursive: true }) - // Seed one workspace + one session through the host's own RPC surface (the - // calls the UI makes) instead of driving the workspace picker: DSH 0.1.5 - // ships BOTH a browser directory dialog and an OS-native chooser and picks - // one by platform, so a UI-driven flow only works on hosts whose chooser - // renders in the page. - api = await createHostApi() - const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) - await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) -}) - -test.afterAll(async () => { - await api?.dispose() -}) - -test('right panel keeps desktop session actions in their header positions', async ({ page }) => { - const pageErrors: string[] = [] - const consoleErrors: string[] = [] - const requestFailures: string[] = [] - const badResponses: string[] = [] - page.on('pageerror', error => pageErrors.push(error.stack ?? error.message)) - page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()) }) - page.on('requestfailed', request => requestFailures.push(`${request.method()} ${request.url()} ${request.failure()?.errorText ?? 'unknown'}`)) - page.on('response', response => { if (response.status() >= 400) badResponses.push(`${response.status()} ${response.url()}`) }) - - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await dismissOnboarding(page) - - // The seeded blank session opens with its workspace already attached (the - // chip shows the workspace title), so the hero composer is live without - // touching the workspace picker at all. One message gives the header its - // real actions, which this spec measures. - const composer = page.getByRole('textbox', { name: /^(Describe what you want to build|描述你想要构建)/ }) - await expect(composer).toBeVisible() - // DSH 0.1.5's composer is a Lexical contenteditable: fill() writes the DOM - // but not the editor model, so the send button would stay disabled — type - // real keys instead. - await composer.click() - await page.keyboard.type('Create a desktop side-card layout test session.') - const send = page.getByRole('button', { name: /^(Send message|发送消息)$/ }) - await expect(send).toBeEnabled({ timeout: 30_000 }) - await send.click() - const sessionLog = page.getByRole('button', { name: 'Session log', exact: true }) - const sessionLogLabel = sessionLog.getByText('Session log', { exact: true }) - await expect(sessionLogLabel, 'the desktop session-log action starts as a text button').toBeVisible({ timeout: 30_000 }) - - const root = page.locator('#root') - const frame = page.locator('#root [data-dsh-frame], #root > [data-slot="root"] > div').first() - const appSidebarExpanded = root.getByRole('button', { name: /^(Collapse sidebar|收起侧边栏)$/ }) - const sidebar = page.locator('[data-dsh-better-sidebar]') - await expect(appSidebarExpanded, 'the desktop frame starts with its app sidebar expanded').toBeVisible() - const beforeRoot = await root.boundingBox() - const beforeFrame = await frame.boundingBox() - expect(beforeRoot).not.toBeNull() - expect(beforeFrame).not.toBeNull() - // The header action's own width is the baseline: the assertion below is - // "the panel does not squeeze it", not a host-styling constant. - const beforeSessionLog = await sessionLog.boundingBox() - expect(beforeSessionLog).not.toBeNull() - - await sidebar.getByRole('button', { name: /^(Expand sidebar|展开侧边栏)$/ }).click() - const panel = page.locator('[data-dsh-panel]:not([data-dsh-bottom-panel])') - await expect(panel).toBeVisible() - await expect - .poll(async () => { - const center = page.locator('#root [data-dsh-frame] > [data-pane="conversation"], #root :has(> [data-slot="conversation"])').first() - const [centerBox, panelBox] = await Promise.all([center.boundingBox(), panel.boundingBox()]) - if (centerBox === null || panelBox === null) return Number.POSITIVE_INFINITY - return Math.abs(centerBox.x + centerBox.width - panelBox.x) - }, { timeout: 30_000 }) - .toBeLessThanOrEqual(2) - - const afterRoot = await root.boundingBox() - const afterFrame = await frame.boundingBox() - expect(afterRoot).not.toBeNull() - expect(afterFrame).not.toBeNull() - expect(Math.abs(afterRoot!.width - beforeRoot!.width), 'opening the plugin panel must not shrink #root').toBeLessThanOrEqual(1) - expect(Math.abs(afterFrame!.width - beforeFrame!.width), 'opening the plugin panel must not shrink AppFrame').toBeLessThanOrEqual(1) - await expect(appSidebarExpanded, 'a desktop window must not enter the host narrow layout').toBeVisible() - await expect(sessionLogLabel, 'the session-log action must keep its desktop label').toBeVisible() - expect(await sessionLog.evaluate(element => getComputedStyle(element).position), 'the session-log action must stay in the header flow').not.toBe('fixed') - - const [sessionLogBox, panelBox] = await Promise.all([sessionLog.boundingBox(), panel.boundingBox()]) - expect(sessionLogBox).not.toBeNull() - expect(panelBox).not.toBeNull() - expect( - sessionLogBox!.width, - 'the desktop session-log action must keep its full button width', - ).toBeGreaterThanOrEqual(beforeSessionLog!.width - 1) - expect(sessionLogBox!.x + sessionLogBox!.width, 'the session-log action must not overlap the plugin panel chrome').toBeLessThanOrEqual(panelBox!.x - 8) - expect(pageErrors).toEqual([]) - expect(consoleErrors).toEqual([]) - expect(requestFailures).toEqual([]) - expect(badResponses).toEqual([]) -}) diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts deleted file mode 100644 index 0851c33..0000000 --- a/tests/e2e/drag-layout.e2e.ts +++ /dev/null @@ -1,775 +0,0 @@ -/** - * Drag-layout lane: the width drag must track the app shell 1:1 — the - * regression test for issue #92 ("主会话框左右抖动"). - * - * The layout push reserves `padding-right: var(--dsh-sidebar-width)` inside - * AppFrame (layout.css), and layout.css disables that padding's transition - * while a drag is live via `body[data-dsh-sidebar-dragging]`. If the transition - * stays active during the drag (or the conversation lags the panel edge), the - * conversation visibly shakes at pointer cadence. This spec drives a real - * pointer drag on the width strip while a requestAnimationFrame sampler - * records, per frame: - * - * - the strip's x (the panel edge), - * - the conversation column's right edge (AppFrame's reserved padding lands - * exactly there), - * - whether `body[data-dsh-sidebar-dragging]` is set, - * - AppFrame's computed transition property/duration. - * - * Then it asserts the drag contract: - * 1. the dragging attribute is present during the drag; - * 2. AppFrame's transition is disabled (none) during the drag; - * 3. the conversation edge follows the panel edge monotonically (no - * oscillation) and 1:1 (total travel within a rounding epsilon). - * - * The server is booted by scripts/e2e-mount.sh; this spec only loads the - * page (same contract as mount.e2e.ts, using its own workspace so the two - * lanes never race on seeding). - */ -import { mkdirSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test' -import { PAGE_URL, createHostApi, hostRpc } from './host' - -/** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially - * but against the same server — never share seed paths). */ -const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-drag-workspace') - -let api: APIRequestContext - -/** Seed one workspace + one session through the host's unary RPC surface - * (dual-protocol helper — see ./host-protocol.ts). */ -async function seedSession(): Promise { - mkdirSync(WORKSPACE_PATH, { recursive: true }) - writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag lane\n') - const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) - await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) -} - -test.beforeAll(async () => { - api = await createHostApi() - await seedSession() -}) - -test.afterAll(async () => { - await api?.dispose() -}) - -interface FrameSample { - t: number - stripX: number - convoRight: number - dragging: boolean - transitionProperty: string - transitionDuration: string -} - -test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => { - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) - 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. - try { - await expect - .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 }) - .toBeGreaterThan(0) - } catch { - console.warn('[e2e-drag] no onboarding takeover appeared; proceeding') - } - for (let round = 0; round < 8; round++) { - let dismissed = false - for (const name of ['Continue', 'Configure later']) { - const button = page.getByRole('button', { name, exact: true }).first() - if ((await button.count()) === 0) continue - try { - await button.click({ timeout: 4_000 }) - dismissed = true - await page.waitForTimeout(1_000) - } catch { - // Masked by a takeover stacked above; retry in the next round. - } - } - 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[] = [] - // 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 is the grid item whose right edge meets the - // AppFrame padding reservation and the plugin panel. - const center = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]') - ?? document.querySelector('#root :has(> [data-slot="conversation"])') - const frame = (document.querySelector('#root [data-dsh-frame]') - ?? document.querySelector('#root > [data-slot="root"] > div')) as HTMLElement - const loop = (): void => { - const s = strip?.getBoundingClientRect() ?? { left: 0 } - const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 } - const cs = getComputedStyle(frame) - samples.push({ - t: performance.now(), - stripX: s.left, - convoRight: c.right, - dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'), - transitionProperty: cs.transitionProperty, - transitionDuration: cs.transitionDuration, - }) - requestAnimationFrame(loop) - } - requestAnimationFrame(loop) - ;(window as unknown as { __dragSamples: Sample[] }).__dragSamples = samples - }) - - const startX = stripBox!.x + stripBox!.width / 2 - const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60) - - await page.mouse.move(startX, startY) - await page.mouse.down() - // Drag LEFT in steps (widens the panel): the conversation edge must move - // LEFT in lockstep with the panel edge, monotonically, no oscillation. - for (let i = 1; i <= 14; i++) { - await page.mouse.move(startX - i * 10, startY, { steps: 2 }) - await page.waitForTimeout(40) - } - await page.mouse.up() - await page.waitForTimeout(400) - - const samples = await page.evaluate( - () => (window as unknown as { __dragSamples: FrameSample[] }).__dragSamples, - ) - expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20) - - // The drag must actually have moved the panel (sanity: the store committed). - const first = samples.find(s => s.dragging) - const last = [...samples].reverse().find(s => s.dragging) - expect(first, 'the dragging attribute must appear during the drag').toBeDefined() - expect(last!.stripX, 'the panel edge must have moved during the drag').toBeLessThan(first!.stripX - 40) - // The conversation-column selector must have matched (the push lands on it). - expect(last!.convoRight, 'the conversation edge must have moved with the drag').toBeLessThan(first!.convoRight - 40) - - // Contract 1 + 2: while dragging, the body attribute is set and AppFrame's - // padding transition is disabled (computed `transition: none` reads as - // transition-property "none" with 0s duration; the non-dragging rule would - // compute to "padding-right" with the theme duration). - const draggingSamples = samples.filter(s => s.dragging) - expect(draggingSamples.length).toBeGreaterThan(5) - for (const sample of draggingSamples) { - expect(sample.transitionProperty, 'the padding transition must be off while dragging').toBe('none') - expect(sample.transitionDuration, 'the padding transition must be off while dragging').toBe('0s') - } - - // Contract 3: monotonic, 1:1 tracking. During a leftward drag both the - // strip x and the conversation right edge decrease; allow one frame of - // rAF-batching staleness (0 delta), never a reversal. - const tracked = draggingSamples.filter(s => s.t > first!.t) - for (let i = 1; i < tracked.length; i++) { - const stripDelta = tracked[i]!.stripX - tracked[i - 1]!.stripX - const convoDelta = tracked[i]!.convoRight - tracked[i - 1]!.convoRight - expect(stripDelta, 'the strip must move left during the drag').toBeLessThanOrEqual(2) - expect( - convoDelta, - `conversation edge reversed while dragging (jitter): strip ${stripDelta}px, conversation ${convoDelta}px`, - ).toBeLessThanOrEqual(2) - } - // Total travel in lockstep (rounding + one-frame staleness tolerance). - const stripTravel = first!.stripX - last!.stripX - const convoTravel = first!.convoRight - last!.convoRight - expect(Math.abs(convoTravel - stripTravel), 'conversation must track the panel edge 1:1').toBeLessThanOrEqual(8) -}) - -test('a very fast width drag still commits the dragged position (no rollback on quick release)', async ({ page }) => { - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) - 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 - // main lane) so the pointer can reach the strip. - try { - await expect - .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 }) - .toBeGreaterThan(0) - } catch { - console.warn('[e2e-drag-fast] no onboarding takeover appeared; proceeding') - } - for (let round = 0; round < 8; round++) { - let dismissed = false - for (const name of ['Continue', 'Configure later']) { - const button = page.getByRole('button', { name, exact: true }).first() - if ((await button.count()) === 0) continue - try { - await button.click({ timeout: 4_000 }) - dismissed = true - await page.waitForTimeout(1_000) - } catch { - // Masked by a takeover stacked above; retry in the next round. - } - } - if (!dismissed) break - } - - 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() - 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) - - const readWidth = (): Promise => page.evaluate(() => { - const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) - return Number.isNaN(value) ? 0 : value - }) - const widthBefore = await readWidth() - - // The panel slides in from the right on expand: read the strip box only - // AFTER it settles at the pushed layout edge (a mid-animation read sits - // off-viewport and the drag never reaches the strip). - await expect - .poll(async () => { - const box = await page.evaluate(() => { - const host = document.querySelector('[data-dsh-better-sidebar]') - const el = [...host!.querySelectorAll('*')] - .filter(e => getComputedStyle(e).cursor === 'col-resize') - .map(e => e.getBoundingClientRect()) - .filter(r => r.width > 0 && r.height > 0) - .sort((a, b) => a.x - b.x)[0] - return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth } - }) - if (box === null) return false - const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0) - return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8 - }, { timeout: 30_000 }) - .toBe(true) - const stripBox = await page.evaluate(() => { - 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 - return { x: r.x, y: r.y, width: r.width, height: r.height } - }) - expect(stripBox, 'the width drag strip must be present').not.toBeNull() - - const startX = stripBox!.x + stripBox!.width / 2 - const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60) - - // FAST release: a single move and an immediate up — no per-frame waits, - // so the last pointermove may never reach a requestAnimationFrame before - // the release (the exact quick-flick that used to roll back to the - // pre-drag width). - await page.mouse.move(startX, startY) - await page.mouse.down() - await page.mouse.move(startX - 140, startY, { steps: 1 }) - await page.mouse.up() - - // The committed width must reflect the drag — and stay there (no rollback - // once React settles). - await expect - .poll(async () => await readWidth(), { timeout: 10_000 }) - .toBeGreaterThan(widthBefore + 100) - await page.waitForTimeout(600) - expect(await readWidth(), 'the fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100) -}) - -/* ── issue #247: interrupted fast drags must not roll back ───────────── */ - -/** Dismiss whatever onboarding takeover is present (same dance as above). */ -async function dismissOnboarding(page: Page): Promise { - try { - await expect - .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 }) - .toBeGreaterThan(0) - } catch { - console.warn('[e2e-drag] no onboarding takeover appeared; proceeding') - } - for (let round = 0; round < 8; round++) { - let dismissed = false - for (const name of ['Continue', 'Configure later']) { - const button = page.getByRole('button', { name, exact: true }).first() - if ((await button.count()) === 0) continue - try { - await button.click({ timeout: 4_000 }) - dismissed = true - await page.waitForTimeout(1_000) - } catch { - // Masked by a takeover stacked above; retry in the next round. - } - } - if (!dismissed) break - } -} - -/** Open the sidebar panel and wait for the layout push to go live. */ -async function expandSidebar(page: Page, sidebar: Locator): Promise { - 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() - 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) -} - -/** Locate the width drag strip (the leftmost col-resize element inside the - * sidebar host), wait for it to settle at the pushed layout edge (the - * panel slides in from the right on expand — a strip read during the - * animation sits off-viewport and no pointer event ever reaches it), then - * read its FINAL box and return a drag start point on it. */ -async function settleWidthStrip(page: Page): Promise<{ startX: number; startY: number }> { - await expect - .poll(async () => { - const box = await page.evaluate(() => { - const host = document.querySelector('[data-dsh-better-sidebar]') - const el = [...host!.querySelectorAll('*')] - .filter(e => getComputedStyle(e).cursor === 'col-resize') - .map(e => e.getBoundingClientRect()) - .filter(r => r.width > 0 && r.height > 0) - .sort((a, b) => a.x - b.x)[0] - return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth } - }) - if (box === null) return false - const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0) - return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8 - }, { timeout: 30_000 }) - .toBe(true) - const stripBox = await page.evaluate(() => { - 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 - return { x: r.x, y: r.y, width: r.width, height: r.height } - }) - expect(stripBox, 'the width drag strip must be present').not.toBeNull() - return { - startX: stripBox!.x + stripBox!.width / 2, - startY: stripBox!.y + Math.min(120, stripBox!.height / 2 + 60), - } -} - -/** Dispatch a synthetic pointer event (pointercancel / lostpointercapture) - * onto the width strip. Synthetic events bypass the real pointer-capture - * pipeline, which is exactly the point: the handlers must survive streams - * that end without a component-visible pointerup. */ -async function dispatchPointerOnStrip( - page: Page, - type: 'pointercancel' | 'lostpointercapture', - init: { clientX?: number; clientY?: number }, -): Promise { - await page.evaluate(({ type, init }) => { - const host = document.querySelector('[data-dsh-better-sidebar]') - if (host === null) throw new Error('sidebar host missing') - const el = [...host.querySelectorAll('*')] - .filter(el => getComputedStyle(el).cursor === 'col-resize') - .map(el => ({ el, r: el.getBoundingClientRect() })) - .filter(({ r }) => r.width > 0 && r.height > 0) - .sort((a, b) => a.r.x - b.r.x)[0]?.el - if (el === undefined) throw new Error('width drag strip not found') - el.dispatchEvent(new PointerEvent(type, { - pointerId: 1, - bubbles: true, - cancelable: true, - pointerType: 'mouse', - isPrimary: true, - ...init, - })) - }, { type, init }) -} - -test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the dragged width (issue #247)', async ({ page }) => { - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) - const sidebar = page.locator('[data-dsh-better-sidebar]') - await expect(sidebar).toBeAttached({ timeout: 90_000 }) - await dismissOnboarding(page) - await expandSidebar(page, sidebar) - const readWidth = (): Promise => page.evaluate(() => { - const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) - return Number.isNaN(value) ? 0 : value - }) - const widthBefore = await readWidth() - const { startX, startY } = await settleWidthStrip(page) - - // Ultra-fast flick whose stream the browser then CANCELS (touchpad / - // gesture interception): pointercancel carries the final position and - // lostpointercapture follows it. The double fire must commit ONCE — the - // second interrupt event used to roll the drag back to the pre-drag - // width (the abort path never marked the drag committed). - await page.mouse.move(startX, startY) - await page.mouse.down() - await page.mouse.move(startX - 140, startY, { steps: 1 }) - await dispatchPointerOnStrip(page, 'pointercancel', { clientX: startX - 140, clientY: startY }) - await dispatchPointerOnStrip(page, 'lostpointercapture', {}) - - await expect - .poll(async () => await readWidth(), { timeout: 10_000 }) - .toBeGreaterThan(widthBefore + 100) - await page.waitForTimeout(600) - expect(await readWidth(), 'the interrupted fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100) - - // Release the real pointer stream the synthetic events bypassed. - await page.mouse.up() -}) - -test('a capture-lost drag with no usable coordinates keeps the last applied width (issue #247)', async ({ page }) => { - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) - const sidebar = page.locator('[data-dsh-better-sidebar]') - await expect(sidebar).toBeAttached({ timeout: 90_000 }) - await dismissOnboarding(page) - await expandSidebar(page, sidebar) - const readWidth = (): Promise => page.evaluate(() => { - const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) - return Number.isNaN(value) ? 0 : value - }) - const widthBefore = await readWidth() - const { startX, startY } = await settleWidthStrip(page) - - // A flick whose move DID reach the DOM (the rAF flushed it), followed by - // capture loss with NO coordinates (lostpointercapture does not carry a - // position). The abort must adopt the last applied size — the old code - // explicitly reverted the DOM to the pre-drag width here. - await page.mouse.move(startX, startY) - await page.mouse.down() - await page.mouse.move(startX - 120, startY, { steps: 1 }) - await page.waitForTimeout(100) // let the rAF consume the pending write - await dispatchPointerOnStrip(page, 'lostpointercapture', {}) - - await expect - .poll(async () => await readWidth(), { timeout: 10_000 }) - .toBeGreaterThan(widthBefore + 80) - await page.waitForTimeout(600) - expect(await readWidth(), 'the no-coordinate capture loss must not roll back after settling').toBeGreaterThan(widthBefore + 80) - await page.mouse.up() -}) - -/* ── issue #258: the bottom panel must not flash full-width after a width - drag release ─────────────────────────────────────────────────────── */ - -test('bottom panel never flashes full-width after a width drag release (issue #258)', async ({ page }) => { - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) - const sidebar = page.locator('[data-dsh-better-sidebar]') - await expect(sidebar).toBeAttached({ timeout: 90_000 }) - await dismissOnboarding(page) - await expandSidebar(page, sidebar) - - // The bottom panel must be OPEN too — its right edge is what tracks the - // center column (and what flashes full-width on release). - const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' }) - await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1) - await bottomExpand.click() - await expect - .poll(async () => { - const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) - return value !== '' && value !== '0px' - }, { timeout: 90_000 }) - .toBe(true) - - const { startX, startY } = await settleWidthStrip(page) - - // Per-frame sampler: the bottom panel's right edge vs the center column's - // right edge (where the layout push lands). The release path used to - // TRANSIENTLY REMOVE the push variables between the layout effect's - // cleanup and setup phases — React can yield a render frame in that gap, - // so the browser painted the push-less layout (center column full width), - // the drag-end measure cached that full-width rect, and the bottom panel - // rendered full-width while AppFrame's padding transition animated back - // to the new width. Every post-release frame must keep the two edges glued - // at the committed width: no full-width frame, no drift, no padding - // animation. - await page.evaluate(() => { - const samples: Array<{ t: number; bottomRight: number; colRight: number; varW: string; dragging: boolean; iw: number }> = [] - const loop = (): void => { - const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]') - const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement - samples.push({ - t: performance.now(), - bottomRight: bottom?.getBoundingClientRect().right ?? -1, - colRight: col?.getBoundingClientRect().right ?? -1, - varW: document.documentElement.style.getPropertyValue('--dsh-sidebar-width'), - dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'), - iw: window.innerWidth, - }) - requestAnimationFrame(loop) - } - requestAnimationFrame(loop) - ;(window as unknown as { __flashSamples: typeof samples }).__flashSamples = samples - }) - - await page.mouse.move(startX, startY) - await page.mouse.down() - for (let i = 1; i <= 10; i++) { - await page.mouse.move(startX - i * 12, startY, { steps: 2 }) - await page.waitForTimeout(30) - } - await page.mouse.up() - await page.waitForTimeout(500) - - const samples = await page.evaluate( - () => (window as unknown as { __flashSamples: Array<{ t: number; bottomRight: number; colRight: number; varW: string; dragging: boolean; iw: number }> }).__flashSamples, - ) - expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20) - const lastDrag = [...samples].reverse().find(s => s.dragging) - expect(lastDrag, 'the dragging attribute must appear during the drag').toBeDefined() - const releaseIdx = samples.indexOf(lastDrag!) + 1 - const release = samples[releaseIdx]! - expect(release.colRight, 'the center column must be measurable after release').toBeGreaterThan(0) - expect(release.bottomRight, 'the bottom panel must be measurable after release').toBeGreaterThan(0) - const settled = samples[samples.length - 1]! - for (const s of samples.slice(releaseIdx)) { - expect( - Math.abs(s.bottomRight - s.colRight), - `bottom panel drifted from the center column at t=${Math.round(s.t)} (bottom ${s.bottomRight} vs column ${s.colRight})`, - ).toBeLessThanOrEqual(8) - const fullWidth = s.bottomRight >= s.iw - 1 - expect( - fullWidth && s.varW !== '' && s.varW !== '0px', - `bottom panel flashed full-width at t=${Math.round(s.t)} (right ${s.bottomRight}, push ${s.varW})`, - ).toBe(false) - expect( - Math.abs(s.colRight - settled.colRight), - `center column edge animated after release at t=${Math.round(s.t)} (${s.colRight} vs settled ${settled.colRight})`, - ).toBeLessThanOrEqual(8) - } -}) - -test('the bottom-push anchor resolves through the shell-written center-column tag', async ({ page }) => { - // layout.css pushes the bottom panel via the center column, anchored on - // the [data-dsh-center-col] tag the Sidebar shell's locator writes onto - // the measured column node (Sidebar.tsx locate — the slot wrapper - // [data-slot="conversation"]'s parent). The tag must sit on exactly ONE - // node: a stale tag on a swapped-out node (boot swap / HMR) would leave - // the push rule anchorless or doubled. - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 }) - await expect - .poll(async () => page.evaluate(() => document.querySelectorAll('#root [data-dsh-center-col]').length), { timeout: 90_000 }) - .toBe(1) - const anchors = await page.evaluate(() => { - const tagged = document.querySelector('#root [data-dsh-center-col]') - const slotParent = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null - return { - tagged: tagged !== null, - same: tagged !== null && slotParent !== null && tagged === slotParent, - } - }) - expect(anchors.tagged, 'the tagged center column must resolve').toBe(true) - expect(anchors.same, 'the tag must sit on the conversation slot wrapper\'s parent (the measured column)').toBe(true) -}) - -/* ── bottom-strip drag with the right panel CLOSED must not touch the host - layout — the regression test for the width-gate fix ───────────────── */ - -test('dragging the bottom strip while the right panel is closed keeps the host layout still', async ({ page }) => { - // The bottom drag used to write the panel's persisted width preference into - // `--dsh-sidebar-width` regardless of `panelOpen` (only the idle push - // effect gated it): with the panel closed the variable jumped 0 → ~400px on the - // first drag frame, #root lost 400px instantly, the host frame's viewport - // dropped across its 1024px auto-collapse breakpoint, and the NATIVE left - // sidebar snapped to its 56px rail — then snapped back on release. The - // panel-side DOM writes keep the raw width (the bottom panel's `right` - // derives from it); only the PUSHED width must ride the panelOpen gate. - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) - const sidebar = page.locator('[data-dsh-better-sidebar]') - await expect(sidebar).toBeAttached({ timeout: 90_000 }) - await dismissOnboarding(page) - - // The right panel stays CLOSED (openByDefault off): the width variable - // must read as the collapsed push before anything happens. - const readPushWidth = (): Promise => page.evaluate(() => { - const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) - return Number.isNaN(value) ? 0 : value - }) - await expect - .poll(async () => { - const height = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0) - return height - }, { timeout: 90_000 }) - .toBe(0) - - const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' }) - await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1) - await bottomExpand.click() - await expect - .poll(async () => { - const value = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0) - return value - }, { timeout: 90_000 }) - .toBeGreaterThan(0) - expect(await readPushWidth(), 'the closed right panel must push 0 width').toBe(0) - - // Per-frame sampler on the HOST side: the pushed width variable, the host - // frame's inline grid template (its first track is the native left - // sidebar's width), the rendered left-column width (the grid transition - // animates mid-states, so this catches even a transient snap), and #root's - // right edge (where the width push lands). - await page.evaluate(() => { - const samples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> = [] - const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null - const frame = center?.parentElement ?? null - const leftCol = frame?.firstElementChild ?? null - const root = document.querySelector('#root') as HTMLElement - const loop = (): void => { - const varW = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) - samples.push({ - t: performance.now(), - varW: Number.isNaN(varW) ? 0 : varW, - gridCols: frame instanceof HTMLElement ? frame.style.gridTemplateColumns : '', - leftColW: leftCol?.getBoundingClientRect().width ?? -1, - rootRight: root.getBoundingClientRect().right, - dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'), - }) - requestAnimationFrame(loop) - } - requestAnimationFrame(loop) - ;(window as unknown as { __bottomDragSamples: typeof samples }).__bottomDragSamples = samples - }) - - // The bottom strip is the panel's top-edge row-resize hit strip. The panel - // SLIDES DOWN into place on expand: a box read mid-animation sits above the - // settled geometry and the pointerdown lands off-strip (the drag never - // starts). Wait until the strip's center sits at innerHeight - the pushed - // height (the panel's final top edge). - await expect - .poll(async () => { - const probe = await page.evaluate(() => { - const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]') - if (bottom === null) return null - const strip = [...bottom.querySelectorAll('*')].find(el => getComputedStyle(el).cursor === 'row-resize') - if (strip === undefined) return null - const r = strip.getBoundingClientRect() - const heightVar = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0 - return Math.abs((r.y + r.height / 2) - (window.innerHeight - heightVar)) - }) - return probe === null ? Number.NaN : probe - }, { timeout: 30_000 }) - .toBeLessThanOrEqual(8) - const stripBox = await page.evaluate(() => { - const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]') - if (bottom === null) return null - const strip = [...bottom.querySelectorAll('*')].find(el => getComputedStyle(el).cursor === 'row-resize') - if (strip === undefined) return null - const r = strip.getBoundingClientRect() - return { x: r.x + r.width / 2, y: r.y + r.height / 2 } - }) - expect(stripBox, 'the bottom drag strip must be present (cursor: row-resize)').not.toBeNull() - - // Drag the strip UP (grow the bottom panel) in steps, like a real resize. - await page.mouse.move(stripBox!.x, stripBox!.y) - await page.mouse.down() - for (let i = 1; i <= 12; i++) { - await page.mouse.move(stripBox!.x, stripBox!.y - i * 8, { steps: 2 }) - await page.waitForTimeout(40) - } - await page.mouse.up() - await page.waitForTimeout(500) - - const samples = await page.evaluate( - () => (window as unknown as { __bottomDragSamples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> }).__bottomDragSamples, - ) - expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20) - const dragging = samples.filter(s => s.dragging) - expect(dragging.length, 'the dragging attribute must appear during the drag').toBeGreaterThan(5) - - // The drag must actually have resized the bottom panel (sanity: the height - // push committed to a bigger value than where it started). - const heightAfter = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0) - expect(heightAfter).toBeGreaterThan(0) - - // Contract: the pushed width stays 0 for EVERY frame — mid-drag AND after - // release. The old code let the panel's persisted preference (~400px) - // through on the first drag frame and the release commit. - for (const s of samples) { - expect(s.varW, `pushed width leaked during the bottom drag at t=${Math.round(s.t)} (got ${s.varW}px)`).toBe(0) - } - // Contract: the host layout never moves — same inline grid template, same - // rendered left-column width (the native sidebar would snap to its 56px - // rail when #root loses 400px on a 1280px viewport), same #root right edge. - const first = samples[0]! - for (const s of samples) { - expect(s.gridCols, `host grid template changed at t=${Math.round(s.t)}: ${first.gridCols} -> ${s.gridCols}`).toBe(first.gridCols) - expect(Math.abs(s.leftColW - first.leftColW), `native left column width changed at t=${Math.round(s.t)}: ${first.leftColW} -> ${s.leftColW}`).toBeLessThanOrEqual(1) - expect(Math.abs(s.rootRight - first.rootRight), `#root right edge changed at t=${Math.round(s.t)}: ${first.rootRight} -> ${s.rootRight}`).toBeLessThanOrEqual(1) - } -}) diff --git a/tests/e2e/float-window.e2e.ts b/tests/e2e/float-window.e2e.ts deleted file mode 100644 index 2c92e14..0000000 --- a/tests/e2e/float-window.e2e.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Free-window lane (v0.16.0): against a real `dsh web` instance with the - * npm-packed plugin mounted (scripts/e2e-mount.sh, DSH_E2E_URL). - * - * 1. The tab context menu's "Move to Free Window" floats the seeded Files - * tab: `[data-dsh-float-window]` mounts and the tab leaves the strip. - * 2. The header drag moves the window (geometry committed to the session - * state). - * 3. A reload restores the window from the persisted session state. - * 4. The header context menu's "Dock Back to Sidebar" returns the tab to - * the workbench and removes the window. - * 5. The drag-out gesture itself (HTML5 DnD onto the conversation column) - * floats another tab with the hint overlay showing mid-drag. - * - * Crash discipline mirrors the mount lane: pageerror / plugin console errors - * / dsh-better-sidebar strips fail the test at every step. - */ -import { mkdirSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test, expect, type APIRequestContext, type Page } from '@playwright/test' -import { PAGE_URL, createHostApi, hostRpc } from './host' - -const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-float-workspace') - -const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/] - -let api: APIRequestContext - -async function seedSession(): Promise { - mkdirSync(WORKSPACE_PATH, { recursive: true }) - const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) - await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) -} - -test.beforeAll(async () => { - api = await createHostApi() - await seedSession() -}) - -test.afterAll(async () => { - await api?.dispose() -}) - -/** Dismiss DSH's first-run takeovers (onboarding modal etc.): stacked masks - * would intercept every subsequent pointer interaction. They mount only - * after the settings join resolves (seconds after load — also after a - * reload), so first wait bounded for one to appear, then sweep until none - * remain. Mirrors the mount lane's dance. */ -async function dismissTakeovers(page: Page): Promise { - try { - await expect - .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 }) - .toBeGreaterThan(0) - } catch { - return // A DSH build without onboarding takeovers: nothing to sweep. - } - for (let round = 0; round < 8; round++) { - let dismissed = false - for (const name of ['Continue', 'Configure later']) { - const button = page.getByRole('button', { name, exact: true }).first() - if ((await button.count()) === 0) continue - try { - await button.click({ timeout: 4_000 }) - dismissed = true - await page.waitForTimeout(1_000) - } catch { - // Masked by a takeover stacked above; the next round retries. - } - } - if (!dismissed) break - } -} - -/** Load the shell, dismiss onboarding takeovers, and expand the panel. - * Returns the sidebar host locator. (An arrow const, not a hoisted function - * declaration, so the module-level PAGE_URL narrowing carries in.) */ -const bootExpanded = async (page: Page): Promise> => { - const pageErrors: string[] = [] - const consoleErrors: string[] = [] - page.on('pageerror', (error) => pageErrors.push(String(error))) - page.on('console', (message) => { - if (message.type() === 'error') consoleErrors.push(message.text()) - }) - await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) - await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) - const sidebar = page.locator('[data-dsh-better-sidebar]') - await expect(sidebar).toBeAttached({ timeout: 90_000 }) - await dismissTakeovers(page) - await expect(sidebar.locator('[title]').first()).toBeAttached({ timeout: 90_000 }) - const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' }) - if ((await expandButton.count()) === 1) await expandButton.click() - await expect - .poll(async () => ( - await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) - ), { timeout: 90_000 }) - .not.toBe('') - expect(pageErrors, 'page errors during boot').toEqual([]) - expect(consoleErrors.filter(text => text.includes('dsh-better-sidebar')), 'plugin console errors during boot').toEqual([]) - return sidebar -} - -/** The crash assertions shared by every step (mount-lane discipline). */ -async function assertNoCrash(page: Page): Promise { - const sidebar = page.locator('[data-dsh-better-sidebar]') - const stripTexts = await sidebar.locator('div').evaluateAll( - (nodes, patterns) => nodes.filter((node) => { - const text = (node.textContent ?? '').trim() - return patterns.some((pattern) => pattern.test(text)) - }).map((node) => (node.textContent ?? '').trim()), - CRASH_STRIP_PATTERNS, - ) - expect(stripTexts, 'a dsh-better-sidebar error strip is present').toEqual([]) -} - -/** The conversation column's viewport rect — the production drag-out target - * (Sidebar.tsx): the [data-slot="conversation"] WRAPPER may be zero-size (a - * slot pass-through), so the sized element is its parent column. */ -async function conversationBox(page: Page): Promise<{ x: number; y: number; width: number; height: number } | null> { - return await page.evaluate(() => { - const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement - if (col === null || col === undefined) return null - const rect = col.getBoundingClientRect() - return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } - }) -} - -/** The float's content cell must hand its child a real width (the - * column-flex contract): a collapsed 0px cell paints nothing — the tab is - * "in" the window but invisible. */ -async function assertContentPainted(page: Page, label: string): Promise { - const width = await page.evaluate(() => { - const cell = document.querySelector('[data-dsh-float-window] [class*="floatContent"]') - const child = cell?.firstElementChild - return child === null || child === undefined ? -1 : Math.round(child.getBoundingClientRect().width) - }) - expect(width, `${label}: the floated tab's content must have real width`).toBeGreaterThan(200) -} - -test('float a tab, move the window, reload restores it, dock it back', async ({ page }) => { - const sidebar = await bootExpanded(page) - - // 1. The tab context menu floats the seeded Files home tab. - const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first() - await expect(filesTab).toHaveCount(1) - await filesTab.click({ button: 'right' }) - const floatItem = page.getByRole('menuitem', { name: 'Move to Free Window' }).first() - await expect(floatItem, 'the tab context menu must offer "Move to Free Window"').toHaveCount(1) - await floatItem.click() - const floatWindow = sidebar.locator('[data-dsh-float-window]') - await expect(floatWindow).toHaveCount(1, { timeout: 10_000 }) - // The tab left the strip with the window born over the conversation area - // (center), well clear of the panel's right edge. - await assertContentPainted(page, 'context-menu float') - const leftBefore = await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left)) - expect(Number.isFinite(leftBefore), `style.left must be numeric, got ${leftBefore}`).toBe(true) - expect(leftBefore).toBeGreaterThan(0) - expect(leftBefore).toBeLessThan(1440 - 390) - await assertNoCrash(page) - - // 2. The header drag moves the window (pointer capture + commit). - const header = floatWindow.locator('[class*="floatHeader"]') - const box = await header.boundingBox() - const conv = await conversationBox(page) - expect(box, 'the float header must have geometry').not.toBeNull() - expect(conv, 'the conversation column must have geometry').not.toBeNull() - const dragToX = conv!.x + conv!.width * 0.35 - const dragToY = conv!.y + conv!.height * 0.35 - await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2) - await page.mouse.down() - await page.mouse.move(dragToX, dragToY, { steps: 12 }) - await page.mouse.up() - await expect - .poll(async () => ( - await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left)) - ), { timeout: 10_000 }) - .not.toBe(leftBefore) - await assertNoCrash(page) - - // 3. A reload restores the window from the persisted session state. Give - // the store's debounced persist a beat first: reloading the instant the - // drag poll resolves can beat the 200ms flush timer. - await page.waitForTimeout(500) - await page.reload({ waitUntil: 'domcontentloaded' }) - await dismissTakeovers(page) - const sidebar2 = page.locator('[data-dsh-better-sidebar]') - await expect(sidebar2.locator('[data-dsh-float-window]')).toHaveCount(1, { timeout: 90_000 }) - await assertNoCrash(page) - - // 4. The header context menu docks the tab back into the sidebar. - const header2 = sidebar2.locator('[data-dsh-float-window] [class*="floatHeader"]') - await header2.click({ button: 'right' }) - const dockItem = page.getByRole('menuitem', { name: 'Dock Back to Sidebar' }).first() - await expect(dockItem, 'the float header context menu must offer "Dock Back to Sidebar"').toHaveCount(1) - await dockItem.click() - await expect(sidebar2.locator('[data-dsh-float-window]')).toHaveCount(0, { timeout: 10_000 }) - await expect(sidebar2.locator('[title="Files"][draggable="true"]').first()).toHaveCount(1) - await assertNoCrash(page) -}) - -test('dragging a tab onto the conversation area floats it (drag-out gesture)', async ({ page }) => { - const sidebar = await bootExpanded(page) - - // Open a terminal through the + menu (a second tab to drag out; the Files - // home tab keeps the pane non-empty afterwards). The terminal tab's title - // is the host shell name once resolved (fallback "Terminal"), so address - // it by POSITION: the strip's second tab. - await sidebar.getByRole('button', { name: 'New tab' }).first().click() - const terminalItem = page.getByRole('menuitem', { name: 'Terminal' }).first() - await expect(terminalItem).toHaveCount(1) - await terminalItem.click() - const stripTabs = sidebar.locator('[class*="tabList"] > [draggable="true"]') - await expect(stripTabs, 'the strip must hold the seeded Files tab plus the terminal').toHaveCount(2, { timeout: 30_000 }) - const terminalTab = stripTabs.nth(1) - const terminalTitle = (await terminalTab.getAttribute('title')) ?? 'Terminal' - - // Drag it onto the conversation column: the hint overlay appears mid-drag - // and the drop floats the tab. ([class*="floatDropHint"] matches BOTH the - // overlay and its label span — expect the pair.) - const tabBox = await terminalTab.boundingBox() - const conv = await conversationBox(page) - expect(tabBox, 'the terminal tab must have geometry').not.toBeNull() - expect(conv, 'the conversation column must have geometry').not.toBeNull() - const hint = sidebar.locator('[class*="floatDropHint"]') - await page.mouse.move(tabBox!.x + tabBox!.width / 2, tabBox!.y + tabBox!.height / 2) - await page.mouse.down() - await page.mouse.move(conv!.x + conv!.width / 2, conv!.y + conv!.height / 2, { steps: 12 }) - await expect(hint, 'the drag-out hint must mark the conversation column mid-drag').toHaveCount(2, { timeout: 10_000 }) - await page.mouse.up() - const floatWindow = sidebar.locator('[data-dsh-float-window]') - await expect(floatWindow, 'the drop must float the tab').toHaveCount(1, { timeout: 10_000 }) - await expect(hint).toHaveCount(0) - // The terminal tab left the strip (only Files remains); the window shows - // its title. - await expect(stripTabs).toHaveCount(1) - await expect(floatWindow.locator('[class*="floatTitle"]')).toHaveText(terminalTitle) - await assertContentPainted(page, 'drag-out float') - await assertNoCrash(page) -}) diff --git a/tests/e2e/host.ts b/tests/e2e/host.ts index 1b07849..9fb0f3e 100644 --- a/tests/e2e/host.ts +++ b/tests/e2e/host.ts @@ -11,7 +11,7 @@ * - `createHostApi()` for an APIRequestContext carrying the auth cookie, * - `hostRpc(api, 'workspace.create', {...})` for the host's unary RPC. */ -import { request, type APIRequestContext, type Page } from '@playwright/test' +import { expect, request, type APIRequestContext, type Page } from '@playwright/test' import { parseLaunchUrl, pageUrlWith, rpcAttempt } from './host-protocol' const envUrl = process.env.DSH_E2E_URL @@ -132,3 +132,24 @@ export async function hostRpc( } return result } + +/** + * Send one message in the session the shell opened, so the conversation header + * (and the native right Sidebar's corner control) exists: DSH renders the + * header only for a session with content, while a freshly seeded blank + * session shows the empty hero. + * + * DSH 0.1.5's composer is a Lexical contenteditable — `fill()` writes the DOM + * but not the editor model, so the send button would stay disabled. Type real + * keys instead. + * @param page - the lane's page. + */ +export async function sendFirstMessage(page: Page): Promise { + const composer = page.getByRole('textbox', { name: /^(Describe what you want to build|描述你想要构建)/ }) + await expect(composer).toBeVisible({ timeout: 60_000 }) + await composer.click() + await page.keyboard.type('mount lane smoke') + const send = page.getByRole('button', { name: /^(Send message|发送消息)$/ }) + await expect(send).toBeEnabled({ timeout: 30_000 }) + await send.click() +} diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts index 126fc6c..d073f06 100644 --- a/tests/e2e/mount.e2e.ts +++ b/tests/e2e/mount.e2e.ts @@ -30,7 +30,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test, expect, type APIRequestContext } from '@playwright/test' -import { PAGE_URL, createHostApi, gotoPage, hostRpc, sidebarApi } from './host' +import { PAGE_URL, createHostApi, gotoPage, hostRpc, sendFirstMessage, sidebarApi } from './host' /** Workspace the sidebar renders against (created by the lane's seeding). */ const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace') @@ -57,7 +57,7 @@ const SEEDED_README_FILE = 'readme-style.md' 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 = ['Files', 'Changes', 'Tasks', 'Side Chat (beta)', 'Terminal', 'Browser'] +const NATIVE_TABS = ['files', 'git', 'subagent', 'sidechat', 'terminal', 'browser'] let api: APIRequestContext /** The seeded session id (captured by seedSession; the Side Chat smoke's parent). */ @@ -235,22 +235,6 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ) .toEqual({ overflowX: true, overflowY: true }) - // 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 (docs/external-plugin-guide.md §12): 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 - // mounts (its absence would mean the panel never opened with the session). - await expect - .poll(async () => ( - await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) - ), { timeout: 90_000 }) - .not.toBe('') - // Crash-marker assertions shared by every step. const assertNoCrash = async (): Promise => { await expect @@ -268,75 +252,87 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async expect(stripTexts, 'a dsh-better-sidebar error strip is present in the sidebar').toEqual([]) } - // Sweep every built-in tab through the "+" menu (the sidebar's own open-tab - // affordance, reachable from any pane state). Each open may fetch a lazy - // chunk (/sidebar/bundle/client-terminal.js / client-editor.js) and mount a - // real viewer — the highest-risk crash surfaces. The pinned plugin must - // offer every listed built-in: a missing or renamed descriptor is a real - // regression and fails the lane loudly instead of silently narrowing the - // sweep. A failure anywhere surfaces as a pageerror or a console error, - // both of which the next assertion sees. - const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first() - for (const title of BUILTIN_TABS) { - await newTabButton.click() - const item = page.getByRole('menuitem', { name: title }).first() - await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1) - if (title === BUILTIN_TABS[0]) { - // Layout-level evidence for the compact density: rows render ~26px - // tall (the default density would be 40px). Guard loose at ≤30px so - // font/zoom differences across runners cannot flake it. - const rowHeight = await item.evaluate(el => el.getBoundingClientRect().height) - expect(rowHeight, '+ menu rows must render at the compact density (≤30px)').toBeLessThanOrEqual(30) - } - await item.click() - // Let the activation commit (including any lazy-chunk fetch) before the - // crash assertions run. + // The native Sidebar's way in lives in the conversation header's corner, + // which DSH renders only for a session with content — the seeded session + // starts blank, so give it one message first. + await sendFirstMessage(page) + + // DSH 0.1.5 owns the right column: the plugin contributes tab TYPES to the + // host's native right Sidebar instead of drawing its own panel. Open it + // through the host's own control (the conversation header's corner) and + // drive the plugin's content from the guide page the surface seeds. + const pane = page.locator('[data-sidebar-right-panel]') + const addTab = page.locator('[data-dockkit-add-tab]').first() + await page.locator('[data-sidebar-right-expand]').first().click() + await expect(pane).toBeVisible({ timeout: 30_000 }) + await expect( + page.locator('[data-sidebar-right-guide]'), + 'a freshly expanded surface shows the guide page', + ).toBeVisible({ timeout: 30_000 }) + + // Every built-in descriptor must be registered as a native tab type: the + // guide lists one entry per type, so a missing entry is a real regression + // (descriptor removed, registration failed, or the type got disabled). + for (const kind of NATIVE_TABS) { + await expect( + page.locator(`[data-sidebar-right-guide-entry="${kind}"]`), + `the plugin's native tab type "${kind}" is not offered by the guide`, + ).toHaveCount(1) + } + + // Sweep every type through the guide. Each open mounts a real viewer (the + // terminal fetches its lazy chunk); a failure anywhere surfaces as a + // pageerror or a crash strip, both of which the next assertion sees. A pane + // holds one guide tab, so re-seed it through the strip's add control before + // every pick. + for (const kind of NATIVE_TABS) { + if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click() + const entry = page.locator(`[data-sidebar-right-guide-entry="${kind}"]`) + await expect(entry, `guide entry "${kind}" must be reachable`).toHaveCount(1, { timeout: 30_000 }) + await entry.click() await page.waitForTimeout(1_500) await assertNoCrash() } - // The sweep opened the Side Chat tab, whose view auto-creates a thread and + // The sweep opened the Side Chat type, whose view auto-creates a thread and // polls the transcript — that poll MUST ride the plugin's own - // sidechat.events route (the DSH-0.1.2-safe transport this lane locks). - const polledTranscript = await page.evaluate(() => - performance.getEntriesByType('resource').some(entry => entry.name.includes('/sidebar/api/sidechat.events'))) - expect(polledTranscript, 'the Side Chat tab must poll sidechat.events for its transcript').toBe(true) + // sidechat.events route (the host transport this lane locks). The poll runs + // only while the tab is visible, so activate its chip first. + await pane.getByRole('tab', { name: /Side Chat|侧边对话|侧边聊天/ }).first().click() + await expect + .poll( + () => page.evaluate(() => + performance.getEntriesByType('resource').some(entry => entry.name.includes('/sidebar/api/sidechat.events'))), + { timeout: 30_000 }, + ) + .toBe(true) // Side Chat host-route smoke against the REAL host: create a thread child // under the seeded session (custom-seed creation through AgentRegistry), // deliver a follow-up, cancel, and release it. The turn itself cannot run // (keyless boot has no model route), but admission + creation + the wire // envelope must all succeed — this is the deepest functional proof the - // mount lane can make without a provider. + // mount lane can make without a provider, and it is what caught the 0.1.5 + // `sessionPersistence.inspect` removal. const start = await api.post(sidebarApi('sidechat.start'), { data: { sessionId: seededSessionId, question: 'mount lane smoke' }, }) expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true) - const startBody = (await start.json()) as { - ok: boolean - value?: { childId?: string } - error?: { code?: string; message?: string } - } + const startBody = (await start.json()) as { ok: boolean; value?: { childId?: string }; error?: { code?: string; message?: string } } expect(startBody.ok, `sidechat.start envelope: ${JSON.stringify(startBody)}`).toBe(true) const childId = startBody.value?.childId expect(childId, 'sidechat.start must return a child session id').toMatch(/^session-/) - // The child must be a REAL session in the host's store (provider-free - // proof of the custom-seed creation; the boundary message itself only - // becomes durable when a turn claims it, which needs a model route). const list = await hostRpc<{ items: Array<{ sessionId: string }> }>(api, 'session.list', {}) - const listedItems = list.value.items expect( - listedItems.some(item => item.sessionId === childId), + list.value.items.some(item => item.sessionId === childId), 'the thread child must appear in the host session list', ).toBe(true) - // Transcript read from the LIVE agent's log (the transport the tab polls; - // the inherited seed is cut host-side, so with no model route the own - // slice carries no message events). const eventsLive = await api.post(sidebarApi('sidechat.events'), { data: { childId } }) expect(eventsLive.ok(), `sidechat.events (live): ${eventsLive.status()} ${await eventsLive.text()}`).toBe(true) - const eventsLiveBody = (await eventsLive.json()) as { ok: boolean; value?: { events?: Array<{ type: string }> } } + const eventsLiveBody = (await eventsLive.json()) as { ok: boolean; value?: { events?: Array<{ type: string }>; live?: unknown[] } } expect(eventsLiveBody.ok, `sidechat.events envelope: ${JSON.stringify(eventsLiveBody)}`).toBe(true) expect(Array.isArray(eventsLiveBody.value?.events), 'sidechat.events must answer an events array').toBe(true) + expect(Array.isArray(eventsLiveBody.value?.live), 'sidechat.events must answer a live-delta array').toBe(true) for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) { const response = await api.post(sidebarApi(method), { data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId }, @@ -348,193 +344,110 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async const eventsCold = await api.post(sidebarApi('sidechat.events'), { data: { childId } }) expect(eventsCold.ok(), `sidechat.events (cold): ${eventsCold.status()} ${await eventsCold.text()}`).toBe(true) - // The Codex-style immediate-create flow: a blank question creates an - // EMPTY thread (no prompt admitted), sidechat.info reports the live - // agent, and the first prompt delivers the boundary host-side. - const empty = await api.post(sidebarApi('sidechat.start'), { - data: { sessionId: seededSessionId, question: '' }, - }) - expect(empty.ok(), `sidechat.start (empty): ${empty.status()} ${await empty.text()}`).toBe(true) - const emptyBody = (await empty.json()) as { ok: boolean; value?: { childId?: string } } - const emptyChildId = emptyBody.value?.childId - expect(emptyChildId, 'immediate create must return a child session id').toMatch(/^session-/) - const info = await api.post(sidebarApi('sidechat.info'), { - data: { childId: emptyChildId }, - }) - expect(info.ok(), `sidechat.info: ${info.status()} ${await info.text()}`).toBe(true) - const infoBody = (await info.json()) as { ok: boolean; value?: { live?: boolean; preset?: string } } - expect(infoBody.ok, `sidechat.info envelope: ${JSON.stringify(infoBody)}`).toBe(true) - expect(infoBody.value?.live, 'the fresh thread must have a live agent').toBe(true) - for (const method of ['sidechat.prompt', 'sidechat.dispose']) { - const response = await api.post(sidebarApi(method), { - data: method === 'sidechat.prompt' ? { childId: emptyChildId, text: 'first message' } : { childId: emptyChildId }, - }) - expect(response.ok(), `${method} (immediate thread): ${response.status()} ${await response.text()}`).toBe(true) - } - await assertNoCrash() - - // 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 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 }) + // The editor chunk (client-editor.js) only loads when the plugin's files + // window renders. The plugin took the built-in `files` kind over, so the + // guide's Files entry IS the plugin's explorer: open it and click the seeded + // file in its tree — the highest-risk surface (CodeMirror chunk + fs routes). + if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click() + await page.locator('[data-sidebar-right-guide-entry="files"]').click() + const fileRow = pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`) + await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the plugin's explorer`).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 - // Separate-mode default (editorExplorer off): the tree click OPENS A NEW - // file tab (openSidebarFile, id `editor:`) instead of rewriting the - // home tab in place. The seeded "Files" home tab stays put — it is the - // standalone explorer now, not a file window. await expect( - sidebar.locator(`[title="${SEEDED_FILE}"][draggable="true"]`), - 'separate mode opens a new file tab for the tree click', - ).toHaveCount(1) - // The seeded home tab survives (separate mode never rewrites it). The - // sweep's + menu opened a SECOND path-less Files window (each is its own - // explorer in separate mode), so assert presence, not an exact count. - await expect( - sidebar.locator('[title="Files"][draggable="true"]').first(), - 'the seeded files-window home tab must survive the file open', - ).toHaveCount(1) - const pathInput = sidebar.locator('input[placeholder^="File path"]:visible') - await expect(pathInput, 'the file tab header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`)) + pane.locator('.cm-editor').first(), + 'the plugin editor must render the seeded file inside the native tab', + ).toBeVisible({ timeout: 30_000 }) await page.waitForTimeout(1_500) await assertNoCrash() - // The mermaid chunk (client-mermaid.js) only loads when a previewed - // markdown file contains a mermaid fence. Open the seeded diagram file - // from the files window's tree and require the full round-trip: chunk - // fetch + sanitized SVG diagram in the preview, so a missing/corrupt - // mermaid chunk or a broken render fails the lane. In separate mode the - // tree click above activated the hello.txt tab, so switch back to the - // Files explorer first (its tree is the only one visible while active). + // The mermaid chunk (client-mermaid.js) only loads when a previewed markdown + // file contains a mermaid fence. Open the seeded diagram file from the + // explorer and require the full round-trip: chunk fetch + sanitized SVG + // diagram in the preview. const mermaidChunk = page.waitForResponse( (response) => response.url().includes('/sidebar/bundle/mermaid.js'), { timeout: 30_000 }, ) - await sidebar.locator('[title="Files"][draggable="true"]').first().click() - const mdRow = sidebar.locator(`[role="button"][title$="${SEEDED_MD_FILE}"]:visible`) - await expect(mdRow, `the seeded "${SEEDED_MD_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 }) + await pane.getByRole('tab', { name: /Files|文件/ }).first().click() + const mdRow = pane.locator(`[role="button"][title$="${SEEDED_MD_FILE}"]:visible`) + await expect(mdRow, `the seeded "${SEEDED_MD_FILE}" file must appear in the plugin's explorer`).toHaveCount(1, { timeout: 30_000 }) await mdRow.click({ position: { x: 8, y: 8 } }) - // Separate mode: the md file opens its own tab (like hello.txt above). await expect( - sidebar.locator(`[title="${SEEDED_MD_FILE}"][draggable="true"]`), - 'separate mode opens a new tab for the markdown file', - ).toHaveCount(1, { timeout: 30_000 }) - // The markdown PREVIEW must render before the mermaid chunk can be - // requested — this assertion separates a preview/render regression from a - // chunk-loading one. (sidebar is already scoped to [data-dsh-better-sidebar].) - await expect( - sidebar.getByText('tail text'), + pane.getByText('tail text'), 'the markdown preview must render the seeded document', ).toHaveCount(1, { timeout: 30_000 }) await mermaidChunk await expect( - sidebar.locator('[data-mermaid-diagram] svg'), + pane.locator('[data-mermaid-diagram] svg'), 'the mermaid fence must render into an SVG diagram in the markdown preview', ).toHaveCount(1, { timeout: 30_000 }) - // Labels must survive as real SVG (htmlLabels stays off so the - // sanitizer's foreignObject strip cannot eat the node text). await expect( - sidebar.locator('[data-mermaid-diagram]').first(), + pane.locator('[data-mermaid-diagram]').first(), 'the diagram node labels must render inside the SVG', ).toContainText('Hello', { timeout: 30_000 }) // Cross-fence semantics: the reference-style link [before][shared] must // resolve to the definition that sits AFTER the fence — proof that the // preview is a single markdown parse and not per-fence fragments. await expect( - sidebar.locator('a[href="https://example.com"]').first(), + pane.locator('a[href="https://example.com"]').first(), 'reference-style links with definitions across a mermaid fence must resolve', ).toContainText('before', { timeout: 30_000 }) - // Click-to-enlarge: clicking the diagram opens the zoom modal (portalled - // to document.body), Esc closes it again. + // Click-to-enlarge: clicking the diagram opens the zoom modal (portalled to + // document.body), Esc closes it again. const modal = page.locator('[data-mermaid-modal]') - await sidebar.locator('[data-mermaid-diagram] svg').first().click() + await pane.locator('[data-mermaid-diagram] svg').first().click() await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 }) await page.keyboard.press('Escape') await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 }) - // The preview/edit toggle is mutually exclusive: in preview mode the - // CodeMirror surface must be hidden (regression guard — a stale css copy - // in the page made the editor stay visible under the preview, breaking - // the toggle semantics). - await expect( - sidebar.locator('.cm-editor').first(), - 'preview mode must hide the CodeMirror editor (mutually exclusive toggle)', - ).toBeHidden() await assertNoCrash() // README-style markdown (raw-HTML runs + TOC): open the seeded file and - // require the full round-trip — sanitized HTML leaves (badge image as a - // real element, active content stripped), markdown nested inside the - // unclosed
run, the inline pass turning the table cell's
- // into an element, the reference link resolving across the HTML run, and - // the TOC outline jumping into the collapsed details (auto-expanding it). - await sidebar.locator('[title="Files"][draggable="true"]').first().click() - const readmeRow = sidebar.locator(`[role="button"][title$="${SEEDED_README_FILE}"]:visible`) + // require the full round-trip — sanitized HTML leaves (badge image as a real + // element, active content stripped), markdown nested inside the unclosed + //
run, the inline pass turning the table cell's
into an + // element, the reference link resolving across the HTML run, and the TOC + // outline jumping into the collapsed details (auto-expanding it). + await pane.getByRole('tab', { name: /Files|文件/ }).first().click() + const readmeRow = pane.locator(`[role="button"][title$="${SEEDED_README_FILE}"]:visible`) await expect( readmeRow, - `the seeded "${SEEDED_README_FILE}" file must appear in the files window's tree`, + `the seeded "${SEEDED_README_FILE}" file must appear in the plugin's explorer`, ).toHaveCount(1, { timeout: 30_000 }) await readmeRow.click({ position: { x: 8, y: 8 } }) await expect( - sidebar.locator(`[title="${SEEDED_README_FILE}"][draggable="true"]`), - 'separate mode opens a new tab for the README-style markdown file', - ).toHaveCount(1, { timeout: 30_000 }) - // The hero div renders as a sanitized leaf with the badge image as a real - // element (the remote src may not load in the sandboxed lane; the ELEMENT - // is the proof of rendering, not the bytes). - await expect( - sidebar.locator('[data-dsh-html-segment] img[src*="img.shields.io"]'), + pane.locator('[data-dsh-html-segment] img[src*="img.shields.io"]'), 'the badge-wall div must render its image as a real element', ).toHaveCount(1, { timeout: 30_000 }) - // Active content never survives sanitization. await expect( - sidebar.locator('script'), + pane.locator('script'), 'the embedded