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/README.md +71 -0
- package/assets/shell/dsh-open.cs +182 -0
- package/assets/shell/dsh-open.ps1 +52 -0
- package/assets/shell/dsh-open.sh +70 -0
- package/assets/shell/dsh-whale.ico +0 -0
- package/lib/client.js +718 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +1086 -5
- package/lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/client/chooseDialog.ts +70 -0
- package/src/client/externalOpen.ts +130 -0
- package/src/client/index.ts +4 -0
- package/src/client/openFlow.ts +330 -0
- package/src/client/ui/IntegrationSettings.ts +243 -0
- package/src/client/ui/McpSettings.ts +2 -0
- package/src/externalHandoff.ts +70 -0
- package/src/fileOpenSettings.ts +10 -2
- package/src/index.ts +3 -0
- package/src/integrate.ts +576 -0
- package/src/rpc.ts +90 -3
- package/src/shared/externalOpen.ts +62 -0
- package/src/shared/integration.ts +73 -0
- package/src/shared/rpc.ts +26 -3
- package/src/unityBridge.ts +234 -0
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +267 -0
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs.meta +11 -0
- package/unity/com.dsh.editor/Editor/com.dsh.editor.asmdef +15 -0
- package/unity/com.dsh.editor/Editor/com.dsh.editor.asmdef.meta +7 -0
- package/unity/com.dsh.editor/Editor.meta +8 -0
- package/unity/com.dsh.editor/README.md +33 -0
- package/unity/com.dsh.editor/README.md.meta +7 -0
- package/unity/com.dsh.editor/package.json +7 -0
- package/unity/com.dsh.editor/package.json.meta +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
+
}
|