From 67ec136e9866ad521a7b0dc8934780c37cd853e8 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 16:48:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8B=A6=E6=88=AA=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=8C=BA=E6=96=87=E4=BB=B6=E6=89=93=E5=BC=80=20=E2=80=94=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E9=87=8C=E7=9A=84=E6=96=87=E4=BB=B6=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E9=BB=98=E8=AE=A4=E5=BC=80=E8=BF=9B=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包装 ctx.workspaces.openPath(聊天区文件打开的唯一漏斗),接管时 经 openSidebarFile 开进 sidebar 编辑器而非系统默认应用;由 interceptOpenPath pref(默认开)与 editor tab 启用态双重门控, 拒接时原样放行,dispose 还原原始方法引用(HMR 安全)。 - prefs-shared/config/prefs: 新增 interceptOpenPath 字段(默认 true) - openpath-intercept.ts: 零依赖包装器,纯插件层面实现,不改 DSH - intercept.tsx: registerOpenPathInterception 接线(当前会话 scope) - SideCardSection: 常规区新增开关卡片(zh/en 文案) - 测试: 包装器单测 6 个 + 接线集成测试 + pref 解析/设置页断言更新 --- src/client/SideCardSection.tsx | 8 ++ src/client/index.tsx | 16 ++- src/client/intercept.tsx | 18 ++++ src/client/locales.ts | 4 + src/client/openpath-intercept.ts | 62 ++++++++++++ src/client/prefs.ts | 3 + src/config.ts | 1 + src/context-types.ts | 12 +++ src/prefs-shared.ts | 9 ++ tests/plugin-shape.spec.ts | 2 +- tests/side-card-section.spec.tsx | 11 ++- tests/smoke.spec.ts | 1 + tests/unit.spec.ts | 163 ++++++++++++++++++++++++++++++- 13 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 src/client/openpath-intercept.ts diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 9af63ed..fb159ac 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -32,6 +32,7 @@ import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' import { IconCheckOutline16, + IconCodeOutline16, IconPanelLeftOutline16, IconSettingsOutline16, Input, @@ -347,6 +348,13 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { {t('settingsWidthSuffix')} + {renderCard({ + title: t('settingsOpenPathTitle'), + desc: t('settingsOpenPathDesc'), + icon: , + enabled: prefs.interceptOpenPath, + onToggle: (next) => { togglePref({ interceptOpenPath: next }) }, + })} {/* 侧边栏内容: one small card per registered tab type in a responsive grid; features declaring `settings.toggles` open their settings in diff --git a/src/client/index.tsx b/src/client/index.tsx index 0af52ee..a7bf720 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -15,7 +15,7 @@ import { createSidebarStore } from './state.ts' import { createBetterSidebarService } from './service.ts' import { registerBuiltins } from './builtins/index.ts' import { Sidebar } from './Sidebar.tsx' -import { registerTurnTailInterception } from './intercept.tsx' +import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' import { loadPrefs } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' @@ -24,7 +24,7 @@ import css from './sidebar.module.css' import './layout.css' /** Services required before mounting (provided by the client runtime). */ -export const inject = ['slots', 'sessions', 'connection'] +export const inject = ['slots', 'sessions', 'connection', 'workspaces'] /** * Error boundary over the sidebar tree: a render error must never blank the @@ -146,6 +146,18 @@ export function apply(ctx: Context): void { 'dsh-better-sidebar: turn-tail interception', ) + ctx.effect( + () => { + try { + return registerOpenPathInterception(ctx, sidebarStore) + } catch (error) { + fail('interception', error) + return undefined + } + }, + 'dsh-better-sidebar: open-path interception', + ) + // The "Side card" settings section: appears in the DSH Settings shell // once the shell's declaration is on the ledger (slots.inject waits for // it); the section reads/writes the prefs through the plugin's own diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index a34cf0d..867fc48 100644 --- a/src/client/intercept.tsx +++ b/src/client/intercept.tsx @@ -11,6 +11,7 @@ import type { Context } from '../context-types.ts' import type { SidebarStore } from './state.ts' import { t } from './locales.ts' import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts' +import { wrapOpenPath } from './openpath-intercept.ts' import css from './sidebar.module.css' /** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */ @@ -74,3 +75,20 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore): }), }, SidebarProducedFiles) } + +/** + * Register the chat file-open interception: wraps `ctx.workspaces.openPath` + * — the single funnel every chat-side file open goes through (tool-row path + * links, the produced-files row, prose mentions) — so opens land in the + * sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath` + * pref and the editor tab's enable switch; declined opens fall through to + * the original method. Returns the disposer restoring the original (HMR-safe). + */ +export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void { + return wrapOpenPath(ctx.workspaces, { + takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false + && store.getPrefs().tabsEnabled['editor'] !== false, + currentSessionId: () => ctx.sessions.list.getSnapshot().current, + openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) }, + }) +} diff --git a/src/client/locales.ts b/src/client/locales.ts index 9e7cb42..d138dd1 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -105,6 +105,8 @@ const zh = { settingsWidthTitle: '默认宽度占比', settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)', settingsWidthSuffix: '%', + settingsOpenPathTitle: '聊天区文件在侧边栏打开', + settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用', settingsSaveFailed: '保存失败', settingsConflict: '设置已被其他窗口修改,请重试', binaryNoPreview: '此文件类型不支持预览', @@ -251,6 +253,8 @@ const en: Record = { settingsWidthTitle: 'Default width share', settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)', settingsWidthSuffix: '%', + settingsOpenPathTitle: 'Open chat files in the sidebar', + settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app', settingsSaveFailed: 'Failed to save', settingsConflict: 'The setting changed in another window — please retry', binaryNoPreview: 'This file type cannot be previewed', diff --git a/src/client/openpath-intercept.ts b/src/client/openpath-intercept.ts new file mode 100644 index 0000000..708e897 --- /dev/null +++ b/src/client/openpath-intercept.ts @@ -0,0 +1,62 @@ +/** + * Interception of the chat's file-open funnel. The client runtime's + * `ctx.workspaces.openPath` is the SINGLE door every chat-side file open goes + * through — ui-conversation's apply.ts resolves the path against the session + * cwd and calls it for tool-row path links, the produced-files row, and + * prose file mentions alike (verified against the DSH source: + * `packages/client/ui-conversation/src/client/apply.ts` is the only + * production caller). Wrapping that one method reroutes those opens into the + * sidebar editor instead of the Host OS — no DSH modification needed. + * + * The wrapper is dependency-free by design (no React / ui-primitives), so + * the takeover logic is unit-testable and the file stays importable from the + * test runtime. + */ + +/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */ +export interface OpenPathService { + openPath(path: string): Promise +} + +/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */ +export interface OpenPathInterceptDeps { + /** + * Whether to take over this call: the `interceptOpenPath` pref AND the + * editor tab's own enable switch must both be on (an editor that cannot + * open must not swallow opens — they fall through to the Host). + */ + takeoverEnabled(): boolean + /** The session whose scope the sidebar editor loads the file in (current session). */ + currentSessionId(): string | undefined + /** Route the open into the sidebar editor (the established openSidebarFile). */ + openInSidebar(path: string, sessionId: string): void +} + +/** + * Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar + * editor instead of the Host OS and resolve as success (the original's + * callers ignore the result); anything that declines falls through to the + * original method untouched. + * @param workspaces - the client workspaces service to wrap. + * @param deps - per-call takeover decisions. + * @returns the disposer restoring the original method (HMR-safe). + */ +export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathInterceptDeps): () => void { + // The RAW method reference (never a bound copy): restore must put back the + // exact original so a chain of wrappers (other plugins wrapping the same + // method) keeps working across disposals in any order. + const original = workspaces.openPath + workspaces.openPath = (path: string): Promise => { + if (deps.takeoverEnabled()) { + const sessionId = deps.currentSessionId() + if (sessionId !== undefined) { + deps.openInSidebar(path, sessionId) + return Promise.resolve() + } + } + return original.call(workspaces, path) + } + return () => { + workspaces.openPath = original + } +} diff --git a/src/client/prefs.ts b/src/client/prefs.ts index c7d3d82..9f973ce 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -43,6 +43,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { agentTerminalTools: typeof record.agentTerminalTools === 'boolean' ? record.agentTerminalTools : SIDEBAR_PREFS_DEFAULTS.agentTerminalTools, + interceptOpenPath: typeof record.interceptOpenPath === 'boolean' + ? record.interceptOpenPath + : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath, tabsEnabled: booleanMapOf(record.tabsEnabled), viewersEnabled: booleanMapOf(record.viewersEnabled), } diff --git a/src/config.ts b/src/config.ts index 3b4774a..e744c3e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -80,6 +80,7 @@ export const PrefsSchema: z = z.object({ defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), autoOpenSubagent: z.boolean().default(true), agentTerminalTools: z.boolean().default(false), + interceptOpenPath: z.boolean().default(true), // Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or // external): an absent key means enabled, so old documents resolve to {} // (everything on) with no migration. Non-boolean values fail validation. diff --git a/src/context-types.ts b/src/context-types.ts index aab1b7a..ba7716b 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -232,6 +232,17 @@ export interface SidebarConversation { } } +/** + * The client workspaces service face (mirror of the runtime IWorkspaces). Only + * the chat's file-open funnel is touched: `openPath` hands an absolute path + * to the Host OS's default application, and every chat-side file open + * (tool rows, produced-files, prose mentions) funnels through it. + */ +export interface SidebarWorkspacesService { + /** Open a filesystem path with the Host operating system's default application. */ + openPath(path: string): Promise +} + /** * The invariant service face (mirror of @deepseek-ai/dsh-invariants' * InvariantService). The upstream augmentation does not reach this Context @@ -307,6 +318,7 @@ declare module 'cordis' { connection: SidebarConnectionHandle loader: SidebarLoader slots: SidebarSlotsService + workspaces: SidebarWorkspacesService settings: SidebarSettingsService invariants: SidebarInvariantsService tools: SidebarToolsService diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 2b1742f..9d9178b 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -27,6 +27,14 @@ export interface SidebarPrefs { * user explicitly enables it in the side card settings. */ agentTerminalTools: boolean + /** + * Whether chat-side file opens (tool-row path links, the produced-files + * row, prose file mentions — every path that funnels through the client + * runtime's `ctx.workspaces.openPath`) open in the sidebar editor instead + * of the Host OS's default application. On by default; the editor tab's + * own enable switch gates it too (both must be on for the takeover). + */ + interceptOpenPath: boolean /** * Per-tab enable switches, keyed by tab descriptor id (`'explorer'`, * `'my-plugin:db'`). An ABSENT key means enabled — only an explicit @@ -57,6 +65,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { defaultWidthPercent: WIDTH_PERCENT_DEFAULT, autoOpenSubagent: true, agentTerminalTools: false, + interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {}, } diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index b473251..f6858bd 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -57,6 +57,6 @@ describe('dsh-better-sidebar plugin export shape', () => { const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} }) }) }) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index db825be..faffe9e 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -78,10 +78,10 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('>explorer<') expect(html).toContain('data-icon="subagent"') expect(html).toContain('>Subagents<') - // Default prefs: openByDefault + both tabs + the image viewer are all - // enabled → 4 cards pressed, none pressed=false. The nested auto-open - // toggle is NOT an inline card (it lives in the popup). - expect(pressedCount(html, 'true')).toBe(4) + // Default prefs: openByDefault + interceptOpenPath + both tabs + the + // image viewer are all enabled → 5 cards pressed, none pressed=false. + // The nested auto-open toggle is NOT an inline card (it lives in the popup). + expect(pressedCount(html, 'true')).toBe(5) expect(pressedCount(html, 'false')).toBe(0) expect(html).not.toContain('Auto-open Subagents') }) @@ -111,7 +111,8 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('>Subagents<') expect(html).toContain('>Image<') expect(pressedCount(html, 'false')).toBe(2) - expect(pressedCount(html, 'true')).toBe(2) + // openByDefault + interceptOpenPath + the explorer stay pressed. + expect(pressedCount(html, 'true')).toBe(3) }) it('hides the gear of a disabled feature (its related settings are dormant)', () => { diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 0f81ce8..c2ddb8e 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -507,6 +507,7 @@ describe('side card settings routes', () => { defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, + interceptOpenPath: true, // The enable-switch maps default to {} (everything on). tabsEnabled: {}, viewersEnabled: {}, diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index df8d379..3123e94 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -15,6 +15,9 @@ import { isPdfExt } from '../src/client/pdf-types.ts' import { isImageExt } from '../src/client/image-types.ts' import { relativeTo } from '../src/client/paths.ts' import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts' +import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts' +import { registerOpenPathInterception } from '../src/client/intercept.tsx' +import type { Context } from '../src/context-types.ts' import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts' import { collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor, @@ -834,6 +837,7 @@ describe('side card preferences', () => { defaultWidthPercent: 60, autoOpenSubagent: false, agentTerminalTools: true, + interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {}, }) @@ -846,6 +850,7 @@ describe('side card preferences', () => { defaultWidthPercent: 33, autoOpenSubagent: true, agentTerminalTools: false, + interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {}, }) @@ -858,6 +863,7 @@ describe('side card preferences', () => { defaultWidthPercent: 40, autoOpenSubagent: true, agentTerminalTools: false, + interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {}, }) @@ -872,6 +878,16 @@ describe('side card preferences', () => { .toBe(true) }) + it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => { + // Absent or malformed → on (the takeover is the safe default). + expect((await loadPrefs(wire({}))).interceptOpenPath).toBe(true) + expect((await loadPrefs(wire({ interceptOpenPath: 'yes' }))).interceptOpenPath).toBe(true) + expect((await loadPrefs(wire({ interceptOpenPath: 0 }))).interceptOpenPath).toBe(true) + // Explicit booleans survive verbatim. + expect((await loadPrefs(wire({ interceptOpenPath: false }))).interceptOpenPath).toBe(false) + expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true) + }) + it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => { // A non-object map falls back to {} (everything enabled). expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({}) @@ -889,9 +905,9 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false) @@ -904,7 +920,7 @@ describe('side card preferences', () => { it('skips the default explorer tab when the explorer type is disabled', () => { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: { explorer: false }, viewersEnabled: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: { explorer: false }, viewersEnabled: {} }) store.setSession('no-explorer') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) @@ -912,7 +928,7 @@ describe('side card preferences', () => { expect(state.splits.kind).toBe('leaf') // Re-enabling seeds the explorer tab again. const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} }) + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} }) openStore.setSession('with-explorer') const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) @@ -1103,3 +1119,142 @@ describe('subagent activity summary parser', () => { expect(live.tool).toEqual({ name: 'web', args: '' }) }) }) + +describe('open-path interception', () => { + /** A minimal fake of the workspaces.openPath service method. */ + const service = (): OpenPathService & { calls: string[]; opened: string[] } => { + const fake = { + calls: [] as string[], + opened: [] as string[], + async openPath(path: string): Promise { + this.calls.push(path) + this.opened.push(path) + }, + } + return fake + } + + const deps = (overrides: Partial = {}): OpenPathInterceptDeps & { + sidebar: string[] + } => { + const sidebar: string[] = [] + return { + sidebar, + takeoverEnabled: () => true, + currentSessionId: () => 's1', + openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) }, + ...overrides, + } + } + + it('routes an intercepted open into the sidebar and resolves without the original call', async () => { + const ws = service() + const d = deps() + const restore = wrapOpenPath(ws, d) + await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined() + expect(ws.calls).toEqual([]) + expect(d.sidebar).toEqual(['s1:/abs/a.ts']) + restore() + }) + + it('falls through to the original when the takeover is disabled', async () => { + const ws = service() + const d = deps({ takeoverEnabled: () => false }) + const restore = wrapOpenPath(ws, d) + await ws.openPath('/abs/a.ts') + expect(ws.opened).toEqual(['/abs/a.ts']) + expect(d.sidebar).toEqual([]) + restore() + }) + + it('falls through when no session is current (nothing to scope the editor load to)', async () => { + const ws = service() + const d = deps({ currentSessionId: () => undefined }) + const restore = wrapOpenPath(ws, d) + await ws.openPath('/abs/a.ts') + expect(ws.opened).toEqual(['/abs/a.ts']) + expect(d.sidebar).toEqual([]) + restore() + }) + + it('passes the current session into the sidebar opener', async () => { + const ws = service() + let current = 's1' + const d = deps({ currentSessionId: () => current }) + const restore = wrapOpenPath(ws, d) + await ws.openPath('/abs/a.ts') + current = 's2' + await ws.openPath('/abs/b.ts') + expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts']) + restore() + }) + + it('restores the original method on dispose (HMR-safe)', async () => { + const ws = service() + const d = deps() + const original = ws.openPath + const restore = wrapOpenPath(ws, d) + expect(ws.openPath).not.toBe(original) + restore() + expect(ws.openPath).toBe(original) + await ws.openPath('/abs/a.ts') + expect(ws.opened).toEqual(['/abs/a.ts']) + }) + + it('treats a rejected promise like the original would (no swallowing)', async () => { + const failing: OpenPathService = { + async openPath() { throw new Error('host refused') }, + } + const d = deps({ takeoverEnabled: () => false }) + const restore = wrapOpenPath(failing, d) + await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused') + restore() + }) +}) + +describe('open-path interception wiring', () => { + it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => { + // A realistic client-context fake: the sessions list feed (current + cwd), + // the workspaces funnel, and the sidebar service the editor goes through. + const opened: Array> = [] + const funnel = { openPath: async (): Promise => {} } + const ctx = { + sessions: { + list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) }, + }, + workspaces: funnel, + betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record) } }, + } as unknown as Context + const store = createSidebarStore() + const original = ctx.workspaces.openPath + const restore = registerOpenPathInterception(ctx, store) + + // Default prefs: the takeover routes the open into the sidebar editor + // with the session-scoped absolute path (chat already resolved it). + await ctx.workspaces.openPath('/w/src/a.ts') + expect(opened).toEqual([{ + type: 'editor', + title: 'a.ts', + path: '/w/src/a.ts', + id: 'editor:/w/src/a.ts', + }]) + + // The interceptOpenPath pref off → the original funnel runs untouched. + store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false }) + const calls: string[] = [] + ctx.workspaces.openPath = async (path: string) => { calls.push(path) } + await ctx.workspaces.openPath('/w/src/b.ts') + expect(calls).toEqual(['/w/src/b.ts']) + expect(opened).toHaveLength(1) + + // The editor tab disabled → falls through too (an editor that cannot + // open must not swallow opens). + store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } }) + await ctx.workspaces.openPath('/w/src/c.ts') + expect(calls).toEqual(['/w/src/b.ts', '/w/src/c.ts']) + + // Disposal restores the raw original method (HMR-safe). + restore() + expect(ctx.workspaces.openPath).toBe(original) + }) +})