Merge pull request #494 from Cyancat/fix/openpath-intercept-alpha
fix(client): intercept the alpha chat file-open funnel (remote.session.openWorkspacePath)
This commit is contained in:
@@ -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` 四处同步)均已无该条目。
|
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` 默认会拒装新鲜包。
|
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` 注明,待上游发版再追平。
|
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)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -727,6 +727,7 @@ ctx.effect(() =>
|
|||||||
| **i18n 跟随** | 文案跟随 DSH `ctx.locale`(词典在 `betterSidebar` 命名空间;Host-backed `locale.preference` 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件**不要**依赖内部 `t()`——标题传字符串或 `() => string` |
|
| **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 |
|
| **第三语言覆盖(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-<name>.js`),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 物化,**不经** `__ModuleLoader__`。对消费插件透明 |
|
| **懒加载 chunk** | 重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[<name>]`,由 `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` 已无此方法) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
@@ -34,8 +34,10 @@ import './layout.css'
|
|||||||
* (rc.8+) is the client module system the chunk loader resolves its
|
* (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
|
* externals through; `connection` (0.1.2-alpha.2+) is the Remote transport's
|
||||||
* recovery lifecycle the side chat's disconnect banner reads — Cordis guards
|
* recovery lifecycle the side chat's disconnect banner reads — Cordis guards
|
||||||
* service access without inject. */
|
* service access without inject. The `remote.session` namespace is NOT here:
|
||||||
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules', 'connection']
|
* 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
|
* 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 { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
|
||||||
import { t } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.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'
|
import css from './sidebar.module.css'
|
||||||
|
|
||||||
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
|
/** 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`
|
* Register the chat file-open interception: shadows
|
||||||
* — the single funnel every chat-side file open goes through (tool-row path
|
* `remote.session.openWorkspacePath` — the single funnel every chat-side
|
||||||
* links, the produced-files row, prose mentions) — so opens land in the
|
* file open goes through on alpha hosts (tool-row path links, the
|
||||||
* sidebar editor instead of the Host OS. The folder-reveal gesture ("Show in
|
* produced-files row, prose mentions, inline-code paths) — so opens land in
|
||||||
* folder" passes `'.'`) is the one exception: it is routed to the explorer.
|
* the sidebar editor instead of the Host OS. The folder-reveal gesture
|
||||||
* Gated by BOTH the `interceptOpenPath` pref and the editor tab's enable
|
* ("Show in folder" passes `'.'`) is the one exception: it is routed to the
|
||||||
* switch; declined opens fall through to the original method. Returns the
|
* explorer. Gated by BOTH the `interceptOpenPath` pref and the editor tab's
|
||||||
* disposer restoring the original (HMR-safe).
|
* 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 {
|
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||||
return wrapOpenPath(ctx.workspaces, {
|
const fiber = ctx.inject(['remote.session'], (fctx) => {
|
||||||
takeoverEnabled: () => !store.getSuspended()
|
fctx.effect(() => {
|
||||||
&& store.getPrefs().interceptOpenPath !== false
|
const service = fctx.get('remote.session') as OpenWorkspacePathService
|
||||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
return wrapOpenWorkspacePath(service, {
|
||||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
takeoverEnabled: () => !store.getSuspended()
|
||||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
&& store.getPrefs().interceptOpenPath !== false
|
||||||
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
|
&& 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
|
* Interception of the chat's file-open funnel (DSH 0.1.2-alpha hosts).
|
||||||
* `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
|
* The alpha funnel is the Typert remote namespace `remote.session`: every
|
||||||
* cwd and calls it for tool-row path links, the produced-files row, and
|
* chat-side file open (tool-row path links, the produced-files row, prose
|
||||||
* prose file mentions alike (verified against the DSH source:
|
* file mentions, inline-code paths) funnels through ChatView's injected
|
||||||
* `packages/client/ui-conversation/src/client/apply.ts` is the only
|
* `openFile`, which calls `ctx.remote.session.openWorkspacePath({ path })`
|
||||||
* production caller). Wrapping that one method reroutes those opens into the
|
* with the path ALREADY resolved against the session cwd (verified against
|
||||||
* sidebar editor instead of the Host OS — no DSH modification needed.
|
* 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 wrapper is dependency-free by design (no React / ui-primitives), so
|
||||||
* the takeover logic is unit-testable and the file stays importable from the
|
* the takeover logic is unit-testable and the file stays importable from
|
||||||
* test runtime.
|
* the test runtime.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */
|
/** The one request shape the funnel method takes (mirror of the host's
|
||||||
export interface OpenPathService {
|
* SessionOpenWorkspacePathRequest). */
|
||||||
openPath(path: string): Promise<void>
|
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). */
|
/** 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
|
* Shadow `remote.session.openWorkspacePath`: intercepted calls open the file
|
||||||
* editor instead of the Host OS and resolve as success (the original's
|
* in the sidebar editor and resolve with the remote SUCCESS ENVELOPE
|
||||||
* callers ignore the result); anything that declines falls through to the
|
* (`{ ok: true, value: { opened: true } }`, so ChatView shows no error
|
||||||
* original method untouched. The one exception is the folder-reveal gesture,
|
* dialog); anything that declines falls through to the captured original
|
||||||
* which is routed to {@link OpenPathInterceptDeps.revealInExplorer} instead.
|
* closure (the host OS's default application) untouched.
|
||||||
* @param workspaces - the client workspaces service to wrap.
|
* 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.
|
* @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 {
|
export function wrapOpenWorkspacePath(
|
||||||
// The RAW method reference (never a bound copy): restore must put back the
|
service: OpenWorkspacePathService,
|
||||||
// exact original so a chain of wrappers (other plugins wrapping the same
|
deps: OpenPathInterceptDeps,
|
||||||
// method) keeps working across disposals in any order.
|
): () => void {
|
||||||
const original = workspaces.openPath
|
const KEY = 'openWorkspacePath'
|
||||||
workspaces.openPath = (path: string): Promise<void> => {
|
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()) {
|
if (deps.takeoverEnabled()) {
|
||||||
const sessionId = deps.currentSessionId()
|
const sessionId = deps.currentSessionId()
|
||||||
if (sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
if (isFolderRevealPath(path)) deps.revealInExplorer(path, sessionId)
|
if (isFolderRevealPath(request.path)) deps.revealInExplorer(request.path, sessionId)
|
||||||
else deps.openInSidebar(path, sessionId)
|
else deps.openInSidebar(request.path, sessionId)
|
||||||
return Promise.resolve()
|
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 () => {
|
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 client `remote.session` namespace face (mirror of the gateway client's
|
||||||
* the chat's file-open funnel is touched: `openPath` hands an absolute path
|
* RemoteNamespaceService for the session-controller contribution on alpha
|
||||||
* to the Host OS's default application, and every chat-side file open
|
* hosts). The chat's file-open funnel is `openWorkspacePath`: the caller
|
||||||
* (tool rows, produced-files, prose mentions) funnels through it.
|
* 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 {
|
export interface SidebarRemoteSessionService {
|
||||||
/** Open a filesystem path with the Host operating system's default application. */
|
/**
|
||||||
openPath(path: string): Promise<void>
|
* 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
|
webRuntime: SidebarWebRuntime
|
||||||
/** The client slot registry (register/inject). */
|
/** The client slot registry (register/inject). */
|
||||||
slots: SidebarSlotsService
|
slots: SidebarSlotsService
|
||||||
/** The client workspaces service face (file-open funnel). */
|
|
||||||
workspaces: SidebarWorkspacesService
|
|
||||||
/** The settings service face (prefs persistence + namespace reads). */
|
/** The settings service face (prefs persistence + namespace reads). */
|
||||||
settings: SidebarSettingsService
|
settings: SidebarSettingsService
|
||||||
/** The invariant registry face. */
|
/** 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
|
* Whether chat-side file opens (tool-row path links, the produced-files
|
||||||
* row, prose file mentions — every path that funnels through the client
|
* row, prose file mentions — every path that funnels through the client
|
||||||
* runtime's `ctx.workspaces.openPath`) open in the sidebar editor instead
|
* runtime's `remote.session.openWorkspacePath`) open in the sidebar editor
|
||||||
* of the Host OS's default application. On by default; the editor tab's
|
* instead of the Host OS's default application. On by default; the editor
|
||||||
* own enable switch gates it too (both must be on for the takeover).
|
* tab's own enable switch gates it too (both must be on for the takeover).
|
||||||
*/
|
*/
|
||||||
interceptOpenPath: boolean
|
interceptOpenPath: boolean
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,148 +1,281 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { registerOpenPathInterception } from '../src/client/intercept.tsx'
|
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 { createSidebarStore } from '../src/client/state.ts'
|
||||||
import type { Context } from '../src/context-types.ts'
|
import type { Context } from '../src/context-types.ts'
|
||||||
|
|
||||||
describe('open-path interception', () => {
|
/**
|
||||||
/** A minimal fake of the workspaces.openPath service method. */
|
* A fake of the gateway client's RemoteNamespaceService: the method is an
|
||||||
const service = (): OpenPathService & { calls: string[]; opened: string[] } => {
|
* ACCESSOR property (getter returning the invocation closure), exactly like
|
||||||
const fake = {
|
* `Object.defineProperty(service, method, { configurable: true, get })` in
|
||||||
calls: [] as string[],
|
* the real implementation — the wrapper must shadow it with a data property
|
||||||
opened: [] as string[],
|
* and restore the accessor on dispose.
|
||||||
async openPath(path: string): Promise<void> {
|
*/
|
||||||
this.calls.push(path)
|
function fakeNamespaceService(opened: string[]): OpenWorkspacePathService {
|
||||||
this.opened.push(path)
|
const target = {} as Record<string, unknown>
|
||||||
},
|
Object.defineProperty(target, 'openWorkspacePath', {
|
||||||
}
|
configurable: true,
|
||||||
return fake
|
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> = {}): OpenPathInterceptDeps & {
|
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): OpenPathInterceptDeps & {
|
||||||
sidebar: string[]
|
sidebar: string[]
|
||||||
|
revealed: string[]
|
||||||
} => {
|
} => {
|
||||||
const sidebar: string[] = []
|
const sidebar: string[] = []
|
||||||
|
const revealed: string[] = []
|
||||||
return {
|
return {
|
||||||
sidebar,
|
sidebar,
|
||||||
|
revealed,
|
||||||
takeoverEnabled: () => true,
|
takeoverEnabled: () => true,
|
||||||
currentSessionId: () => 's1',
|
currentSessionId: () => 's1',
|
||||||
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
|
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
|
||||||
revealInExplorer: () => {},
|
revealInExplorer: (path, sessionId) => { revealed.push(`${sessionId}:${path}`) },
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('routes an intercepted open into the sidebar and resolves without the original call', async () => {
|
it('routes an intercepted open into the sidebar and resolves with the remote success envelope', async () => {
|
||||||
const ws = service()
|
const opened: string[] = []
|
||||||
|
const service = fakeNamespaceService(opened)
|
||||||
const d = deps()
|
const d = deps()
|
||||||
const restore = wrapOpenPath(ws, d)
|
const restore = wrapOpenWorkspacePath(service, d)
|
||||||
await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined()
|
// The caller (ChatView's openFile) branches on `result.ok` and reads
|
||||||
expect(ws.calls).toEqual([])
|
// `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'])
|
expect(d.sidebar).toEqual(['s1:/abs/a.ts'])
|
||||||
restore()
|
restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls through to the original when the takeover is disabled', async () => {
|
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 d = deps({ takeoverEnabled: () => false })
|
||||||
const restore = wrapOpenPath(ws, d)
|
const restore = wrapOpenWorkspacePath(service, d)
|
||||||
await ws.openPath('/abs/a.ts')
|
await service.openWorkspacePath({ path: '/abs/a.ts' })
|
||||||
expect(ws.opened).toEqual(['/abs/a.ts'])
|
expect(opened).toEqual(['/abs/a.ts'])
|
||||||
expect(d.sidebar).toEqual([])
|
expect(d.sidebar).toEqual([])
|
||||||
restore()
|
restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls through when no session is current (nothing to scope the editor load to)', async () => {
|
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 d = deps({ currentSessionId: () => undefined })
|
||||||
const restore = wrapOpenPath(ws, d)
|
const restore = wrapOpenWorkspacePath(service, d)
|
||||||
await ws.openPath('/abs/a.ts')
|
await service.openWorkspacePath({ path: '/abs/a.ts' })
|
||||||
expect(ws.opened).toEqual(['/abs/a.ts'])
|
expect(opened).toEqual(['/abs/a.ts'])
|
||||||
expect(d.sidebar).toEqual([])
|
expect(d.sidebar).toEqual([])
|
||||||
restore()
|
restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes the current session into the sidebar opener', async () => {
|
it('passes the current session into the sidebar opener', async () => {
|
||||||
const ws = service()
|
const opened: string[] = []
|
||||||
|
const service = fakeNamespaceService(opened)
|
||||||
let current = 's1'
|
let current = 's1'
|
||||||
const d = deps({ currentSessionId: () => current })
|
const d = deps({ currentSessionId: () => current })
|
||||||
const restore = wrapOpenPath(ws, d)
|
const restore = wrapOpenWorkspacePath(service, d)
|
||||||
await ws.openPath('/abs/a.ts')
|
await service.openWorkspacePath({ path: '/abs/a.ts' })
|
||||||
current = 's2'
|
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'])
|
expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts'])
|
||||||
restore()
|
restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores the original method on dispose (HMR-safe)', async () => {
|
it('routes the folder-reveal gesture to the explorer, not the editor', async () => {
|
||||||
const ws = service()
|
const opened: string[] = []
|
||||||
|
const service = fakeNamespaceService(opened)
|
||||||
const d = deps()
|
const d = deps()
|
||||||
const original = ws.openPath
|
const restore = wrapOpenWorkspacePath(service, d)
|
||||||
const restore = wrapOpenPath(ws, d)
|
for (const path of ['.', './', '/w/.', 'C:\\w\\.']) {
|
||||||
expect(ws.openPath).not.toBe(original)
|
await service.openWorkspacePath({ path })
|
||||||
|
}
|
||||||
|
expect(opened).toEqual([])
|
||||||
|
expect(d.sidebar).toEqual([])
|
||||||
|
expect(d.revealed).toEqual(['s1:.', 's1:./', 's1:/w/.', 's1:C:\\w\\.'])
|
||||||
restore()
|
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 () => {
|
it('restores the original accessor on dispose (HMR-safe)', async () => {
|
||||||
const failing: OpenPathService = {
|
const opened: string[] = []
|
||||||
async openPath() { throw new Error('host refused') },
|
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<string, unknown>
|
||||||
|
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 d = deps({ takeoverEnabled: () => false })
|
||||||
const restore = wrapOpenPath(failing, d)
|
const restore = wrapOpenWorkspacePath(service, d)
|
||||||
await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused')
|
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()
|
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', () => {
|
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),
|
* A client-context fake whose `inject` mimics cordis: the callback runs
|
||||||
// the workspaces funnel, and the sidebar service the editor goes through.
|
* once the dependency appears (here: when `mount()` is called) and its
|
||||||
const opened: Array<Record<string, unknown>> = []
|
* effects dispose with the fiber.
|
||||||
const funnel = { openPath: async (): Promise<void> => {} }
|
*/
|
||||||
|
function fakeCtx(opened: Array<Record<string, unknown>>, 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 = {
|
const ctx = {
|
||||||
sessions: {
|
sessions: {
|
||||||
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
|
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
|
||||||
},
|
},
|
||||||
workspaces: funnel,
|
|
||||||
betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } },
|
|
||||||
get: (name: string) => name === 'betterSidebar'
|
get: (name: string) => name === 'betterSidebar'
|
||||||
? { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } }
|
? { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } }
|
||||||
: undefined,
|
: 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<Record<string, unknown>> = []
|
||||||
|
const hostOpened: string[] = []
|
||||||
|
const remoteSession = fakeNamespaceService(hostOpened)
|
||||||
|
const ctx = fakeCtx(opened, remoteSession)
|
||||||
const store = createSidebarStore()
|
const store = createSidebarStore()
|
||||||
const original = ctx.workspaces.openPath
|
const restore = registerOpenPathInterception(ctx as unknown as Context, store)
|
||||||
const restore = registerOpenPathInterception(ctx, 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
|
// Default prefs: the takeover routes the open into the sidebar editor
|
||||||
// with the session-scoped absolute path (chat already resolved it).
|
// 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([{
|
expect(opened).toEqual([{
|
||||||
type: 'editor',
|
type: 'editor',
|
||||||
title: 'a.ts',
|
title: 'a.ts',
|
||||||
path: '/w/src/a.ts',
|
path: '/w/src/a.ts',
|
||||||
id: 'editor:/w/src/a.ts',
|
id: 'editor:/w/src/a.ts',
|
||||||
}])
|
}])
|
||||||
|
expect(hostOpened).toEqual([])
|
||||||
|
|
||||||
// The interceptOpenPath pref off → the original funnel runs untouched.
|
// The interceptOpenPath pref off → the original funnel runs untouched.
|
||||||
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
|
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
|
||||||
const calls: string[] = []
|
await remoteSession.openWorkspacePath({ path: '/w/src/b.ts' })
|
||||||
ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
|
expect(hostOpened).toEqual(['/w/src/b.ts'])
|
||||||
await ctx.workspaces.openPath('/w/src/b.ts')
|
|
||||||
expect(calls).toEqual(['/w/src/b.ts'])
|
|
||||||
expect(opened).toHaveLength(1)
|
expect(opened).toHaveLength(1)
|
||||||
|
|
||||||
// The editor tab disabled → falls through too (an editor that cannot
|
// The editor tab disabled → falls through too (an editor that cannot
|
||||||
// open must not swallow opens).
|
// open must not swallow opens).
|
||||||
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
|
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
|
||||||
await ctx.workspaces.openPath('/w/src/c.ts')
|
await remoteSession.openWorkspacePath({ path: '/w/src/c.ts' })
|
||||||
expect(calls).toEqual(['/w/src/b.ts', '/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()
|
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<Record<string, unknown>> = []
|
||||||
|
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<string, unknown>) } }
|
||||||
|
: 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([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user