feat: 拦截聊天区文件打开 — 聊天里的文件点击默认开进侧边栏编辑器
包装 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 解析/设置页断言更新
This commit is contained in:
@@ -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) {
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenPathTitle'),
|
||||
desc: t('settingsOpenPathDesc'),
|
||||
icon: <IconCodeOutline16 size={16} />,
|
||||
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
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ const zh = {
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
@@ -251,6 +253,8 @@ const en: Record<keyof typeof zh, string> = {
|
||||
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',
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/** 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<void> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export const PrefsSchema: z<SidebarPrefs> = 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.
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
|
||||
@@ -57,6 +57,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ 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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
+159
-4
@@ -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<void> {
|
||||
this.calls.push(path)
|
||||
this.opened.push(path)
|
||||
},
|
||||
}
|
||||
return fake
|
||||
}
|
||||
|
||||
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): 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<Record<string, unknown>> = []
|
||||
const funnel = { openPath: async (): Promise<void> => {} }
|
||||
const ctx = {
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
|
||||
},
|
||||
workspaces: funnel,
|
||||
betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } },
|
||||
} 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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user