dsh-vscode-mode 0.14.7 → 0.14.9
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 +10 -7
- package/lib/client.js +265 -6
- package/lib/client.js.map +1 -1
- package/lib/index.js +25 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/editorModelState.ts +16 -0
- package/src/client/index.ts +9 -1
- package/src/client/monaco/editActions.ts +58 -0
- package/src/client/openFlow.ts +33 -3
- package/src/client/pageReload.ts +73 -0
- package/src/client/shortcutsOfficial.ts +14 -3
- package/src/client/state/uiStatePersist.ts +22 -0
- package/src/client/ui/EditorView.ts +25 -1
- package/src/client/ui/commandCatalog.ts +16 -0
- package/src/index.ts +9 -1
- package/src/openBridge.ts +13 -0
- package/src/rpc.ts +1 -1
- package/src/shared/keybindings.ts +16 -0
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +58 -4
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +13 -2
- package/unity/com.dsh.editor/package.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.9",
|
|
4
4
|
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
|
|
5
5
|
"icon": "assets/icon.svg",
|
|
6
6
|
"keywords": [
|
|
@@ -19,6 +19,22 @@ export const EDITOR_ROOT_SELECTOR = '.edrv-editor-row'
|
|
|
19
19
|
/** Monaco 编辑器实例选择器(限定在插件编辑器行内,排除官方预览等其它 Monaco)。 */
|
|
20
20
|
export const EDITOR_MODEL_SELECTOR = EDITOR_ROOT_SELECTOR + ' .monaco-editor'
|
|
21
21
|
|
|
22
|
+
/** @public @author ddj 2026年10月12号 @param target Optional shortcut target. @returns Whether focus belongs to plugin code input, excluding widgets and read-only editors. */
|
|
23
|
+
export function hasCodeFocus(target?: unknown): boolean {
|
|
24
|
+
if (typeof document === 'undefined') return false
|
|
25
|
+
const node = target ?? document.activeElement
|
|
26
|
+
if (!(node instanceof Element)) return false
|
|
27
|
+
const editor = node.closest(EDITOR_MODEL_SELECTOR)
|
|
28
|
+
if (!editor || editor.classList.contains('readonly')) return false
|
|
29
|
+
if (node.closest('.find-widget, .suggest-widget, .rename-box, .monaco-inputbox')) return false
|
|
30
|
+
const host = typeof window === 'undefined' ? null : window as unknown as {
|
|
31
|
+
monaco?: { editor?: { getEditors?(): Array<{ getDomNode(): unknown; hasTextFocus(): boolean; getModel(): unknown }> } }
|
|
32
|
+
}
|
|
33
|
+
return host?.monaco?.editor?.getEditors?.().some((item) =>
|
|
34
|
+
item.getDomNode() === editor && item.hasTextFocus() && Boolean(item.getModel()),
|
|
35
|
+
) ?? false
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
/**
|
|
23
39
|
* 已打开文件页签选择器(页签栏在编辑器行内;空态时无此节点)。
|
|
24
40
|
* 判据同样不依赖 Monaco:图片/PDF 页签没有 Monaco 实例,但仍是「有页签可关」。
|
package/src/client/index.ts
CHANGED
|
@@ -65,7 +65,8 @@ import { disposeLaunchJson } from './dap/launchSnippetProvider.js'
|
|
|
65
65
|
import { disposeAiInline } from './ai/inlineProvider.js'
|
|
66
66
|
import { readSessionScope, subscribeScope } from './sessionScope.js'
|
|
67
67
|
import { installPlanBridge } from './planBridge.js'
|
|
68
|
-
import {
|
|
68
|
+
import { installPageReload } from './pageReload.js'
|
|
69
|
+
import { flushPageState, flushUiState, hydrateUiState, installUiStatePersist, onUiStateReady, reportUiStateEvent, uiStateSettled } from './state/uiStatePersist.js'
|
|
69
70
|
import { workspaceScopeOf } from './state/scopeStore.js'
|
|
70
71
|
import { dapStore } from './dap/store.js'
|
|
71
72
|
import type { CompatAdapter } from '../shared/compat.js'
|
|
@@ -185,6 +186,13 @@ export async function apply(ctx: any): Promise<void> {
|
|
|
185
186
|
|
|
186
187
|
// 指令系统(命令栏 + 指令注册表)先装配:命令栏在被任何 React slot 渲染前即可唤起
|
|
187
188
|
const commands = setupCommands(ctx)
|
|
189
|
+
/** @private @author ddj 2026年10月12号 @param message Refresh refusal; surfaced in mounted editor status and diagnostic log. */
|
|
190
|
+
const notifyReload = (message: string): void => {
|
|
191
|
+
log.warn(message)
|
|
192
|
+
window.dispatchEvent(new CustomEvent('edrv:reload-status', { detail: message }))
|
|
193
|
+
window.alert(message)
|
|
194
|
+
}
|
|
195
|
+
ctx.effect(() => installPageReload(flushPageState, notifyReload), 'vscode-mode: guarded page reload')
|
|
188
196
|
|
|
189
197
|
const registry: FileOpenerRegistry = createFileOpenerRegistry()
|
|
190
198
|
const workspaces = ctx.get('workspaces')
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Plugin-local Monaco editing bindings. @author ddj 2026年10月12号 */
|
|
2
|
+
export interface EditTarget {
|
|
3
|
+
getModel(): unknown
|
|
4
|
+
getOption(option: number): unknown
|
|
5
|
+
hasTextFocus(): boolean
|
|
6
|
+
focus(): void
|
|
7
|
+
trigger(source: string, action: string, payload: null): void
|
|
8
|
+
createContextKey(name: string, value: boolean): { reset(): void }
|
|
9
|
+
onDidDispose(listener: () => void): { dispose(): void }
|
|
10
|
+
addAction(action: { id: string; label: string; precondition: string; contextMenuGroupId: string; run(): void }): { dispose(): void }
|
|
11
|
+
}
|
|
12
|
+
export interface EditMonaco {
|
|
13
|
+
KeyMod: { CtrlCmd: number }
|
|
14
|
+
KeyCode: { Slash: number; KeyD: number }
|
|
15
|
+
editor: {
|
|
16
|
+
EditorOption: { readOnly: number }
|
|
17
|
+
addKeybindingRules(rules: Array<{ keybinding: number; command: string; when: string }>): { dispose(): void }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @public @author ddj 2026年10月12号 @param editor Target editor. @param monaco Monaco API. @param action Native action. @returns Whether a writable model accepted the action. @sideEffects Restores focus and runs an undoable native edit. */
|
|
22
|
+
export function runEditAction(editor: EditTarget | null, monaco: EditMonaco | null, action: string): boolean {
|
|
23
|
+
if (!editor?.getModel() || !monaco || editor.getOption(monaco.editor.EditorOption.readOnly)) return false
|
|
24
|
+
editor.focus()
|
|
25
|
+
editor.trigger('edrv-edit', action, null)
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @public @author ddj 2026年10月12号 @param editor Plugin editor. @param monaco Monaco API. @returns Idempotent disposer. @sideEffects Overrides only plugin-local bindings and menu entries until editor disposal. */
|
|
30
|
+
export function bindEditActions(editor: EditTarget, monaco: EditMonaco): () => void {
|
|
31
|
+
const local = editor.createContextKey('edrvLocalEdit', true)
|
|
32
|
+
const focus = 'edrvLocalEdit && editorTextFocus'
|
|
33
|
+
const writable = focus + ' && !editorReadonly'
|
|
34
|
+
const rules = monaco.editor.addKeybindingRules([
|
|
35
|
+
{ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.Slash, command: '', when: focus },
|
|
36
|
+
{ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD, command: 'editor.action.copyLinesDownAction', when: writable },
|
|
37
|
+
])
|
|
38
|
+
/** @private @author ddj 2026年10月12号 Runs the native language-aware comment action. */
|
|
39
|
+
const comment = (): void => { runEditAction(editor, monaco, 'editor.action.commentLine') }
|
|
40
|
+
/** @private @author ddj 2026年10月12号 Runs the native multi-selection line-copy action. */
|
|
41
|
+
const duplicate = (): void => { runEditAction(editor, monaco, 'editor.action.copyLinesDownAction') }
|
|
42
|
+
const actions = [
|
|
43
|
+
editor.addAction({ id: 'edrv.toggleComment', label: '切换行注释(Ctrl+Alt+/)', precondition: writable, contextMenuGroupId: '1_edrv', run: comment }),
|
|
44
|
+
editor.addAction({ id: 'edrv.duplicateLine', label: '向下复制行(Ctrl+D,编辑器本地)', precondition: writable, contextMenuGroupId: '1_edrv', run: duplicate }),
|
|
45
|
+
]
|
|
46
|
+
let disposed = false
|
|
47
|
+
/** @private @author ddj 2026年10月12号 Releases global rule contributions owned by this instance. */
|
|
48
|
+
const dispose = (): void => {
|
|
49
|
+
if (disposed) return
|
|
50
|
+
disposed = true
|
|
51
|
+
rules.dispose()
|
|
52
|
+
for (const action of actions) action.dispose()
|
|
53
|
+
local.reset()
|
|
54
|
+
lifetime.dispose()
|
|
55
|
+
}
|
|
56
|
+
const lifetime = editor.onDidDispose(dispose)
|
|
57
|
+
return dispose
|
|
58
|
+
}
|
package/src/client/openFlow.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { rpc } from './rpc.js'
|
|
|
10
10
|
import { chooseWorkspace } from './chooseDialog.js'
|
|
11
11
|
import { requestOpen } from './openReceipt.js'
|
|
12
12
|
import { toastDom } from './externalOpen.js'
|
|
13
|
-
import { createAddToConversation } from './addToConversation.js'
|
|
13
|
+
import { createAddToConversation, newDraftSession } from './addToConversation.js'
|
|
14
14
|
import type { AddOutcome, CtxLike } from './addToConversation.js'
|
|
15
15
|
import type { OpenParams } from '../shared/externalOpen.js'
|
|
16
16
|
|
|
@@ -307,12 +307,42 @@ function realDeps(ctx: unknown, overrides: Partial<OpenDeps> = {}): OpenDeps {
|
|
|
307
307
|
const snap = (service('sessions')?.list?.getSnapshot?.() ?? {}) as { ids?: string[]; byId?: Record<string, SessionRow>; phase?: string }
|
|
308
308
|
return { ids: snap.ids ?? [], byId: snap.byId ?? {}, ready: snap.phase !== 'pending' }
|
|
309
309
|
},
|
|
310
|
-
createSession: async (opts) =>
|
|
310
|
+
createSession: async (opts) => {
|
|
311
|
+
// 桌面端 sessions 服务面缺 create(实证缺 open;create 同代面可能缺)——
|
|
312
|
+
// 直连 create 不可用时回落 newDraftSession 的多通道建会话(宽松解析 id)。
|
|
313
|
+
const sessions = service('sessions') as { create?: (o: object) => unknown } | undefined
|
|
314
|
+
if (typeof sessions?.create === 'function') {
|
|
315
|
+
try {
|
|
316
|
+
const created = await sessions.create(opts)
|
|
317
|
+
const id = typeof created === 'string' && created ? created
|
|
318
|
+
: typeof (created as { id?: unknown } | null)?.id === 'string' ? (created as { id: string }).id
|
|
319
|
+
: typeof (created as { sessionId?: unknown } | null)?.sessionId === 'string' ? (created as { sessionId: string }).sessionId
|
|
320
|
+
: ''
|
|
321
|
+
if (id) return id
|
|
322
|
+
} catch { /* 回落下一通道 */ }
|
|
323
|
+
}
|
|
324
|
+
const draft = await newDraftSession(ctx as CtxLike, opts.cwd)
|
|
325
|
+
if (draft.ok && draft.id) return draft.id
|
|
326
|
+
throw new Error('新建会话失败')
|
|
327
|
+
},
|
|
311
328
|
createWorkspace: async (path) => {
|
|
312
329
|
const ws = await service('workspaces').create({ path })
|
|
313
330
|
return { workspaceId: ws.workspaceId, title: ws.title }
|
|
314
331
|
},
|
|
315
|
-
openSession: (id) =>
|
|
332
|
+
openSession: (id) => {
|
|
333
|
+
// 桌面端 sessions 服务面缺 .open(实证 TypeError)——按 startDraftSession 同款
|
|
334
|
+
// 多面切换:materializeScope 铸 scope → manager.select 切会话 → open 兜底,缺面静默。
|
|
335
|
+
try {
|
|
336
|
+
const sessions = service('sessions') as {
|
|
337
|
+
materializeScope?: (id: string) => unknown
|
|
338
|
+
manager?: { select?: (id: string) => void }
|
|
339
|
+
open?: (id: string) => void
|
|
340
|
+
} | undefined
|
|
341
|
+
sessions?.materializeScope?.(id)
|
|
342
|
+
void sessions?.manager?.select?.(id)
|
|
343
|
+
void sessions?.open?.(id)
|
|
344
|
+
} catch { /* 面能力缺失不致命:打开失败由 openChecked 抛错兜底 */ }
|
|
345
|
+
},
|
|
316
346
|
reference: async (sessionId, path, appearance) => {
|
|
317
347
|
const outcome: AddOutcome = await add.appendReference(sessionId, path, undefined, appearance)
|
|
318
348
|
// ok/busy(纯文本降级)都算已落点;unavailable/failed 交给 settleRef 继续重试
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Guarded page-only reload; never restarts Host. @author ddj 2026年10月12号 */
|
|
2
|
+
export interface ReloadGuard { check(): string | null; prepare(): void }
|
|
3
|
+
export interface ReloadDeps {
|
|
4
|
+
flush(): Promise<void>
|
|
5
|
+
reload(): void
|
|
6
|
+
notify(message: string): void
|
|
7
|
+
timeoutMs?: number
|
|
8
|
+
}
|
|
9
|
+
const guards = new Set<ReloadGuard>()
|
|
10
|
+
|
|
11
|
+
/** @public @author ddj 2026年10月12号 @param guard Live editor checks and view-state capture. @returns Unregister function. */
|
|
12
|
+
export function addReloadGuard(guard: ReloadGuard): () => void {
|
|
13
|
+
guards.add(guard)
|
|
14
|
+
return () => { guards.delete(guard) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @private @author ddj 2026年10月12号 @param checks Current editor guards. @returns First unsafe state, or null. */
|
|
18
|
+
function reloadBlock(checks: Iterable<ReloadGuard>): string | null {
|
|
19
|
+
for (const guard of checks) {
|
|
20
|
+
const reason = guard.check()
|
|
21
|
+
if (reason) return reason
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @public @author ddj 2026年10月12号 @param deps Page operations. @param checks Live editor guards. @returns Serialized request and cancellation. @sideEffects Flushes view state then reloads only when still safe. */
|
|
27
|
+
export function createPageReload(deps: ReloadDeps, checks: Iterable<ReloadGuard> = guards) {
|
|
28
|
+
let busy = false
|
|
29
|
+
let disposed = false
|
|
30
|
+
/** @private @author ddj 2026年10月12号 @returns Completion of one guarded reload attempt. */
|
|
31
|
+
async function request(): Promise<void> {
|
|
32
|
+
if (busy || disposed) return
|
|
33
|
+
busy = true
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
35
|
+
try {
|
|
36
|
+
const reason = reloadBlock(checks)
|
|
37
|
+
if (reason) { deps.notify('未刷新:' + reason); return }
|
|
38
|
+
for (const guard of checks) guard.prepare()
|
|
39
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
40
|
+
timer = setTimeout(() => reject(new Error('界面状态保存超时')), deps.timeoutMs ?? 5000)
|
|
41
|
+
})
|
|
42
|
+
await Promise.race([deps.flush(), timeout])
|
|
43
|
+
if (disposed) return
|
|
44
|
+
const changed = reloadBlock(checks)
|
|
45
|
+
if (changed) { deps.notify('未刷新:' + changed); return }
|
|
46
|
+
deps.reload()
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!disposed) deps.notify('未刷新:' + String(error))
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer)
|
|
51
|
+
busy = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { request, dispose: () => { disposed = true } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @public @author ddj 2026年10月12号 @returns Whether no live modal blocks page commands. */
|
|
58
|
+
export function pageKeysAllowed(): boolean {
|
|
59
|
+
if (typeof document === 'undefined') return true
|
|
60
|
+
return document.querySelector('[role="dialog"], [aria-modal="true"]') === null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @public @author ddj 2026年10月12号 @param flush Durable UI-state flush. @param notify User-visible failure sink. @returns Root-scope listener disposer. */
|
|
64
|
+
export function installPageReload(flush: () => Promise<void>, notify: (message: string) => void): () => void {
|
|
65
|
+
const controller = createPageReload({ flush, notify, reload: () => window.location.reload() })
|
|
66
|
+
/** @private @author ddj 2026年10月12号 Ignores modal-blocked commands even from programmatic callers. */
|
|
67
|
+
const onReload = (): void => { if (pageKeysAllowed()) void controller.request() }
|
|
68
|
+
window.addEventListener('edrv.command.reloadWindow', onReload)
|
|
69
|
+
return () => {
|
|
70
|
+
controller.dispose()
|
|
71
|
+
window.removeEventListener('edrv.command.reloadWindow', onReload)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -11,6 +11,15 @@ import { bindingToChord, chordToBinding, defaultKeybindings, normalizeKeybinding
|
|
|
11
11
|
import { applyKeyAria, matchEvent, parseChords } from './keybindings.js'
|
|
12
12
|
import type { OfficialBinding, ShortcutProfileKey } from '../shared/keybindings.js'
|
|
13
13
|
import { log } from './log.js'
|
|
14
|
+
import { hasCodeFocus } from './editorModelState.js'
|
|
15
|
+
import { pageKeysAllowed } from './pageReload.js'
|
|
16
|
+
|
|
17
|
+
/** @private @author ddj 2026年10月12号 @param id Command id. @param target Shortcut target. @returns Whether new page commands can safely run from this context. */
|
|
18
|
+
function shortcutAllowed(id: string, target?: unknown): boolean {
|
|
19
|
+
if (id === 'edrv.toggleComment' || id === 'edrv.duplicateLine') return pageKeysAllowed() && hasCodeFocus(target)
|
|
20
|
+
if (id === 'edrv.reloadWindow') return pageKeysAllowed()
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
const bridgeLog = log.child('shortcuts-official')
|
|
16
25
|
|
|
@@ -197,6 +206,7 @@ export function installLegacyKeys(
|
|
|
197
206
|
const onKey = (event: Event): void => {
|
|
198
207
|
if (disposed) return
|
|
199
208
|
const key = event as KeyboardEvent
|
|
209
|
+
if (key.isComposing || key.keyCode === 229 || key.defaultPrevented) return
|
|
200
210
|
const current = chords()
|
|
201
211
|
for (const def of defs) {
|
|
202
212
|
const chord = current[def.id]
|
|
@@ -206,7 +216,8 @@ export function installLegacyKeys(
|
|
|
206
216
|
bindings = parseChords(chord)
|
|
207
217
|
cache.set(chord, bindings)
|
|
208
218
|
}
|
|
209
|
-
if (
|
|
219
|
+
if (def.id === 'edrv.duplicateLine') continue // Monaco owns Ctrl+D; no global duplicate dispatch.
|
|
220
|
+
if (!matchEvent(key, bindings) || !shortcutAllowed(def.id, key.target) || !runner.isAvailable(def.id)) continue
|
|
210
221
|
key.preventDefault()
|
|
211
222
|
key.stopPropagation()
|
|
212
223
|
runner.run(def.id)
|
|
@@ -239,8 +250,8 @@ export function officialCommandOf(def: { id: string; label: string }, runner: Co
|
|
|
239
250
|
defaults: undefined,
|
|
240
251
|
regions: ['page', 'editable'],
|
|
241
252
|
modals: [],
|
|
242
|
-
resolve: () => {
|
|
243
|
-
if (!runner.isAvailable(def.id)) return { status: 'pass' }
|
|
253
|
+
resolve: (context) => {
|
|
254
|
+
if (context.modal !== null || !shortcutAllowed(def.id, context.target) || !runner.isAvailable(def.id)) return { status: 'pass' }
|
|
244
255
|
return {
|
|
245
256
|
status: 'handled',
|
|
246
257
|
run: () => {
|
|
@@ -101,6 +101,8 @@ export interface UiStateMirror {
|
|
|
101
101
|
hydrate(): Promise<number>
|
|
102
102
|
/** 立即上送待推送批次,返回最终丢弃的键数(0 = 全部落盘;供卸载与测试驱动)。 */
|
|
103
103
|
flush(): Promise<number>
|
|
104
|
+
/** Refresh-only strict resend; rejects on lost current state. */
|
|
105
|
+
flushStrict(): Promise<void>
|
|
104
106
|
/** 是否已完成一次水合。 */
|
|
105
107
|
hydrated(): boolean
|
|
106
108
|
/** 水合阶段是否已结束(成功或重试耗尽)。未结束时界面不应写入存档,否则会把空初值推给 host。 */
|
|
@@ -269,6 +271,19 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
|
|
|
269
271
|
return run
|
|
270
272
|
}
|
|
271
273
|
|
|
274
|
+
/** @private @author ddj 2026年10月12号 @returns Durable refresh snapshot. @throws When state is unreadable, unsettled or rejected by Host. @sideEffects Resends current local state after waiting for inflight writes. */
|
|
275
|
+
async function flushStrict(): Promise<void> {
|
|
276
|
+
if (!settleDone || !installed) throw new Error('界面状态镜像尚未就绪')
|
|
277
|
+
if (inflight) await inflight
|
|
278
|
+
// Read directly: the lenient key scanner intentionally hides storage errors.
|
|
279
|
+
for (let index = 0; index < storage.length; index++) {
|
|
280
|
+
const key = storage.key(index)
|
|
281
|
+
if (key && isMirroredKey(key)) queue(key, storage.getItem(key))
|
|
282
|
+
}
|
|
283
|
+
const lost = await flush()
|
|
284
|
+
if (lost > 0) throw new Error('界面状态保存失败(' + lost + ' 个键):' + lastError)
|
|
285
|
+
}
|
|
286
|
+
|
|
272
287
|
/** 回填本地缺失键(本地已有值一律保留)。 */
|
|
273
288
|
function applyHydration(snapshot: UiStateSnapshot): number {
|
|
274
289
|
let filled = 0
|
|
@@ -393,6 +408,7 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
|
|
|
393
408
|
},
|
|
394
409
|
hydrate,
|
|
395
410
|
flush,
|
|
411
|
+
flushStrict,
|
|
396
412
|
hydrated(): boolean {
|
|
397
413
|
return done
|
|
398
414
|
},
|
|
@@ -559,4 +575,10 @@ export function onUiStateReady(listener: () => void): () => void {
|
|
|
559
575
|
export async function flushUiState(): Promise<void> {
|
|
560
576
|
if (singleton) await singleton.flush()
|
|
561
577
|
}
|
|
578
|
+
|
|
579
|
+
/** @public @author ddj 2026年10月12号 @returns Durable page state. @throws If the mirror is unavailable or cannot persist. */
|
|
580
|
+
export async function flushPageState(): Promise<void> {
|
|
581
|
+
if (!singleton) throw new Error('界面状态镜像不可用')
|
|
582
|
+
await singleton.flushStrict()
|
|
583
|
+
}
|
|
562
584
|
// --endregion
|
|
@@ -14,6 +14,8 @@ import { bindPanelCommand } from '../panelCommands.js'
|
|
|
14
14
|
import { dbg, isDebug, rpc } from '../rpc.js'
|
|
15
15
|
import { emitFileChanged, emitRefresh } from '../events.js'
|
|
16
16
|
import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
|
|
17
|
+
import { bindEditActions, runEditAction } from '../monaco/editActions.js'
|
|
18
|
+
import { addReloadGuard } from '../pageReload.js'
|
|
17
19
|
import { clampZoom, clearZoomMem, dataUrlOf, isImagePath, isSvgPath, recallZoom, rememberZoom, zoomKeyOf, zoomStepOf } from '../imagePreview.js'
|
|
18
20
|
import { isMarkdownPath } from '../markdownPreview.js'
|
|
19
21
|
import { setActiveEditorPath } from '../activePathStore.js'
|
|
@@ -565,6 +567,24 @@ export function EditorView(props) {
|
|
|
565
567
|
const saveViewState = (path) => viewKeeperRef.current?.save(path)
|
|
566
568
|
saveViewStateRef.current = saveViewState
|
|
567
569
|
|
|
570
|
+
React.useEffect(() => {
|
|
571
|
+
/** @private @author ddj 2026年10月12号 @returns Unsafe editor state without saving or discarding file content. */
|
|
572
|
+
function checkReload() {
|
|
573
|
+
if (Object.values(dirtyRef.current).some(Boolean)) return '存在未保存文件'
|
|
574
|
+
if (saveTimerRef.current.isPending() || savingRef.current.size) return '文件正在等待保存或保存中'
|
|
575
|
+
for (const ctl of pdfCtlRef.current.values()) {
|
|
576
|
+
if (ctl.isDirty?.()) return '存在未保存 PDF'
|
|
577
|
+
}
|
|
578
|
+
for (const path of tabPathsRef.current) {
|
|
579
|
+
if (readSync(scope, path)) return '存在外部文件冲突'
|
|
580
|
+
}
|
|
581
|
+
return null
|
|
582
|
+
}
|
|
583
|
+
/** @private @author ddj 2026年10月12号 Captures view state only; never flushes file auto-save. */
|
|
584
|
+
function prepareReload() { saveViewStateRef.current?.() }
|
|
585
|
+
return addReloadGuard({ check: checkReload, prepare: prepareReload })
|
|
586
|
+
}, [scope])
|
|
587
|
+
|
|
568
588
|
/** @private @author ddj 2026年10月09号 ref 脱离与 effect 卸载共用:保存视图/查找后销毁,重复调用无副作用。 */
|
|
569
589
|
const disposeEditor = React.useCallback(() => {
|
|
570
590
|
viewKeeperRef.current?.dispose()
|
|
@@ -1690,6 +1710,9 @@ export function EditorView(props) {
|
|
|
1690
1710
|
['edrv.command.svnTortoiseDiff', () => runSvnTortoise('diff', activeRef.current)],
|
|
1691
1711
|
['edrv.command.svnTortoiseBlame', () => runSvnTortoise('blame', activeRef.current)],
|
|
1692
1712
|
['edrv.command.svnTortoiseRevert', () => runSvnTortoise('revert', activeRef.current)],
|
|
1713
|
+
['edrv:reload-status', (event) => setStatus(event.detail)],
|
|
1714
|
+
['edrv.command.toggleComment', () => runEditAction(editorRef.current, monacoRef.current, 'editor.action.commentLine')],
|
|
1715
|
+
['edrv.command.duplicateLine', () => runEditAction(editorRef.current, monacoRef.current, 'editor.action.copyLinesDownAction')],
|
|
1693
1716
|
// 转到行:转发 Monaco 原生 action(widget 本体/占位/跳转全原生,插件只补键位与命令栏入口)
|
|
1694
1717
|
['edrv.command.goToLine', () => {
|
|
1695
1718
|
const ed = editorRef.current
|
|
@@ -1702,7 +1725,7 @@ export function EditorView(props) {
|
|
|
1702
1725
|
const onCommand = (event) => {
|
|
1703
1726
|
const action = byName.get(event.type)
|
|
1704
1727
|
if (!action) return
|
|
1705
|
-
try { action() } catch (error) { dbg(sessionId, '指令 ' + event.type + ' 失败:' + String(error)) }
|
|
1728
|
+
try { action(event) } catch (error) { dbg(sessionId, '指令 ' + event.type + ' 失败:' + String(error)) }
|
|
1706
1729
|
}
|
|
1707
1730
|
for (const [eventName] of handlers) window.addEventListener(eventName, onCommand)
|
|
1708
1731
|
return () => {
|
|
@@ -2157,6 +2180,7 @@ export function EditorView(props) {
|
|
|
2157
2180
|
// AI 内联补全(ghost text):开关经 edrv.ai.configGet 拉取;provider 见 client/ai/inlineProvider
|
|
2158
2181
|
inlineSuggest: { enabled: true },
|
|
2159
2182
|
})
|
|
2183
|
+
bindEditActions(ed, m)
|
|
2160
2184
|
// 保存改由窗口级快捷键监听执行(键位可配置;见上方 edrv.save 监听)
|
|
2161
2185
|
ed.onDidChangeModelContent(() => {
|
|
2162
2186
|
if (!ed.getModel() || programmaticRef.current) return
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* 作者 ddj 2026年09月10号
|
|
8
8
|
*/
|
|
9
9
|
import { panelAvailable, runPanelCommand } from '../panelCommands.js'
|
|
10
|
+
import { pageKeysAllowed } from '../pageReload.js'
|
|
10
11
|
import { hasEditorModel, hasOpenTabs } from '../editorModelState.js'
|
|
11
12
|
import { activeEditorPath } from '../activePathStore.js'
|
|
12
13
|
import { isMarkdownPath } from '../markdownPreview.js'
|
|
@@ -195,6 +196,21 @@ function svnEventOf(action: SvnActionDef): string {
|
|
|
195
196
|
* @author ddj 2026年09月10号 / 2026年10月
|
|
196
197
|
*/
|
|
197
198
|
export const EDITOR_COMMANDS: readonly CommandDef[] = [
|
|
199
|
+
{
|
|
200
|
+
id: 'edrv.toggleComment', label: '切换行注释', category: '编辑', order: 20,
|
|
201
|
+
available: needsModel,
|
|
202
|
+
run: () => emit('toggleComment'),
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'edrv.duplicateLine', label: '向下复制行(Ctrl+D,编辑器本地)', category: '编辑', order: 30,
|
|
206
|
+
available: needsModel,
|
|
207
|
+
run: () => emit('duplicateLine'),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'edrv.reloadWindow', label: '刷新当前界面(不重启 Host)', category: '视图', order: 40,
|
|
211
|
+
available: pageKeysAllowed,
|
|
212
|
+
run: () => emit('reloadWindow'),
|
|
213
|
+
},
|
|
198
214
|
{
|
|
199
215
|
id: 'edrv.save', label: '保存文件', category: '文件', order: 10,
|
|
200
216
|
available: needsModel,
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { registerRoutes } from './routes.js'
|
|
3
3
|
import { captureToolResult } from './capture.js'
|
|
4
4
|
import { closeArchiveDbs } from './archiveDb.js'
|
|
5
|
-
import { handleRpc } from './rpc.js'
|
|
5
|
+
import { handleRpc, integrationBaseUrlOf } from './rpc.js'
|
|
6
6
|
import { newSearcher } from './search/orchestrator.js'
|
|
7
7
|
import { newContentSearcher } from './search/content.js'
|
|
8
8
|
import { installIsolation } from './mcpIsolation.js'
|
|
@@ -13,6 +13,7 @@ import type { SettingsDeps } from './fileOpenSettings.js'
|
|
|
13
13
|
import z from '@deepseek-ai/schemastery'
|
|
14
14
|
import { shellMenuLifecycle } from './integrate.js'
|
|
15
15
|
import { startOpenInbox } from './openInbox.js'
|
|
16
|
+
import { ensureActiveBridge } from './openBridge.js'
|
|
16
17
|
import { installPlanTool } from './svnPlanTool.js'
|
|
17
18
|
import { disposeIndex } from './treeIndex.js'
|
|
18
19
|
import { sweepTreeCache } from './paths.js'
|
|
@@ -142,6 +143,13 @@ export async function apply(ctx: Ctx, config?: unknown): Promise<void> {
|
|
|
142
143
|
await installPlanTool(ctx, submitPlan)
|
|
143
144
|
ctx.effect(mountInbox, 'vscode-mode:open-inbox')
|
|
144
145
|
ctx.effect(() => shellMenuLifecycle(ctx), 'vscode-mode:integration')
|
|
146
|
+
/** @author ddj 2026年10月12号 @returns Active-profile bridge disposer; failures never block boot. */
|
|
147
|
+
async function provisionBridge(): Promise<() => void> {
|
|
148
|
+
try { await ensureActiveBridge(ctx, await integrationBaseUrlOf(ctx)) }
|
|
149
|
+
catch (error) { noteWarning('外部打开桥接未就绪:' + String(error)) }
|
|
150
|
+
return () => {}
|
|
151
|
+
}
|
|
152
|
+
ctx.effect(provisionBridge, 'vscode-mode:bridge')
|
|
145
153
|
void sweepTreeCache()
|
|
146
154
|
await logCompatSummary(ctx, warnings)
|
|
147
155
|
log.info('编辑差异审查已装配(认证路由、项目 MCP 作用域、SVN 方案工具与外部打开通道)')
|
package/src/openBridge.ts
CHANGED
|
@@ -56,6 +56,19 @@ export async function ensureOpenBridge(ctx: Ctx, baseUrl: string): Promise<strin
|
|
|
56
56
|
return join(dir, 'dsh-open.ini')
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** @public @author ddj 2026年10月12号
|
|
60
|
+
* Provision the active profile's independent bridge at startup (desktop or web).
|
|
61
|
+
* Mirrors unityInstall's per-profile bridge without requiring a registered project,
|
|
62
|
+
* so Unity can select either target. Unknown profile returns null instead of failing.
|
|
63
|
+
* @param ctx Active host context.
|
|
64
|
+
* @param baseUrl Selected public Web URL (unused for desktop wake).
|
|
65
|
+
* @returns Absolute profile bridge INI path, or null when no owned profile is active.
|
|
66
|
+
*/
|
|
67
|
+
export async function ensureActiveBridge(ctx: Ctx, baseUrl: string): Promise<string | null> {
|
|
68
|
+
if (!findProfileDir(ctx)) return null
|
|
69
|
+
return ensureOpenBridge(ctx, baseUrl)
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
/** @private @author ddj 2026年09月28号
|
|
60
73
|
* Replace a bridge file atomically without following a preexisting file link.
|
|
61
74
|
* @param path Destination in the validated bridge directory.
|
package/src/rpc.ts
CHANGED
|
@@ -461,7 +461,7 @@ async function latestPatchBackup(patchPath: string): Promise<string | undefined>
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
/** 读取设置中的深链基址(缺省/非法回退默认 3080)。 */
|
|
464
|
-
async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
|
|
464
|
+
export async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
|
|
465
465
|
const settings = ctx.get('settings')
|
|
466
466
|
const value = sectionOf(settings, FILE_OPEN_SETTINGS_NS)?.value as { integrationBaseUrl?: unknown } | undefined
|
|
467
467
|
const raw = typeof value?.integrationBaseUrl === 'string' ? value.integrationBaseUrl.trim() : ''
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
/** 命令 id → 默认键位(可为多候选)。命令目录以此为准,新增命令只需加一项。 */
|
|
14
14
|
export const KEYBINDING_DEFAULTS: Record<string, string> = {
|
|
15
15
|
'edrv.save': 'Ctrl+S',
|
|
16
|
+
'edrv.toggleComment': 'Ctrl+Alt+/',
|
|
17
|
+
'edrv.duplicateLine': '', // Ctrl+D is Monaco-local, never a page-wide binding.
|
|
18
|
+
'edrv.reloadWindow': '', // Desktop-only defaults are declared below.
|
|
16
19
|
'edrv.quickOpen': 'Ctrl+P',
|
|
17
20
|
'edrv.toggleSidebar': 'Ctrl+B',
|
|
18
21
|
'edrv.searchInFiles': 'Ctrl+Shift+F',
|
|
@@ -206,6 +209,19 @@ function bind(code: string, modifiers: OfficialModifier[]): OfficialBinding {
|
|
|
206
209
|
*/
|
|
207
210
|
export const SHORTCUT_PROFILES: Record<string, ShortcutProfiles> = {
|
|
208
211
|
'edrv.toggleSidebar': {},
|
|
212
|
+
'edrv.duplicateLine': {},
|
|
213
|
+
'edrv.toggleComment': {
|
|
214
|
+
'desktop:macos': bind('Slash', ['primary', 'alt']),
|
|
215
|
+
'desktop:windows': bind('Slash', ['primary', 'alt']),
|
|
216
|
+
'desktop:linux': bind('Slash', ['primary', 'alt']),
|
|
217
|
+
'web:macos': bind('Slash', ['primary', 'alt']),
|
|
218
|
+
'web:windows': bind('Slash', ['primary', 'alt']),
|
|
219
|
+
},
|
|
220
|
+
'edrv.reloadWindow': {
|
|
221
|
+
'desktop:macos': bind('KeyR', ['primary', 'shift']),
|
|
222
|
+
'desktop:windows': bind('KeyR', ['primary', 'shift']),
|
|
223
|
+
'desktop:linux': bind('KeyR', ['primary', 'shift']),
|
|
224
|
+
},
|
|
209
225
|
'edrv.save': {
|
|
210
226
|
'desktop:macos': bind('KeyS', ['primary']),
|
|
211
227
|
'desktop:windows': bind('KeyS', ['primary']),
|
|
@@ -15,8 +15,13 @@ namespace Dsh.EditorIntegration
|
|
|
15
15
|
{
|
|
16
16
|
private const string EditorName = "DSH 文件编辑";
|
|
17
17
|
private const string VirtualPath = "dsh-editor://vscode-mode";
|
|
18
|
-
private const string DshVersion = "0.3.
|
|
18
|
+
private const string DshVersion = "0.3.1";
|
|
19
19
|
internal const string VersionText = DshVersion;
|
|
20
|
+
private const string TargetAuto = "auto";
|
|
21
|
+
private const string TargetWeb = "web";
|
|
22
|
+
private const string TargetDesktop = "desktop";
|
|
23
|
+
private static readonly string[] TargetLabels = { "自动", "DSH Web", "DSH 桌面端" };
|
|
24
|
+
private static readonly string[] TargetValues = { TargetAuto, TargetWeb, TargetDesktop };
|
|
20
25
|
|
|
21
26
|
/// <summary>Register this editor without changing the user's selection. @author ddj 2026-09-28</summary>
|
|
22
27
|
static DshCodeEditor()
|
|
@@ -57,7 +62,13 @@ namespace Dsh.EditorIntegration
|
|
|
57
62
|
string selected = EditorPrefs.GetString(key, string.Empty);
|
|
58
63
|
string value = EditorGUILayout.TextField("DSH 桥接配置", selected);
|
|
59
64
|
if (value != selected) EditorPrefs.SetString(key, value.Trim());
|
|
60
|
-
|
|
65
|
+
string targetKey = TargetKey();
|
|
66
|
+
string current = EditorPrefs.GetString(targetKey, TargetAuto);
|
|
67
|
+
int picked = System.Array.IndexOf(TargetValues, current);
|
|
68
|
+
if (picked < 0) picked = 0;
|
|
69
|
+
int chosen = EditorGUILayout.Popup("打开目标", picked, TargetLabels);
|
|
70
|
+
if (TargetValues[chosen] != current) EditorPrefs.SetString(targetKey, TargetValues[chosen]);
|
|
71
|
+
EditorGUILayout.HelpBox("留空时自动读取本项目 UserSettings/dsh-editor.ini;没有安装提示时使用 DSH_HOME 中的旧 shell 配置。填写绝对路径可覆盖自动选择。打开目标选择 DSH 桌面端或 DSH Web 时优先使用对应 profile 的独立 bridge。请求须收到 ACK 才确认成功。", MessageType.Info);
|
|
61
72
|
if (GUILayout.Button("测试:在 DSH 中打开当前项目")) BeginOpen(ProjectRoot(), 0, 0);
|
|
62
73
|
}
|
|
63
74
|
|
|
@@ -134,7 +145,8 @@ namespace Dsh.EditorIntegration
|
|
|
134
145
|
private static void BeginOpen(string path, int line, int column)
|
|
135
146
|
{
|
|
136
147
|
string config = EditorPrefs.GetString(ConfigKey(), string.Empty);
|
|
137
|
-
|
|
148
|
+
string target = EditorPrefs.GetString(TargetKey(), TargetAuto);
|
|
149
|
+
var worker = new DshOpenWorker(config, path, line, column, ProjectRoot(), LegacyConfig(), target);
|
|
138
150
|
var thread = new Thread(worker.Run) { IsBackground = true };
|
|
139
151
|
thread.Start();
|
|
140
152
|
}
|
|
@@ -143,6 +155,10 @@ namespace Dsh.EditorIntegration
|
|
|
143
155
|
/// <returns>Project-local editor preference key.</returns>
|
|
144
156
|
private static string ConfigKey() { return "DshEditor.Config." + ProjectRoot(); }
|
|
145
157
|
|
|
158
|
+
/// <summary>Derive a project-specific open-target preference key. @author ddj 2026-10-12</summary>
|
|
159
|
+
/// <returns>Project-local open-target preference key.</returns>
|
|
160
|
+
private static string TargetKey() { return "DshEditor.Target." + ProjectRoot(); }
|
|
161
|
+
|
|
146
162
|
/// <summary>Read the project's installed profile hint on the worker. @author ddj 2026-09-28</summary>
|
|
147
163
|
/// <param name="root">Project root captured on the main thread.</param><param name="fallback">Legacy shell INI.</param>
|
|
148
164
|
/// <returns>The hinted absolute INI, or legacy path only when the hint is absent.</returns>
|
|
@@ -171,10 +187,48 @@ namespace Dsh.EditorIntegration
|
|
|
171
187
|
/// <summary>Honor custom DSH_HOME for legacy installations. @author ddj 2026-09-28</summary>
|
|
172
188
|
/// <returns>Legacy installed shell INI path, without filesystem IO.</returns>
|
|
173
189
|
private static string LegacyConfig()
|
|
190
|
+
{
|
|
191
|
+
return Path.Combine(Path.Combine(Path.Combine(Home(), "dsh-vscode-mode"), "shell"), "dsh-open.ini");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// <summary>Resolve the user DSH home honoring DSH_HOME. @author ddj 2026-10-12</summary>
|
|
195
|
+
/// <returns>Absolute user DSH home directory.</returns>
|
|
196
|
+
internal static string Home()
|
|
174
197
|
{
|
|
175
198
|
string home = Environment.GetEnvironmentVariable("DSH_HOME");
|
|
176
199
|
if (string.IsNullOrEmpty(home)) home = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dsh");
|
|
177
|
-
return
|
|
200
|
+
return home;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// <summary>Find the first profile bridge whose mode matches a requested target. @author ddj 2026-10-12</summary>
|
|
204
|
+
/// <param name="mode">Target mode (web or desktop) stored in the bridge INI.</param>
|
|
205
|
+
/// <returns>Absolute matching bridge INI path, or null when no profile provides it.</returns>
|
|
206
|
+
internal static string FindBridge(string mode)
|
|
207
|
+
{
|
|
208
|
+
string profiles = Path.Combine(Home(), "profiles");
|
|
209
|
+
if (!Directory.Exists(profiles)) return null;
|
|
210
|
+
foreach (string profile in Directory.GetDirectories(profiles))
|
|
211
|
+
{
|
|
212
|
+
string ini = Path.Combine(Path.Combine(Path.Combine(profile, "dsh-vscode-mode"), "bridge"), "dsh-open.ini");
|
|
213
|
+
if (!File.Exists(ini) || ModeOf(ini) != mode) continue;
|
|
214
|
+
return Path.GetFullPath(ini);
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// <summary>Read the mode value of a bridge INI without guessing a profile. @author ddj 2026-10-12</summary>
|
|
220
|
+
/// <param name="ini">Absolute bridge INI path.</param><returns>mode value, or empty when absent.</returns>
|
|
221
|
+
private static string ModeOf(string ini)
|
|
222
|
+
{
|
|
223
|
+
foreach (string text in File.ReadAllLines(ini))
|
|
224
|
+
{
|
|
225
|
+
int equal = text.IndexOf('=');
|
|
226
|
+
if (equal < 0) continue;
|
|
227
|
+
string key = text.Substring(0, equal).Trim();
|
|
228
|
+
if (!string.Equals(key, "mode", StringComparison.OrdinalIgnoreCase)) continue;
|
|
229
|
+
return text.Substring(equal + 1).Trim();
|
|
230
|
+
}
|
|
231
|
+
return string.Empty;
|
|
178
232
|
}
|
|
179
233
|
|
|
180
234
|
/// <summary>Resolve asset paths from the Unity project root. @author ddj 2026-09-28</summary>
|