feat(native): render the plugin's tabs in DSH's native right Sidebar

Phase 2 of the 0.1.5 adaptation: the plugin stops drawing its own right
panel and contributes tab TYPES to DSH's native right Sidebar, which owns
the right column. The bottom workbench (terminal dock) stays plugin-owned.

Registrations (src/client/native/):
- every TabDescriptor becomes a native tab type (kind = descriptor.id, the
  default `extension` band) plus a keyed body in `sidebar.right.pane.tab`
  and a live chip title in `sidebar.right.pane.tab.title`
- the `editor` type also claims `dsh-resource://file/**`, so it outranks
  the built-in `text` preview (fallback) and 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: `openTab('files')` draws the plugin's
  explorer, and the built-in resumes when the plugin unregisters
- registrations follow the plugin registry + the side-card settings: a
  type registered later (external plugin) gets its native type too, a
  disabled type is unregistered (absent from the guide, refuses opens)

Adapter (src/client/native/tab-adapter.tsx):
- one synthetic plugin `SidebarTab` per native tab id, minted from the
  native record + navigation params (or the descriptor's own createTab,
  which a native guide open bypasses), kept live across navigations and
  updateTab writes
- the explorer's per-tab expansion/reveal sets and the per-kind instance
  counter, which the plugin's own layout used to hold

Service routing (src/client/service.ts):
- `setSurface` installs the native write face; openTab/openFile land
  natively (a path becomes a resource address, a path-less editor open
  becomes the `files` page, the rest carry the seed as navigation params),
  closeTab/updateTab/activateTab recognise native tab ids
- new optional `OpenTabSeed.target` ('right' default, 'bottom' keeps the
  bottom workbench); the bottom panel's own + menu and auto-terminal pass
  'bottom'
- src/client/native/surface.ts probes the controller's per-session writes
  (`openTabIn`/`openResourceIn`/`closeIn`, not in `ISidebarRight`) and
  queues opens for a session whose surface is not mounted yet

Right panel retired behaviourally (Phase 3 deletes the code): Sidebar
renders its bottom workbench only, keeps the panel collapsed, and hides
the right-panel toggle. The panel-specific e2e specs are removed; the
mount lane now drives the native guide (tab sweep), the plugin explorer
inside the native pane (editor/mermaid/README chunks) and the sidechat
host routes.

