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.
Files changed (41) hide show
  1. package/README.md +71 -0
  2. package/assets/shell/dsh-open.cs +182 -0
  3. package/assets/shell/dsh-open.ps1 +52 -0
  4. package/assets/shell/dsh-open.sh +70 -0
  5. package/assets/shell/dsh-whale.ico +0 -0
  6. package/lib/client.js +1038 -13
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +1130 -7
  9. package/lib/index.js.map +1 -1
  10. package/package.json +2 -1
  11. package/src/client/chooseDialog.ts +70 -0
  12. package/src/client/compat.ts +31 -0
  13. package/src/client/externalOpen.ts +130 -0
  14. package/src/client/imagePreview.ts +45 -0
  15. package/src/client/index.ts +50 -8
  16. package/src/client/openFlow.ts +330 -0
  17. package/src/client/remoteOpenRouter.ts +78 -0
  18. package/src/client/styles/editor.css +6 -0
  19. package/src/client/ui/EditorView.ts +116 -1
  20. package/src/client/ui/IntegrationSettings.ts +243 -0
  21. package/src/client/ui/McpSettings.ts +2 -0
  22. package/src/compat.ts +1 -1
  23. package/src/dshVersion.ts +1 -0
  24. package/src/externalHandoff.ts +70 -0
  25. package/src/fileOpenSettings.ts +10 -2
  26. package/src/index.ts +3 -0
  27. package/src/integrate.ts +576 -0
  28. package/src/rpc.ts +97 -3
  29. package/src/shared/externalOpen.ts +62 -0
  30. package/src/shared/integration.ts +73 -0
  31. package/src/shared/rpc.ts +58 -5
  32. package/src/unityBridge.ts +234 -0
  33. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +267 -0
  34. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs.meta +11 -0
  35. package/unity/com.dsh.editor/Editor/com.dsh.editor.asmdef +15 -0
  36. package/unity/com.dsh.editor/Editor/com.dsh.editor.asmdef.meta +7 -0
  37. package/unity/com.dsh.editor/Editor.meta +8 -0
  38. package/unity/com.dsh.editor/README.md +33 -0
  39. package/unity/com.dsh.editor/README.md.meta +7 -0
  40. package/unity/com.dsh.editor/package.json +7 -0
  41. 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 {