diff --git a/AGENTS.md b/AGENTS.md index f2442a1..dcac287 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ 7. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。 8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。 9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平。 +10. **聊天文件打开漏斗是 `remote.session.openWorkspacePath`**(`ctx.workspaces.openPath` 已随 alpha 删除,`IWorkspaces` 无此方法):「聊天区文件在侧边栏打开」拦截在 `ctx.inject(['remote.session'], …)` 内以 **defineProperty 数据属性遮蔽**该命名空间方法(gateway client 的方法是 accessor 属性、异步挂载、contribution 重挂载时服务重建——inject 回调重跑即自愈);实现见 `src/client/openpath-intercept.ts`,设计见 [docs/plans/2026-08-31-openpath-intercept-alpha-design.md](docs/plans/2026-08-31-openpath-intercept-alpha-design.md)。 --- diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index bf6f3ae..d9070d4 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -727,6 +727,7 @@ ctx.effect(() => | **i18n 跟随** | 文案跟随 DSH `ctx.locale`(词典在 `betterSidebar` 命名空间;Host-backed `locale.preference` 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件**不要**依赖内部 `t()`——标题传字符串或 `() => string` | | **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(optional)提供 ja/ko 覆盖,**借用 DSH 英文槽位**(仅 DSH=en 时生效,zh 下惰性)。经 `ctx.get('betterLocale')` 注入 `t()`;未安装整段 no-op | | **懒加载 chunk** | 重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-.js`),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[]`,由 `src/client/chunk-loader.ts` 物化,**不经** `__ModuleLoader__`。对消费插件透明 | +| **聊天文件打开漏斗(alpha 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)汇入 `ctx.remote.session.openWorkspacePath`(Typert remote 命名空间,cordis 服务 key `remote.session`,方法为 **accessor 属性**、异步挂载)。better-sidebar 的「聊天区文件在侧边栏打开」即在 `ctx.inject(['remote.session'], …)` 内以 defineProperty 遮蔽该方法(`src/client/openpath-intercept.ts`)。你的插件若要观测/旁路聊天文件打开,走同一服务;不要假设 pre-alpha 的 `ctx.workspaces.openPath` 存在(alpha 的 `IWorkspaces` 已无此方法) | --- diff --git a/docs/plans/2026-08-31-openpath-intercept-alpha-design.md b/docs/plans/2026-08-31-openpath-intercept-alpha-design.md new file mode 100644 index 0000000..575f4ee --- /dev/null +++ b/docs/plans/2026-08-31-openpath-intercept-alpha-design.md @@ -0,0 +1,72 @@ +# 聊天文件打开拦截的 0.1.2-alpha 适配(openPath 漏斗迁移) + +日期:2026-08-31 分支:`fix/openpath-intercept-alpha` + +## 问题 + +0.1.2-alpha.x 宿主上,聊天里点击文件链接(工具行、产物行、正文行内代码路径)直接落到系统默认应用(Linux 即 `xdg-open`),「聊天区文件在侧边栏打开」(`interceptOpenPath`,默认开)形同虚设。 + +**根因**:alpha 架构把聊天打开文件的漏斗从客户端服务换成了远程调用——`ui-chat/src/client/apply.ts` 注入 ChatView 的 `openFile` 直连 `ctx.remote.session.openWorkspacePath({ path })` → 宿主 `session-controller` → `openNativePath`。插件的 `wrapOpenPath` 包的是旧漏斗 `ctx.workspaces.openPath`,而 alpha 的 `IWorkspaces`(`api/workspace-controller/src/client/service.ts`)**已删除 `openPath` 方法**——wrap 只是给 service 实例挂了个永无调用方的新属性,死代码。这是 #435/#472 适配的漏网项(挂载冒烟不点聊天文件链接,抓不到)。 + +**连带影响**:#158 的聊天路径/行号超链接(`owner.openFile` → openPath 假设)同样失效。(#171 的「以默认应用打开」旁路 `openPathWithSystem` 已随重构消失,现行走插件自有 `open.external` 路由,不受影响。) + +**版本前提**:插件已进 alpha 通道(#472,peer `^0.1.2-alpha.2`,pre-alpha 兼容层已删)——本修复只面向 alpha 宿主,**整体替换**旧漏斗,不做双版本兼容。 + +## 新漏斗的事实(宿主源码核实) + +1. **唯一生产调用方**:`ui-chat` apply 的 `openFile` 注入(工具行 / 产物行 / 正文提及 / 行内代码路径全部经 ChatView 的 `requestOpenFile` 汇入)。调用前路径已被 `resolveWorkspacePath(cwd, path)` 解析成绝对路径——与旧漏斗收到的形态一致。`canOpenWorkspacePath` 仅被 ui-deliverables 用于按钮可见性,无打开语义,不拦。 +2. **`remote.session` 是 cordis 服务**(gateway client 的 `RemoteNamespaceService`,service key `remote.session`,经 `ctx.plugin` 注册): + - 方法以 `Object.defineProperty(service, method, { configurable: true, enumerable: true, get })` 安装,getter 在**访问时**读 `methods` 表返回调用闭包(因此直接赋值包装无效——无 setter); + - 命名空间整组方法与 service 构造在**同一同步窗口**安装——`ctx.inject(['remote.session'], cb)` 回调触发时方法必已就位; + - contribution 卸载/重挂载时服务 dispose/重建,inject 回调随之重跑——包装天然自愈。 +3. 宿主端成功返回 `{ opened: true }`;失败抛 TypertRemoteFailure(ChatView 弹错误框)。接管方必须返回同形成功值。 + +## 方案:`wrapOpenWorkspacePath`(沿用「包单漏斗」模式,defineProperty 版) + +改写 `src/client/openpath-intercept.ts`: + +```ts +// 伪代码 +const descriptor = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath') +const original = service.openWorkspacePath // 访问 getter,捕获当前调用闭包 +Object.defineProperty(service, 'openWorkspacePath', { + configurable: true, enumerable: true, writable: true, + value: (request: { path: string }, signal?: AbortSignal) => { + if (takeover()) { /* 接管 */ return Promise.resolve({ opened: true }) } + return original.call(service, request, signal) // 放行 = 宿主 native open + }, +}) +// dispose:descriptor 存在则原样 defineProperty 恢复,否则 delete —— HMR 安全 +``` + +- **接管语义不变**(复用现有 deps):`!suspended && interceptOpenPath !== false && tabsEnabled.editor !== false && 有 current session`;`isFolderRevealPath` → `revealInExplorer`;其余 → `openInSidebar`(`:line` 拆分在既有链路内,#158 功能随之恢复)。 +- **进入时机**:`registerOpenPathInterception` 改为 `ctx.inject(['remote.session'], ...)` 内包装;宿主裁掉该服务时回调永不触发 = 不拦截不报错。 +- **已知取舍**:包装期间若 contribution 重挂方法记录,捕获的 `original` 闭包指向旧记录(getter 访问期绑定语义决定);session 命名空间实际常驻,可接受,代码注释记录。 + +## 改动清单 + +| 文件 | 变更 | +|------|------| +| `src/client/openpath-intercept.ts` | 重写为 `wrapOpenWorkspacePath`(defineProperty 包装/恢复);`isFolderRevealPath` 保留 | +| `src/client/intercept.tsx` | `registerOpenPathInterception` 改走 `ctx.inject(['remote.session'], …)`;deps 不变 | +| `src/context-types.ts` | `SidebarWorkspacesService{openPath}` 替换为 `SidebarRemoteSessionService{openWorkspacePath(request): Promise<{opened: boolean}>}` | +| `src/client/index.tsx` | inject 数组移除 `'workspaces'`(全仓唯一消费者是旧拦截,typecheck 验证后删) | +| `tests/openpath-intercept.spec.ts` | 重写:getter 形态假服务、接管/放行/恢复、inject 晚到场景 | +| `docs/external-plugin-guide.md` | §10 平台陷阱补「聊天文件打开拦截走 remote.session.openWorkspacePath」一条 | +| AGENTS.md §3 | 新增一条 alpha 宿主契约记录(对齐 #463 的做法) | + +## 验证 + +- 单测:接管 / 放行 / 文件夹揭示手势三态;dispose 恢复原 descriptor;inject 延迟出现。 +- 门禁:`pnpm typecheck` → `pnpm test` → `pnpm build` → `pnpm test:mount`(alpha.2 真机 14/14 不回归;mount lane 不驱动聊天点击,拦截正确性靠单测 + 手测)。 +- 真机手测:让模型在回复里输出一个工作区文件路径(行内代码)→ 点击落侧边栏编辑器;关闭「聊天区文件在侧边栏打开」后点击 → 系统默认应用;`path:line` 链接 → 侧边栏跳行(#158 回归)。 + +## 实施偏差记录 + +- **接管返回值必须是 typert `RemoteResult` 包络**(初版实现踩坑修正):plan 中「接管返回 `{ opened: true }`」有误——gateway client 的每个 remote 方法都把业务值折叠成 `{ ok: true, value }` / `{ ok: false, error }`,ChatView 的 `openFile` 按 `result.ok` 分支、falsy 时读 `result.error.message`。初版返回裸业务值导致「文件已在侧边栏打开,但仍弹『无法打开文件: Cannot read properties of undefined (reading 'message')』」。修正为接管时返回 `{ ok: true, value: { opened: true } }`(`OpenWorkspacePathResult` 类型钉住,单测断言包络形状)。 + +## 不做 + +- pre-alpha 双版本兼容(alpha-only 通道,#472 已删兼容层)。 +- `canOpenWorkspacePath` 包装(仅按钮可见性)。 +- DSH 侧任何改动(仓库硬约束 §1)。 diff --git a/src/client/index.tsx b/src/client/index.tsx index ff25c8a..2915d42 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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 diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index f55de6c..8630a8b 100644 --- a/src/client/intercept.tsx +++ b/src/client/intercept.tsx @@ -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() } } diff --git a/src/client/openpath-intercept.ts b/src/client/openpath-intercept.ts index 4bac265..f87e05d 100644 --- a/src/client/openpath-intercept.ts +++ b/src/client/openpath-intercept.ts @@ -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 +/** 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 } /** 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 => { +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 => { 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) } } diff --git a/src/context-types.ts b/src/context-types.ts index d52f385..55e3076 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -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 +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. */ diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index a40a212..3e1ac12 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -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 /** diff --git a/tests/openpath-intercept.spec.ts b/tests/openpath-intercept.spec.ts index 619928b..76f8697 100644 --- a/tests/openpath-intercept.spec.ts +++ b/tests/openpath-intercept.spec.ts @@ -1,148 +1,281 @@ import { describe, expect, it } from 'vitest' import { registerOpenPathInterception } from '../src/client/intercept.tsx' -import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts' +import { + isFolderRevealPath, + wrapOpenWorkspacePath, + type OpenPathInterceptDeps, + type OpenWorkspacePathService, +} from '../src/client/openpath-intercept.ts' import { createSidebarStore } from '../src/client/state.ts' import type { Context } from '../src/context-types.ts' -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 - } +/** + * A fake of the gateway client's RemoteNamespaceService: the method is an + * ACCESSOR property (getter returning the invocation closure), exactly like + * `Object.defineProperty(service, method, { configurable: true, get })` in + * the real implementation — the wrapper must shadow it with a data property + * and restore the accessor on dispose. + */ +function fakeNamespaceService(opened: string[]): OpenWorkspacePathService { + const target = {} as Record + Object.defineProperty(target, 'openWorkspacePath', { + configurable: true, + enumerable: true, + get() { + return async (request: { path: string }) => { + opened.push(request.path) + return { ok: true, value: { opened: true } } as const + } + }, + }) + return target as unknown as OpenWorkspacePathService +} +describe('open-path interception (wrapOpenWorkspacePath)', () => { const deps = (overrides: Partial = {}): OpenPathInterceptDeps & { sidebar: string[] + revealed: string[] } => { const sidebar: string[] = [] + const revealed: string[] = [] return { sidebar, + revealed, takeoverEnabled: () => true, currentSessionId: () => 's1', openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) }, - revealInExplorer: () => {}, + revealInExplorer: (path, sessionId) => { revealed.push(`${sessionId}:${path}`) }, ...overrides, } } - it('routes an intercepted open into the sidebar and resolves without the original call', async () => { - const ws = service() + it('routes an intercepted open into the sidebar and resolves with the remote success envelope', async () => { + const opened: string[] = [] + const service = fakeNamespaceService(opened) const d = deps() - const restore = wrapOpenPath(ws, d) - await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined() - expect(ws.calls).toEqual([]) + const restore = wrapOpenWorkspacePath(service, d) + // The caller (ChatView's openFile) branches on `result.ok` and reads + // `result.error.message` on falsy — a bare business value would surface + // as a bogus "cannot open" dialog. + await expect(service.openWorkspacePath({ path: '/abs/a.ts' })) + .resolves.toEqual({ ok: true, value: { opened: true } }) + expect(opened).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 opened: string[] = [] + const service = fakeNamespaceService(opened) const d = deps({ takeoverEnabled: () => false }) - const restore = wrapOpenPath(ws, d) - await ws.openPath('/abs/a.ts') - expect(ws.opened).toEqual(['/abs/a.ts']) + const restore = wrapOpenWorkspacePath(service, d) + await service.openWorkspacePath({ path: '/abs/a.ts' }) + expect(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 opened: string[] = [] + const service = fakeNamespaceService(opened) const d = deps({ currentSessionId: () => undefined }) - const restore = wrapOpenPath(ws, d) - await ws.openPath('/abs/a.ts') - expect(ws.opened).toEqual(['/abs/a.ts']) + const restore = wrapOpenWorkspacePath(service, d) + await service.openWorkspacePath({ path: '/abs/a.ts' }) + expect(opened).toEqual(['/abs/a.ts']) expect(d.sidebar).toEqual([]) restore() }) it('passes the current session into the sidebar opener', async () => { - const ws = service() + const opened: string[] = [] + const service = fakeNamespaceService(opened) let current = 's1' const d = deps({ currentSessionId: () => current }) - const restore = wrapOpenPath(ws, d) - await ws.openPath('/abs/a.ts') + const restore = wrapOpenWorkspacePath(service, d) + await service.openWorkspacePath({ path: '/abs/a.ts' }) current = 's2' - await ws.openPath('/abs/b.ts') + await service.openWorkspacePath({ path: '/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() + it('routes the folder-reveal gesture to the explorer, not the editor', async () => { + const opened: string[] = [] + const service = fakeNamespaceService(opened) const d = deps() - const original = ws.openPath - const restore = wrapOpenPath(ws, d) - expect(ws.openPath).not.toBe(original) + const restore = wrapOpenWorkspacePath(service, d) + for (const path of ['.', './', '/w/.', 'C:\\w\\.']) { + await service.openWorkspacePath({ path }) + } + expect(opened).toEqual([]) + expect(d.sidebar).toEqual([]) + expect(d.revealed).toEqual(['s1:.', 's1:./', 's1:/w/.', 's1:C:\\w\\.']) 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') }, - } + it('restores the original accessor on dispose (HMR-safe)', async () => { + const opened: string[] = [] + const service = fakeNamespaceService(opened) + const before = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath') + expect(typeof before?.get).toBe('function') + const d = deps() + const restore = wrapOpenWorkspacePath(service, d) + // While wrapped, the property is our data-property shadow. + const during = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath') + expect(during?.get).toBeUndefined() + expect(typeof during?.value).toBe('function') + restore() + const after = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath') + expect(typeof after?.get).toBe('function') + await service.openWorkspacePath({ path: '/abs/a.ts' }) + expect(opened).toEqual(['/abs/a.ts']) + expect(d.sidebar).toEqual([]) + }) + + it('propagates a rejection from the original (no swallowing)', async () => { + const target = {} as Record + Object.defineProperty(target, 'openWorkspacePath', { + configurable: true, + enumerable: true, + get: () => async () => { throw new Error('host refused') }, + }) + const service = target as unknown as OpenWorkspacePathService const d = deps({ takeoverEnabled: () => false }) - const restore = wrapOpenPath(failing, d) - await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused') + const restore = wrapOpenWorkspacePath(service, d) + await expect(service.openWorkspacePath({ path: '/abs/a.ts' })).rejects.toThrow('host refused') + restore() + }) + + it('declines to wrap when the method is not installed (hand-rolled composition)', async () => { + const service = {} as OpenWorkspacePathService + const d = deps() + const restore = wrapOpenWorkspacePath(service, d) + expect(service.openWorkspacePath).toBeUndefined() + expect(d.sidebar).toEqual([]) restore() }) }) +describe('isFolderRevealPath', () => { + it('recognizes the dot gestures on both separator styles', () => { + expect(isFolderRevealPath('.')).toBe(true) + expect(isFolderRevealPath('./')).toBe(true) + expect(isFolderRevealPath('/w/.')).toBe(true) + expect(isFolderRevealPath('/w/./')).toBe(true) + expect(isFolderRevealPath('C:\\w\\.')).toBe(true) + expect(isFolderRevealPath('/w/a.ts')).toBe(false) + expect(isFolderRevealPath('/w/.hidden')).toBe(false) + }) +}) + 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 => {} } + /** + * A client-context fake whose `inject` mimics cordis: the callback runs + * once the dependency appears (here: when `mount()` is called) and its + * effects dispose with the fiber. + */ + function fakeCtx(opened: Array>, remoteSession: OpenWorkspacePathService) { + let effectDisposer: (() => void) | undefined + let injectCallback: ((fctx: unknown) => void) | undefined + const fctx = { + get: (name: string) => (name === 'remote.session' ? remoteSession : undefined), + effect: (fn: () => () => void) => { effectDisposer = fn() }, + } const ctx = { sessions: { list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) }, }, - workspaces: funnel, - betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record) } }, get: (name: string) => name === 'betterSidebar' ? { openTab: (seed: unknown) => { opened.push(seed as Record) } } : undefined, - } as unknown as Context + inject: (_deps: string[], callback: (fctx: unknown) => void) => { + injectCallback = callback + return { + dispose: async () => { effectDisposer?.() }, + } + }, + /** Test helper: simulate the namespace service appearing. */ + mount: () => { injectCallback?.(fctx) }, + } + return ctx + } + + it('registers through ctx.inject and routes chat opens into the editor tab', async () => { + const opened: Array> = [] + const hostOpened: string[] = [] + const remoteSession = fakeNamespaceService(hostOpened) + const ctx = fakeCtx(opened, remoteSession) const store = createSidebarStore() - const original = ctx.workspaces.openPath - const restore = registerOpenPathInterception(ctx, store) + const restore = registerOpenPathInterception(ctx as unknown as Context, store) + + // Before the namespace mounts, nothing is wrapped. + expect(Object.getOwnPropertyDescriptor(remoteSession, 'openWorkspacePath')?.get).toBeDefined() + ctx.mount() // 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') + await remoteSession.openWorkspacePath({ path: '/w/src/a.ts' }) expect(opened).toEqual([{ type: 'editor', title: 'a.ts', path: '/w/src/a.ts', id: 'editor:/w/src/a.ts', }]) + expect(hostOpened).toEqual([]) // 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']) + await remoteSession.openWorkspacePath({ path: '/w/src/b.ts' }) + expect(hostOpened).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']) + await remoteSession.openWorkspacePath({ path: '/w/src/c.ts' }) + expect(hostOpened).toEqual(['/w/src/b.ts', '/w/src/c.ts']) - // Disposal restores the raw original method (HMR-safe). + // Disposal restores the accessor (HMR-safe). restore() - expect(ctx.workspaces.openPath).toBe(original) + const descriptor = Object.getOwnPropertyDescriptor(remoteSession, 'openWorkspacePath') + expect(typeof descriptor?.get).toBe('function') + }) + + it('re-applies the shadow when the namespace service is remounted', async () => { + const opened: Array> = [] + const hostOpened: string[] = [] + // A ctx whose inject can fire multiple times (service recreate). + let effectDisposer: (() => void) | undefined + let injectCallback: ((fctx: unknown) => void) | undefined + let current = fakeNamespaceService(hostOpened) + const ctx = { + sessions: { list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) } }, + get: (name: string) => name === 'betterSidebar' + ? { openTab: (seed: unknown) => { opened.push(seed as Record) } } + : undefined, + inject: (_deps: string[], callback: (fctx: unknown) => void) => { + injectCallback = callback + return { dispose: async () => { effectDisposer?.() } } + }, + remount: () => { + effectDisposer?.() + current = fakeNamespaceService(hostOpened) + injectCallback?.({ + get: (name: string) => (name === 'remote.session' ? current : undefined), + effect: (fn: () => () => void) => { effectDisposer = fn() }, + }) + }, + } + const store = createSidebarStore() + registerOpenPathInterception(ctx as unknown as Context, store) + ctx.remount() + await current.openWorkspacePath({ path: '/w/src/a.ts' }) + expect(opened).toHaveLength(1) + // A remount (dispose + fresh service) must not leak the old shadow and + // must wrap the fresh instance. + ctx.remount() + await current.openWorkspacePath({ path: '/w/src/b.ts' }) + expect(opened).toHaveLength(2) + expect(hostOpened).toEqual([]) }) })