fix(client): intercept the alpha chat file-open funnel (remote.session.openWorkspacePath)
0.1.2-alpha 宿主删除了 ctx.workspaces.openPath,聊天文件点击全部改走 remote 命名空间,旧拦截成为死代码(点击穿透到 xdg-open)。改为在 ctx.inject(['remote.session']) 内以 defineProperty 遮蔽该方法(accessor 属性,赋值无效),接管时返回 RemoteResult 成功包络。语义不变, 设计:docs/plans/2026-08-31-openpath-intercept-alpha-design.md
This commit is contained in:
@@ -34,8 +34,10 @@ import './layout.css'
|
||||
* (rc.8+) is the client module system the chunk loader resolves its
|
||||
* externals through; `connection` (0.1.2-alpha.2+) is the Remote transport's
|
||||
* recovery lifecycle the side chat's disconnect banner reads — Cordis guards
|
||||
* service access without inject. */
|
||||
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules', 'connection']
|
||||
* service access without inject. The `remote.session` namespace is NOT here:
|
||||
* it mounts asynchronously, so the open-path interception reaches it through
|
||||
* `ctx.inject` (see intercept.tsx). */
|
||||
export const inject = ['slots', 'sessions', 'locale', 'modules', 'connection']
|
||||
|
||||
/**
|
||||
* Error boundary over the sidebar tree (root scope): a render error in the
|
||||
|
||||
+30
-16
@@ -11,7 +11,7 @@ import type { Context } from '../context-types.ts'
|
||||
import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||
import { wrapOpenPath } from './openpath-intercept.ts'
|
||||
import { wrapOpenWorkspacePath, type OpenWorkspacePathService } 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). */
|
||||
@@ -151,22 +151,36 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. The folder-reveal gesture ("Show in
|
||||
* folder" passes `'.'`) is the one exception: it is routed to the explorer.
|
||||
* 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).
|
||||
* Register the chat file-open interception: shadows
|
||||
* `remote.session.openWorkspacePath` — the single funnel every chat-side
|
||||
* file open goes through on alpha hosts (tool-row path links, the
|
||||
* produced-files row, prose mentions, inline-code paths) — so opens land in
|
||||
* the sidebar editor instead of the Host OS. The folder-reveal gesture
|
||||
* ("Show in folder" passes `'.'`) is the one exception: it is routed to the
|
||||
* explorer. Gated by BOTH the `interceptOpenPath` pref and the editor tab's
|
||||
* enable switch; declined opens fall through to the original remote call.
|
||||
*
|
||||
* The `remote.session` namespace service mounts asynchronously (the gateway
|
||||
* client creates it when the session-controller contribution arrives) and
|
||||
* is recreated on contribution remounts, so the wrapper installs through
|
||||
* `ctx.inject`: the callback runs once the service exists and re-runs after
|
||||
* every remount, re-applying the shadow on the fresh instance. Returns the
|
||||
* disposer (disposes the inject fiber, which restores the original method
|
||||
* descriptor — HMR-safe).
|
||||
*/
|
||||
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return wrapOpenPath(ctx.workspaces, {
|
||||
takeoverEnabled: () => !store.getSuspended()
|
||||
&& store.getPrefs().interceptOpenPath !== false
|
||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
|
||||
const fiber = ctx.inject(['remote.session'], (fctx) => {
|
||||
fctx.effect(() => {
|
||||
const service = fctx.get('remote.session') as OpenWorkspacePathService
|
||||
return wrapOpenWorkspacePath(service, {
|
||||
takeoverEnabled: () => !store.getSuspended()
|
||||
&& store.getPrefs().interceptOpenPath !== false
|
||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
|
||||
})
|
||||
}, 'dsh-better-sidebar: open-path interception wrap')
|
||||
})
|
||||
return () => { void fiber.dispose() }
|
||||
}
|
||||
|
||||
@@ -1,21 +1,57 @@
|
||||
/**
|
||||
* 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.
|
||||
* Interception of the chat's file-open funnel (DSH 0.1.2-alpha hosts).
|
||||
*
|
||||
* The alpha funnel is the Typert remote namespace `remote.session`: every
|
||||
* chat-side file open (tool-row path links, the produced-files row, prose
|
||||
* file mentions, inline-code paths) funnels through ChatView's injected
|
||||
* `openFile`, which calls `ctx.remote.session.openWorkspacePath({ path })`
|
||||
* with the path ALREADY resolved against the session cwd (verified against
|
||||
* the DSH source: `packages/client/ui-chat/src/client/apply.ts` is the only
|
||||
* production caller). The host side (`api/session-controller`) hands the
|
||||
* path to the OS's default application (`openNativePath` → xdg-open on
|
||||
* Linux). The pre-alpha funnel `ctx.workspaces.openPath` is gone — the
|
||||
* alpha `IWorkspaces` has no openPath at all.
|
||||
*
|
||||
* `remote.session` is a cordis service (the gateway client's
|
||||
* RemoteNamespaceService, key `remote.session`) whose methods are installed
|
||||
* as ACCESSOR properties (`Object.defineProperty(service, method, {
|
||||
* configurable: true, get })`) — the getter reads the method table at
|
||||
* ACCESS time and returns the invocation closure. A plain assignment would
|
||||
* not stick (no setter), so the wrapper shadows the method with a data
|
||||
* property and restores the captured accessor descriptor on dispose.
|
||||
*
|
||||
* The namespace service appears asynchronously (the session-controller
|
||||
* contribution mounts after the connection is up) and is disposed/recreated
|
||||
* on contribution remounts; the caller therefore enters through
|
||||
* `ctx.inject(['remote.session'], …)`, which re-fires on every remount.
|
||||
*
|
||||
* 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 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>
|
||||
/** The one request shape the funnel method takes (mirror of the host's
|
||||
* SessionOpenWorkspacePathRequest). */
|
||||
export interface OpenWorkspacePathRequest {
|
||||
/** Absolute path (the chat caller already resolved it against the cwd). */
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What the funnel method resolves to (mirror of the typert `RemoteResult`
|
||||
* envelope): EVERY remote method folds its business value into
|
||||
* `{ ok: true, value }` / `{ ok: false, error }` — the chat caller branches
|
||||
* on `result.ok`, so a bare business value would be misread as a failure
|
||||
* (`result.error.message` reads `message` off undefined).
|
||||
*/
|
||||
export type OpenWorkspacePathResult =
|
||||
| { readonly ok: true; readonly value: { opened: boolean } }
|
||||
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } }
|
||||
|
||||
/** The namespace slice the wrapper shadows (mirror of the gateway client's
|
||||
* RemoteNamespaceService for the `session` namespace). */
|
||||
export interface OpenWorkspacePathService {
|
||||
openWorkspacePath(request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise<OpenWorkspacePathResult>
|
||||
}
|
||||
|
||||
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
|
||||
@@ -48,32 +84,61 @@ export function isFolderRevealPath(path: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. The one exception is the folder-reveal gesture,
|
||||
* which is routed to {@link OpenPathInterceptDeps.revealInExplorer} instead.
|
||||
* @param workspaces - the client workspaces service to wrap.
|
||||
* Shadow `remote.session.openWorkspacePath`: intercepted calls open the file
|
||||
* in the sidebar editor and resolve with the remote SUCCESS ENVELOPE
|
||||
* (`{ ok: true, value: { opened: true } }`, so ChatView shows no error
|
||||
* dialog); anything that declines falls through to the captured original
|
||||
* closure (the host OS's default application) untouched.
|
||||
* The one exception is the folder-reveal gesture, which is routed to
|
||||
* {@link OpenPathInterceptDeps.revealInExplorer} instead.
|
||||
*
|
||||
* The original closure is captured by ACCESSING the accessor once at wrap
|
||||
* time — it invokes whatever method records are mounted at that moment. If
|
||||
* the contribution remounts its methods while the shadow is installed, the
|
||||
* captured closure points at the old records; the session namespace is
|
||||
* effectively permanent in practice, so this is accepted and the shadow is
|
||||
* re-applied anyway when the remount recreates the service (the caller's
|
||||
* `ctx.inject` re-fires).
|
||||
*
|
||||
* @param service - the `remote.session` namespace service.
|
||||
* @param deps - per-call takeover decisions.
|
||||
* @returns the disposer restoring the original method (HMR-safe).
|
||||
* @returns the disposer restoring the original accessor descriptor (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> => {
|
||||
export function wrapOpenWorkspacePath(
|
||||
service: OpenWorkspacePathService,
|
||||
deps: OpenPathInterceptDeps,
|
||||
): () => void {
|
||||
const KEY = 'openWorkspacePath'
|
||||
const target = service as object
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, KEY)
|
||||
// Invoke the accessor once to capture the CURRENT invocation closure.
|
||||
const original = service.openWorkspacePath
|
||||
// The gateway installs the namespace's whole method group in the same
|
||||
// synchronous window as the service registration, so an inject-observed
|
||||
// service always has the method — but a hand-rolled composition might not;
|
||||
// wrapping nothing would turn fall-throughs into crashes, so decline.
|
||||
if (typeof original !== 'function') return () => {}
|
||||
const wrapped = (request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise<OpenWorkspacePathResult> => {
|
||||
if (deps.takeoverEnabled()) {
|
||||
const sessionId = deps.currentSessionId()
|
||||
if (sessionId !== undefined) {
|
||||
if (isFolderRevealPath(path)) deps.revealInExplorer(path, sessionId)
|
||||
else deps.openInSidebar(path, sessionId)
|
||||
return Promise.resolve()
|
||||
if (isFolderRevealPath(request.path)) deps.revealInExplorer(request.path, sessionId)
|
||||
else deps.openInSidebar(request.path, sessionId)
|
||||
return Promise.resolve({ ok: true, value: { opened: true } })
|
||||
}
|
||||
}
|
||||
return original.call(workspaces, path)
|
||||
return original.call(service, request, signal)
|
||||
}
|
||||
Object.defineProperty(target, KEY, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: wrapped,
|
||||
})
|
||||
return () => {
|
||||
workspaces.openPath = original
|
||||
// Restore the exact original property (the gateway's accessor) so a
|
||||
// chain of wrappers keeps working across disposals in any order.
|
||||
if (descriptor !== undefined) Object.defineProperty(target, KEY, descriptor)
|
||||
else Reflect.deleteProperty(target, KEY)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-9
@@ -407,14 +407,27 @@ 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.
|
||||
* The client `remote.session` namespace face (mirror of the gateway client's
|
||||
* RemoteNamespaceService for the session-controller contribution on alpha
|
||||
* hosts). The chat's file-open funnel is `openWorkspacePath`: the caller
|
||||
* resolves the path against the session cwd, and the host hands it to the
|
||||
* OS's default application. Namespace methods are accessor properties (see
|
||||
* `client/openpath-intercept.ts` for how the interception shadows them).
|
||||
*/
|
||||
export interface SidebarWorkspacesService {
|
||||
/** Open a filesystem path with the Host operating system's default application. */
|
||||
openPath(path: string): Promise<void>
|
||||
export interface SidebarRemoteSessionService {
|
||||
/**
|
||||
* Open an absolute path with the Host operating system's default
|
||||
* application. Resolves with the typert `RemoteResult` envelope
|
||||
* (`{ ok: true, value: { opened } }` / `{ ok: false, error }`), like every
|
||||
* remote method — callers branch on `result.ok`.
|
||||
*/
|
||||
openWorkspacePath(
|
||||
request: { path: string },
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| { readonly ok: true; readonly value: { opened: boolean } }
|
||||
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } }
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,8 +512,6 @@ export interface SidebarContextShape {
|
||||
webRuntime: SidebarWebRuntime
|
||||
/** The client slot registry (register/inject). */
|
||||
slots: SidebarSlotsService
|
||||
/** The client workspaces service face (file-open funnel). */
|
||||
workspaces: SidebarWorkspacesService
|
||||
/** The settings service face (prefs persistence + namespace reads). */
|
||||
settings: SidebarSettingsService
|
||||
/** The invariant registry face. */
|
||||
|
||||
+3
-3
@@ -63,9 +63,9 @@ export interface SidebarPrefs {
|
||||
/**
|
||||
* 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).
|
||||
* runtime's `remote.session.openWorkspacePath`) 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
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user