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: {},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user