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:
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
@@ -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 起把自己改造成一个**注册表服务**:
|
||||
|
||||
+27
-16
@@ -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,6 +924,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!nativeSurface && (
|
||||
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -925,6 +935,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<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
@@ -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()
|
||||
|
||||
@@ -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: 'قراءة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'पठन',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '閲覧',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
export const ko: Record<string, string> = {
|
||||
files: '파일',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
|
||||
changesRead: '읽기',
|
||||
changesMdReading: '읽기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
export const ru: Record<string, string> = {
|
||||
files: 'Файлы',
|
||||
nativeGuideDesc: 'Open in the DSH sidebar',
|
||||
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
|
||||
changesRead: 'Чтение',
|
||||
changesMdReading: 'Чтение',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'อ่าน',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '閱讀',
|
||||
|
||||
@@ -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: '閱讀',
|
||||
|
||||
@@ -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: '閱讀',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
@@ -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() },
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
+101
-186
@@ -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)
|
||||
// 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)
|
||||
}
|
||||
await item.click()
|
||||
// Let the activation commit (including any lazy-chunk fetch) before the
|
||||
// crash assertions run.
|
||||
|
||||
// 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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user