dsh-vscode-mode 0.12.0 → 0.13.0
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 +129 -103
- package/assets/shell/dsh-open.cs +65 -149
- package/assets/shell/dsh-open.mjs +241 -0
- package/assets/shell/dsh-open.ps1 +28 -47
- package/assets/shell/dsh-open.sh +25 -66
- package/lib/client.js +12465 -11526
- package/lib/client.js.map +1 -1
- package/lib/index.js +3889 -2336
- package/lib/index.js.map +1 -1
- package/package.json +9 -8
- package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
- package/src/childEnv.ts +19 -0
- package/src/client/addToConversation.ts +4 -2
- package/src/client/ai/inlineProvider.ts +1 -8
- package/src/client/commandBridge.ts +6 -2
- package/src/client/externalOpen.ts +40 -19
- package/src/client/index.ts +10 -4
- package/src/client/keybindings.ts +18 -1
- package/src/client/monaco/loader.ts +3 -1
- package/src/client/openFlow.ts +36 -28
- package/src/client/openReceipt.ts +64 -0
- package/src/client/panelCommands.ts +101 -0
- package/src/client/pdf/pdfLoader.ts +4 -2
- package/src/client/receiptState.ts +120 -0
- package/src/client/rpc.ts +30 -4
- package/src/client/shortcutRecorder.ts +161 -0
- package/src/client/shortcutsOfficial.ts +13 -4
- package/src/client/sidebarBridge.ts +7 -0
- package/src/client/ui/DiffBadge.ts +2 -1
- package/src/client/ui/EditorView.ts +237 -55
- package/src/client/ui/McpSettings.ts +6 -3
- package/src/client/ui/OfficialSideTab.ts +5 -2
- package/src/client/ui/QuickOpen.ts +10 -9
- package/src/client/ui/ShortcutSettings.ts +34 -54
- package/src/client/ui/commandCatalog.ts +5 -4
- package/src/client/useOpenReceipt.ts +128 -0
- package/src/compat.ts +5 -3
- package/src/dap/manager.ts +46 -13
- package/src/devForm.ts +252 -92
- package/src/externalHandoff.ts +93 -52
- package/src/fileOpenSettings.ts +1 -24
- package/src/hostImport.ts +19 -0
- package/src/httpGuard.ts +101 -0
- package/src/index.ts +106 -97
- package/src/integrate.ts +56 -50
- package/src/lsp/transport.ts +84 -127
- package/src/mcp.ts +58 -9
- package/src/mcpIsolation.ts +211 -102
- package/src/mcpProject.ts +220 -133
- package/src/mcpRuntime.ts +343 -0
- package/src/openBridge.ts +69 -0
- package/src/openInbox.ts +207 -0
- package/src/paths.ts +8 -5
- package/src/processTree.ts +63 -0
- package/src/reveal.ts +5 -3
- package/src/routes.ts +156 -154
- package/src/rpc.ts +14 -13
- package/src/shared/appUrl.ts +19 -0
- package/src/shared/externalOpen.ts +71 -0
- package/src/shared/integration.ts +2 -0
- package/src/shared/keybindings.ts +8 -5
- package/src/shared/mcp.ts +4 -1
- package/src/shared/rpc.ts +7 -4
- package/src/shared/svn.ts +6 -8
- package/src/svnPlanTool.ts +51 -0
- package/src/unityBridge.ts +26 -3
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
- package/unity/com.dsh.editor/README.md +16 -25
- package/unity/com.dsh.editor/package.json +2 -2
package/src/client/openFlow.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { rpc } from './rpc.js'
|
|
10
10
|
import { chooseWorkspace } from './chooseDialog.js'
|
|
11
|
-
import {
|
|
11
|
+
import { requestOpen } from './openReceipt.js'
|
|
12
12
|
import { toastDom } from './externalOpen.js'
|
|
13
13
|
import { createAddToConversation } from './addToConversation.js'
|
|
14
14
|
import type { AddOutcome, CtxLike } from './addToConversation.js'
|
|
@@ -47,7 +47,7 @@ export interface OpenDeps {
|
|
|
47
47
|
openSession(id: string): void
|
|
48
48
|
reference(sessionId: string, path: string, appearance: 'file' | 'folder'): Promise<boolean>
|
|
49
49
|
choose(title: string, folder: string): Promise<'recent' | 'create' | null>
|
|
50
|
-
openEditor(path: string | null, line?: number, column?: number):
|
|
50
|
+
openEditor(path: string | null, line?: number, column?: number, sessionId?: string): Promise<boolean>
|
|
51
51
|
schedule(fn: () => void, ms: number): void
|
|
52
52
|
notify(text: string): void
|
|
53
53
|
}
|
|
@@ -57,8 +57,6 @@ const READY_TIMEOUT_MS = 15000
|
|
|
57
57
|
const READY_TICK_MS = 300
|
|
58
58
|
const REF_RETRY_MAX = 20
|
|
59
59
|
const REF_RETRY_MS = 300
|
|
60
|
-
const REST_STEP_MS = 150
|
|
61
|
-
const LINE_AT_DELAY_MS = 200
|
|
62
60
|
|
|
63
61
|
/**
|
|
64
62
|
* 路径规范化:`\→/`、小写、去尾分隔符(Windows 大小写不敏感前缀匹配用)。
|
|
@@ -124,11 +122,11 @@ export function pickRule(kind: PathKind, matched: boolean, hasSession: boolean):
|
|
|
124
122
|
* @param params 深链参数(首路径驱动规则)
|
|
125
123
|
* @param overrides 依赖覆盖(测试注入)
|
|
126
124
|
*/
|
|
127
|
-
export async function openDeepLink(ctx: unknown, params: OpenParams, overrides: Partial<OpenDeps> = {}): Promise<
|
|
125
|
+
export async function openDeepLink(ctx: unknown, params: OpenParams, overrides: Partial<OpenDeps> = {}): Promise<boolean> {
|
|
128
126
|
const deps = realDeps(ctx, overrides)
|
|
129
127
|
if (!(await waitListsReady(deps))) {
|
|
130
128
|
deps.notify('DSH 会话列表未就绪,请稍后重试')
|
|
131
|
-
return
|
|
129
|
+
return false
|
|
132
130
|
}
|
|
133
131
|
const primary = params.paths[0]
|
|
134
132
|
const kind = await deps.stat(primary).catch(() => 'missing' as PathKind)
|
|
@@ -137,14 +135,16 @@ export async function openDeepLink(ctx: unknown, params: OpenParams, overrides:
|
|
|
137
135
|
try {
|
|
138
136
|
if (rule === 'abort') {
|
|
139
137
|
deps.notify('路径不存在:' + primary)
|
|
140
|
-
return
|
|
138
|
+
return false
|
|
141
139
|
}
|
|
142
|
-
if (rule === 'choose')
|
|
143
|
-
if (rule === 'folderNew')
|
|
144
|
-
if (rule === 'fileRecent')
|
|
145
|
-
|
|
140
|
+
if (rule === 'choose') await runChoose(deps, primary, params)
|
|
141
|
+
else if (rule === 'folderNew') await runFolderNew(deps, primary, params)
|
|
142
|
+
else if (rule === 'fileRecent') await runFileRecent(deps, primary, params)
|
|
143
|
+
else await runFileBootstrap(deps, primary, params)
|
|
144
|
+
return true
|
|
146
145
|
} catch (error) {
|
|
147
146
|
deps.notify('打开失败:' + String((error as Error)?.message ?? error))
|
|
147
|
+
return false
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
@@ -162,12 +162,12 @@ async function runChoose(deps: OpenDeps, folder: string, params: OpenParams): Pr
|
|
|
162
162
|
const matches = matchWorkspaces(deps.workspaces().items, folder)
|
|
163
163
|
const best = bestMatch(deps, matches)
|
|
164
164
|
const choice = await deps.choose(best?.title ?? '工作区', folder)
|
|
165
|
-
if (choice === null)
|
|
165
|
+
if (choice === null) throw new Error('用户取消打开')
|
|
166
166
|
if (choice === 'create') return runFolderNew(deps, folder, params)
|
|
167
167
|
const ws = best ?? deps.workspaces().items[0]
|
|
168
168
|
const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
|
|
169
169
|
await settleRef(deps, sessionId, folder, 'folder')
|
|
170
|
-
deps
|
|
170
|
+
await openChecked(deps, sessionId, null)
|
|
171
171
|
await openRest(deps, sessionId, params, 1)
|
|
172
172
|
}
|
|
173
173
|
|
|
@@ -178,21 +178,21 @@ async function runFileRecent(deps: OpenDeps, file: string, params: OpenParams):
|
|
|
178
178
|
const anchor = recentSessionIn(deps, ws)
|
|
179
179
|
if (anchor) {
|
|
180
180
|
deps.openSession(anchor.id)
|
|
181
|
-
deps.
|
|
181
|
+
await openChecked(deps, anchor.id, file, params.line, params.column)
|
|
182
182
|
await openRest(deps, anchor.id, params, 1)
|
|
183
183
|
return
|
|
184
184
|
}
|
|
185
185
|
// 文件所属工作区存在但无会话 → 在该工作区新建对话 + 文件引用
|
|
186
186
|
const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
|
|
187
187
|
await settleRef(deps, sessionId, file, 'file')
|
|
188
|
-
deps
|
|
188
|
+
await openChecked(deps, sessionId, file, params.line, params.column)
|
|
189
189
|
await openRest(deps, sessionId, params, 1)
|
|
190
190
|
return
|
|
191
191
|
}
|
|
192
192
|
const anchor = recentSession(deps.sessions().byId, deps.sessions().ids)
|
|
193
193
|
if (!anchor) return runFileBootstrap(deps, file, params)
|
|
194
194
|
deps.openSession(anchor.id)
|
|
195
|
-
deps.
|
|
195
|
+
await openChecked(deps, anchor.id, file, params.line, params.column)
|
|
196
196
|
await openRest(deps, anchor.id, params, 1)
|
|
197
197
|
}
|
|
198
198
|
|
|
@@ -228,7 +228,7 @@ async function runFileBootstrap(deps: OpenDeps, file: string, params: OpenParams
|
|
|
228
228
|
const ws = await deps.createWorkspace(dirnameOf(file))
|
|
229
229
|
const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
|
|
230
230
|
await settleRef(deps, sessionId, file, 'file')
|
|
231
|
-
deps
|
|
231
|
+
await openChecked(deps, sessionId, file, params.line, params.column)
|
|
232
232
|
await openRest(deps, sessionId, params, 1)
|
|
233
233
|
}
|
|
234
234
|
|
|
@@ -237,7 +237,7 @@ async function runFolderNew(deps: OpenDeps, folder: string, params: OpenParams):
|
|
|
237
237
|
const ws = await deps.createWorkspace(folder)
|
|
238
238
|
const sessionId = await spawnInWorkspace(deps, ws.workspaceId, undefined)
|
|
239
239
|
await settleRef(deps, sessionId, folder, 'folder')
|
|
240
|
-
deps
|
|
240
|
+
await openChecked(deps, sessionId, null)
|
|
241
241
|
await openRest(deps, sessionId, params, 1)
|
|
242
242
|
}
|
|
243
243
|
|
|
@@ -264,17 +264,20 @@ async function settleRef(deps: OpenDeps, sessionId: string, path: string, appear
|
|
|
264
264
|
if (await deps.reference(sessionId, path, appearance)) return
|
|
265
265
|
await sleep(deps, REF_RETRY_MS)
|
|
266
266
|
}
|
|
267
|
+
throw new Error('引用插入未完成')
|
|
267
268
|
}
|
|
268
269
|
|
|
269
270
|
/** 其余路径:文件进编辑器(步进),文件夹补文件夹引用。 */
|
|
270
271
|
async function openRest(deps: OpenDeps, sessionId: string | null, params: OpenParams, from: number): Promise<void> {
|
|
271
272
|
const rest = params.paths.slice(from)
|
|
272
|
-
for (const
|
|
273
|
+
for (const path of rest) {
|
|
273
274
|
const kind = await deps.stat(path).catch(() => 'missing' as PathKind)
|
|
274
275
|
if (kind === 'file') {
|
|
275
|
-
|
|
276
|
+
await openChecked(deps, sessionId ?? undefined, path)
|
|
276
277
|
} else if (kind === 'directory' && sessionId) {
|
|
277
278
|
await settleRef(deps, sessionId, path, 'folder')
|
|
279
|
+
} else {
|
|
280
|
+
throw new Error('路径不存在:' + path)
|
|
278
281
|
}
|
|
279
282
|
}
|
|
280
283
|
}
|
|
@@ -316,16 +319,21 @@ function realDeps(ctx: unknown, overrides: Partial<OpenDeps> = {}): OpenDeps {
|
|
|
316
319
|
return outcome === 'ok' || outcome === 'busy'
|
|
317
320
|
},
|
|
318
321
|
choose: (title, folder) => chooseWorkspace(title, folder),
|
|
319
|
-
openEditor:
|
|
320
|
-
if (path == null) {
|
|
321
|
-
openEditorView(null)
|
|
322
|
-
return
|
|
323
|
-
}
|
|
324
|
-
openEditorView(path)
|
|
325
|
-
if (line != null) setTimeout(() => emitOpenAt(path, line, column), LINE_AT_DELAY_MS)
|
|
326
|
-
},
|
|
322
|
+
openEditor: requestOpen,
|
|
327
323
|
schedule: (fn, ms) => setTimeout(fn, ms),
|
|
328
324
|
notify: (text) => toastDom(text),
|
|
329
325
|
}
|
|
330
326
|
return { ...base, ...overrides }
|
|
331
327
|
}
|
|
328
|
+
|
|
329
|
+
/** @private @author ddj 2026年09月28号
|
|
330
|
+
* Await actual editor completion and propagate failure to the request result.
|
|
331
|
+
* @param deps Open dependencies.
|
|
332
|
+
* @param sessionId Target session.
|
|
333
|
+
* @param path Target file or editor mount.
|
|
334
|
+
* @param line Optional line.
|
|
335
|
+
* @param column Optional column.
|
|
336
|
+
*/
|
|
337
|
+
async function openChecked(deps: OpenDeps, sessionId: string | undefined, path: string | null, line?: number, column?: number): Promise<void> {
|
|
338
|
+
if (!(await deps.openEditor(path, line, column, sessionId))) throw new Error('编辑器打开未完成:' + (path ?? '工作区'))
|
|
339
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { openEditorView } from './events.js'
|
|
2
|
+
|
|
3
|
+
export interface EditorRequest { requestId: string; sessionId: string; path: string | null; line?: number; column?: number }
|
|
4
|
+
interface Waiter { request: EditorRequest; finish: (success: boolean) => void; claimed?: boolean }
|
|
5
|
+
const waiters = new Map<string, Waiter>()
|
|
6
|
+
|
|
7
|
+
/** @public @author ddj 2026年09月28号
|
|
8
|
+
* Atomically claim the pending request for this editor's session on mount.
|
|
9
|
+
* @param sessionId Owning session.
|
|
10
|
+
* @returns Unclaimed request, if any.
|
|
11
|
+
*/
|
|
12
|
+
export function peekOpen(sessionId: string): EditorRequest | undefined {
|
|
13
|
+
const row = [...waiters.values()].find((item) => item.request.sessionId === sessionId && !item.claimed)
|
|
14
|
+
if (!row) return undefined
|
|
15
|
+
row.claimed = true
|
|
16
|
+
return row.request
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @public @author ddj 2026年09月28号
|
|
20
|
+
* Complete an actual editor open by its unguessable local request ID.
|
|
21
|
+
* @param requestId Claimed request identity.
|
|
22
|
+
* @param success Whether content and navigation are ready.
|
|
23
|
+
* @param error Optional diagnostic for the caller.
|
|
24
|
+
*/
|
|
25
|
+
export function finishOpen(requestId: string, success: boolean, error?: string): void {
|
|
26
|
+
const row = waiters.get(requestId)
|
|
27
|
+
if (row?.claimed) row.finish(success === true)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @public @author ddj 2026年09月28号
|
|
31
|
+
* Route an editor open and await a correlated model-ready completion.
|
|
32
|
+
* @param path File path, or null to mount the editor.
|
|
33
|
+
* @param line Optional target line.
|
|
34
|
+
* @param column Optional target column.
|
|
35
|
+
* @param sessionId Owning session, required for routing.
|
|
36
|
+
* @returns Actual completion; timeout and disposal are false.
|
|
37
|
+
*/
|
|
38
|
+
export function requestOpen(path: string | null, line?: number, column?: number, sessionId = ''): Promise<boolean> {
|
|
39
|
+
if (typeof window === 'undefined' || !sessionId) return Promise.resolve(false)
|
|
40
|
+
const request: EditorRequest = { requestId: crypto.randomUUID(), sessionId, path, line, column }
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
let timer: ReturnType<typeof setTimeout>
|
|
43
|
+
/** @private @author ddj 2026年09月28号 Complete once and release the deadline. */
|
|
44
|
+
function finish(success: boolean): void {
|
|
45
|
+
clearTimeout(timer)
|
|
46
|
+
waiters.delete(request.requestId)
|
|
47
|
+
resolve(success)
|
|
48
|
+
}
|
|
49
|
+
waiters.set(request.requestId, { request, finish })
|
|
50
|
+
timer = setTimeout(() => finish(false), 20_000)
|
|
51
|
+
openEditorView(null)
|
|
52
|
+
window.dispatchEvent(new CustomEvent('edrv:open-request', { detail: request }))
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @public @author ddj 2026年09月28号
|
|
57
|
+
* Check that an editor receipt is still awaited after timeout or disposal.
|
|
58
|
+
* @param requestId Local request identity.
|
|
59
|
+
* @returns Whether this request may still be completed.
|
|
60
|
+
*/
|
|
61
|
+
export function hasOpen(requestId: string): boolean { return waiters.has(requestId) }
|
|
62
|
+
|
|
63
|
+
/** @public @author ddj 2026年09月28号 Cancel outstanding receipts when the client plugin unloads. */
|
|
64
|
+
export function disposeOpenWait(): void { for (const waiter of [...waiters.values()]) waiter.finish(false) }
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { SessionScope } from './sessionScope.js'
|
|
2
|
+
|
|
3
|
+
export type PanelAction = 'quickOpen' | 'searchInFiles'
|
|
4
|
+
export interface PanelRoute {
|
|
5
|
+
readScope(): SessionScope
|
|
6
|
+
canOpen(): boolean
|
|
7
|
+
open(): boolean
|
|
8
|
+
}
|
|
9
|
+
interface PendingPanel extends SessionScope { action: PanelAction; expires: number }
|
|
10
|
+
interface PanelHost { action: PanelAction; scope: SessionScope; run(): void }
|
|
11
|
+
let route: PanelRoute | null = null
|
|
12
|
+
const hosts = new Set<PanelHost>()
|
|
13
|
+
const storage = globalThis as typeof globalThis & { __edrvPanelCommand__?: PendingPanel }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compare the command's session and workspace identity.
|
|
17
|
+
* @author ddj 2026年09月28号
|
|
18
|
+
* @param left Requested scope.
|
|
19
|
+
* @param right Mounted scope.
|
|
20
|
+
* @returns Whether both refer to the same session and known workspace.
|
|
21
|
+
*/
|
|
22
|
+
function sameScope(left: SessionScope, right: SessionScope): boolean {
|
|
23
|
+
return Boolean(left.sessionId && left.sessionId === right.sessionId
|
|
24
|
+
&& (!left.cwd || !right.cwd || left.cwd === right.cwd))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the most recently mounted matching command receiver.
|
|
29
|
+
* @author ddj 2026年09月28号
|
|
30
|
+
* @param action Panel action.
|
|
31
|
+
* @param scope Active session scope.
|
|
32
|
+
* @returns Receiver if mounted.
|
|
33
|
+
*/
|
|
34
|
+
function panelHost(action: PanelAction, scope: SessionScope): PanelHost | undefined {
|
|
35
|
+
return [...hosts].reverse().find((host) => host.action === action && sameScope(scope, host.scope))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Install the existing sidebar route and current-session reader.
|
|
40
|
+
* @author ddj 2026年09月28号
|
|
41
|
+
* @param next Route capabilities; no side effects during availability checks.
|
|
42
|
+
* @returns Identity-safe disposal; pending intent survives plugin reload.
|
|
43
|
+
*/
|
|
44
|
+
export function setPanelRoute(next: PanelRoute): () => void {
|
|
45
|
+
route = next
|
|
46
|
+
return () => { if (route === next) route = null }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check that a command has a mounted receiver or a usable editor route.
|
|
51
|
+
* @author ddj 2026年09月28号
|
|
52
|
+
* @param action Panel action.
|
|
53
|
+
* @returns False when genuinely unavailable, allowing official key dispatch to pass.
|
|
54
|
+
*/
|
|
55
|
+
export function panelAvailable(action: PanelAction): boolean {
|
|
56
|
+
const scope = route?.readScope()
|
|
57
|
+
if (!scope?.sessionId) return false
|
|
58
|
+
return Boolean(panelHost(action, scope) || route?.canOpen())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Deliver now or preserve an intent until the editor mounts.
|
|
63
|
+
* @author ddj 2026年09月28号
|
|
64
|
+
* @param action Panel action.
|
|
65
|
+
* @returns Whether the mounted host or sidebar accepted the request.
|
|
66
|
+
*/
|
|
67
|
+
export function runPanelCommand(action: PanelAction): boolean {
|
|
68
|
+
const scope = route?.readScope()
|
|
69
|
+
if (!scope?.sessionId) return false
|
|
70
|
+
const host = panelHost(action, scope)
|
|
71
|
+
if (host) { host.run(); return true }
|
|
72
|
+
if (!route?.canOpen()) return false
|
|
73
|
+
const pending = { ...scope, action, expires: Date.now() + 30_000 }
|
|
74
|
+
storage.__edrvPanelCommand__ = pending
|
|
75
|
+
try {
|
|
76
|
+
if (route.open()) return true
|
|
77
|
+
} catch { /* A disappearing sidebar leaves the command unhandled. */ }
|
|
78
|
+
if (storage.__edrvPanelCommand__ === pending) delete storage.__edrvPanelCommand__
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Attach one scoped receiver and consume a still-current pending intent once.
|
|
84
|
+
* @author ddj 2026年09月28号
|
|
85
|
+
* @param action Panel action.
|
|
86
|
+
* @param scope Mounted session/workspace.
|
|
87
|
+
* @param run Action using the mounted component's state.
|
|
88
|
+
* @returns Receiver disposal.
|
|
89
|
+
*/
|
|
90
|
+
export function bindPanelCommand(action: PanelAction, scope: SessionScope, run: () => void): () => void {
|
|
91
|
+
const host = { action, scope, run }
|
|
92
|
+
hosts.add(host)
|
|
93
|
+
const pending = storage.__edrvPanelCommand__
|
|
94
|
+
if (pending && (pending.expires < Date.now() || !sameScope(pending, route?.readScope() ?? {}))) {
|
|
95
|
+
delete storage.__edrvPanelCommand__
|
|
96
|
+
} else if (pending?.action === action && sameScope(pending, scope)) {
|
|
97
|
+
delete storage.__edrvPanelCommand__
|
|
98
|
+
run()
|
|
99
|
+
}
|
|
100
|
+
return () => { hosts.delete(host) }
|
|
101
|
+
}
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* 作者 ddj 2026-09-22
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { appUrl } from '../../shared/appUrl.js'
|
|
11
|
+
|
|
12
|
+
/** vendor 根,保留官方桌面协议及反向代理前缀。 */
|
|
13
|
+
export const PDFJS_BASE = appUrl('/edrv/vendor/pdfjs')
|
|
12
14
|
/** pdf.js Worker 地址(GlobalWorkerOptions.workerSrc)。 */
|
|
13
15
|
export const PDFJS_WORKER_SRC = PDFJS_BASE + '/build/pdf.worker.mjs'
|
|
14
16
|
/** CJK 字符映射目录(中文 PDF 渲染必需,尾斜杠固定)。 */
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { absoluteOf } from './tabActions.js'
|
|
2
|
+
import type { EditorRequest } from './openReceipt.js'
|
|
3
|
+
|
|
4
|
+
/** Identity of one load, committed together with its text or preview state. */
|
|
5
|
+
export interface ReceiptLoad {
|
|
6
|
+
sessionId: string
|
|
7
|
+
cwd: string | null
|
|
8
|
+
path: string
|
|
9
|
+
sequence: number
|
|
10
|
+
status: 'loading' | 'ready' | 'error'
|
|
11
|
+
error?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Current rendered facts; generic save/SVN errors intentionally have no receipt field. */
|
|
15
|
+
export interface ReceiptView {
|
|
16
|
+
sessionId: string
|
|
17
|
+
cwd?: string | null
|
|
18
|
+
active: string | null
|
|
19
|
+
sequence: number
|
|
20
|
+
load: ReceiptLoad | null
|
|
21
|
+
mounted: boolean
|
|
22
|
+
blocked: boolean
|
|
23
|
+
kind: 'text' | 'image' | 'pdf' | 'markdown'
|
|
24
|
+
contentPath: string | null
|
|
25
|
+
contentReady: boolean
|
|
26
|
+
modelMatches: boolean
|
|
27
|
+
positionMatches: boolean
|
|
28
|
+
imageLoaded: boolean
|
|
29
|
+
pdfLoaded: boolean
|
|
30
|
+
engineError?: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize identity using the workspace's path syntax, never the browser OS.
|
|
35
|
+
* @public @author ddj 2026年09月28号
|
|
36
|
+
* @param path File path.
|
|
37
|
+
* @param cwd Workspace root, including remote POSIX roots on Windows clients.
|
|
38
|
+
* @returns Absolute normalized identity, case folded only for Windows paths.
|
|
39
|
+
*/
|
|
40
|
+
export function receiptPath(path: string, cwd?: string | null): string {
|
|
41
|
+
const value = absoluteOf(path, cwd)
|
|
42
|
+
return /^(?:[a-z]:\/|\/\/)/i.test(value) ? value.toLowerCase() : value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Preserve POSIX case when converting an external path into a tab path.
|
|
47
|
+
* @public @author ddj 2026年09月28号
|
|
48
|
+
* @param path External file path.
|
|
49
|
+
* @param cwd Workspace root.
|
|
50
|
+
* @returns Relative path only when it is inside the same correctly cased root.
|
|
51
|
+
*/
|
|
52
|
+
export function receiptTabPath(path: string, cwd?: string | null): string {
|
|
53
|
+
const value = path.replace(/\\/g, '/')
|
|
54
|
+
const base = (cwd ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
55
|
+
if (base && receiptPath(value).startsWith(receiptPath(base) + '/')) return value.slice(base.length + 1)
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Match a load to the current request, rendered path, and live load sequence.
|
|
61
|
+
* @private @author ddj 2026年09月28号
|
|
62
|
+
* @param request Claimed request.
|
|
63
|
+
* @param view Current render and load metadata.
|
|
64
|
+
* @returns Whether load facts belong to this exact target and generation.
|
|
65
|
+
*/
|
|
66
|
+
function ownsLoad(request: EditorRequest, view: ReceiptView): boolean {
|
|
67
|
+
const load = view.load
|
|
68
|
+
if (!request.path || !view.active || !load) return false
|
|
69
|
+
const wanted = receiptPath(request.path, view.cwd)
|
|
70
|
+
return request.sessionId === view.sessionId && load.sessionId === view.sessionId
|
|
71
|
+
&& load.sequence === view.sequence && receiptPath(load.path, load.cwd) === wanted
|
|
72
|
+
&& receiptPath(view.active, view.cwd) === wanted
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Confirm actual content/preview/navigation readiness for a correlated generation.
|
|
77
|
+
* @public @author ddj 2026年09月28号
|
|
78
|
+
* @param request Claimed external open.
|
|
79
|
+
* @param view Rendered state, including model identity and DOM readiness.
|
|
80
|
+
* @returns True only when the requested target is genuinely presented.
|
|
81
|
+
*/
|
|
82
|
+
export function receiptReadyFor(request: EditorRequest, view: ReceiptView): boolean {
|
|
83
|
+
if (request.sessionId !== view.sessionId) return false
|
|
84
|
+
if (!request.path) return view.mounted
|
|
85
|
+
if (!ownsLoad(request, view) || view.load?.status !== 'ready' || view.blocked || view.engineError) return false
|
|
86
|
+
if (view.kind === 'image') return view.imageLoaded
|
|
87
|
+
if (view.kind === 'pdf') return view.pdfLoaded
|
|
88
|
+
if (!view.contentReady || !view.contentPath || receiptPath(view.contentPath, view.cwd) !== receiptPath(request.path, view.cwd)) return false
|
|
89
|
+
const positioned = request.line != null || request.column != null
|
|
90
|
+
if (view.kind === 'markdown') return !positioned
|
|
91
|
+
return view.modelMatches && (!positioned || view.positionMatches)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Return only errors from this target's current load/render generation.
|
|
96
|
+
* @public @author ddj 2026年09月28号
|
|
97
|
+
* @param request Claimed request.
|
|
98
|
+
* @param view Rendered metadata and editor-engine startup failure.
|
|
99
|
+
* @returns Correlated error, otherwise null; prior files and save errors are ignored.
|
|
100
|
+
*/
|
|
101
|
+
export function receiptErrorFor(request: EditorRequest, view: ReceiptView): string | null {
|
|
102
|
+
if (!ownsLoad(request, view)) return null
|
|
103
|
+
if (view.engineError) return view.engineError
|
|
104
|
+
return view.load?.status === 'error' ? view.load.error ?? '文件加载失败' : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Guard delayed load/render callbacks against a replacement generation.
|
|
109
|
+
* @public @author ddj 2026年09月28号
|
|
110
|
+
* @param load Current committed metadata.
|
|
111
|
+
* @param sessionId Callback owner.
|
|
112
|
+
* @param path Callback target, already normalized as a tab path.
|
|
113
|
+
* @param sequence Callback load sequence.
|
|
114
|
+
* @param currentSequence Live loader sequence.
|
|
115
|
+
* @returns Whether this callback may update the receipt status.
|
|
116
|
+
*/
|
|
117
|
+
export function acceptsLoad(load: ReceiptLoad | null, sessionId: string, path: string, sequence: number, currentSequence: number): boolean {
|
|
118
|
+
return Boolean(load && load.sessionId === sessionId && load.path === path
|
|
119
|
+
&& load.sequence === sequence && sequence === currentSequence)
|
|
120
|
+
}
|
package/src/client/rpc.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 诊断开关运行时化:内存缓存 + localStorage 持久(日志弹窗开关即改即生效,免刷新)。
|
|
5
5
|
* 作者 ddj 2026-08-20 / 2026-09-17
|
|
6
6
|
*/
|
|
7
|
+
import { appUrl } from '../shared/appUrl.js'
|
|
7
8
|
import { RPC_PATH } from '../shared/rpc.js'
|
|
8
9
|
import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
|
|
9
10
|
import type { LoggerLevel } from '../shared/logger.js'
|
|
@@ -35,13 +36,38 @@ export function setDebugEnabled(on: boolean): void {
|
|
|
35
36
|
} catch (e) { /* 无 localStorage 环境:仅内存生效 */ }
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Call the authenticated RPC route using the current application base.
|
|
41
|
+
* @author ddj 2026年09月28号
|
|
42
|
+
* @param method Typed business method.
|
|
43
|
+
* @param args Business arguments.
|
|
44
|
+
* @param signal Optional cancellation signal.
|
|
45
|
+
* @returns A validated business result; transport failures reject with a readable error.
|
|
46
|
+
*/
|
|
47
|
+
export async function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M], signal?: AbortSignal): Promise<RpcResult<M>> {
|
|
48
|
+
const res = await fetch(appUrl(RPC_PATH), {
|
|
41
49
|
method: 'POST',
|
|
50
|
+
credentials: 'same-origin',
|
|
42
51
|
headers: { 'content-type': 'application/json' },
|
|
43
52
|
body: JSON.stringify({ method, args }),
|
|
44
|
-
|
|
53
|
+
signal,
|
|
54
|
+
})
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const message = res.status === 401 ? 'DSH 登录已失效,请重新打开已登录的应用页面'
|
|
57
|
+
: res.status === 403 ? 'DSH 拒绝了当前页面的访问'
|
|
58
|
+
: res.status === 413 ? '请求超过支持的文件大小'
|
|
59
|
+
: res.status === 503 ? 'DSH 认证服务尚未就绪'
|
|
60
|
+
: 'DSH 请求失败(HTTP ' + res.status + ')'
|
|
61
|
+
throw new Error(message)
|
|
62
|
+
}
|
|
63
|
+
if (!/^application\/json(?:\s*;|\s*$)/i.test(res.headers.get('content-type') ?? '')) {
|
|
64
|
+
throw new Error('DSH 返回了非 JSON 响应,请检查应用地址和代理配置')
|
|
65
|
+
}
|
|
66
|
+
const value: unknown = await res.json()
|
|
67
|
+
if (!value || typeof value !== 'object' || typeof (value as { ok?: unknown }).ok !== 'boolean') {
|
|
68
|
+
throw new Error('DSH RPC 响应格式不正确')
|
|
69
|
+
}
|
|
70
|
+
return value as RpcResult<M>
|
|
45
71
|
}
|
|
46
72
|
|
|
47
73
|
/**
|