dsh-vscode-mode 0.1.53 → 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/lib/client.js +320 -12
- package/lib/client.js.map +1 -1
- package/lib/index.js +44 -2
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/compat.ts +31 -0
- package/src/client/imagePreview.ts +45 -0
- package/src/client/index.ts +46 -8
- 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/compat.ts +1 -1
- package/src/dshVersion.ts +1 -0
- package/src/rpc.ts +7 -0
- package/src/shared/rpc.ts +32 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.54",
|
|
4
4
|
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
package/src/client/compat.ts
CHANGED
|
@@ -92,3 +92,34 @@ export function patchMethod<T extends object, K extends keyof T>(
|
|
|
92
92
|
if (owner[key] === patched) owner[key] = original
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/** patchAccessor 的包装实现形状(original 为补丁前的实时实现)。 */
|
|
97
|
+
export type PatchWrapper = (original: (...args: never[]) => unknown, ...args: never[]) => unknown
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* accessor 感知的链式补丁:DSH 0.1.3+ 的 remote 命名空间方法(如
|
|
101
|
+
* remote.session.openWorkspacePath)是 getter-only accessor(Object.defineProperty
|
|
102
|
+
* 仅定义 get,直接赋值在严格模式下抛 TypeError),须整体替换属性描述符。
|
|
103
|
+
* 新 getter 每次访问现场调用原 getter 取最新一次性函数作 original,天然跟随
|
|
104
|
+
* DSH 内部 methods 表变化;data 属性降级走 patchMethod;属性缺失返回 null。
|
|
105
|
+
* @author ddj 2026年09月08号
|
|
106
|
+
* @param owner 目标对象
|
|
107
|
+
* @param key 属性名
|
|
108
|
+
* @param wrapper 包装实现(original 为补丁前的实时实现)
|
|
109
|
+
* @returns 恢复函数(幂等、带归属校验);属性缺失时返回 null
|
|
110
|
+
*/
|
|
111
|
+
export function patchAccessor(owner: object, key: string, wrapper: PatchWrapper): (() => void) | null {
|
|
112
|
+
const descriptor = Object.getOwnPropertyDescriptor(owner, key)
|
|
113
|
+
if (!descriptor) return null
|
|
114
|
+
if (typeof descriptor.get !== 'function') return patchMethod(owner, key as never, wrapper as never)
|
|
115
|
+
const rawGet = descriptor.get as (this: object) => unknown
|
|
116
|
+
const patchedGet = function (): unknown {
|
|
117
|
+
const original = (...args: never[]) => (rawGet.call(owner) as (...args: never[]) => unknown)(...args)
|
|
118
|
+
return (...args: never[]) => wrapper(original, ...args)
|
|
119
|
+
}
|
|
120
|
+
Object.defineProperty(owner, key, { configurable: true, enumerable: descriptor.enumerable, get: patchedGet })
|
|
121
|
+
return () => {
|
|
122
|
+
const current = Object.getOwnPropertyDescriptor(owner, key)
|
|
123
|
+
if (current?.get === patchedGet) Object.defineProperty(owner, key, descriptor)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 图片文件判定与 data URL 组装(编辑区图片预览面板用)。
|
|
3
|
+
* 纯函数、无 DOM 依赖,便于单测;MIME 表收敛在 shared/rpc.ts(host base64 响应同源)。
|
|
4
|
+
* @author ddj 2026年09月08号
|
|
5
|
+
*/
|
|
6
|
+
import { IMAGE_MIME } from '../shared/rpc.js'
|
|
7
|
+
|
|
8
|
+
/** 可预览的图片扩展名集合(小写、无点)。 */
|
|
9
|
+
const IMAGE_EXT_SET: Set<string> = new Set(Object.keys(IMAGE_MIME))
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 判断路径是否为可预览的图片文件(取 basename 扩展名,大小写不敏感)。
|
|
13
|
+
* @author ddj 2026年09月08号
|
|
14
|
+
* @param path 文件路径(`/` 或 `\` 分隔均可)
|
|
15
|
+
* @returns 是否图片文件
|
|
16
|
+
*/
|
|
17
|
+
export function isImagePath(path: string): boolean {
|
|
18
|
+
const base = String(path || '').split(/[\\/]/).pop() || ''
|
|
19
|
+
const dot = base.lastIndexOf('.')
|
|
20
|
+
if (dot <= 0) return false
|
|
21
|
+
return IMAGE_EXT_SET.has(base.slice(dot + 1).toLowerCase())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 判断路径是否为 SVG 文件(唯一提供「以文本打开」切换的图片格式)。
|
|
26
|
+
* @author ddj 2026年09月08号
|
|
27
|
+
* @param path 文件路径(`/` 或 `\` 分隔均可)
|
|
28
|
+
* @returns 是否 SVG
|
|
29
|
+
*/
|
|
30
|
+
export function isSvgPath(path: string): boolean {
|
|
31
|
+
const base = String(path || '').split(/[\\/]/).pop() || ''
|
|
32
|
+
const dot = base.lastIndexOf('.')
|
|
33
|
+
return dot > 0 && base.slice(dot + 1).toLowerCase() === 'svg'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* base64 文本 → 可直接赋给 `<img src>` 的 data URL。
|
|
38
|
+
* @author ddj 2026年09月08号
|
|
39
|
+
* @param base64 base64 文本(不含 data: 前缀)
|
|
40
|
+
* @param mime MIME 类型(host edrv.read base64 响应携带)
|
|
41
|
+
* @returns data URL 字符串
|
|
42
|
+
*/
|
|
43
|
+
export function dataUrlOf(base64: string, mime: string): string {
|
|
44
|
+
return 'data:' + (mime || 'application/octet-stream') + ';base64,' + base64
|
|
45
|
+
}
|
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 { patchRemoteOpen, probeRemoteOpen } from './remoteOpenRouter.js'
|
|
28
29
|
import { setupExtOpen } from './externalOpen.js'
|
|
29
30
|
import { SettingsContext } from './settingsContext.js'
|
|
30
31
|
import { SIDEBAR_PLUGIN, pickSettingsBinder, registerSlotSafely } from './compat.js'
|
|
@@ -98,6 +99,17 @@ export function apply(ctx: any): void {
|
|
|
98
99
|
const binder = pickSettingsBinder(ctx)
|
|
99
100
|
const settings = binder.scope
|
|
100
101
|
let selected = autoValue('auto')
|
|
102
|
+
/** 0.1.3+ 会话文件链接路由(remote.session.openWorkspacePath)是否已安装(compatSummary 展示用)。 */
|
|
103
|
+
let remoteOpenInstalled = false
|
|
104
|
+
/** 两条文件链接路由共用的路由日志(openPathRouter / remoteOpenRouter)。 */
|
|
105
|
+
const routeLogger = (message: string): void => console.warn('[dsh-vscode-mode] ' + message)
|
|
106
|
+
/** 两条路由共用的 FileOpenContext:当前会话 id 与工作区 cwd。 */
|
|
107
|
+
const openContext = (): FileOpenContext => {
|
|
108
|
+
const current = sessions?.list?.getSnapshot?.()
|
|
109
|
+
const sessionId = current?.current
|
|
110
|
+
const summary = sessionId ? current?.byId?.[sessionId] : undefined
|
|
111
|
+
return { sessionId, cwd: summary?.cwd } as FileOpenContext
|
|
112
|
+
}
|
|
101
113
|
/** 可选探测 betterSidebar 服务(不进 inject:缺失会让插件停靠等待,杀死回退路径)。 */
|
|
102
114
|
let sideService = detectSidebarService(ctx)
|
|
103
115
|
/** 「添加到对话」动作集:编辑区/Tab 右键菜单注入文件引用与代码块(conversation 服务缺失时各动作安全降级)。 */
|
|
@@ -106,7 +118,8 @@ export function apply(ctx: any): void {
|
|
|
106
118
|
/** 客户端侧兼容摘要(与 host 报告合并展示;惰性求值保证 HMR 后仍新鲜)。 */
|
|
107
119
|
const compatSummary = (): CompatAdapter[] => [
|
|
108
120
|
{ name: '设置桥', active: settings !== undefined, note: settings !== undefined ? '使用 ' + binder.service + ' 桥' : '未绑定设置服务(fileOpenTool 持久化不可用)' },
|
|
109
|
-
{ name: '文件打开路由(workspaces.openPath)', active: Boolean(workspaces?.openPath), note: 'vscode
|
|
121
|
+
{ name: '文件打开路由(workspaces.openPath)', active: Boolean(workspaces?.openPath), note: 'vscode 打开器优先,失败回退系统打开(0.1.2 及更早 DSH 的对话文件链接主路径)' },
|
|
122
|
+
{ name: '会话文件链接路由(remote.session.openWorkspacePath)', active: remoteOpenInstalled, note: '0.1.3+ 对话文件引用/产物打开优先走插件打开器,失败回退系统打开' },
|
|
110
123
|
{ name: '侧边栏打开器(' + SIDEBAR_PLUGIN + ')', active: registry.get(SIDEBAR_PLUGIN) !== undefined, note: registry.get(SIDEBAR_PLUGIN) !== undefined ? '已注册(优先级 80)' : '未检测到侧边栏打开能力' },
|
|
111
124
|
{ name: '侧边栏编辑区(' + SIDEBAR_PLUGIN + ')', active: sideService !== undefined, note: sideService !== undefined ? '编辑区=侧边栏 Tab(对话+编辑同屏)' : '未检测到;安装 dsh-better-sidebar 后刷新启用侧边栏形态(' + SIDEBAR_INSTALL_CMD + ')' },
|
|
112
125
|
]
|
|
@@ -173,15 +186,40 @@ export function apply(ctx: any): void {
|
|
|
173
186
|
workspaces,
|
|
174
187
|
registry,
|
|
175
188
|
selected: () => selected,
|
|
176
|
-
context:
|
|
177
|
-
|
|
178
|
-
const sessionId = current?.current
|
|
179
|
-
const summary = sessionId ? current?.byId?.[sessionId] : undefined
|
|
180
|
-
return { sessionId, cwd: summary?.cwd } as FileOpenContext
|
|
181
|
-
},
|
|
182
|
-
logger: (message: string) => console.warn('[dsh-vscode-mode] ' + message),
|
|
189
|
+
context: openContext,
|
|
190
|
+
logger: routeLogger,
|
|
183
191
|
}), 'vscode-mode: file link routing')
|
|
184
192
|
}
|
|
193
|
+
// 0.1.3+ 会话文件链接主路径:dsh-client-ui-chat 的 openFile 直接调
|
|
194
|
+
// remote.session.openWorkspacePath(session/openWorkspacePath RPC),绕开
|
|
195
|
+
// workspaces.openPath;该 namespace 由 api-remotes 在 loader.await 后挂载,
|
|
196
|
+
// 晚于本插件 apply,且旧版 DSH 可能整段不存在——不加 inject(避免旧版停等
|
|
197
|
+
// 永不激活),改为定时探测有界重试(与侧栏服务探测同款节奏)。
|
|
198
|
+
let remoteRetries = 0
|
|
199
|
+
const retryRemoteOpen = (): void => {
|
|
200
|
+
if (remoteOpenInstalled || remoteRetries >= 15) {
|
|
201
|
+
if (!remoteOpenInstalled) console.warn('[dsh-vscode-mode] 未探测到 remote.session.openWorkspacePath,0.1.3+ 会话文件链接路由未安装')
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
remoteRetries += 1
|
|
205
|
+
schedule(() => {
|
|
206
|
+
if (remoteOpenInstalled) return
|
|
207
|
+
const service = probeRemoteOpen(ctx)
|
|
208
|
+
if (!service) {
|
|
209
|
+
retryRemoteOpen()
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
const disposer = patchRemoteOpen(service, { registry, selected: () => selected, context: openContext, logger: routeLogger })
|
|
213
|
+
if (!disposer) {
|
|
214
|
+
console.warn('[dsh-vscode-mode] remote.session 存在但 openWorkspacePath 不可补丁,会话文件链接路由未安装')
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
remoteOpenInstalled = true
|
|
218
|
+
ctx.effect(() => disposer, 'vscode-mode: remote file link routing')
|
|
219
|
+
console.info('[dsh-vscode-mode] 已安装 0.1.3+ 会话文件链接路由(remote.session.openWorkspacePath)')
|
|
220
|
+
}, 2000)
|
|
221
|
+
}
|
|
222
|
+
retryRemoteOpen()
|
|
185
223
|
|
|
186
224
|
// 中央「文件编辑」页签(旧形态回退):类 VSCode 编辑器;侧边栏形态可用时编辑器住 betterSidebar Tab,
|
|
187
225
|
// 本页签不注册(避免双实例:Monaco×2 + diff dock 每会话单源抢占)。
|
|
@@ -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 {
|
package/src/compat.ts
CHANGED
|
@@ -129,7 +129,7 @@ export function detectGuards(ctx: Ctx): CompatAdapter[] {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
/** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。 */
|
|
132
|
-
const TESTED_DSH_MAX = '0.1.
|
|
132
|
+
const TESTED_DSH_MAX = '0.1.3-alpha.2'
|
|
133
133
|
|
|
134
134
|
/** 版本适配机制状态行:DSH 版本探测 + 设置 section 安装策略。 */
|
|
135
135
|
export function versionAdapters(dshVersion: string): CompatAdapter[] {
|
package/src/dshVersion.ts
CHANGED
|
@@ -86,6 +86,7 @@ export function inDshRange(version: DshVersion | null | undefined, range: DshRan
|
|
|
86
86
|
export function familyLabel(input: string): string {
|
|
87
87
|
const version = parseDshVersion(input)
|
|
88
88
|
if (!version) return '未知'
|
|
89
|
+
if (inDshRange(version, { from: '0.1.3-alpha.1' })) return '0.1.3-alpha 及更新(对话文件链接=remote.session.openWorkspacePath)'
|
|
89
90
|
if (inDshRange(version, { from: '0.1.2-alpha.1' })) return '0.1.2-alpha 及更新(设置 API=settings.installSection)'
|
|
90
91
|
return '0.1.0/0.1.1 rc 线(设置 API=installSettingsSection)'
|
|
91
92
|
}
|
package/src/rpc.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* 作者 ddj 2026-08-20
|
|
6
6
|
*/
|
|
7
7
|
import type { DecideItem, DecideResult, RpcHandlerMap, RpcMethod, RpcRequestMap, RpcResult, RpcScope } from './shared/rpc.js'
|
|
8
|
+
import { imageMimeOf } from './shared/rpc.js'
|
|
8
9
|
import type { DiffRecord, RecordView } from './shared/types.js'
|
|
9
10
|
import type { Ctx, Session } from './store.js'
|
|
10
11
|
import {
|
|
@@ -303,6 +304,12 @@ export function buildHandlers(
|
|
|
303
304
|
return { ok: false, error: '文件不存在', resolvedPath: fs.processPath(target) }
|
|
304
305
|
}
|
|
305
306
|
if ((info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
|
|
307
|
+
if (args.encoding === 'base64') {
|
|
308
|
+
// 图片等二进制预览:readBytes 无解码、无二进制拒绝;超上限已由上方 stat 拦截
|
|
309
|
+
const bytes = await fs.readBytes(target, undefined, READ_CAP)
|
|
310
|
+
const content = Buffer.from(bytes).toString('base64')
|
|
311
|
+
return { ok: true, content, size: bytes.byteLength, encoding: 'base64', mime: imageMimeOf(args.path) }
|
|
312
|
+
}
|
|
306
313
|
const content = await fs.readText(target)
|
|
307
314
|
return { ok: true, content, size: content.length }
|
|
308
315
|
} catch (error) {
|
package/src/shared/rpc.ts
CHANGED
|
@@ -21,6 +21,36 @@ import type { LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocatio
|
|
|
21
21
|
/** webServer 精确路由。 */
|
|
22
22
|
export const RPC_PATH = '/edrv/rpc'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 图片扩展名(小写、无点)→ MIME。edrv.read base64 响应与 client 图片预览共用,
|
|
26
|
+
* 收敛在此避免 host/client 两处漂移。
|
|
27
|
+
* @author ddj 2026年09月08号
|
|
28
|
+
*/
|
|
29
|
+
export const IMAGE_MIME: Record<string, string> = {
|
|
30
|
+
png: 'image/png',
|
|
31
|
+
jpg: 'image/jpeg',
|
|
32
|
+
jpeg: 'image/jpeg',
|
|
33
|
+
gif: 'image/gif',
|
|
34
|
+
webp: 'image/webp',
|
|
35
|
+
bmp: 'image/bmp',
|
|
36
|
+
ico: 'image/x-icon',
|
|
37
|
+
avif: 'image/avif',
|
|
38
|
+
svg: 'image/svg+xml',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 路径 → 图片 MIME:按 basename 扩展名小写查 {@link IMAGE_MIME},未命中回退 application/octet-stream。
|
|
43
|
+
* @author ddj 2026年09月08号
|
|
44
|
+
* @param path 文件路径(`/` 或 `\` 分隔均可)
|
|
45
|
+
* @returns MIME 字符串
|
|
46
|
+
*/
|
|
47
|
+
export function imageMimeOf(path: string): string {
|
|
48
|
+
const base = String(path || '').split(/[\\/]/).pop() || ''
|
|
49
|
+
const dot = base.lastIndexOf('.')
|
|
50
|
+
const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : ''
|
|
51
|
+
return IMAGE_MIME[ext] ?? 'application/octet-stream'
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
/** 决策作用域:call=整条记录,hunk=单个差异块。 */
|
|
25
55
|
export type RpcScope = 'call' | 'hunk'
|
|
26
56
|
|
|
@@ -111,7 +141,7 @@ export interface RpcRequestMap {
|
|
|
111
141
|
'edrv.accept': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
|
|
112
142
|
'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
|
|
113
143
|
'edrv.decideBatch': { sessionId?: string; items: DecideItem[] }
|
|
114
|
-
'edrv.read': { sessionId?: string; path: string }
|
|
144
|
+
'edrv.read': { sessionId?: string; path: string; encoding?: 'base64' }
|
|
115
145
|
'edrv.original': { sessionId?: string; path: string }
|
|
116
146
|
'edrv.save': { sessionId?: string; path: string; content: string }
|
|
117
147
|
'edrv.archiveList': { sessionId?: string }
|
|
@@ -194,7 +224,7 @@ export interface RpcOkMap {
|
|
|
194
224
|
'edrv.accept': { record: RecordView }
|
|
195
225
|
'edrv.reject': { record: RecordView }
|
|
196
226
|
'edrv.decideBatch': { results: DecideResult[] }
|
|
197
|
-
'edrv.read': { content: string; size: number }
|
|
227
|
+
'edrv.read': { content: string; size: number; encoding?: 'base64'; mime?: string }
|
|
198
228
|
'edrv.original': { content: string; size: number; stale: StaleHunk[]; fallback: boolean }
|
|
199
229
|
'edrv.save': object
|
|
200
230
|
'edrv.archiveList': { entries: ArchiveEntry[] }
|