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:
Menghuan1918
2026-08-11 16:48:59 +08:00
parent 8f3bb1b264
commit 67ec136e98
13 changed files with 298 additions and 12 deletions
+8
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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) },
})
}
+4
View File
@@ -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',
+62
View File
@@ -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
}
}
+3
View File
@@ -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),
}
+1
View File
@@ -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.
+12
View File
@@ -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
+9
View File
@@ -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: {},
}