Verified: pnpm typecheck / lint / build / test (126 files, 1349 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (7/7).
This commit is contained in:
Menghuan1918
2026-09-09 11:47:31 +08:00
parent ba0114749f
commit 51546f7433
40 changed files with 1560 additions and 1568 deletions
+2 -2
View File
@@ -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:<tarball>`)并启动真实 `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 <tarball>`)并启动真实 `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:<id>`);`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`。
---
+21
View File
@@ -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 起把自己改造成一个**注册表服务**:
+37 -26
View File
@@ -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 }) {
</button>
</Tooltip>
)}
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.panelOpen ? t('collapse') : t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelRightOutline16 />
</button>
</Tooltip>
{!nativeSurface && (
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.panelOpen ? t('collapse') : t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelRightOutline16 />
</button>
</Tooltip>
)}
</div>
{/*
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}
+19 -1
View File
@@ -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()
+1
View File
@@ -18,6 +18,7 @@
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ar: Record<string, string> = {
files: 'الملفات',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesMdReading: 'قراءة',
+1
View File
@@ -3,6 +3,7 @@
*/
export const de: Record<string, string> = {
files: 'Dateien',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
changesRead: 'Lesen',
changesMdReading: 'Lesen',
+1
View File
@@ -10,6 +10,7 @@
/** fr dictionary for the `betterSidebar` namespace. */
export const fr: Record<string, string> = {
files: 'Fichiers',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
changesRead: 'Lecture',
changesMdReading: 'Lecture',
+1
View File
@@ -17,6 +17,7 @@
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const hi: Record<string, string> = {
files: 'फ़ाइलें',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesMdReading: 'पठन',
+1
View File
@@ -15,6 +15,7 @@
export const id: Record<string, string> = {
files: 'Berkas',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
changesRead: 'Baca',
changesMdReading: 'Baca',
+1
View File
@@ -8,6 +8,7 @@
export const it: Record<string, string> = {
files: 'File',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
changesRead: 'Lettura',
changesMdReading: 'Lettura',
+1
View File
@@ -17,6 +17,7 @@
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ja: Record<string, string> = {
files: 'ファイル',
nativeGuideDesc: 'DSH のサイドバーで開く',
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesMdReading: '閲覧',
+1
View File
@@ -9,6 +9,7 @@
export const ko: Record<string, string> = {
files: '파일',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesMdReading: '읽기',
+1
View File
@@ -15,6 +15,7 @@
*/
export const nl: Record<string, string> = {
files: 'Bestanden',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
changesRead: 'Lezen',
changesMdReading: 'Lezen',
+1
View File
@@ -19,6 +19,7 @@
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const pl: Record<string, string> = {
files: 'Pliki',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
changesRead: 'Odczyt',
changesMdReading: 'Czytanie',
+1
View File
@@ -1,5 +1,6 @@
export const pt: Record<string, string> = {
files: 'Arquivos',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
changesRead: 'Leitura',
changesMdReading: 'Leitura',
+1
View File
@@ -13,6 +13,7 @@
export const ru: Record<string, string> = {
files: 'Файлы',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesMdReading: 'Чтение',
+1
View File
@@ -1,5 +1,6 @@
export const sv: Record<string, string> = {
files: 'Filer',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
changesRead: 'Läs',
changesMdReading: 'Läsning',
+1
View File
@@ -17,6 +17,7 @@
/** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const th: Record<string, string> = {
files: 'ไฟล์',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
changesRead: 'อ่าน',
changesMdReading: 'อ่าน',
+1
View File
@@ -17,6 +17,7 @@
/** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const tr: Record<string, string> = {
files: 'Dosyalar',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
changesRead: 'Okuma',
changesMdReading: 'Okuma',
+1
View File
@@ -17,6 +17,7 @@
/** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const vi: Record<string, string> = {
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',
+1
View File
@@ -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<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
+1
View File
@@ -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<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
+1
View File
@@ -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<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: '本作業階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
+2
View File
@@ -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<keyof typeof zh, string> = {
files: 'Files',
nativeGuideDesc: 'Open in the DSH sidebar',
changesSessionEmpty: 'No file operations in this session yet',
changesRead: 'Read',
changesMdReading: 'Reading',
+229
View File
@@ -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<string, Registration>()
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<NativeBodyInjected, 'sessionId'>,
params: Pick<NativeBodyInjected, 'paramsOf' | 'sessionIdOf'>,
): 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<string, () => () => 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 }
+149
View File
@@ -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() },
}
}
+338
View File
@@ -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<string, View>()
const instances = new Map<string, number>()
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<SidebarTab> = {}
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<string, unknown>
: {}
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<NativeBodyInjected & NativeBodyFrameworkProps>
readonly title: ComponentType<NativeTitleInjected & NativeBodyFrameworkProps>
}
+42
View File
@@ -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}`)
}
}
+138
View File
@@ -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/<sessionId>/<path>` names a file by its path
* relative to that session's workspace root (`src/a.ts`, no leading `/`);
* - `dsh-resource://file/absolute/<path>` 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/<sessionId>/<path>` 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/<path>` 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)
}
+145 -6
View File
@@ -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<string, TabDescriptor>()
const viewers = new Map<string, FileViewerDescriptor>()
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. */
+31
View File
@@ -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. */
-128
View File
@@ -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<void> {
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([])
})
-775
View File
@@ -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<void> {
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<StripBox | null>(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<StripBox | null>(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<HTMLElement>('*')]
.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<number> => 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<void> {
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<void> {
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<void> {
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<HTMLElement>('*')]
.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<number> => 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<number> => 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<number> => 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<HTMLElement>('*')].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<HTMLElement>('*')].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)
}
})
-239
View File
@@ -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<void> {
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<void> {
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<ReturnType<Page['locator']>> => {
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<void> {
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<void> {
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)
})
+22 -1
View File
@@ -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<T = unknown>(
}
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<void> {
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()
}
+102 -187
View File
@@ -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<void> => {
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:<path>`) 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 <text> (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 <details> run, the inline pass turning the table cell's <br/>
// 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
// <details> run, the inline pass turning the table cell's <br/> 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 <script> must be sanitized away',
).toHaveCount(0)
// The unclosed <details> run lowers the following markdown inside itself.
const details = sidebar.locator('details')
await expect(
details,
'the details run must render as a real element',
).toHaveCount(1, { timeout: 30_000 })
const details = pane.locator('details')
await expect(details, 'the details run must render as a real element').toHaveCount(1, { timeout: 30_000 })
await expect(details.locator('summary'), 'the details summary must render').toHaveCount(1)
await expect(
details.locator('h3', { hasText: 'Inside' }),
'the heading between the details tags must nest inside the element',
).toHaveCount(1)
// The inline pass: the table cell's literal <br/> became a real element.
await expect(
sidebar.locator('[data-html-inline] br'),
pane.locator('[data-html-inline] br'),
'the table cell <br/> must render as a real element',
).toHaveCount(1, { timeout: 30_000 })
// The reference link resolves although its definition sits after an HTML run.
await expect(
sidebar.locator('a[href="https://example.com/def"]'),
pane.locator('a[href="https://example.com/def"]'),
'reference links must resolve across lifted HTML runs',
).toHaveCount(1, { timeout: 30_000 })
// TOC: the outline button appears (4 headings), opens the panel, and
// jumping into the collapsed details expands it.
const tocButton = sidebar.locator('[data-dsh-md-toc]')
const tocButton = pane.locator('[data-dsh-md-toc]')
await expect(
tocButton,
'the TOC button must appear once the document has enough headings',
).toHaveCount(1, { timeout: 30_000 })
await tocButton.click()
const tocPanel = sidebar.locator('[data-dsh-md-toc-panel]')
const tocPanel = pane.locator('[data-dsh-md-toc-panel]')
await expect(tocPanel, 'the TOC panel must open').toHaveCount(1)
const insideItem = tocPanel.locator('button', { hasText: 'Inside' })
await expect(insideItem, 'the nested heading must appear in the outline').toHaveCount(1)
@@ -571,9 +484,11 @@ test('conservative auto: URL stamps alone never modify the layout; plugin chrome
await expect
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
.toBe('')
// The stable addressing surface for presets / custom CSS is mounted.
// The stable addressing surface for presets / custom CSS is mounted: the
// plugin's own host plus its bottom workbench (the right column belongs to
// DSH's native Sidebar on 0.1.5).
await expect(page.locator('[data-dsh-toggle-cluster]')).toBeAttached()
await expect(page.locator('[data-dsh-panel]').first()).toBeAttached()
await expect(page.locator('[data-dsh-bottom-panel]')).toBeAttached()
// The plugin's interactive chrome opts out of Electron drag regions
// (issues #103/#111) — inert in plain browsers, present in the bundle
// (the bundler minifies the property's whitespace, so match loosely).
+18 -19
View File
@@ -26,14 +26,14 @@ import { mkdirSync, writeFileSync } 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'
import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
/** This lane's own workspace (lanes run serially against one server — never
* share seed paths with mount/drag). */
const WORKSPACE_PATH = process.env.DSH_E2E_PERF_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-perf-workspace')
/** 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
@@ -122,24 +122,23 @@ test('measure: mount latency, longtasks and bundle cost through a full tab sweep
await dismissOnboarding(page)
// Open the panel, then sweep every built-in tab through the "+" menu — the
// same crash-sweep surface as mount.e2e.ts, reused here as the workload.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton).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)
// The native Sidebar's way in lives in the conversation header's corner,
// which DSH renders only for a session with content.
await sendFirstMessage(page)
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}" must be offered by the + menu`).toHaveCount(1)
await item.click()
// Open DSH's native right Sidebar and sweep every plugin tab type through
// its guide page — the same crash-sweep surface as mount.e2e.ts, reused
// here as the workload (the plugin's content lives in the native surface on
// 0.1.5; the plugin's own panel keeps only its bottom workbench).
await page.locator('[data-sidebar-right-expand]').first().click()
const pane = page.locator('[data-sidebar-right-panel]')
await expect(pane).toBeVisible({ timeout: 90_000 })
const addTab = page.locator('[data-dockkit-add-tab]').first()
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, `native tab type "${kind}" must be offered by the guide`).toHaveCount(1)
await entry.click()
await page.waitForTimeout(1_500)
}
-184
View File
@@ -1,184 +0,0 @@
/**
* Toggle-layout lane (issue #315): while the right panel slides open/closed
* with its 300ms layout push, the BOTTOM panel must keep tracking the center
* column's horizontal edges frame by frame.
*
* The shell changed the bottom panel's geometry source from React state
* (setCenterRect per ResizeObserver frame → full Sidebar re-render at
* animation cadence) to a ref + direct inline writes, so the panel still
* follows the animated center column with zero React work. This lane samples
* the transition and asserts the bottom panel's edges stay glued to the
* center column (left/right within a rounding + one-frame tolerance) — the
* visual-equivalence proof for that change.
*
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
* page (own workspace, like drag-layout.e2e.ts).
*/
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, hostRpc } from './host'
const WORKSPACE_PATH = process.env.DSH_E2E_TOGGLE_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-toggle-workspace')
let api: APIRequestContext
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'toggle 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
centerLeft: number
centerRight: number
bottomLeft: number
bottomRight: number
}
test('bottom panel tracks the center column during the right-panel toggle transition (issue #315)', 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 onboarding (same dance as the drag lane).
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-toggle] 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(800)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
// Open the right panel (fresh sessions start collapsed).
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton).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)
// Open the bottom panel too: the twin panels make the toggle exercise the
// full geometry chain (bottom panel edges follow the animated push).
const expandBottom = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(expandBottom).toHaveCount(1)
await expandBottom.click()
// Wait until the bottom panel is REALLY tracking the center column (not
// the hidden {0,0} fallback): the panel's box exists even while hidden, so
// wait for the computed visibility + a matched right edge before sampling.
await expect
.poll(async () => page.evaluate(() => {
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
const bottom = document.querySelector('[data-dsh-bottom-panel]')
if (col === null || col === undefined || bottom === null) return false
const cr = col.getBoundingClientRect()
const br = bottom.getBoundingClientRect()
const visible = getComputedStyle(bottom).visibility !== 'hidden'
return cr.width > 100 && visible && Math.abs(br.right - cr.right) <= 3
}), { timeout: 30_000 })
.toBe(true)
await page.waitForTimeout(500) // let both open transitions settle
// Per-frame sampler: center column (same anchor as layout.css) vs the
// bottom panel's visible edges. The loop re-resolves
// window.__toggleSamples every frame, so clearing it (sampleToggle) drops
// old frames without detaching the loop.
await page.evaluate(() => {
const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement
const bottom = document.querySelector('[data-dsh-bottom-panel]')
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
const loop = (): void => {
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
const b = bottom?.getBoundingClientRect() ?? { left: 0, right: 0 }
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples.push({
t: performance.now(), centerLeft: c.left, centerRight: c.right, bottomLeft: b.left, bottomRight: b.right,
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
})
const collapseButton = sidebar.getByRole('button', { name: 'Collapse sidebar' })
const expandButton2 = sidebar.getByRole('button', { name: 'Expand sidebar' })
async function sampleToggle(click: () => Promise<void>): Promise<FrameSample[]> {
// Clear accumulated frames so the sample window starts at the click.
await page.evaluate(() => {
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
})
await click()
await page.waitForTimeout(800)
const samples = await page.evaluate(() => {
const data = (window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
return data
})
return samples
}
function trackMiss(samples: FrameSample[], label: string): void {
// The bottom panel's geometry rides the center column's ResizeObserver:
// RO callbacks run AFTER layout, so a write there lands in the NEXT
// frame's paint — the panel is exactly ONE animation frame behind the
// column mid-transition (measured: bottom(t) === center(t-1)).
// Anything more is a real desync; the settled frames must agree exactly.
const valid = samples.filter(s => s.centerRight - s.centerLeft > 100)
expect(valid.length, `${label}: sampler must have captured the transition`).toBeGreaterThan(3)
let maxOneFrame = 0
for (let i = 1; i < valid.length; i++) {
maxOneFrame = Math.max(
maxOneFrame,
Math.abs(valid[i]!.bottomLeft - valid[i - 1]!.centerLeft),
Math.abs(valid[i]!.bottomRight - valid[i - 1]!.centerRight),
)
}
const settled = valid.slice(-5)
const settleMiss = Math.max(
...settled.map(s => Math.max(
Math.abs(s.bottomLeft - s.centerLeft),
Math.abs(s.bottomRight - s.centerRight),
)),
)
console.log(`[e2e-toggle] ${label}: ${valid.length} frames, max one-frame miss=${maxOneFrame.toFixed(1)}px, settle miss=${settleMiss.toFixed(1)}px`)
expect(maxOneFrame, `${label}: bottom panel must track the center column one frame behind (RO cadence)`).toBeLessThanOrEqual(4)
expect(settleMiss, `${label}: bottom panel must settle exactly on the center column`).toBeLessThanOrEqual(3)
}
const collapseSamples = await sampleToggle(() => collapseButton.click())
trackMiss(collapseSamples, 'collapse')
const expandSamples = await sampleToggle(() => expandButton2.click())
trackMiss(expandSamples, 'expand')
})
+151
View File
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
/**
* Unit tests for the native-surface adapter: the per-tab record registry
* (src/client/native/tab-adapter.tsx) and the service's routing into it
* (src/client/service.ts `setSurface`).
*/
import { describe, expect, it, vi } from 'vitest'
import { createNativeTabRecords } from '../src/client/native/tab-adapter.tsx'
import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
const scope = { sessionId: 's1', cwd: '/work' }
describe('createNativeTabRecords', () => {
it('mints a synthetic tab from the native record + params', () => {
const records = createNativeTabRecords()
const view = records.ensure({
id: 'tab-1', kind: 'browser', title: 'Browser', params: { url: 'https://a.test', meta: { k: 1 } }, scope,
})
expect(view.tab).toMatchObject({ id: 'tab-1', type: 'browser', title: 'Browser', meta: { k: 1 } })
expect(view.scope).toBe(scope)
expect(view.expanded).toEqual([])
})
it('calls the descriptor factory once for a record that arrives without seed fields', () => {
const records = createNativeTabRecords()
const mint = vi.fn(() => ({ title: 'Side chat', meta: { autoCreate: true } }))
const view = records.ensure({ id: 'tab-2', kind: 'sidechat', title: 'Side Chat', params: undefined, scope, mint })
expect(mint).toHaveBeenCalledTimes(1)
expect(view.tab).toMatchObject({ title: 'Side chat', meta: { autoCreate: true } })
// A second render of the same record does not re-mint.
records.ensure({ id: 'tab-2', kind: 'sidechat', title: 'Side Chat', params: undefined, scope, mint })
expect(mint).toHaveBeenCalledTimes(1)
})
it('refreshes the seed fields on navigation but keeps the record identity', () => {
const records = createNativeTabRecords()
records.ensure({ id: 'tab-3', kind: 'editor', title: 'a.ts', params: { path: '/work/a.ts' }, scope })
records.update('tab-3', { title: 'renamed.ts' })
const view = records.ensure({ id: 'tab-3', kind: 'editor', title: 'b.ts', params: { path: '/work/b.ts' }, scope })
expect(view.tab).toMatchObject({ id: 'tab-3', path: '/work/b.ts', title: 'renamed.ts' })
})
it('tracks expansion per record and bumps its version', () => {
const records = createNativeTabRecords()
records.ensure({ id: 'tab-4', kind: 'editor', title: 'Files', params: undefined, scope })
const before = records.versionOf('tab-4')
records.toggleExpanded('tab-4', '/work/src')
expect(records.get('tab-4')?.expanded).toEqual(['/work/src'])
expect(records.versionOf('tab-4')).toBeGreaterThan(before)
records.toggleExpanded('tab-4', '/work/src')
expect(records.get('tab-4')?.expanded).toEqual([])
})
it('notifies subscribers and forgets a dropped record', () => {
const records = createNativeTabRecords()
records.ensure({ id: 'tab-5', kind: 'terminal', title: 'Terminal', params: undefined, scope })
const listener = vi.fn()
const off = records.subscribe(listener)
records.update('tab-5', { title: 'zsh' })
expect(listener).toHaveBeenCalledTimes(1)
expect(records.get('tab-5')?.tab.title).toBe('zsh')
records.drop('tab-5')
expect(records.has('tab-5')).toBe(false)
off()
records.ensure({ id: 'tab-6', kind: 'terminal', title: 'Terminal', params: undefined, scope })
records.update('tab-6', { title: 'x' })
expect(listener).toHaveBeenCalledTimes(2)
})
})
describe('service routing into the native surface', () => {
const mount = (): { surface: SidebarSurface; calls: unknown[]; service: ReturnType<typeof createBetterSidebarService> } => {
const calls: unknown[] = []
const surface: SidebarSurface = {
openTab: input => { calls.push({ op: 'openTab', ...input }) },
openResource: input => { calls.push({ op: 'openResource', ...input }) },
fileAddress: (sessionId, cwd, path) => `addr://${sessionId}${cwd === undefined ? '' : cwd}${path}`,
close: (sessionId, tabId) => ({ type: 'terminal', title: `closed ${tabId} in ${sessionId}` }),
update: tabId => tabId === 'native-1',
activate: tabId => tabId === 'native-1',
has: tabId => tabId === 'native-1',
}
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.setSurface(surface)
service.registerTab({ id: 'terminal', title: 'Terminal', createTab: state => ({ tab: { id: `terminal:${state.nextTerminal}`, type: 'terminal', title: 'Terminal', meta: { n: state.nextTerminal } } }) })
service.registerTab({ id: 'git', title: 'Changes', component: () => null })
service.registerTab({ id: 'editor', title: 'Files', component: () => null })
return { surface, calls, service }
}
it('opens a page type natively, carrying the descriptor factory seed', () => {
const { service, calls } = mount()
service.openTab({ type: 'terminal' }, scope)
expect(calls).toEqual([{
op: 'openTab',
sessionId: 's1',
kind: 'terminal',
params: { title: 'Terminal', meta: { n: 1 } },
revealIfOpened: false,
}])
})
it('opens a file path as a resource address', () => {
const { service, calls } = mount()
service.openTab({ type: 'editor', path: '/work/a.ts', title: 'a.ts' }, scope)
expect(calls).toEqual([{ op: 'openResource', sessionId: 's1', address: 'addr://s1/work/work/a.ts', revealIfOpened: true }])
})
it('maps a path-less editor open to the files page kind', () => {
const { service, calls } = mount()
service.openTab({ type: 'editor' }, scope)
expect(calls).toEqual([{ op: 'openTab', sessionId: 's1', kind: 'files', params: {}, revealIfOpened: true }])
})
it('keeps a bottom-targeted open in the plugin layout', () => {
const { service, calls } = mount()
service.openTab({ type: 'terminal', target: 'bottom' }, scope)
expect(calls).toEqual([])
})
it('routes record operations to the native surface when the id is native', () => {
const { service } = mount()
expect(() => service.updateTab('native-1', { title: 'x' })).not.toThrow()
expect(() => service.activateTab('native-1')).not.toThrow()
expect(() => service.closeTab('native-1', scope)).not.toThrow()
// A non-native id keeps the plugin's own layout path (a strict no-op here).
expect(() => service.updateTab('other', { title: 'x' })).not.toThrow()
expect(() => service.closeTab('other', scope)).not.toThrow()
})
it('refuses a disabled type before touching the surface', () => {
const { service, calls } = mount()
service.setSurface(undefined)
const store = createSidebarStore()
void store
service.setSurface({
openTab: input => { calls.push({ op: 'openTab', ...input }) },
openResource: () => { calls.push({ op: 'openResource' }) },
fileAddress: () => 'addr',
close: () => undefined,
update: () => false,
activate: () => false,
has: () => false,
})
service.openTab({ type: 'missing' }, scope)
expect(calls).toEqual([])
})
})
+95
View File
@@ -0,0 +1,95 @@
/**
* Unit tests for the file-address grammar (src/client/resource-address.ts):
* the plugin parses DSH's `dsh-resource://file/…` addresses itself (the
* client bundle's purity gate forbids value-importing the upstream util), so
* these tests pin the upstream shapes it must agree with.
*/
import { describe, expect, it } from 'vitest'
import {
absoluteFileAddress,
fileAddressFor,
parseFileAddress,
sessionFileAddress,
} from '../src/client/resource-address.ts'
describe('parseFileAddress', () => {
it('reads a session-scoped address', () => {
expect(parseFileAddress('dsh-resource://file/session/s1/src/a.ts')).toEqual({
scope: 'session', sessionId: 's1', path: 'src/a.ts',
})
})
it('reads an absolute address (POSIX, drive, UNC)', () => {
expect(parseFileAddress('dsh-resource://file/absolute/home/me/notes.txt')).toEqual({
scope: 'absolute', path: '/home/me/notes.txt',
})
expect(parseFileAddress('dsh-resource://file/absolute/C:/x/y.txt')).toEqual({
scope: 'absolute', path: 'C:/x/y.txt',
})
expect(parseFileAddress('dsh-resource://file/absolute//server/share/x.txt')).toEqual({
scope: 'absolute', path: '//server/share/x.txt',
})
})
it('decodes per segment so #, ? and spaces survive', () => {
expect(parseFileAddress('dsh-resource://file/session/s1/a%20b/c%23d.txt')).toEqual({
scope: 'session', sessionId: 's1', path: 'a b/c#d.txt',
})
})
it('refuses anything that is not a file address', () => {
for (const address of [
'sidebar://guide',
'dsh-resource://attachment/session/s1/a.png',
'dsh-resource://file/other/s1/a.ts',
'dsh-resource://file/session/s1',
'dsh-resource://file/absolute/',
'not a url',
]) {
expect(parseFileAddress(address), address).toBeUndefined()
}
})
})
describe('address builders', () => {
it('builds a session address and round-trips it', () => {
const address = sessionFileAddress('s1', './src\\a b.ts')
expect(address).toBe('dsh-resource://file/session/s1/src/a%20b.ts')
expect(parseFileAddress(address)).toEqual({ scope: 'session', sessionId: 's1', path: 'src/a b.ts' })
})
it('builds an absolute address and round-trips it', () => {
for (const [path, address] of [
['/home/me/x.txt', 'dsh-resource://file/absolute/home/me/x.txt'],
['C:\\x\\y.txt', 'dsh-resource://file/absolute/C:/x/y.txt'],
['\\\\server\\share\\x.txt', 'dsh-resource://file/absolute//server/share/x.txt'],
] as const) {
expect(absoluteFileAddress(path)).toBe(address)
expect(parseFileAddress(address)?.path.replace(/\\/g, '/')).toBe(path.replace(/\\/g, '/'))
}
})
it('keeps a drive colon literal', () => {
expect(absoluteFileAddress('C:/x/y.txt')).toContain('C:')
expect(absoluteFileAddress('C:/x/y.txt')).not.toContain('%3A')
})
})
describe('fileAddressFor', () => {
it('scopes a relative path to the session', () => {
expect(fileAddressFor('s1', '/work', 'src/a.ts')).toBe('dsh-resource://file/session/s1/src/a.ts')
})
it('scopes an absolute path inside the workspace to the session', () => {
expect(fileAddressFor('s1', '/work', '/work/src/a.ts')).toBe('dsh-resource://file/session/s1/src/a.ts')
})
it('scopes the workspace root itself to the session', () => {
expect(fileAddressFor('s1', '/work', '/work')).toBe('dsh-resource://file/session/s1/')
})
it('falls back to an absolute address outside the workspace (or with no cwd)', () => {
expect(fileAddressFor('s1', '/work', '/other/a.ts')).toBe('dsh-resource://file/absolute/other/a.ts')
expect(fileAddressFor('s1', undefined, '/other/a.ts')).toBe('dsh-resource://file/absolute/other/a.ts')
})
})