dsh-vscode-mode 0.15.7 → 0.15.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.15.7",
3
+ "version": "0.15.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": [
@@ -67,6 +67,7 @@ import { disposeAiInline } from './ai/inlineProvider.js'
67
67
  import { readSessionScope, subscribeScope } from './sessionScope.js'
68
68
  import { installPlanBridge } from './planBridge.js'
69
69
  import { installPageReload } from './pageReload.js'
70
+ import { installUpdateNotice } from './updateNotice.js'
70
71
  import { flushPageState, flushUiState, hydrateUiState, installUiStatePersist, onUiStateReady, reportUiStateEvent, uiStateSettled } from './state/uiStatePersist.js'
71
72
  import { workspaceScopeOf } from './state/scopeStore.js'
72
73
  import { dapStore } from './dap/store.js'
@@ -195,6 +196,9 @@ export async function apply(ctx: any): Promise<void> {
195
196
  }
196
197
  ctx.effect(() => installPageReload(flushPageState, notifyReload), 'vscode-mode: guarded page reload')
197
198
 
199
+ // 可用更新主动提醒:启动延迟 + 抖动后检查 registry,有新版本且未提醒过时 toast(失败静默)
200
+ installUpdateNotice(ctx, schedule)
201
+
198
202
  const registry: FileOpenerRegistry = createFileOpenerRegistry()
199
203
  const workspaces = ctx.get('workspaces')
200
204
  const sessions = ctx.get('sessions')
@@ -0,0 +1,38 @@
1
+ /**
2
+ * dsh-vscode-mode client — 会话缺失错误识别与友好文案。
3
+ * 跨端场景:桌面端与 Web 端各自运行独立 DSH 实例,会话列表跨实例可见,
4
+ * 但会话仅在一端激活;另一端发起的 edrv.* RPC 会被 host requireSession
5
+ * 以「会话不存在」拒绝。此处把该错误归类为终态提示(非反复重试可解),
6
+ * 并给出引导文案,替代裸错误卡死观感。
7
+ * 作者 ddj 2026年10月16号
8
+ */
9
+ import { SESSION_MISSING_ERROR } from '../shared/rpc.js'
10
+
11
+ /**
12
+ * 判断错误文案是否为「会话不存在」类错误。
13
+ * @author ddj 2026年10月16号
14
+ * @param text 任意错误文本(host 业务 error、异常 message 等原始形态)
15
+ * @returns 命中规范文案(含拼接形态,如「会话不存在:路径」)返回 true;空值安全
16
+ */
17
+ export function isSessionMissingError(text: unknown): boolean {
18
+ return String(text ?? '').includes(SESSION_MISSING_ERROR)
19
+ }
20
+
21
+ /**
22
+ * 会话缺失提示标题(编辑区终态面板与侧栏红条共用)。
23
+ * @author ddj 2026年10月16号
24
+ * @returns 简短标题文案
25
+ */
26
+ export function sessionMissingTitle(): string {
27
+ return '会话在当前端不可用'
28
+ }
29
+
30
+ /**
31
+ * 会话缺失提示详情:说明成因并给出引导(去持有端查看,或在本端打开会话后重试)。
32
+ * @author ddj 2026年10月16号
33
+ * @returns 多句详情文案
34
+ */
35
+ export function sessionMissingDetail(): string {
36
+ return '该会话可能正被其他 DSH 实例(桌面端 / Web 端)占用,或尚未在当前实例激活。' +
37
+ '请在持有该会话的一端查看,或在本端打开该会话后重试。'
38
+ }
@@ -13,6 +13,7 @@
13
13
  import React from 'react'
14
14
  import { IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, fileIconEl } from '../../ui/icons.js'
15
15
  import { rpc } from '../../rpc.js'
16
+ import { isSessionMissingError, sessionMissingDetail, sessionMissingTitle } from '../../sessionError.js'
16
17
  import { ContextMenu } from '../../ui/ContextMenu.js'
17
18
  import { buildTreeMenu } from '../contextMenu.js'
18
19
  import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
@@ -423,7 +424,12 @@ export function FileExplorer(props) {
423
424
  const updateHint = !ctx?.svn?.svnCli ? 'SVN CLI 不可用' : !ctx?.openSvnUpdate ? 'SVN 更新窗口不可用' : '更新项目(打开 SVN 更新窗口)'
424
425
 
425
426
  const errorList = Object.entries(errors)
426
- const errorText = errorList.length ? errorList[0][1] : null
427
+ // 跨端会话缺失:裸错误替换为友好标题 + 详情(重试仍保留,会话在本端可用后即恢复)
428
+ const rawErrorText = errorList.length ? errorList[0][1] : null
429
+ const sessionMissing = isSessionMissingError(rawErrorText)
430
+ const errorText = rawErrorText === null
431
+ ? null
432
+ : sessionMissing ? sessionMissingTitle() + ':' + sessionMissingDetail() : rawErrorText
427
433
 
428
434
  return React.createElement('div', { className: 'edrv-side-panel', onContextMenu: onPanelContext },
429
435
  React.createElement('div', { className: 'edrv-side-head' },
@@ -1,4 +1,9 @@
1
- /** Workspace-local Ctrl+F state bridge; ddj 2026年10月09号. */
1
+ /**
2
+ * Workspace-local Ctrl+F state bridge; ddj 2026年10月09号.
3
+ *
4
+ * 快照键语义(ddj 2026年10月16号):键由调用方传入(findKey),不再限定为工作区 scope——
5
+ * 编辑器按分组隔离搜索框时传「scope + 分组 id」组合键;旧调用传 scope 时行为完全不变。
6
+ */
2
7
  export interface FindSnapshot {
3
8
  searchString: string
4
9
  isRevealed: boolean
@@ -58,17 +63,17 @@ function applyFind(controller: FindController, saved: FindSnapshot): Promise<unk
58
63
  /** @public @author ddj 2026年10月09号 @param editor Monaco editor. @returns Model-switch bridge; dispose releases the native listener. */
59
64
  export function createFindBridge(editor: FindEditor) {
60
65
  const controller = controllerOf(editor)
61
- let scope: string | null = null
66
+ let findKey: string | null = null
62
67
  let paused = true
63
68
  let disposed = false
64
69
 
65
- /** @private @author ddj 2026年10月09号 Capture only user changes, not model-reset or restoration events. */
70
+ /** @private @author ddj 2026年10月16号 Capture only user changes into the current findKey bucket, not model-reset or restoration events. */
66
71
  function capture(): void {
67
- if (disposed || paused || !scope || !controller) return
72
+ if (disposed || paused || !findKey || !controller) return
68
73
  try {
69
74
  const saved = snapshotOf(controller.getState())
70
- snapshots.delete(scope)
71
- snapshots.set(scope, saved)
75
+ snapshots.delete(findKey)
76
+ snapshots.set(findKey, saved)
72
77
  if (snapshots.size > 20) snapshots.delete(snapshots.keys().next().value!)
73
78
  } catch { /* A disposed contribution must not interrupt view capture. */ }
74
79
  }
@@ -77,11 +82,11 @@ export function createFindBridge(editor: FindEditor) {
77
82
  /** @public @author ddj 2026年10月09号 Save before setModel; suppress Monaco's per-model option resets. */
78
83
  function pause(): void { capture(); paused = true }
79
84
 
80
- /** @public @author ddj 2026年10月09号 @param nextScope Workspace owning the bound model. Restore with moveCursor=false; no per-file range or match index. */
81
- function resume(nextScope: string): void {
85
+ /** @public @author ddj 2026年10月16号 @param nextKey Bucket owning the bound view(scope 或 scope+分组 id 组合键). Restore with moveCursor=false; no per-file range or match index. */
86
+ function resume(nextKey: string): void {
82
87
  if (disposed || !controller) return
83
- scope = nextScope
84
- const saved = snapshots.get(scope) ?? {
88
+ findKey = nextKey
89
+ const saved = snapshots.get(findKey) ?? {
85
90
  searchString: '', isRevealed: false, matchCase: false, wholeWord: false, isRegex: false,
86
91
  }
87
92
  try {
@@ -14,6 +14,8 @@ export interface ViewEditor extends FindEditor {
14
14
  export function createViewKeeper(editor: ViewEditor) {
15
15
  const find = createFindBridge(editor)
16
16
  let bound: { scope: string; path: string; model: unknown } | null = null
17
+ // Ctrl+F 快照键跟随最近一次 bind(默认回退 scope);编辑器跨分组复用时按组存取搜索框状态
18
+ let lastFindKey: string | null = null
17
19
  let disposed = false
18
20
 
19
21
  /** @public @author ddj 2026年10月09号 @param path Optional expected path; mismatches never write a snapshot under the wrong file. Persist viewport even if cursor never moved. */
@@ -27,20 +29,21 @@ export function createViewKeeper(editor: ViewEditor) {
27
29
  } catch { /* A missing/disposed model must not overwrite a valid snapshot. */ }
28
30
  }
29
31
 
30
- /** @public @author ddj 2026年10月09号 @param scope Model workspace. @param path Cache path. @param model Ready text model. Restore only on identity change, before explicit navigation effects. */
31
- function bind(scope: string, path: string, model: unknown): void {
32
+ /** @public @author ddj 2026年10月16号 @param scope Model workspace. @param path Cache path. @param model Ready text model. @param findKey Optional Ctrl+F snapshot bucket(scope+分组 id);缺省沿用 scope. Restore only on identity change, before explicit navigation effects. */
33
+ function bind(scope: string, path: string, model: unknown, findKey?: string): void {
32
34
  if (disposed || !path || !model) return
33
35
  if (bound?.scope === scope && bound.path === path && bound.model === model
34
- && editor.getModel() === model) return
36
+ && editor.getModel() === model && (findKey === undefined || findKey === lastFindKey)) return
35
37
  save()
36
38
  find.pause()
37
39
  if (editor.getModel() !== model) editor.setModel(model)
38
40
  bound = { scope, path, model }
41
+ lastFindKey = findKey ?? scope
39
42
  const state = viewStatesLoad(scope)[path]
40
43
  if (state) {
41
44
  try { editor.restoreViewState(state) } catch { /* Stale fold ranges may be invalid. */ }
42
45
  }
43
- find.resume(scope)
46
+ find.resume(lastFindKey)
44
47
  }
45
48
 
46
49
  /** @public @author ddj 2026年10月09号 Save before dispose/ref clearing; repeated cleanup is a no-op. */
@@ -23,6 +23,9 @@
23
23
  [data-edrv-view] .edrv-pathbar .edrv-pb-full { flex: 1; min-width: 0; color: var(--dsw-alias-label-tertiary, #9aa5b1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; }
24
24
  [data-edrv-view] .edrv-pathbar .edrv-pb-meta { flex-shrink: 0; font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-variant-numeric: tabular-nums; }
25
25
  [data-edrv-view] .edrv-pathbar .edrv-pb-status { color: var(--dsw-alias-label-secondary, #52606d); }
26
+ /* 非焦点分组的静态路径条(GroupPathbar):可点击聚焦所属组,悬停给反馈 @author ddj 2026-10-16 */
27
+ [data-edrv-view] .edrv-pathbar[data-edrv-pb-static] { cursor: pointer; }
28
+ [data-edrv-view] .edrv-pathbar[data-edrv-pb-static]:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.06)); }
26
29
  [data-edrv-view] .edrv-pb-update { display: inline-flex; align-items: center; gap: 4px; height: 20px; flex: none; padding: 0 6px; border: 1px solid var(--dsw-alias-border-l1, #d9e0e4); border-radius: 4px; background: var(--dsw-alias-bg-layer-2, #f1f4f5); color: var(--dsw-alias-label-secondary, #485762); font-size: 11px; cursor: pointer; }
27
30
  [data-edrv-view] .edrv-pb-update:hover { background: var(--dsw-alias-interactive-bg-hover, #e7edf0); }
28
31
  [data-edrv-view] .edrv-pb-update:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #2869b4); outline-offset: 1px; }
@@ -61,14 +61,34 @@ export function bindPaneView(editor: ViewEditor, scope: string, path: string, mo
61
61
 
62
62
  export interface PassiveProps {
63
63
  path: string | null
64
- mount(host: HTMLDivElement, path: string): (() => void) | undefined
64
+ groupId?: string
65
+ mount(host: HTMLDivElement, path: string, groupId?: string): (() => void) | undefined
65
66
  }
66
67
 
67
- /** @public @author ddj 2026年10月14号 @param props Workbench-owned mounting contract. @returns Independent visible file view without global command listeners. */
68
+ /**
69
+ * 非焦点分组的静态路径条:让路径条在每个编辑分组常驻(分组高度不随焦点切换变化)。
70
+ * 只读展示文件名 + 完整路径,点击聚焦所属组(与 passive 编辑器 pointerdown 同语义)。
71
+ * @public @author ddj 2026年10月16号
72
+ * @param props path 当前组活动文件(null 显示占位);onFocus 点击回调。
73
+ * @returns 恒定 26px 高的路径条元素。
74
+ */
75
+ export function GroupPathbar(props: { path: string | null; onFocus?(): void }): React.ReactElement {
76
+ const name = props.path ? props.path.split(/[\\/]/).pop() : '未打开文件'
77
+ return React.createElement('div', {
78
+ className: 'edrv-pathbar', 'data-edrv-pb-static': '1',
79
+ title: props.path || '此分页未打开文件', onClick: props.onFocus,
80
+ },
81
+ React.createElement('span', { className: 'edrv-pb-name' }, name),
82
+ React.createElement('span', { className: 'edrv-pb-full' }, props.path || ''))
83
+ }
84
+
85
+ /** @public @author ddj 2026年10月16号 @param props Workbench-owned mounting contract. @returns Independent visible file view without global command listeners. */
68
86
  export function PassiveEditor(props: PassiveProps): React.ReactElement {
69
87
  const host = React.useRef<HTMLDivElement>(null)
88
+ const groupRef = React.useRef(props.groupId)
89
+ groupRef.current = props.groupId
70
90
  React.useLayoutEffect(() => {
71
- if (host.current && props.path) return props.mount(host.current, props.path)
91
+ if (host.current && props.path) return props.mount(host.current, props.path, groupRef.current)
72
92
  }, [props.path, props.mount])
73
93
  return React.createElement('div', { ref: host, className: 'edrv-passive-view' })
74
94
  }
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import React from 'react'
11
11
  import { createPortal } from 'react-dom'
12
- import { EditorPane, EditorSplit, PassiveEditor } from './EditorPane.js'
12
+ import { EditorPane, EditorSplit, PassiveEditor, GroupPathbar } from './EditorPane.js'
13
13
  import { FloatingEditor } from './FloatingEditor.js'
14
14
  import { FloatingWindow } from './FloatingWindow.js'
15
15
  import { createLayout, parseLayout, syncTabs, ownerOf, focusGroup, moveTab, returnGroup, GROUP_LIMIT, MAIN_GROUP } from '../state/editorLayout.js'
@@ -118,6 +118,7 @@ import { buildBpMenu } from '../dap/bpMenu.js'
118
118
  import { clampToolbarPosition, loadToolbarPosition, saveToolbarPosition } from '../dap/toolbarDrag.js'
119
119
  import { editorModelPathOf } from '../dap/modelPath.js'
120
120
  import { dapTrace } from '../dap/trace.js'
121
+ import { isSessionMissingError, sessionMissingDetail, sessionMissingTitle } from '../sessionError.js'
121
122
 
122
123
  /**
123
124
  * 只在 Lua 注解类型槽开启注释自动建议;跨文件/离开类型槽立即恢复默认。
@@ -1897,6 +1898,37 @@ export function EditorView(props) {
1897
1898
  loadContent(active, sessionId)
1898
1899
  }, [active, sessionId])
1899
1900
 
1901
+ /**
1902
+ * 切换焦点分组时同步重置内容状态(与 setWorkbench 同批渲染)。
1903
+ *
1904
+ * 为什么不能只靠上面的 active effect:它在本轮 commit **之后**才执行——而 mountFull 在 commit 阶段
1905
+ * 就把 fullHost 搬进新焦点组,导致新组先 paint 一帧旧组文件内容(用户所见「内容串组」闪烁)。
1906
+ *
1907
+ * 两个分支(ddj 2026年10月16号):
1908
+ * - 新组文件 model 已缓存 → 直接用缓存内容 seed(同批渲染即最终内容,无 loading 帧 → 切组零空白);
1909
+ * - 未缓存 → setContent(null)(loading 帧),避免旧内容串组。
1910
+ * 之后 active effect 的 loadContent 照常校验/接管(缓存分支 value 相同,React 不会多渲染)。
1911
+ */
1912
+ const resetContentForSwitch = (nextPath) => {
1913
+ const cached = nextPath ? modelsRef.current.get(nextPath) : null
1914
+ if (cached) {
1915
+ setContent(cached.getValue())
1916
+ setContentPath(nextPath)
1917
+ setLoadStage((prev) => ({ progress: Math.max(prev.progress, 84), message: '文件已读取,准备创建编辑器…' }))
1918
+ setStatus('已加载')
1919
+ return
1920
+ }
1921
+ setContent(null)
1922
+ setContentPath(null)
1923
+ setLoadStage({ progress: 10, message: '准备读取文件…' })
1924
+ setStatus('加载中…')
1925
+ setImageSrc(null)
1926
+ setImgSize(null)
1927
+ setImgBroken(false)
1928
+ setPdfBytes(null)
1929
+ setLoadError(null)
1930
+ }
1931
+
1900
1932
  // 页签右键菜单的 Esc 关闭由 ContextMenu 组件自己处理(capture 监听),此处不再重复挂监听
1901
1933
 
1902
1934
  React.useEffect(() => {
@@ -2190,7 +2222,7 @@ export function EditorView(props) {
2190
2222
  if (!monaco || !editorRef.current || !active || !contentReady) return
2191
2223
  const ed = editorRef.current
2192
2224
  const model = getModel(active, content)
2193
- viewKeeperRef.current?.bind(String(scope), active, model)
2225
+ viewKeeperRef.current?.bind(String(scope), active, model, findKeyOf(scope, focusedGroupOf(active)))
2194
2226
  syncTypeSuggest(ed, monaco)
2195
2227
  setLoadStage((prev) => ({ progress: Math.max(96, prev.progress), message: '创建编辑器视图…' }))
2196
2228
  }, [monaco, scope, active, content, contentReady, mdPreviewing, svnDiff])
@@ -2290,8 +2322,9 @@ export function EditorView(props) {
2290
2322
  language: 'plaintext',
2291
2323
  theme: themeNameOf(),
2292
2324
  fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
2293
- fontSize: 13,
2294
- lineHeight: 20,
2325
+ // 16px/21px:与 md 预览渲染态同字号,切分页/切组时观感不再跳变(ddj 2026-10-16)
2326
+ fontSize: 16,
2327
+ lineHeight: 21,
2295
2328
  minimap: { enabled: !side, scale: 1 },
2296
2329
  glyphMargin: true,
2297
2330
  lineDecorationsWidth: 16,
@@ -3703,16 +3736,21 @@ export function EditorView(props) {
3703
3736
  saveViewState()
3704
3737
  recordNav()
3705
3738
  const next = focusGroup(benchRef.current, group, path)
3739
+ const nextActive = next.groups.find((item) => item.id === group)?.active ?? null
3740
+ // 新焦点组文件与当前 contentPath 不同才重置:同文件聚焦无内容切换;缓存命中同步 seed(零空白),未命中清空防串组
3741
+ if (nextActive && nextActive !== activeRef.current) resetContentForSwitch(nextActive)
3706
3742
  setWorkbench(next)
3707
3743
  setPlanActive(null)
3708
- setDiskActive(next.groups.find((item) => item.id === group)?.active ?? null)
3744
+ setDiskActive(nextActive)
3709
3745
  }
3710
3746
  /** @private @author ddj 2026年10月14号 @param next Atomic layout. Accept ownership without close/save side effects. */
3711
3747
  function acceptLayout(next) {
3712
3748
  saveViewState()
3749
+ const nextActive = next.groups.find((group) => group.id === next.focused)?.active ?? null
3750
+ if (nextActive && nextActive !== activeRef.current) resetContentForSwitch(nextActive)
3713
3751
  setWorkbench(next)
3714
3752
  setPlanActive(null)
3715
- setDiskActive(next.groups.find((group) => group.id === next.focused)?.active ?? null)
3753
+ setDiskActive(nextActive)
3716
3754
  }
3717
3755
  /** @private @author ddj 2026年10月14号 @param path File. @param edge Split direction. Move the file into a new group. */
3718
3756
  function splitTab(path, edge) {
@@ -3764,9 +3802,62 @@ export function EditorView(props) {
3764
3802
  }
3765
3803
  /** @private @author ddj 2026年10月14号 @param host Group body. Keep the full editor subtree stable while changing group/window ownership. */
3766
3804
  function mountFull(host) { if (host && fullHost.parentElement !== host) { host.appendChild(fullHost); editorRef.current?.layout() } }
3767
- /** @private @author ddj 2026年10月14号 @param host Passive body. @param path Canonical file. @returns View-only cleanup, models remain workbench-owned. */
3768
- function mountPassive(host, path) {
3769
- let disposed = false, keeper = null, subscription = null, observer = null
3805
+ /** @private @author ddj 2026年10月16号 @param editor Passive editor instance. @param host Passive view container. @param path Canonical file of this group. @returns Gutter cleanup(断点装饰订阅 + 右键捕获解除). 断点 glyph 列每分页可用:装饰/左键切换/hover 预览/右键菜单与焦点编辑器同语义。 */
3806
+ function bindBpGutter(editor, host, path) {
3807
+ const m = window.monaco
3808
+ const GUTTER = m.editor?.MouseTargetType?.GUTTER_GLYPH_MARGIN ?? 2
3809
+ const bpListOf = () => (dapStore.bpMapOf(dapScopeRef.current)[path] ?? [])
3810
+
3811
+ const applyBp = () => {
3812
+ const map = dapStore.bpMapOf(dapScopeRef.current)
3813
+ const unverified = bpLinesOf(map, path).filter((line) => dapStore.ackOf(path, line)?.verified === false)
3814
+ applyBpDecorations(editor, bpLinesOf(map, path), bpDisabledLinesOf(map, path), unverified)
3815
+ }
3816
+ applyBp()
3817
+ const unsubscribeBp = dapStore.subscribe(applyBp)
3818
+
3819
+ editor.onMouseDown((e) => {
3820
+ if (!e?.event?.leftButton) return
3821
+ const target = e?.target
3822
+ if (!target || target.type !== GUTTER) return
3823
+ const line = target.position?.lineNumber
3824
+ if (line) dapToggleRef.current?.(path, line)
3825
+ })
3826
+ editor.onMouseMove((e) => {
3827
+ const target = e?.target
3828
+ const line = hintLineFor(target?.type, target?.position?.lineNumber, bpListOf().map((it) => it.line))
3829
+ setHintLine(editor, line || null)
3830
+ })
3831
+ editor.onMouseLeave(() => setHintLine(editor, null))
3832
+
3833
+ const glyphHit = (ev) => {
3834
+ if (!editor.getTargetAtClientPoint) return null
3835
+ const rect = editor.getDomNode?.()?.getBoundingClientRect?.()
3836
+ const layout = editor.getLayoutInfo?.()
3837
+ if (!rect || !layout) return null
3838
+ const relX = ev.clientX - rect.left
3839
+ if (relX < layout.glyphMarginLeft || relX > layout.glyphMarginLeft + layout.glyphMarginWidth) return null
3840
+ const target = editor.getTargetAtClientPoint(ev.clientX, ev.clientY)
3841
+ if (!target || target.type !== GUTTER) return null
3842
+ return target.position?.lineNumber ?? null
3843
+ }
3844
+ const onCtx = (ev) => {
3845
+ const line = glyphHit(ev)
3846
+ if (!line) return
3847
+ const entry = bpListOf().find((it) => it.line === line)
3848
+ ev.preventDefault()
3849
+ ev.stopPropagation()
3850
+ setDapBpMenu({
3851
+ x: ev.clientX, y: ev.clientY, path, line,
3852
+ hasBp: !!entry, enabled: entry ? entry.enabled !== false : true, isLogpoint: !!(entry && entry.logMessage),
3853
+ })
3854
+ }
3855
+ host.addEventListener('contextmenu', onCtx, true)
3856
+ return () => { unsubscribeBp?.(); host.removeEventListener('contextmenu', onCtx) }
3857
+ }
3858
+ /** @private @author ddj 2026年10月16号 @param host Passive body. @param path Canonical file. @param groupId Owning group id(find 快照按组隔离). @returns View-only cleanup, models remain workbench-owned. */
3859
+ function mountPassive(host, path, groupId) {
3860
+ let disposed = false, keeper = null, subscription = null, observer = null, unbindGutter = null
3770
3861
  const focus = () => { const owner = ownerOf(benchRef.current, path); if (owner) selectGroup(owner.id, path) }
3771
3862
  host.addEventListener('pointerdown', focus)
3772
3863
  if (isPdfPath(path) && pdfNodes.current.has(path)) {
@@ -3791,9 +3882,13 @@ export function EditorView(props) {
3791
3882
  if (!window.monaco) { host.textContent = '编辑器正在加载'; return }
3792
3883
  const model = cached || getModel(path, result.content)
3793
3884
  if (result.version) { revRef.current[path] = result.version; recordBaseline(scope, path, result.version) }
3794
- const editor = window.monaco.editor.create(host, { model, automaticLayout: true, minimap: { enabled: false }, scrollBeyondLastLine: false })
3885
+ const editor = window.monaco.editor.create(host, { model, automaticLayout: true, minimap: { enabled: false }, scrollBeyondLastLine: false, glyphMargin: true,
3886
+ // 与焦点编辑器(ensureEditor)同观感参数:字号/行高/顶部留白(side 形态 6,tab 形态 8)/装饰宽度,切分页行号位置不跳变(ddj 2026-10-16)
3887
+ fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)', fontSize: 16, lineHeight: 21,
3888
+ lineDecorationsWidth: 16, padding: { top: layoutRef.current === 'side' ? 6 : 8 } })
3889
+ const unbindGutter = bindBpGutter(editor, host, path)
3795
3890
  keeper = createViewKeeper(editor)
3796
- keeper.bind(scope, path, model)
3891
+ keeper.bind(scope, path, model, groupId ? findKeyOf(scope, groupId) : undefined)
3797
3892
  paneEditors.current.set(path, editor)
3798
3893
  const store = documents()
3799
3894
  store.bind(path, model, revRef.current[path])
@@ -3801,11 +3896,15 @@ export function EditorView(props) {
3801
3896
  observer = new ResizeObserver(() => editor.layout())
3802
3897
  observer.observe(host)
3803
3898
  }).catch((failure) => { if (!disposed) host.textContent = '文件加载失败:' + String(failure) })
3804
- return () => { disposed = true; host.removeEventListener('pointerdown', focus); observer?.disconnect(); subscription?.dispose(); keeper?.dispose(); paneEditors.current.delete(path) }
3899
+ return () => { disposed = true; host.removeEventListener('pointerdown', focus); unbindGutter?.(); observer?.disconnect(); subscription?.dispose(); keeper?.dispose(); paneEditors.current.delete(path) }
3805
3900
  }
3806
3901
  const passiveOps = React.useRef(mountPassive)
3807
3902
  passiveOps.current = mountPassive
3808
- const passiveMount = React.useCallback((host, path) => passiveOps.current(host, path), [])
3903
+ const passiveMount = React.useCallback((host, path, groupId) => passiveOps.current(host, path, groupId), [])
3904
+ /** @private @author ddj 2026年10月16号 @param scope Workspace scope. @param groupId Editor group id. @returns Ctrl+F snapshot bucket key isolating find state per group. */
3905
+ function findKeyOf(scope, groupId) { return String(scope) + '\u0000' + String(groupId) }
3906
+ /** @private @author ddj 2026年10月16号 @param active Active file path. @returns Owning group id, falling back to the focused group. */
3907
+ function focusedGroupOf(active) { return ownerOf(benchRef.current, active)?.id ?? benchRef.current.focused }
3809
3908
  /** @private @author ddj 2026年10月14号 @param node Split branch. @param ratio Updated branch ratio. */
3810
3909
  function changeRatio(node, ratio) {
3811
3910
  const replace = (current) => current === node ? { ...current, ratio } : 'group' in current ? current : { ...current, first: replace(current.first), second: replace(current.second) }
@@ -3817,7 +3916,9 @@ export function EditorView(props) {
3817
3916
  if (!group) return null
3818
3917
  const focused = id === workbench.focused
3819
3918
  return React.createElement(EditorPane, { key: id, group, focused, dirty: dirtyMap, onSelect: selectGroup, onClose: closeTab, onMenu: paneMenu, onDrag: dragTab, consumeClick: consumeDrag },
3820
- focused ? React.createElement('div', { className: 'edrv-full-slot', ref: mountFull }) : React.createElement(PassiveEditor, { key: String(scope) + ':' + group.active, path: group.active, mount: passiveMount }))
3919
+ focused ? React.createElement('div', { className: 'edrv-full-slot', ref: mountFull }) : React.createElement(React.Fragment, null,
3920
+ React.createElement(GroupPathbar, { path: group.active, onFocus: () => selectGroup(id) }),
3921
+ React.createElement(PassiveEditor, { key: String(scope) + ':' + group.active, path: group.active, groupId: id, mount: passiveMount })))
3821
3922
  }
3822
3923
  /** @private @author ddj 2026年10月14号 @param node Layout subtree. @returns Recursive resizable panes. */
3823
3924
  function renderTree(node) {
@@ -4024,6 +4125,18 @@ export function EditorView(props) {
4024
4125
  body = React.createElement('div', { className: 'edrv-empty' },
4025
4126
  React.createElement('div', null, '暂无打开的文件'),
4026
4127
  React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开工作区文件;' + (chordOf('edrv.showCommands') ?? 'Ctrl+Shift+P') + ' 打开命令栏;agent 修改文件后顶部会出现差异角标'))
4128
+ } else if (content === null && loadError && isSessionMissingError(loadError)) {
4129
+ // 跨端会话缺失:终态提示而非加载失败卡死(会话仅在一端激活,本端 RPC 均被拒)
4130
+ body = React.createElement('div', { className: 'edrv-empty edrv-loading', 'aria-live': 'polite' },
4131
+ React.createElement('div', { className: 'edrv-loading-title' }, sessionMissingTitle()),
4132
+ React.createElement('div', { style: { fontSize: 12, maxWidth: 420, lineHeight: 1.6 } }, sessionMissingDetail()),
4133
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: () => {
4134
+ setLoadError(null)
4135
+ setContent(null)
4136
+ setContentPath(null)
4137
+ setLoadStage({ progress: 10, message: '重新读取文件…' })
4138
+ loadContent(active, sessionId)
4139
+ } }, '重试'))
4027
4140
  } else if (content === null && loadError) {
4028
4141
  body = loadingBody('文件加载失败:' + loadError, 0, () => {
4029
4142
  setLoadError(null)
@@ -4346,10 +4459,12 @@ export function EditorView(props) {
4346
4459
  React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '复制安装命令', onClick: copyInstallCmd }, '复制命令'),
4347
4460
  React.createElement('button', { className: 'edrv-side-hint-close', title: '关闭提示', 'aria-label': '关闭提示', onClick: dismissHint }, '×'))
4348
4461
  : null
4349
- const fullView = createPortal(React.createElement(React.Fragment, null, pathBar, sideHintEl, diskBanner(), editorArea, statusBar), fullHost, 'edrv-full-editor')
4462
+ // 底部状态栏共用一整条:挂在 mainCol 层级(所有分组之下),不随焦点分组搬家(ddj 2026-10-16)
4463
+ const fullView = createPortal(React.createElement(React.Fragment, null, pathBar, sideHintEl, diskBanner(), editorArea), fullHost, 'edrv-full-editor')
4350
4464
  const mainCol = React.createElement('div', { className: 'edrv-main-col edrv-workbench', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
4351
4465
  React.createElement('div', { className: 'edrv-workbench-tools' }, tabRow),
4352
4466
  renderTree(workbench.tree), fullView,
4467
+ statusBar,
4353
4468
  workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group))),
4354
4469
  dragPreview ? createPortal(React.createElement(React.Fragment, null,
4355
4470
  dragPreview.rect ? React.createElement('div', { className: 'edrv-drag-overlay', style: dragPreview.rect }, React.createElement('span', { className: 'edrv-drop-label' }, dragPreview.label)) : null,
@@ -115,6 +115,9 @@ function GeneralSettings({ registry, getShortcuts }) {
115
115
  const [devForm, setDevForm] = React.useState(null)
116
116
  const [devBusy, setDevBusy] = React.useState(false)
117
117
  const [devMessage, setDevMessage] = React.useState('')
118
+ const [updateState, setUpdateState] = React.useState(null) // { current, latest, hasUpdate }
119
+ const [updateBusy, setUpdateBusy] = React.useState(false)
120
+ const [updateMessage, setUpdateMessage] = React.useState('')
118
121
  const [minW, setMinW] = React.useState(SIDEBAR_MIN_DEFAULT)
119
122
  const [minDraft, setMinDraft] = React.useState(null) // 输入草稿(null=跟随已保存值;blur/Enter 提交)
120
123
  const [limit, setLimit] = React.useState(EDITOR_LIMIT_DEFAULT)
@@ -198,6 +201,27 @@ function GeneralSettings({ registry, getShortcuts }) {
198
201
  setDevMessage(result.restart ? '已切换为正式版,重启 DSH 后生效。' : '已关闭开发形态。')
199
202
  }).catch((e) => setDevMessage('关闭失败:' + String(e))).finally(() => setDevBusy(false))
200
203
  }
204
+ /** 检查 npm registry 是否有比当前版本更新的发布(结果落 updateState)。 */
205
+ const runUpdateCheck = () => {
206
+ setUpdateBusy(true)
207
+ setUpdateMessage('')
208
+ rpc('vscode.updateCheck', {}).then((result) => {
209
+ if (!result.ok) { setUpdateMessage('检查失败:' + String(result.error)); return }
210
+ setUpdateState({ current: result.current ?? '', latest: result.latest ?? '', hasUpdate: result.hasUpdate === true })
211
+ setUpdateMessage(result.hasUpdate ? '发现新版本 v' + result.latest : '已是最新版本。')
212
+ }).catch((e) => setUpdateMessage('检查失败:' + String(e))).finally(() => setUpdateBusy(false))
213
+ }
214
+ /** 触发官方 pluginManager「先删后装」自我更新(页面会断开,成功与否均不再回报本页)。 */
215
+ const runSelfUpdate = () => {
216
+ const target = updateState?.latest ?? ''
217
+ if (!window.confirm('更新到 v' + target + ':更新中插件会先卸载再安装,页面将短暂断开,完成后请刷新页面或重启 DSH。\n注意:若安装失败(如断网),插件会保持未安装状态,可在官方插件管理页重新安装恢复。确认更新?')) return
218
+ setUpdateBusy(true)
219
+ setUpdateMessage('')
220
+ rpc('vscode.updateSelf', {}).then((result) => {
221
+ if (!result.ok) { setUpdateMessage('更新失败:' + String(result.error)); setUpdateBusy(false); return }
222
+ setUpdateMessage('更新已开始,页面即将断开;完成后请刷新页面或重启 DSH。')
223
+ }).catch((e) => { setUpdateMessage('更新失败:' + String(e)); setUpdateBusy(false) })
224
+ }
201
225
  /**
202
226
  * 唤起官方快捷键弹窗(键位统一归官方机制;入口不可用时展示手动打开方式)。
203
227
  * @author ddj 2026年09月28号
@@ -215,6 +239,17 @@ function GeneralSettings({ registry, getShortcuts }) {
215
239
  React.createElement('button', { className: 'vsm-devform-close', disabled: devBusy, onClick: closeDevForm }, devBusy ? '切换中…' : '关闭开发形态'),
216
240
  ),
217
241
  ) : null
242
+ const updateRow = React.createElement('div', { className: 'vsm-general-row' },
243
+ React.createElement('span', null, '插件更新',
244
+ React.createElement('small', { className: 'vsm-devform-note' },
245
+ '当前版本 v' + (updateState?.current ?? '') + (devForm?.enabled ? ';开发形态下请走工作区同步流程' : ''))),
246
+ React.createElement('div', { className: 'vsm-devform-actions' },
247
+ updateMessage && React.createElement('small', { className: 'vsm-devform-msg' }, updateMessage),
248
+ updateState?.hasUpdate
249
+ ? React.createElement('button', { disabled: updateBusy || devForm?.enabled === true, onClick: runSelfUpdate, title: devForm?.enabled ? '开发形态下请走工作区同步流程' : undefined }, updateBusy ? '更新中…' : '更新到 v' + (updateState.latest ?? ''))
250
+ : React.createElement('button', { disabled: updateBusy || devForm?.enabled === true, onClick: runUpdateCheck, title: devForm?.enabled ? '开发形态下请走工作区同步流程' : undefined }, updateBusy ? '检查中…' : '检查更新'),
251
+ ),
252
+ )
218
253
  return React.createElement('section', { className: 'vsm-general-page' },
219
254
  React.createElement('h2', null, '通用'),
220
255
  React.createElement('p', null, '配置 DSH 对话中文件链接的打开工具。工具由当前运行时自动扫描。'),
@@ -225,6 +260,7 @@ function GeneralSettings({ registry, getShortcuts }) {
225
260
  React.createElement('div', { className: 'vsm-panel-body' },
226
261
  React.createElement('label', { className: 'vsm-general-row' }, React.createElement('span', null, '文件链接使用工具'), React.createElement('select', { value: currentOption, disabled: loading || unavailable || notReady || busy || snapshot?.writable === false, onChange: (event) => save(event.target.value) }, options.map((item) => React.createElement('option', { key: item.id, value: item.id }, item.label))), loading && React.createElement('small', null, '读取中…')),
227
262
  devFormRow,
263
+ updateRow,
228
264
  ),
229
265
  ),
230
266
  React.createElement('section', { className: 'vsm-panel' },
@@ -0,0 +1,64 @@
1
+ /**
2
+ * dsh-vscode-mode client — 可用更新主动提醒:启动延迟 + 抖动后检查 registry 新版本,
3
+ * 有更新且该版本未提醒过时经 toastDom 弹提示(每版本一次,localStorage 去重)。
4
+ * 设计:检查失败静默(不打扰);开发形态(link: 安装)下不提醒(应走工作区同步)。
5
+ * 作者 ddj 2026-10-16
6
+ */
7
+ import { rpc } from './rpc.js'
8
+ import { toastDom } from './externalOpen.js'
9
+ import { PLUGIN_NAME } from '../compat.js'
10
+
11
+ /** localStorage 去重键:值为已提醒过的最新版本号。 */
12
+ const NOTICE_KEY = 'vsm-update-notice'
13
+
14
+ /** 启动后首次检查的延迟(毫秒)。 */
15
+ const NOTICE_DELAY_MS = 30000
16
+
17
+ /** 抖动上限(毫秒):避免多客户端同时请求 registry。 */
18
+ const NOTICE_JITTER_MS = 30000
19
+
20
+ /**
21
+ * 读取开发形态状态(客户端侧经 RPC 查询;失败按未开启处理)。
22
+ * @private
23
+ * @author ddj 2026年10月16号
24
+ * @returns devForm.enabled 是否为 true
25
+ */
26
+ async function devFormEnabled(): Promise<boolean> {
27
+ try {
28
+ const result = await rpc('vscode.devFormGet', {})
29
+ return result.ok === true && (result.devForm as { enabled?: boolean } | undefined)?.enabled === true
30
+ } catch {
31
+ return false
32
+ }
33
+ }
34
+
35
+ /**
36
+ * 执行一次可用更新检查并在需要时弹 toast 提醒(同版本只提醒一次)。
37
+ * @private
38
+ * @author ddj 2026年10月16号
39
+ */
40
+ async function checkAndNotify(): Promise<void> {
41
+ if (await devFormEnabled()) return
42
+ const result = await rpc('vscode.updateCheck', {})
43
+ if (result.ok !== true || result.hasUpdate !== true) return
44
+ const latest = String(result.latest ?? '')
45
+ if (!latest) return
46
+ let storage: Storage | null = null
47
+ try { storage = window.localStorage } catch { /* 隐私模式等场景静默降级 */ }
48
+ if (storage?.getItem(NOTICE_KEY) === latest) return
49
+ storage?.setItem(NOTICE_KEY, latest)
50
+ toastDom(PLUGIN_NAME + ' 有可用更新:v' + latest + '(设置页「文件与安装」可一键更新)')
51
+ }
52
+
53
+ /**
54
+ * 装配更新提醒:启动 30s + 0~30s 抖动后检查一次;失败静默。
55
+ * @public
56
+ * @author ddj 2026年10月16号
57
+ * @param ctx DSH client 上下文
58
+ * @param schedule 定时器注入(ctx.timeout),便于测试与生命周期统一
59
+ */
60
+ export function installUpdateNotice(ctx: { timeout(fn: () => void, ms: number): unknown }, schedule: (fn: () => void, ms: number) => void): void {
61
+ schedule(() => {
62
+ void checkAndNotify().catch(() => { /* 检查失败静默,不打扰 */ })
63
+ }, NOTICE_DELAY_MS + Math.floor(Math.random() * NOTICE_JITTER_MS))
64
+ }
package/src/lsp/rpc.ts CHANGED
@@ -15,6 +15,7 @@ import { onRuntimeProvisioned, envInstallStates } from './dotnetProvision.js'
15
15
  import { envRequirementsFor, installRequirement } from './envRequirements.js'
16
16
  import type { LspManager } from './manager.js'
17
17
  import type { RpcHandlerMap } from '../shared/rpc.js'
18
+ import { SESSION_MISSING_ERROR, SESSION_NO_WORKSPACE_ERROR } from '../shared/rpc.js'
18
19
  import type { LspLocation, LspServerStatus } from '../shared/lsp.js'
19
20
  import { installFromMarket, installVsixFile, listInstalled, marketGet, marketSearch, uninstall, updateExtension, type ExtInfo } from './extmgr.js'
20
21
  import { toWorkspacePath } from './uri.js'
@@ -152,9 +153,9 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
152
153
  /** 解析会话与工作区根(失败 → null + error)。 */
153
154
  const rootOf = async (sessionId: string | undefined): Promise<{ root: string; sessionId: string } | { err: string }> => {
154
155
  const session = sessionOf(ctx, sessionId)
155
- if (!session) return { err: '会话不存在' }
156
+ if (!session) return { err: SESSION_MISSING_ERROR }
156
157
  const cwd = cwdOf(session)
157
- if (!cwd) return { err: '会话无工作区' }
158
+ if (!cwd) return { err: SESSION_NO_WORKSPACE_ERROR }
158
159
  try {
159
160
  const root = await searchRoot(ctx, session)
160
161
  const sid = typeof session.id === 'string' && session.id ? session.id : sessionId ?? ''