dsh-vscode-mode 0.1.52 → 0.1.54
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 +1038 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +1130 -7
- package/lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/client/chooseDialog.ts +70 -0
- package/src/client/compat.ts +31 -0
- package/src/client/externalOpen.ts +130 -0
- package/src/client/imagePreview.ts +45 -0
- package/src/client/index.ts +50 -8
- package/src/client/openFlow.ts +330 -0
- package/src/client/remoteOpenRouter.ts +78 -0
- package/src/client/styles/editor.css +6 -0
- package/src/client/ui/EditorView.ts +116 -1
- package/src/client/ui/IntegrationSettings.ts +243 -0
- package/src/client/ui/McpSettings.ts +2 -0
- package/src/compat.ts +1 -1
- package/src/dshVersion.ts +1 -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 +97 -3
- package/src/shared/externalOpen.ts +62 -0
- package/src/shared/integration.ts +73 -0
- package/src/shared/rpc.ts +58 -5
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — DSH 对话文件链接的 0.1.3+ 路由(remote.session 面)。
|
|
3
|
+
* 0.1.3 起 dsh-client-ui-chat 的 openFile 直接调 ctx.remote.session.openWorkspacePath
|
|
4
|
+
* (浏览器→host RPC session/openWorkspacePath),不再经过 workspaces.openPath,
|
|
5
|
+
* 旧路由补丁因此落空;该方法是 getter-only accessor,补丁经 compat.patchAccessor
|
|
6
|
+
* 换描述符安装。行为与 openPathRouter 对齐:插件打开器优先,"."(打开工作区文件夹)、
|
|
7
|
+
* 无可用打开器或打开失败时透传原实现(系统默认应用打开)。
|
|
8
|
+
* 纯逻辑,node 环境可单测(不依赖 DOM / events 模块)。
|
|
9
|
+
* 作者 ddj 2026年09月08号
|
|
10
|
+
*/
|
|
11
|
+
import { patchAccessor } from './compat.js'
|
|
12
|
+
import { isAvailable, selectOpener, type FileOpenContext, type FileOpenerRegistry } from './fileOpeners.js'
|
|
13
|
+
|
|
14
|
+
/** session.openWorkspacePath 的结果形状(host 返回 { opened: true };客户端不做输出校验)。 */
|
|
15
|
+
export interface OpenPathResult {
|
|
16
|
+
ok: boolean
|
|
17
|
+
value?: { opened: boolean }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** openWorkspacePath 的请求形状;path 为 "." 时表示打开工作区文件夹。 */
|
|
21
|
+
export interface OpenPathRequest {
|
|
22
|
+
path?: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 补丁前 openWorkspacePath 的实时实现形状。 */
|
|
26
|
+
type RemoteOpen = (request: OpenPathRequest) => Promise<OpenPathResult>
|
|
27
|
+
|
|
28
|
+
/** 插件打开器接管成功时返回给调用方(chat 只看 ok,产物区读 value)。 */
|
|
29
|
+
const OPENED_RESULT: OpenPathResult = { ok: true, value: { opened: true } }
|
|
30
|
+
|
|
31
|
+
export interface RemoteOpenRouterOptions {
|
|
32
|
+
registry: FileOpenerRegistry
|
|
33
|
+
selected: () => string
|
|
34
|
+
context: () => FileOpenContext
|
|
35
|
+
logger?: (message: string) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 结构化探测 remote.session 命名空间服务及其 openWorkspacePath 方法。
|
|
40
|
+
* 旧版 DSH 无该 namespace、或 api-remotes 尚未挂载时返回 undefined。
|
|
41
|
+
* @author ddj 2026年09月08号
|
|
42
|
+
* @param ctx 客户端服务上下文
|
|
43
|
+
* @returns 命名空间服务实例;不可用返回 undefined
|
|
44
|
+
*/
|
|
45
|
+
export function probeRemoteOpen(ctx: { get: (name: string) => unknown }): object | undefined {
|
|
46
|
+
try {
|
|
47
|
+
const service = ctx.get('remote.session') as { openWorkspacePath?: unknown } | undefined
|
|
48
|
+
if (!service || typeof service.openWorkspacePath !== 'function') return undefined
|
|
49
|
+
return service
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 安装可卸载的 remote.session.openWorkspacePath 路由(0.1.3+ 会话文件链接主路径)。
|
|
57
|
+
* @author ddj 2026年09月08号
|
|
58
|
+
* @param service probeRemoteOpen 命中的命名空间服务
|
|
59
|
+
* @param options 路由依赖
|
|
60
|
+
* @returns 恢复函数;目标属性缺失时返回 null(调用方停止安装并告警)
|
|
61
|
+
*/
|
|
62
|
+
export function patchRemoteOpen(service: object, options: RemoteOpenRouterOptions): (() => void) | null {
|
|
63
|
+
return patchAccessor(service, 'openWorkspacePath', async (original, ...args) => {
|
|
64
|
+
const fallback = original as unknown as RemoteOpen
|
|
65
|
+
const request = (args[0] ?? {}) as OpenPathRequest
|
|
66
|
+
const path = typeof request.path === 'string' ? request.path : ''
|
|
67
|
+
if (!path || path === '.') return fallback(request)
|
|
68
|
+
const opener = selectOpener(options.registry, options.selected())
|
|
69
|
+
if (!opener || !isAvailable(opener)) return fallback(request)
|
|
70
|
+
try {
|
|
71
|
+
await opener.open(path, options.context())
|
|
72
|
+
return OPENED_RESULT
|
|
73
|
+
} catch (error) {
|
|
74
|
+
options.logger?.('文件打开器 ' + opener.id + ' 失败,回退系统打开: ' + String(error))
|
|
75
|
+
return fallback(request)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
|
@@ -58,6 +58,12 @@
|
|
|
58
58
|
[data-edrv-view] .edrv-minus-item::before { content: ''; position: absolute; left: 4px; top: 50%; width: 8px; height: 2px; transform: translateY(-50%); border-radius: 2px; background: #d9534f; }
|
|
59
59
|
[data-edrv-view] .edrv-monaco-host { flex: 1 1 auto; min-width: 0; min-height: 0; position: relative; overflow: hidden; }
|
|
60
60
|
[data-edrv-view] .edrv-monaco-host > div { position: absolute; inset: 0; min-width: 0; min-height: 0; }
|
|
61
|
+
[data-edrv-view] .edrv-imgview { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
62
|
+
[data-edrv-view] .edrv-imgview-bar { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; flex-shrink: 0; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); background: var(--dsw-alias-bg-layer-1, transparent); }
|
|
63
|
+
[data-edrv-view] .edrv-imgview-meta { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); white-space: nowrap; }
|
|
64
|
+
[data-edrv-view] .edrv-imgview-stage { flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 14px; background-color: var(--dsw-alias-bg-layer-1, #f0f4f4); background-image: conic-gradient(rgba(128,128,128,.14) 25%, transparent 0 50%, rgba(128,128,128,.14) 0 75%, transparent 0); background-size: 16px 16px; }
|
|
65
|
+
[data-edrv-view] .edrv-imgview-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; box-shadow: 0 2px 12px rgba(31,41,51,.18); }
|
|
66
|
+
[data-edrv-view] .edrv-imgview-broken { display: flex; flex-direction: column; align-items: center; gap: 10px; font-size: 13px; color: var(--dsw-alias-label-tertiary, #777); }
|
|
61
67
|
[data-edrv-view] .edrv-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; color: var(--dsw-alias-label-tertiary, #777); font-size: 13px; }
|
|
62
68
|
[data-edrv-view] .edrv-diffbar { position: absolute; left: 0; right: 0; bottom: 10px; margin: 0 auto; z-index: 25; width: fit-content; max-width: calc(100% - 20px); border-radius: 12px; background: var(--dsw-specific-tip, var(--dsw-alias-bg-layer-2, #f0f4f4)); border: 1px solid var(--dsw-alias-border-l1, #e0e6e8); box-shadow: none; overflow: visible; display: flex; flex-direction: column; }
|
|
63
69
|
[data-edrv-view] .edrv-editor-area > .edrv-diffbar { max-height: calc(100% - 20px); }
|
|
@@ -9,6 +9,7 @@ import React from 'react'
|
|
|
9
9
|
import { dbg, rpc } from '../rpc.js'
|
|
10
10
|
import { emitRefresh } from '../events.js'
|
|
11
11
|
import { langOf, loadMonaco } from '../monaco/loader.js'
|
|
12
|
+
import { dataUrlOf, isImagePath, isSvgPath } from '../imagePreview.js'
|
|
12
13
|
import { applyTheme, registerThemes, themeNameOf } from '../monaco/theme.js'
|
|
13
14
|
import { createDiffRenderer } from '../monaco/diffRender.js'
|
|
14
15
|
import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
|
|
@@ -53,6 +54,10 @@ export function EditorView(props) {
|
|
|
53
54
|
const [dirtyMap, setDirtyMap] = React.useState({})
|
|
54
55
|
const [content, setContent] = React.useState(null)
|
|
55
56
|
const [contentPath, setContentPath] = React.useState(null)
|
|
57
|
+
const [imageSrc, setImageSrc] = React.useState(null) // 图片预览 data URL(图片 tab 专用,文本 tab 恒为 null)
|
|
58
|
+
const [imgSize, setImgSize] = React.useState(null) // 图片自然尺寸 { w, h }(路径栏 meta)
|
|
59
|
+
const [imgBroken, setImgBroken] = React.useState(false) // 图片解码失败(onError),显示占位与重试
|
|
60
|
+
const svgTextRef = React.useRef(new Set()) // 强制以文本打开的 SVG 路径集合(toggleSvgText 维护)
|
|
56
61
|
const [status, setStatus] = React.useState('')
|
|
57
62
|
const [lspServers, setLspServers] = React.useState([])
|
|
58
63
|
const [error, setError] = React.useState(null)
|
|
@@ -117,6 +122,8 @@ export function EditorView(props) {
|
|
|
117
122
|
}, [records, active])
|
|
118
123
|
|
|
119
124
|
const contentReady = content !== null && contentPath === active
|
|
125
|
+
// 当前 tab 是否处于图片预览模式(与 loadContent 的分派条件同源;SVG 文本模式时为 false)
|
|
126
|
+
const isImageActive = !!active && isImagePath(active) && !svgTextRef.current.has(active)
|
|
120
127
|
const regions = React.useMemo(() => diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded), [currentRecords, content, contentPath, active])
|
|
121
128
|
// useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
|
|
122
129
|
const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
|
|
@@ -318,6 +325,11 @@ export function EditorView(props) {
|
|
|
318
325
|
*/
|
|
319
326
|
const loadContent = (path, sid, force) => {
|
|
320
327
|
const seq = ++loadSeqRef.current
|
|
328
|
+
// 图片文件走专用通道:base64 → data URL 预览,不建 Monaco model、不进文本/差异流程
|
|
329
|
+
if (isImagePath(path) && !svgTextRef.current.has(path)) {
|
|
330
|
+
loadImage(path, sid, seq)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
321
333
|
const cachedModel = force ? null : modelsRef.current.get(path)
|
|
322
334
|
if (cachedModel) {
|
|
323
335
|
// model 命中:直接显示其内容(切 tab 零等待),后台静默校验
|
|
@@ -361,6 +373,47 @@ export function EditorView(props) {
|
|
|
361
373
|
})
|
|
362
374
|
}
|
|
363
375
|
|
|
376
|
+
/**
|
|
377
|
+
* 加载图片文件为 data URL(编辑区只读预览);失败走 loadError 面板(重试经 loadContent 分派回此)。
|
|
378
|
+
* @author ddj 2026年09月08号
|
|
379
|
+
* @param path 图片文件路径
|
|
380
|
+
* @param sid 会话 id
|
|
381
|
+
* @param seq 加载序号(过期响应丢弃)
|
|
382
|
+
*/
|
|
383
|
+
const loadImage = (path, sid, seq) => {
|
|
384
|
+
setImageSrc(null)
|
|
385
|
+
setImgSize(null)
|
|
386
|
+
setImgBroken(false)
|
|
387
|
+
setLoadStage({ progress: monaco ? 72 : 12, message: '读取图片…' })
|
|
388
|
+
rpc('edrv.read', { sessionId: sid, path, encoding: 'base64' }).then((res) => {
|
|
389
|
+
if (seq !== loadSeqRef.current || path !== active) return
|
|
390
|
+
if (res && res.ok && res.encoding === 'base64') {
|
|
391
|
+
if (!res.mime) {
|
|
392
|
+
setLoadError('host 版本过旧:图片响应缺少 mime')
|
|
393
|
+
setError('host 版本过旧:图片响应缺少 mime')
|
|
394
|
+
setStatus('读取失败')
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
setImageSrc(dataUrlOf(res.content, res.mime))
|
|
398
|
+
setLoadStage({ progress: 100, message: '图片已就绪' })
|
|
399
|
+
setStatus('已加载')
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
const base = res?.error ? String(res.error) : '读取失败'
|
|
403
|
+
// 带出 host 解析后的真实路径:跳转失败时一眼看出是路径解析错还是目标不存在
|
|
404
|
+
const message = res?.resolvedPath ? base + ':' + String(res.resolvedPath) : base
|
|
405
|
+
setLoadError(message)
|
|
406
|
+
setError(message)
|
|
407
|
+
setStatus('读取失败')
|
|
408
|
+
}).catch((e) => {
|
|
409
|
+
if (seq !== loadSeqRef.current) return
|
|
410
|
+
const message = 'read异常:' + String(e)
|
|
411
|
+
setLoadError(message)
|
|
412
|
+
setError(message)
|
|
413
|
+
setStatus('读取失败')
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
364
417
|
// 挂载轮询 + 事件订阅
|
|
365
418
|
React.useEffect(() => {
|
|
366
419
|
if (!sessionId) return
|
|
@@ -633,6 +686,9 @@ export function EditorView(props) {
|
|
|
633
686
|
if (!active) return
|
|
634
687
|
setContent(null)
|
|
635
688
|
setContentPath(null)
|
|
689
|
+
setImageSrc(null)
|
|
690
|
+
setImgSize(null)
|
|
691
|
+
setImgBroken(false)
|
|
636
692
|
setLoadError(null)
|
|
637
693
|
setLoadStage({ progress: 10, message: '准备读取文件…' })
|
|
638
694
|
setStatus('加载中…')
|
|
@@ -703,6 +759,7 @@ export function EditorView(props) {
|
|
|
703
759
|
const doSave = (silent) => {
|
|
704
760
|
const ed = editorRef.current
|
|
705
761
|
if (!active || !ed) return
|
|
762
|
+
if (isImageActive) { setStatus('图片只读预览'); return }
|
|
706
763
|
const text = ed.getValue()
|
|
707
764
|
if (!silent) setStatus('保存中…')
|
|
708
765
|
rpc('edrv.save', { sessionId, path: active, content: text }).then((res) => {
|
|
@@ -975,6 +1032,53 @@ export function EditorView(props) {
|
|
|
975
1032
|
refreshRecords(skipStale === true)
|
|
976
1033
|
}
|
|
977
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* SVG 在图片预览与文本编辑间切换(按路径记忆;重载分派随集合状态自动路由)。
|
|
1037
|
+
* @author ddj 2026年09月08号
|
|
1038
|
+
* @param path SVG 文件路径
|
|
1039
|
+
*/
|
|
1040
|
+
const toggleSvgText = (path) => {
|
|
1041
|
+
const next = new Set(svgTextRef.current)
|
|
1042
|
+
if (next.has(path)) next.delete(path)
|
|
1043
|
+
else next.add(path)
|
|
1044
|
+
svgTextRef.current = next
|
|
1045
|
+
setContent(null)
|
|
1046
|
+
setContentPath(null)
|
|
1047
|
+
setImageSrc(null)
|
|
1048
|
+
setImgSize(null)
|
|
1049
|
+
setImgBroken(false)
|
|
1050
|
+
setLoadError(null)
|
|
1051
|
+
setStatus(next.has(path) ? '已切换为文本模式' : '已切换为图片预览')
|
|
1052
|
+
loadContent(path, sessionId, true)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* 图片预览面板:工具条(尺寸 meta / SVG 文本切换 / 刷新)+ 棋盘底自适应图片。
|
|
1057
|
+
* @author ddj 2026年09月08号
|
|
1058
|
+
*/
|
|
1059
|
+
const imagePanel = () => React.createElement('div', { className: 'edrv-imgview' },
|
|
1060
|
+
React.createElement('div', { className: 'edrv-imgview-bar' },
|
|
1061
|
+
React.createElement('span', { className: 'edrv-imgview-meta' },
|
|
1062
|
+
imgBroken ? '图片无法显示' : (imgSize ? imgSize.w + '×' + imgSize.h : '…')),
|
|
1063
|
+
React.createElement('span', { style: { flex: 1 } }),
|
|
1064
|
+
(isSvgPath(active) ? React.createElement('button', {
|
|
1065
|
+
className: 'edrv-pill edrv-pill-ghost', title: '以文本方式查看/编辑(SVG 为文本格式)',
|
|
1066
|
+
onClick: () => toggleSvgText(active),
|
|
1067
|
+
}, '以文本打开') : null),
|
|
1068
|
+
React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '重新加载图片', onClick: () => reloadFile() }, '⟳ 刷新')),
|
|
1069
|
+
React.createElement('div', { className: 'edrv-imgview-stage' },
|
|
1070
|
+
(imgBroken
|
|
1071
|
+
? React.createElement('div', { className: 'edrv-imgview-broken' },
|
|
1072
|
+
React.createElement('div', null, '图片解码失败,文件可能已损坏'),
|
|
1073
|
+
React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: () => reloadFile() }, '重试'))
|
|
1074
|
+
: React.createElement('img', {
|
|
1075
|
+
className: 'edrv-imgview-img',
|
|
1076
|
+
src: imageSrc,
|
|
1077
|
+
alt: active || '',
|
|
1078
|
+
onLoad: (e) => { setImgBroken(false); setImgSize({ w: e.target.naturalWidth, h: e.target.naturalHeight }) },
|
|
1079
|
+
onError: () => setImgBroken(true),
|
|
1080
|
+
}))))
|
|
1081
|
+
|
|
978
1082
|
/**
|
|
979
1083
|
* 关闭当前差异浮窗并清理 hover 锚点。
|
|
980
1084
|
* @author ddj 2026年08月21号
|
|
@@ -1183,6 +1287,12 @@ export function EditorView(props) {
|
|
|
1183
1287
|
const openPath = () => {
|
|
1184
1288
|
const p = (pathDraft || '').trim()
|
|
1185
1289
|
if (!p) return
|
|
1290
|
+
// 图片文件跳过文本读探测(二进制会被 host 拒绝),直接进 tab 由 loadImage 接管
|
|
1291
|
+
if (isImagePath(p)) {
|
|
1292
|
+
openFile(p, false)
|
|
1293
|
+
setOpenInput(false); setPathDraft(''); setStatus('已打开'); setError(null)
|
|
1294
|
+
return
|
|
1295
|
+
}
|
|
1186
1296
|
setStatus('打开中…')
|
|
1187
1297
|
rpc('edrv.read', { sessionId, path: p }).then((res) => {
|
|
1188
1298
|
if (res && res.ok) {
|
|
@@ -1214,7 +1324,8 @@ export function EditorView(props) {
|
|
|
1214
1324
|
const pathBar = React.createElement('div', { className: 'edrv-pathbar', title: active || '' },
|
|
1215
1325
|
React.createElement('span', { className: 'edrv-pb-name' }, active ? String(active).split(/[\\/]/).pop() : '未打开文件'),
|
|
1216
1326
|
React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开文件'),
|
|
1217
|
-
(active && langOf(active) ? React.createElement('span', { className: 'edrv-pb-meta' }, langOf(active)) : null),
|
|
1327
|
+
(active && !isImageActive && langOf(active) ? React.createElement('span', { className: 'edrv-pb-meta' }, langOf(active)) : null),
|
|
1328
|
+
(isImageActive && imgSize ? React.createElement('span', { className: 'edrv-pb-meta' }, imgSize.w + '×' + imgSize.h) : null),
|
|
1218
1329
|
(cursor ? React.createElement('span', { className: 'edrv-pb-meta' }, cursor) : null),
|
|
1219
1330
|
(status ? React.createElement('span', { className: 'edrv-pb-meta edrv-pb-status' }, status) : null))
|
|
1220
1331
|
|
|
@@ -1304,6 +1415,10 @@ export function EditorView(props) {
|
|
|
1304
1415
|
setLoadStage({ progress: 10, message: '重新读取文件…' })
|
|
1305
1416
|
loadContent(active, sessionId)
|
|
1306
1417
|
})
|
|
1418
|
+
} else if (isImageActive && imageSrc) {
|
|
1419
|
+
body = imagePanel()
|
|
1420
|
+
} else if (isImageActive) {
|
|
1421
|
+
body = loadingBody(loadStage.message || '读取图片…', loadStage.progress)
|
|
1307
1422
|
} else if (content === null) {
|
|
1308
1423
|
body = loadingBody(loadStage.message || '读取文件内容…', loadStage.progress)
|
|
1309
1424
|
} else {
|