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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.14.7",
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 实例,但仍是「有页签可关」。
@@ -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 { flushUiState, hydrateUiState, installUiStatePersist, onUiStateReady, reportUiStateEvent, uiStateSettled } from './state/uiStatePersist.js'
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
+ }
@@ -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) => await service('sessions').create(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) => service('sessions').open(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 (!matchEvent(key, bindings) || !runner.isAvailable(def.id)) continue
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.0";
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
- EditorGUILayout.HelpBox("留空时自动读取本项目 UserSettings/dsh-editor.ini;没有安装提示时使用 DSH_HOME 中的旧 shell 配置。填写绝对路径可覆盖自动选择。请求须收到 ACK 才确认成功。", MessageType.Info);
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
- var worker = new DshOpenWorker(config, path, line, column, ProjectRoot(), LegacyConfig());
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 Path.Combine(Path.Combine(Path.Combine(home, "dsh-vscode-mode"), "shell"), "dsh-open.ini");
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>