dsh-vscode-mode 0.1.52 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -36,6 +36,7 @@
36
36
  "lib",
37
37
  "assets",
38
38
  "src",
39
+ "unity",
39
40
  "cordis.patch.yml"
40
41
  ],
41
42
  "license": "BSD-3-Clause",
@@ -0,0 +1,70 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 工作区选择弹窗(深链打开规则 1)。
4
+ * 复用与「添加 MCP」一致的 vsm-mcp-modal / vsm-mcp-dialog 模态(页面居中卡片 + 遮罩 + 主题
5
+ * token),React portal 挂 body;Esc / 遮罩点击 / 取消 → null(中止流程不改任何状态)。
6
+ * 作者 ddj 2026-09-07
7
+ */
8
+ import React from 'react'
9
+ import { createRoot } from 'react-dom/client'
10
+
11
+ /** 用户选择:使用最近的工作区 / 新建工作区 / null=中止。 */
12
+ export type WorkspaceChoice = 'recent' | 'create' | null
13
+
14
+ /** 弹窗组件属性。 */
15
+ interface ChoiceModalProps {
16
+ /** 匹配到的工作区标题。 */
17
+ title: string
18
+ /** 触发打开的文件夹路径。 */
19
+ folder: string
20
+ /** 选择回调(首次调用即卸载)。 */
21
+ done: (choice: WorkspaceChoice) => void
22
+ }
23
+
24
+ /** 选择弹窗(vsm-mcp-modal 遮罩 + vsm-mcp-dialog 卡片,Esc/遮罩点击取消)。 */
25
+ function ChoiceModal({ title, folder, done }: ChoiceModalProps) {
26
+ React.useEffect(() => {
27
+ const onKey = (event) => {
28
+ if (event.key === 'Escape') done(null)
29
+ }
30
+ window.addEventListener('keydown', onKey)
31
+ return () => window.removeEventListener('keydown', onKey)
32
+ }, [])
33
+ return React.createElement('div', {
34
+ className: 'vsm-mcp-modal',
35
+ onMouseDown: (event) => {
36
+ if (event.target === event.currentTarget) done(null)
37
+ },
38
+ },
39
+ React.createElement('div', { className: 'vsm-mcp-dialog' },
40
+ React.createElement('h3', null, '在 DSH 中打开'),
41
+ React.createElement('label', null, '文件夹 ' + folder + ' 位于已注册的工作区「' + title + '」内,选择打开方式:'),
42
+ React.createElement('div', { className: 'vsm-mcp-dialog-actions' },
43
+ React.createElement('button', { onClick: () => done(null) }, '取消'),
44
+ React.createElement('button', { onClick: () => done('create') }, '新建工作区'),
45
+ React.createElement('button', { className: 'vsm-primary', onClick: () => done('recent') }, '使用最近的工作区'),
46
+ ),
47
+ ),
48
+ )
49
+ }
50
+
51
+ /**
52
+ * 弹出工作区选择弹窗(深链打开规则 1)。
53
+ * @author ddj 2026年09月07号
54
+ * @param title 匹配到的工作区标题
55
+ * @param folder 文件夹路径
56
+ * @returns 用户选择;null = 取消/中止
57
+ */
58
+ export function chooseWorkspace(title: string, folder: string): Promise<WorkspaceChoice> {
59
+ return new Promise((resolve) => {
60
+ const host = document.createElement('div')
61
+ document.body.appendChild(host)
62
+ const root = createRoot(host)
63
+ const done = (choice: WorkspaceChoice) => {
64
+ root.unmount()
65
+ host.remove()
66
+ resolve(choice)
67
+ }
68
+ root.render(React.createElement(ChoiceModal, { title, folder, done }))
69
+ })
70
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * dsh-vscode-mode client — 外部深链落地(Windows 右键菜单 / Unity 外部编辑器)。
3
+ * 启动时解析 location.search 的 edrvOpen 深链:跨源 referrer 守卫防外部网页诱导;
4
+ * 处理完剥离 URL 参数防刷新重开;执行委托 openFlow 打开规则引擎(工作区/对话智能路由)。
5
+ * 另挂 3s 移交轮询(兼活跃心跳):launcher 投递的打开请求由已打开页面就地执行,避免重复开新页。
6
+ * 作者 ddj 2026-09-07 / 2026-09-08
7
+ */
8
+ import { EDRV_PARAM_KEYS, parseOpenParams } from '../shared/externalOpen.js'
9
+ import { openDeepLink } from './openFlow.js'
10
+ import { rpc } from './rpc.js'
11
+
12
+ /** toast 自隐时长。 */
13
+ const TOAST_MS = 5000
14
+ /** 深链移交轮询周期(兼活跃心跳)。 */
15
+ const HANDOFF_POLL_MS = 3000
16
+
17
+ /** 深链装配选项(全部可注入便于测试;缺省读浏览器全局)。 */
18
+ export interface ExtOpenOptions {
19
+ /** 查询串(缺省 location.search)。 */
20
+ search?: string
21
+ /** referrer(缺省 document.referrer)。 */
22
+ referrer?: string
23
+ /** 当前源(缺省 location.origin)。 */
24
+ origin?: string
25
+ }
26
+
27
+ /**
28
+ * 装配外部深链:深链参数缺失时仅保持移交轮询(零额外开销)。
29
+ * @author ddj 2026年09月07号
30
+ * @param ctx 客户端根上下文(sessions/workspaces 服务)
31
+ * @param options 可注入选项
32
+ */
33
+ export function setupExtOpen(ctx: unknown, options: ExtOpenOptions = {}): void {
34
+ startPresencePoll(ctx)
35
+ const search = options.search ?? (typeof location !== 'undefined' ? location.search : '')
36
+ const params = parseOpenParams(search)
37
+ if (!params) return
38
+ const referrer = options.referrer ?? (typeof document !== 'undefined' ? document.referrer : '')
39
+ const origin = options.origin ?? (typeof location !== 'undefined' ? location.origin : '')
40
+ if (!referrerAllowed(referrer, origin)) {
41
+ console.warn('[dsh-vscode-mode] 已忽略跨源深链请求(referrer=' + referrer + ')')
42
+ return
43
+ }
44
+ stripCurrentUrl()
45
+ void openDeepLink(ctx, params).catch((error) => {
46
+ console.warn('[dsh-vscode-mode] 深链打开失败:' + String(error))
47
+ toastDom('深链打开失败:' + String((error as Error)?.message ?? error))
48
+ })
49
+ }
50
+
51
+ /**
52
+ * 深链移交轮询:每 3s 向 host 领取待打开请求(兼活跃心跳,launcher 据此决定是否开新页)。
53
+ * 页面级单例(window 标记防 HMR 重复);领取到即执行打开规则。
54
+ * @author ddj 2026年09月08号
55
+ * @param ctx 客户端根上下文
56
+ */
57
+ function startPresencePoll(ctx: unknown): void {
58
+ if (typeof window === 'undefined') return
59
+ const marker = window as { __edrvExtPoll?: boolean }
60
+ if (marker.__edrvExtPoll) return
61
+ marker.__edrvExtPoll = true
62
+ let executing = false
63
+ window.setInterval(() => {
64
+ if (executing) return
65
+ executing = true
66
+ void rpc('edrv.external.pending', {})
67
+ .then(async (result) => {
68
+ if (!result.ok || !result.open) return
69
+ await openDeepLink(ctx, { paths: result.open.paths, line: result.open.line, column: result.open.column })
70
+ })
71
+ .catch(() => { /* 旧版 host 无此方法/离线:忽略 */ })
72
+ .finally(() => { executing = false })
73
+ }, HANDOFF_POLL_MS)
74
+ }
75
+
76
+ /**
77
+ * 跨源守卫:referrer 为空(launcher/Unity 直开)或同源 → 允许。
78
+ * @author ddj 2026年09月07号
79
+ * @param referrer 文档 referrer
80
+ * @param origin 当前页面源
81
+ * @returns 是否允许处理深链
82
+ */
83
+ export function referrerAllowed(referrer: string, origin: string): boolean {
84
+ if (!referrer) return true
85
+ try {
86
+ return new URL(referrer).origin === origin
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 剥离 URL 中的深链参数(保留其余参数与 hash,防刷新重开)。
94
+ * @author ddj 2026年09月07号
95
+ * @param href 完整 URL
96
+ * @returns 清理后的 URL(pathname+search+hash)
97
+ */
98
+ export function stripOpenHref(href: string): string {
99
+ const url = new URL(href)
100
+ for (const key of EDRV_PARAM_KEYS) url.searchParams.delete(key)
101
+ return url.pathname + url.search + url.hash
102
+ }
103
+
104
+ /** 清理当前页面 URL(best-effort,失败不阻塞打开)。 */
105
+ function stripCurrentUrl(): void {
106
+ try {
107
+ if (typeof location === 'undefined' || typeof history === 'undefined') return
108
+ const next = stripOpenHref(location.href)
109
+ if (next !== location.pathname + location.search + location.hash) history.replaceState(null, '', next)
110
+ } catch { /* 清理失败忽略 */ }
111
+ }
112
+
113
+ /**
114
+ * 轻量 toast(编辑器未挂载也能提示;5s 自隐;openFlow 通知与错误呈现共用)。
115
+ * @author ddj 2026年09月07号
116
+ * @param text 提示文本
117
+ */
118
+ export function toastDom(text: string): void {
119
+ if (typeof document === 'undefined') return
120
+ const toast = document.createElement('div')
121
+ toast.textContent = text
122
+ toast.setAttribute('style', [
123
+ 'position:fixed', 'left:50%', 'bottom:28px', 'transform:translateX(-50%)',
124
+ 'z-index:99999', 'padding:8px 14px', 'border-radius:8px', 'max-width:80vw',
125
+ 'background:#2a2a2a', 'color:#ddd', 'border:1px solid #444', 'font-size:12px',
126
+ 'box-shadow:0 4px 16px rgba(0,0,0,.35)',
127
+ ].join(';'))
128
+ document.body?.appendChild(toast)
129
+ setTimeout(() => toast.remove(), TOAST_MS)
130
+ }
@@ -25,6 +25,7 @@ import { loadMonaco } from './monaco/loader.js'
25
25
  import { createFileOpenerRegistry, scanSidebar, type FileOpenContext } from './fileOpeners.js'
26
26
  import type { FileOpenerRegistry } from './fileOpeners.js'
27
27
  import { installOpenPathRouter, vscodeOpener, autoValue } from './openPathRouter.js'
28
+ import { setupExtOpen } from './externalOpen.js'
28
29
  import { SettingsContext } from './settingsContext.js'
29
30
  import { SIDEBAR_PLUGIN, pickSettingsBinder, registerSlotSafely } from './compat.js'
30
31
  import { detectSidebarService, installSideEditor, setEnsureSideEditor, SIDEBAR_INSTALL_CMD } from './sidebarBridge.js'
@@ -62,6 +63,9 @@ export function apply(ctx: any): void {
62
63
  const workspaces = ctx.get('workspaces')
63
64
  const sessions = ctx.get('sessions')
64
65
 
66
+ // 外部深链落地:Windows 右键菜单 / Unity 外部编辑器 → 浏览器 URL 参数 → 打开规则路由
67
+ setupExtOpen(ctx)
68
+
65
69
  // Monaco 加载时机:不再 DSH 启动即预热,改为进入会话界面(sessions.list.current 出现)后再后台加载,
66
70
  // 用户点开「文件编辑」页签即用;空闲时仍由 EditorView 挂载兜底加载。
67
71
  // 模块级 promise 去重,重复触发只首次真正加载;requestIdleCallback 让出会话首屏带宽,缺省回退延时调度;
@@ -0,0 +1,330 @@
1
+ /**
2
+ * dsh-vscode-mode client — 深链打开规则引擎(资源管理器右键 / Unity 外部编辑器落地)。
3
+ * 规则:文件夹 ⊂ 某已注册工作区 → 页面内弹窗二选一(使用最近的工作区 / 新建工作区);
4
+ * 文件夹不在任何工作区 → 直达最近对话所在工作区新增;文件 → 最近对话 + 编辑器展开
5
+ * (无任何会话 → 以文件父目录注册工作区引导创建)。首路径驱动规则,其余路径:文件进
6
+ * 编辑器、文件夹补引用。纯函数可单测;编排依赖经 OpenDeps 注入(mock 可测)。
7
+ * 作者 ddj 2026-09-07
8
+ */
9
+ import { rpc } from './rpc.js'
10
+ import { chooseWorkspace } from './chooseDialog.js'
11
+ import { emitOpenAt, openEditorView } from './events.js'
12
+ import { toastDom } from './externalOpen.js'
13
+ import { createAddToConversation } from './addToConversation.js'
14
+ import type { AddOutcome, CtxLike } from './addToConversation.js'
15
+ import type { OpenParams } from '../shared/externalOpen.js'
16
+
17
+ /** 路径类型(host edrv.externalStat 判定)。 */
18
+ export type PathKind = 'file' | 'directory' | 'missing'
19
+
20
+ /** 打开规则分派结果。 */
21
+ export type OpenRule = 'choose' | 'folderNew' | 'fileRecent' | 'bootstrap' | 'abort'
22
+
23
+ /** 工作区视图最小形状(ctx.workspaces.list 快照行)。 */
24
+ export interface WsView {
25
+ workspaceId: string
26
+ path: string
27
+ title: string
28
+ sessionIds?: readonly string[]
29
+ updatedAt?: string
30
+ }
31
+
32
+ /** 会话行最小形状(ctx.sessions.list 快照行)。 */
33
+ export interface SessionRow {
34
+ id: string
35
+ cwd?: string
36
+ updatedAt?: number
37
+ origin?: 'subagent'
38
+ }
39
+
40
+ /** 打开编排依赖(全部可注入,便于 mock 测试)。 */
41
+ export interface OpenDeps {
42
+ stat(path: string): Promise<PathKind>
43
+ workspaces(): { items: WsView[]; ready: boolean }
44
+ sessions(): { ids: string[]; byId: Record<string, SessionRow>; ready: boolean }
45
+ createSession(opts: { workspaceId?: string; cwd?: string }): Promise<string>
46
+ createWorkspace(path: string): Promise<{ workspaceId: string; title: string }>
47
+ openSession(id: string): void
48
+ reference(sessionId: string, path: string, appearance: 'file' | 'folder'): Promise<boolean>
49
+ choose(title: string, folder: string): Promise<'recent' | 'create' | null>
50
+ openEditor(path: string | null, line?: number, column?: number): void
51
+ schedule(fn: () => void, ms: number): void
52
+ notify(text: string): void
53
+ }
54
+
55
+ /** 等列表/输入就绪的节奏。 */
56
+ const READY_TIMEOUT_MS = 15000
57
+ const READY_TICK_MS = 300
58
+ const REF_RETRY_MAX = 20
59
+ const REF_RETRY_MS = 300
60
+ const REST_STEP_MS = 150
61
+ const LINE_AT_DELAY_MS = 200
62
+
63
+ /**
64
+ * 路径规范化:`\→/`、小写、去尾分隔符(Windows 大小写不敏感前缀匹配用)。
65
+ * @author ddj 2026年09月07号
66
+ */
67
+ export function normPath(p: string): string {
68
+ return String(p ?? '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
69
+ }
70
+
71
+ /**
72
+ * 目录包含判定:child === root 或 child 位于 root 之下。
73
+ * @author ddj 2026年09月07号
74
+ */
75
+ export function containsPath(root: string, child: string): boolean {
76
+ const r = normPath(root)
77
+ const c = normPath(child)
78
+ return c === r || c.startsWith(r + '/')
79
+ }
80
+
81
+ /**
82
+ * 父目录(文件引导创建工作区用;根路径回退自身)。
83
+ * @author ddj 2026年09月07号
84
+ */
85
+ export function dirnameOf(p: string): string {
86
+ const norm = String(p ?? '').replace(/[\\/]+$/, '')
87
+ const cut = Math.max(norm.lastIndexOf('/'), norm.lastIndexOf('\\'))
88
+ return cut > 0 ? norm.slice(0, cut) : norm
89
+ }
90
+
91
+ /**
92
+ * 最近一次对话:排除子代理行,updatedAt 最新优先。
93
+ * @author ddj 2026年09月07号
94
+ */
95
+ export function recentSession(byId: Record<string, SessionRow>, ids?: string[]): SessionRow | undefined {
96
+ const rows = (ids ?? Object.keys(byId)).map((id) => byId[id]).filter((row) => row && row.origin !== 'subagent')
97
+ return rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]
98
+ }
99
+
100
+ /**
101
+ * 文件夹命中的已注册工作区(前缀包含),updatedAt 新者优先。
102
+ * @author ddj 2026年09月07号
103
+ */
104
+ export function matchWorkspaces(items: WsView[], folder: string): WsView[] {
105
+ return items.filter((ws) => containsPath(ws.path, folder))
106
+ }
107
+
108
+ /**
109
+ * 规则分派:目录+命中工作区 → choose;目录未命中 → folderNew(以该文件夹为根注册新工作区,
110
+ * 用户确认:一律取右键的文件夹本身);文件有会话 → fileRecent;文件无会话 → bootstrap;
111
+ * 路径缺失 → abort。
112
+ * @author ddj 2026年09月08号
113
+ */
114
+ export function pickRule(kind: PathKind, matched: boolean, hasSession: boolean): OpenRule {
115
+ if (kind === 'missing') return 'abort'
116
+ if (kind === 'directory') return matched ? 'choose' : 'folderNew'
117
+ return hasSession ? 'fileRecent' : 'bootstrap'
118
+ }
119
+
120
+ /**
121
+ * 深链打开编排入口:等列表就绪 → 判型 → 规则分派 → 编排 create/open/引用/编辑器。
122
+ * @author ddj 2026年09月07号
123
+ * @param ctx 客户端根上下文(sessions/workspaces 服务)
124
+ * @param params 深链参数(首路径驱动规则)
125
+ * @param overrides 依赖覆盖(测试注入)
126
+ */
127
+ export async function openDeepLink(ctx: unknown, params: OpenParams, overrides: Partial<OpenDeps> = {}): Promise<void> {
128
+ const deps = realDeps(ctx, overrides)
129
+ if (!(await waitListsReady(deps))) {
130
+ deps.notify('DSH 会话列表未就绪,请稍后重试')
131
+ return
132
+ }
133
+ const primary = params.paths[0]
134
+ const kind = await deps.stat(primary).catch(() => 'missing' as PathKind)
135
+ const sess = deps.sessions()
136
+ const rule = pickRule(kind, matchWorkspaces(deps.workspaces().items, primary).length > 0, Boolean(recentSession(sess.byId, sess.ids)))
137
+ try {
138
+ if (rule === 'abort') {
139
+ deps.notify('路径不存在:' + primary)
140
+ return
141
+ }
142
+ if (rule === 'choose') return await runChoose(deps, primary, params)
143
+ if (rule === 'folderNew') return await runFolderNew(deps, primary, params)
144
+ if (rule === 'fileRecent') return await runFileRecent(deps, primary, params)
145
+ return await runFileBootstrap(deps, primary, params)
146
+ } catch (error) {
147
+ deps.notify('打开失败:' + String((error as Error)?.message ?? error))
148
+ }
149
+ }
150
+
151
+ /** 等待会话与工作区列表就绪(上限 15s;超时按当前态判定)。 */
152
+ async function waitListsReady(deps: OpenDeps): Promise<boolean> {
153
+ for (let waited = 0; waited < READY_TIMEOUT_MS; waited += READY_TICK_MS) {
154
+ if (deps.sessions().ready && deps.workspaces().ready) return true
155
+ await sleep(deps, READY_TICK_MS)
156
+ }
157
+ return deps.sessions().ready && deps.workspaces().ready
158
+ }
159
+
160
+ /** 规则 1:命中工作区 → 弹窗分派(取消即中止)。 */
161
+ async function runChoose(deps: OpenDeps, folder: string, params: OpenParams): Promise<void> {
162
+ const matches = matchWorkspaces(deps.workspaces().items, folder)
163
+ const best = bestMatch(deps, matches)
164
+ const choice = await deps.choose(best?.title ?? '工作区', folder)
165
+ if (choice === null) return
166
+ if (choice === 'create') return runFolderNew(deps, folder, params)
167
+ const ws = best ?? deps.workspaces().items[0]
168
+ const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
169
+ await settleRef(deps, sessionId, folder, 'folder')
170
+ deps.openEditor(null)
171
+ await openRest(deps, sessionId, params, 1)
172
+ }
173
+
174
+ /** 规则 3a:文件 → 所属工作区优先(有会话打开其最近会话;无会话新建 + 文件引用);无匹配 → 最近对话。 */
175
+ async function runFileRecent(deps: OpenDeps, file: string, params: OpenParams): Promise<void> {
176
+ const ws = pickFileWorkspace(deps, file)
177
+ if (ws) {
178
+ const anchor = recentSessionIn(deps, ws)
179
+ if (anchor) {
180
+ deps.openSession(anchor.id)
181
+ deps.openEditor(file, params.line, params.column)
182
+ await openRest(deps, anchor.id, params, 1)
183
+ return
184
+ }
185
+ // 文件所属工作区存在但无会话 → 在该工作区新建对话 + 文件引用
186
+ const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
187
+ await settleRef(deps, sessionId, file, 'file')
188
+ deps.openEditor(file, params.line, params.column)
189
+ await openRest(deps, sessionId, params, 1)
190
+ return
191
+ }
192
+ const anchor = recentSession(deps.sessions().byId, deps.sessions().ids)
193
+ if (!anchor) return runFileBootstrap(deps, file, params)
194
+ deps.openSession(anchor.id)
195
+ deps.openEditor(file, params.line, params.column)
196
+ await openRest(deps, anchor.id, params, 1)
197
+ }
198
+
199
+ /**
200
+ * 文件所属工作区:前缀命中的工作区里,优先取有会话且最近活跃的;都无会话取路径最具体的。
201
+ * @author ddj 2026年09月08号
202
+ */
203
+ function pickFileWorkspace(deps: OpenDeps, file: string): WsView | undefined {
204
+ const matches = matchWorkspaces(deps.workspaces().items, file)
205
+ if (!matches.length) return undefined
206
+ const withSessions = matches.filter((ws) => (ws.sessionIds ?? []).some((id) => Boolean(deps.sessions().byId[id])))
207
+ if (!withSessions.length) {
208
+ return [...matches].sort((a, b) => normPath(b.path).length - normPath(a.path).length)[0]
209
+ }
210
+ return withSessions.sort((a, b) => latestSessionAt(deps, b) - latestSessionAt(deps, a))[0]
211
+ }
212
+
213
+ /** 工作区内会话的最近活跃时间(无成员 → 0)。 */
214
+ function latestSessionAt(deps: OpenDeps, ws: WsView): number {
215
+ const byId = deps.sessions().byId
216
+ return (ws.sessionIds ?? []).reduce((max, id) => Math.max(max, byId[id]?.updatedAt ?? 0), 0)
217
+ }
218
+
219
+ /** 工作区内最近一次会话(排除子代理与不在列表中的陈旧行)。 */
220
+ function recentSessionIn(deps: OpenDeps, ws: WsView): SessionRow | undefined {
221
+ const byId = deps.sessions().byId
222
+ const rows = (ws.sessionIds ?? []).map((id) => byId[id]).filter((row) => row && row.origin !== 'subagent')
223
+ return rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]
224
+ }
225
+
226
+ /** 规则 3b:文件 + 无任何会话 → 父目录注册工作区 + 新对话 + 文件引用 + 编辑器打开。 */
227
+ async function runFileBootstrap(deps: OpenDeps, file: string, params: OpenParams): Promise<void> {
228
+ const ws = await deps.createWorkspace(dirnameOf(file))
229
+ const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
230
+ await settleRef(deps, sessionId, file, 'file')
231
+ deps.openEditor(file, params.line, params.column)
232
+ await openRest(deps, sessionId, params, 1)
233
+ }
234
+
235
+ /** 规则 1.2:以文件夹注册新工作区 + 新对话 + 文件夹引用 + 编辑页。 */
236
+ async function runFolderNew(deps: OpenDeps, folder: string, params: OpenParams): Promise<void> {
237
+ const ws = await deps.createWorkspace(folder)
238
+ const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
239
+ await settleRef(deps, sessionId, folder, 'folder')
240
+ deps.openEditor(null)
241
+ await openRest(deps, sessionId, params, 1)
242
+ }
243
+
244
+ /** 在指定工作区(或回退 cwd)新建对话并置为当前;返回新会话 id。 */
245
+ async function spawnInWorkspace(deps: OpenDeps, workspaceId: string | undefined, fallbackCwd: string | undefined): Promise<string> {
246
+ const sessionId = await deps.createSession(workspaceId ? { workspaceId } : fallbackCwd ? { cwd: fallbackCwd } : {})
247
+ deps.openSession(sessionId)
248
+ return sessionId
249
+ }
250
+
251
+ /** 命中工作区集中「最近」的一个(其成员会话 updatedAt 最新者优先)。 */
252
+ function bestMatch(deps: OpenDeps, matches: WsView[]): WsView | undefined {
253
+ const byId = deps.sessions().byId
254
+ const score = (ws: WsView): number => {
255
+ const sessionLatest = (ws.sessionIds ?? []).reduce((max, id) => Math.max(max, byId[id]?.updatedAt ?? 0), 0)
256
+ return Math.max(sessionLatest, Date.parse(ws.updatedAt ?? '') || 0)
257
+ }
258
+ return [...matches].sort((a, b) => score(b) - score(a))[0]
259
+ }
260
+
261
+ /** 引用插入轮询:输入门面就绪前每 300ms 重试(appendReference 忙态已降级视为成功)。 */
262
+ async function settleRef(deps: OpenDeps, sessionId: string, path: string, appearance: 'file' | 'folder'): Promise<void> {
263
+ for (let attempt = 0; attempt < REF_RETRY_MAX; attempt++) {
264
+ if (await deps.reference(sessionId, path, appearance)) return
265
+ await sleep(deps, REF_RETRY_MS)
266
+ }
267
+ }
268
+
269
+ /** 其余路径:文件进编辑器(步进),文件夹补文件夹引用。 */
270
+ async function openRest(deps: OpenDeps, sessionId: string | null, params: OpenParams, from: number): Promise<void> {
271
+ const rest = params.paths.slice(from)
272
+ for (const [index, path] of rest.entries()) {
273
+ const kind = await deps.stat(path).catch(() => 'missing' as PathKind)
274
+ if (kind === 'file') {
275
+ deps.schedule(() => deps.openEditor(path), REST_STEP_MS * (index + 1))
276
+ } else if (kind === 'directory' && sessionId) {
277
+ await settleRef(deps, sessionId, path, 'folder')
278
+ }
279
+ }
280
+ }
281
+
282
+ /** 可注入 sleep。 */
283
+ function sleep(deps: OpenDeps, ms: number): Promise<void> {
284
+ return new Promise((resolve) => deps.schedule(resolve, ms))
285
+ }
286
+
287
+ /** 真实依赖装配(全部来自 ctx 注入服务 + 既有 RPC/弹窗/编辑器入口)。 */
288
+ function realDeps(ctx: unknown, overrides: Partial<OpenDeps> = {}): OpenDeps {
289
+ const service = (name: string): any => {
290
+ const get = (ctx as { get?: (name: string) => unknown } | undefined)?.get
291
+ return typeof get === 'function' ? get.call(ctx, name) : undefined
292
+ }
293
+ const add = createAddToConversation(ctx as CtxLike)
294
+ const base: OpenDeps = {
295
+ stat: async (path) => {
296
+ const result = await rpc('edrv.externalStat', { path })
297
+ return result.ok ? result.kind : 'missing'
298
+ },
299
+ workspaces: () => {
300
+ const snap = (service('workspaces')?.list?.getSnapshot?.() ?? {}) as { items?: WsView[]; phase?: string }
301
+ return { items: snap.items ?? [], ready: snap.phase === 'ready' }
302
+ },
303
+ sessions: () => {
304
+ const snap = (service('sessions')?.list?.getSnapshot?.() ?? {}) as { ids?: string[]; byId?: Record<string, SessionRow>; phase?: string }
305
+ return { ids: snap.ids ?? [], byId: snap.byId ?? {}, ready: snap.phase !== 'pending' }
306
+ },
307
+ createSession: async (opts) => await service('sessions').create(opts),
308
+ createWorkspace: async (path) => {
309
+ const ws = await service('workspaces').create({ path })
310
+ return { workspaceId: ws.workspaceId, title: ws.title }
311
+ },
312
+ openSession: (id) => service('sessions').open(id),
313
+ reference: async (sessionId, path, appearance) => {
314
+ const outcome: AddOutcome = await add.appendReference(sessionId, path, undefined, appearance)
315
+ return outcome !== 'unavailable'
316
+ },
317
+ choose: (title, folder) => chooseWorkspace(title, folder),
318
+ openEditor: (path, line, column) => {
319
+ if (path == null) {
320
+ openEditorView(null)
321
+ return
322
+ }
323
+ openEditorView(path)
324
+ if (line != null) setTimeout(() => emitOpenAt(path, line, column), LINE_AT_DELAY_MS)
325
+ },
326
+ schedule: (fn, ms) => setTimeout(fn, ms),
327
+ notify: (text) => toastDom(text),
328
+ }
329
+ return { ...base, ...overrides }
330
+ }