dsh-vscode-mode 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
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": [
@@ -13,6 +13,8 @@ export interface ClientCompatState {
13
13
  fileClaim: boolean
14
14
  claimWanted: boolean
15
15
  selected: string
16
+ officialMenus?: boolean
17
+ forge?: boolean
16
18
  }
17
19
 
18
20
  /**
@@ -73,5 +75,7 @@ export function clientSummary(state: ClientCompatState, version = ''): CompatAda
73
75
  if (state.fileClaim || (state.claimWanted && (official || officialExpected !== false))) {
74
76
  items.push({ name: '文件链接官方认领(dsh-resource://file)', active: state.fileClaim, note: state.fileClaim ? '聊天文件链接转发进单一编辑器页签' : '官方文件链接认领未安装' })
75
77
  }
78
+ if (state.officialMenus !== undefined) items.push({ name: '官方右键菜单', active: state.officialMenus, note: state.officialMenus ? '插件动作接入共享菜单' : '服务缺失或装配失败,保留插件菜单' })
79
+ if (state.forge !== undefined) items.push({ name: 'Forge 导航', active: state.forge, note: state.forge ? '导航至官方面板与本地变更;PR 保持官方审查' : '官方 Forge 未安装或未启用,不自动启用' })
76
80
  return items
77
81
  }
@@ -0,0 +1,80 @@
1
+ /** Optional shared popup adapter retaining the plugin's action models. @author ddj 2026年11月04号 */
2
+ export interface PopupItem {
3
+ id: string
4
+ label: string
5
+ disabled?: boolean
6
+ danger?: boolean
7
+ separator?: boolean
8
+ commandId?: string
9
+ run: () => void | Promise<void>
10
+ }
11
+ interface MenuTarget { kind: string; value: { owner: string; sessionId: string; path: string } }
12
+ interface MenuService {
13
+ register(contribution: { id: string; target: string; order: number; resolve: (target: MenuTarget['value'], signal: AbortSignal) => unknown[] }): () => void
14
+ open(target: MenuTarget, point: { x: number; y: number }): void
15
+ close(): void
16
+ active?: { getSnapshot(): { kind?: string; value?: { owner?: string } } | null }
17
+ }
18
+ export interface OfficialMenuBridge {
19
+ open(kind: 'resource' | 'tab', sessionId: string, path: string, point: { x: number; y: number }, items: PopupItem[], valid: () => boolean): boolean
20
+ invalidate(valid: () => boolean): void
21
+ dispose(): void
22
+ }
23
+ let ownerSeq = 0
24
+
25
+ /** @public @author ddj 2026年11月04号 @param ctx Optional service context. @returns Retry-on-use bridge for late service registration, with legacy fallback. */
26
+ export function lazyMenus(ctx: { get(name: string): unknown }): OfficialMenuBridge {
27
+ let bridge: OfficialMenuBridge | undefined
28
+ let stopped = false
29
+ return {
30
+ open(...args) { if (stopped) return false; bridge ??= officialMenus(ctx); return bridge?.open(...args) ?? false },
31
+ invalidate(valid) { bridge?.invalidate(valid) },
32
+ dispose() { stopped = true; bridge?.dispose(); bridge = undefined },
33
+ }
34
+ }
35
+
36
+ /** @public @author ddj 2026年11月04号 @param ctx Optional service context. @returns Scoped bridge or no capability, preserving the existing popup. */
37
+ export function officialMenus(ctx: { get(name: string): unknown }): OfficialMenuBridge | undefined {
38
+ let service: MenuService
39
+ try { service = ctx.get('contextMenus') as MenuService } catch { return undefined }
40
+ if (!service || typeof service.register !== 'function' || typeof service.open !== 'function' || typeof service.close !== 'function') return undefined
41
+ const owner = 'edrv-menu-' + ++ownerSeq
42
+ let current: { items: PopupItem[]; valid: () => boolean; revision: number } | undefined
43
+ let revision = 0
44
+ let stopped = false
45
+ const disposers: Array<() => void> = []
46
+ try {
47
+ for (const kind of ['resource', 'tab']) {
48
+ disposers.push(service.register({ id: owner + '-' + kind, target: 'edrv.' + kind, order: 30,
49
+ resolve: (target, signal) => {
50
+ const opening = current
51
+ if (stopped || target.owner !== owner || !opening || !opening.valid() || signal.aborted) return []
52
+ return opening.items.map((item) => ({ id: item.id, label: item.label, disabled: item.disabled,
53
+ danger: item.danger, separatorBefore: item.separator, commandId: item.commandId,
54
+ run: async () => {
55
+ if (stopped || opening.revision !== revision || !opening.valid() || item.disabled) return
56
+ await item.run()
57
+ } }))
58
+ },
59
+ }))
60
+ }
61
+ } catch { for (const dispose of disposers) dispose(); return undefined }
62
+ /** @private @author ddj 2026年11月04号 Close only this bridge's active shared popup. */
63
+ function closeOwned(): void {
64
+ const active = service.active?.getSnapshot()
65
+ if (active?.value?.owner === owner && (active.kind === 'edrv.resource' || active.kind === 'edrv.tab')) service.close()
66
+ }
67
+ return {
68
+ open(kind, sessionId, path, point, items, valid) {
69
+ if (stopped || !sessionId || !valid()) return false
70
+ current = { items, valid, revision: ++revision }
71
+ try { service.open({ kind: 'edrv.' + kind, value: { owner, sessionId, path } }, point); return true }
72
+ catch { current = undefined; return false }
73
+ },
74
+ invalidate(valid) {
75
+ if (current?.valid !== valid) return
76
+ revision++; current = undefined; closeOwned()
77
+ },
78
+ dispose() { stopped = true; revision++; current = undefined; closeOwned(); for (const dispose of disposers) dispose() },
79
+ }
80
+ }
@@ -0,0 +1,28 @@
1
+ /** Optional navigation only; remote snapshots never become local editable files. @author ddj 2026年11月04号 */
2
+ export interface ForgeBridge { available(): boolean; open(sessionId: string, local?: boolean): boolean }
3
+
4
+ /** @public @author ddj 2026年11月04号 @param ctx Client capabilities. @param current Current session reader. @returns Capability-gated official navigation, with no polling or writes. */
5
+ export function forgeBridge(ctx: { get(name: string): unknown }, current: () => string | undefined): ForgeBridge {
6
+ const capabilities = () => {
7
+ try {
8
+ const tabs = ctx.get('sidebarRightTabs') as { get?(kind: string): unknown }
9
+ const service = ctx.get('sidebarRight') as { openTab?(kind: string): void; openResource?(address: string, options?: unknown): void }
10
+ return { tabs, service }
11
+ } catch { return undefined }
12
+ }
13
+ return {
14
+ available() {
15
+ const capabilitiesNow = capabilities()
16
+ return Boolean(capabilitiesNow?.tabs?.get?.('forge-panel') && capabilitiesNow.tabs.get('forge-changes') && capabilitiesNow.service?.openTab && capabilitiesNow.service.openResource)
17
+ },
18
+ open(sessionId, local = false) {
19
+ if (!sessionId || current() !== sessionId || !this.available()) return false
20
+ const service = capabilities()!.service
21
+ try {
22
+ if (local) service.openResource!('dsh-resource://forge-changes/' + encodeURIComponent(sessionId) + '/local', { kind: 'forge-changes' })
23
+ else service.openTab!('forge-panel')
24
+ return true
25
+ } catch { return false }
26
+ },
27
+ }
28
+ }
@@ -22,6 +22,9 @@ import { PerfSizeBadge } from './ui/PerfSizeBadge.js'
22
22
  import { ConversationDiffDock } from './ui/ConversationDiffDock.js'
23
23
  import { McpSettings } from './ui/McpSettings.js'
24
24
  import { mcpSessionState } from './mcpSessionState.js'
25
+ import { forgeBridge } from './forgeBridge.js'
26
+ import { lazyMenus } from './contextMenusOfficial.js'
27
+ import { IconCodeOutline16 } from './ui/icons.js'
25
28
  import { McpSessionIcon, McpSessionDetail, type McpRowProps } from './ui/McpSessionStatus.js'
26
29
  import { PluginDetailSection } from './ui/PluginDetailSection.js'
27
30
  import { rpc } from './rpc.js'
@@ -442,6 +445,7 @@ export async function apply(ctx: any): Promise<void> {
442
445
  sidebarOpener: registry.get(SIDEBAR_PLUGIN) !== undefined,
443
446
  legacySidebar: sideService !== undefined, officialSidebar: officialService !== undefined,
444
447
  fileClaim: claimDisposer !== null, claimWanted: shouldClaimFiles(selected), selected,
448
+ officialMenus: Boolean(ctx.get('contextMenus')), forge: forge.available(),
445
449
  }, version)
446
450
 
447
451
  ctx.provide('fileOpeners', registry)
@@ -484,7 +488,10 @@ export async function apply(ctx: any): Promise<void> {
484
488
  return sidebar ? registry.register(sidebar) : undefined
485
489
  }, 'vscode-mode: sidebar file opener')
486
490
  // 官方侧边栏正文组件装配(页类型正文;兼作 file 认领转发失败时的兜底正文)
487
- const officialRenderTab = (props: Record<string, unknown>) => React.createElement(OfficialSideTab, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems, fileOpeners: registry, sessions, readSessionScope: () => readSessionScope(ctx) }))
491
+ const forge = forgeBridge(ctx, () => readSessionScope(ctx).sessionId ?? undefined)
492
+ const menuBridge = lazyMenus(ctx)
493
+ if (menuBridge) ctx.effect(() => () => menuBridge.dispose(), 'vscode-mode: official menus')
494
+ const officialRenderTab = (props: Record<string, unknown>) => React.createElement(OfficialSideTab, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems, officialMenus: menuBridge, forge, fileOpeners: registry, sessions, readSessionScope: () => readSessionScope(ctx) }))
488
495
  /** 官方 file 地址认领同步:自动/VSCodeMode 档认领(转发进单一编辑器页签),其余交官方查看器。 */
489
496
  const syncFileClaim = (): void => {
490
497
  const official = officialService
@@ -669,7 +676,7 @@ export async function apply(ctx: any): Promise<void> {
669
676
  order: 5,
670
677
  label: () => t('slot.fileEditor'),
671
678
  inject: (sessionId: string) => ({ sessionId }),
672
- }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { layout: 'tab', sideHint: SIDEBAR_INSTALL_CMD, schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems, fileOpeners: registry, sessions })))
679
+ }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { layout: 'tab', sideHint: SIDEBAR_INSTALL_CMD, schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems, officialMenus: menuBridge, forge, fileOpeners: registry, sessions })))
673
680
  }
674
681
 
675
682
  // 侧边栏三形态互斥切换:官方(DSH 0.1.5+)> better-sidebar(归档,仅旧版回退)> 中央页签。
@@ -748,7 +755,7 @@ export async function apply(ctx: any): Promise<void> {
748
755
  if (legacyDisposer !== null) { legacyDisposer(); legacyDisposer = null }
749
756
  sideDisposer = ctx.effect(() => installSideEditor({
750
757
  service: sideService as NonNullable<typeof sideService>,
751
- renderTab: (props: Record<string, unknown>) => React.createElement(SideEditorTab, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems, fileOpeners: registry, sessions })),
758
+ renderTab: (props: Record<string, unknown>) => React.createElement(SideEditorTab, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems, officialMenus: menuBridge, forge, fileOpeners: registry, sessions })),
752
759
  activeSession: () => {
753
760
  const scope = readSessionScope(ctx)
754
761
  if (!scope.sessionId) return undefined
@@ -833,6 +840,14 @@ export async function apply(ctx: any): Promise<void> {
833
840
  getShortcuts: () => detectShortcuts(ctx),
834
841
  })))
835
842
 
843
+ registerSlotSafely(ctx, { name: 'settings.section.icon', id: 'vscode-mode', order: 30 },
844
+ (input: unknown) => {
845
+ const props = input as { size?: number; active?: boolean }
846
+ return React.createElement(IconCodeOutline16 as React.ComponentType<{ size?: number; className?: string }>, {
847
+ size: props.size ?? 16, className: props.active ? 'edrv-settings-active' : undefined,
848
+ })
849
+ })
850
+
836
851
  // 官方插件管理器详情页的只读状态段(DSH 0.2.1 新增扩展位):非本插件页返回 null。
837
852
  // 该 slot 在旧版 DSH 不存在,slots.inject 会一直等待(不报错),故无需版本判断。
838
853
  registerSlotSafely(ctx, {
@@ -32,6 +32,8 @@ const docVersions = new Map()
32
32
  let statusCache = { servers: [], at: 0 }
33
33
  let progressListeners = new Set()
34
34
  let ready = false
35
+ let statusRequest = null
36
+ let statusEpoch = 0
35
37
 
36
38
  /** 会话 id 变更通知(EditorView 持有权威 sessionId,经窗口事件回填)。 */
37
39
  const SESSION_EVENT = 'edrv:lsp-session'
@@ -54,6 +56,9 @@ export function setLspSession(id) {
54
56
  syncTimers.clear()
55
57
  docVersions.clear()
56
58
  sessionId = id
59
+ statusEpoch++
60
+ statusCache = { servers: [], at: 0 }
61
+ ready = false
57
62
  void refreshStatus()
58
63
  }
59
64
 
@@ -308,15 +313,28 @@ export async function fetchSemanticTokens(path, text) {
308
313
  return res.tokens
309
314
  }
310
315
 
311
- /** 拉取服务器状态(带 5s 缓存)。 */
316
+ /** @public @author ddj 2026年11月04号 @param force Bypass cached data, not the in-flight guard. @returns Current-session server status; stale responses never publish. */
312
317
  export async function refreshStatus(force = false) {
318
+ if (statusRequest) {
319
+ const pending = statusRequest
320
+ await pending.promise
321
+ if (pending.epoch !== statusEpoch) return refreshStatus(force)
322
+ return statusCache.servers
323
+ }
324
+ if (typeof document !== 'undefined' && document.hidden) return statusCache.servers
313
325
  if (!force && Date.now() - statusCache.at < 1500) return statusCache.servers
314
- const res = await rpc('edrv.lsp.status', { sessionId }).catch(() => null)
315
- const servers = res && res.ok && Array.isArray(res.servers) ? res.servers : []
316
- statusCache = { servers, at: Date.now() }
317
- ready = true
318
- publishProgress()
319
- return servers
326
+ const epoch = statusEpoch, owner = sessionId
327
+ const request = { epoch, promise: rpc('edrv.lsp.status', { sessionId: owner }).catch(() => null) }
328
+ statusRequest = request
329
+ try {
330
+ const res = await request.promise
331
+ if (epoch !== statusEpoch || owner !== sessionId) return statusCache.servers
332
+ const servers = res && res.ok && Array.isArray(res.servers) ? res.servers : []
333
+ statusCache = { servers, at: Date.now() }
334
+ ready = true
335
+ publishProgress()
336
+ return servers
337
+ } finally { if (statusRequest === request) statusRequest = null }
320
338
  }
321
339
 
322
340
  export function lspReady() {
@@ -64,6 +64,7 @@ export interface OfficialTabParams {
64
64
  preview?: boolean
65
65
  /** 目标行号(行引用/工具行跳转透传;缺省仅打开文件)。 */
66
66
  line?: number
67
+ column?: number
67
68
  }
68
69
 
69
70
  /** sidebarRightTabs 服务的最小结构面(结构性探测,不 import 官方类型)。 */
@@ -133,10 +134,17 @@ export function resolveNavOpen(params: unknown): { path: string | null; focusDif
133
134
  export function resolveNavLine(params: unknown): number | undefined {
134
135
  const value = (params ?? {}) as OfficialTabParams
135
136
  const line = value?.line
136
- if (typeof line !== 'number' || !Number.isFinite(line) || line <= 0) return undefined
137
+ if (typeof line !== 'number' || !Number.isFinite(line) || line < 1) return undefined
137
138
  return Math.floor(line)
138
139
  }
139
140
 
141
+ /** @public @author ddj 2026年11月04号 @param params Plugin navigation extension. @returns Positive column only alongside a valid line; official line-only callers remain unchanged. */
142
+ export function resolveNavColumn(params: unknown): number | undefined {
143
+ if (!resolveNavLine(params)) return undefined
144
+ const column = (params as OfficialTabParams)?.column
145
+ return typeof column === 'number' && Number.isFinite(column) && column >= 1 ? Math.floor(column) : undefined
146
+ }
147
+
140
148
  /** 逐段 component 解码(失败返回 null,对齐官方 parseFileAddress 的容错语义)。 */
141
149
  function decodeSegment(raw: string | undefined): string | null {
142
150
  if (raw === undefined) return null
@@ -158,7 +166,7 @@ function decodeSegment(raw: string | undefined): string | null {
158
166
  */
159
167
  export function parseOfficialFileAddress(address: unknown): { path: string; sessionId?: string } | null {
160
168
  if (typeof address !== 'string' || !address.startsWith(OFFICIAL_FILE_PREFIX)) return null
161
- const [scope, ...tail] = address.slice(OFFICIAL_FILE_PREFIX.length).split('/')
169
+ const [scope, ...tail] = address.split(/[?#]/, 1)[0].slice(OFFICIAL_FILE_PREFIX.length).split('/')
162
170
  const kind = decodeSegment(scope)
163
171
  if (kind === 'session') {
164
172
  const sessionId = decodeSegment(tail[0])
@@ -236,10 +244,14 @@ export function forwardToEditor(
236
244
  path: string | null,
237
245
  line?: number,
238
246
  preview?: boolean,
247
+ column?: number,
239
248
  ): boolean {
240
249
  if (!service || typeof service.openTab !== 'function' || !path) return false
241
250
  const params: OfficialTabParams = { openPath: path }
242
- if (line !== undefined) params.line = line
251
+ const validLine = resolveNavLine({ line })
252
+ if (validLine !== undefined) params.line = validLine
253
+ const validColumn = resolveNavColumn({ line: validLine, column })
254
+ if (validColumn !== undefined) params.column = validColumn
243
255
  if (preview !== undefined) params.preview = preview
244
256
  try {
245
257
  // 官方语义:页类型恒去重,故转发只会聚焦既有编辑器页签,不产生新实例
@@ -581,6 +581,12 @@ function svnMenuItems(): TreeMenuItem[] {
581
581
  export function createDefaultFileMenuItems(): TreeMenuItem[] {
582
582
  return [
583
583
  ...openGroupItems(),
584
+ { id: 'forge-open', label: '打开 Forge', order: 6, group: GROUP_OPEN,
585
+ visible: (_target, ctx) => ctx.forge?.available() === true,
586
+ run: (_target, ctx) => { if (ctx.sessionId) ctx.forge?.open(ctx.sessionId) } },
587
+ { id: 'forge-local', label: '查看本地变更(Forge)', order: 7, group: GROUP_OPEN,
588
+ visible: (_target, ctx) => ctx.forge?.available() === true,
589
+ run: (_target, ctx) => { if (ctx.sessionId) ctx.forge?.open(ctx.sessionId, true) } },
584
590
  ...revealGroupItems(),
585
591
  ...refGroupItems(),
586
592
  ...clipGroupItems(),
@@ -84,11 +84,17 @@ function rowView(props) {
84
84
  function select() { onSelect(path, isDir) }
85
85
  /** @private @author ddj 2026年10月10号 @param event Context trigger. */
86
86
  function menu(event) { onContext(path, type, event) }
87
+ /** @private @author ddj 2026年11月04号 @param event Keyboard context trigger anchored to the focused row. */
88
+ function menuKey(event) {
89
+ if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) return
90
+ const rect = event.currentTarget.getBoundingClientRect()
91
+ onContext(path, type, { preventDefault: () => event.preventDefault(), stopPropagation: () => event.stopPropagation(), clientX: rect.left, clientY: rect.bottom })
92
+ }
87
93
  return React.createElement('div', {
88
94
  className: 'edrv-tree-row' + (active ? ' edrv-tree-active' : '') + (context ? ' edrv-tree-context' : '')
89
95
  + (reveal ? ' edrv-tree-reveal' : '') + (mark?.stale ? ' edrv-tree-stale' : ''),
90
96
  title: path + (mark?.stale ? ';SVN 状态陈旧,等待确认' : ''), 'data-edrv-path': path,
91
- style: { paddingLeft: 6 + depth * 14 }, onClick: select, onContextMenu: menu,
97
+ style: { paddingLeft: 6 + depth * 14 }, tabIndex: 0, onClick: select, onContextMenu: menu, onKeyDown: menuKey,
92
98
  },
93
99
  React.createElement('span', { className: 'edrv-tree-chev' }, isDir ? (isOpen ? '▾' : '▸') : ''),
94
100
  React.createElement('span', { className: 'edrv-tree-iconbox' }, rowIconEl({ name }, isDir, isOpen), svnOverlayEl(viewOverlayOf(mark), remote)),
@@ -480,6 +486,25 @@ export function FileExplorer(props) {
480
486
  /** @private @author ddj 2026年10月22号 Close stale path menus instead of rebinding recycled rows. */
481
487
  function closeLostMenu() { if (menu && !menuValid) setMenu(null) }
482
488
  React.useEffect(closeLostMenu, [menu, menuValid])
489
+ const liveMenu = React.useRef({ scope, treeRows, sessionId: ctx?.sessionId, alive: true })
490
+ const openingValid = React.useRef(null)
491
+ liveMenu.current = { scope, treeRows, sessionId: ctx?.sessionId, alive: liveMenu.current.alive }
492
+ React.useEffect(() => {
493
+ liveMenu.current.alive = ctx?.visible !== false
494
+ return () => { liveMenu.current.alive = false; if (openingValid.current) ctx?.officialMenus?.invalidate(openingValid.current) }
495
+ }, [scope, ctx?.sessionId, ctx?.officialMenus, ctx?.visible])
496
+ React.useEffect(() => {
497
+ if (!menuValid || !ctx?.officialMenus || !ctx.sessionId) return
498
+ const target = { ...menu.target }
499
+ const sessionId = ctx.sessionId
500
+ const valid = () => liveMenu.current.alive && liveMenu.current.sessionId === sessionId && liveMenu.current.scope === menu.scope && (target.path === '' || liveMenu.current.treeRows.some((row) => row.e?.path === target.path))
501
+ openingValid.current = valid
502
+ const items = buildTreeMenu(ctx.fileMenuItems, target, ctx).map((item) => ({
503
+ id: item.id, label: item.label, disabled: item.disabled, danger: item.danger, separator: item.separator,
504
+ run: () => item.run(target, ctx),
505
+ }))
506
+ if (ctx.officialMenus.open('resource', ctx.sessionId, target.path, { x: menu.x, y: menu.y }, items, valid)) setMenu(null)
507
+ }, [menu, menuValid, ctx?.officialMenus])
483
508
  // 当前右键目标的可视菜单项(构建时过滤/排序,registry 变化下次打开生效)。
484
509
  const menuEntries = menuValid
485
510
  ? buildTreeMenu(ctx?.fileMenuItems, menu.target, ctx).map((item) => ({
@@ -11,6 +11,7 @@ import type { TreeMenuRegistry } from './contextMenu.js'
11
11
  /** 面板可用的共享上下文(由 SidebarView 从 EditorView 注入,面板不直碰其内部)。 */
12
12
  export interface SidebarCtx {
13
13
  sessionId?: string
14
+ visible?: boolean
14
15
  /** 当前会话工作区 cwd(来自 sessions 快照;规则面板项目 Tab 自动匹配用)。 */
15
16
  cwd?: string | null
16
17
  /** 状态作用域键(工作区优先,无 cwd 回退会话;面板持久化统一走它,scopeStore 产出)。 */
@@ -31,6 +32,8 @@ export interface SidebarCtx {
31
32
  outlineSources?: OutlineSourceRegistry
32
33
  /** 文件右键菜单项注册表(公开 provide 为 edrvFileContextMenuItems;文件管理面板构建菜单用)。 */
33
34
  fileMenuItems?: TreeMenuRegistry
35
+ officialMenus?: import('../contextMenusOfficial.js').OfficialMenuBridge
36
+ forge?: import('../forgeBridge.js').ForgeBridge
34
37
  /** 「添加到对话」动作集(文件/文件夹引用注入对话输入框;缺省时菜单项降级提示)。 */
35
38
  addToConversation?: AddToConversation
36
39
  /** SVN 能力状态(未加载/未检出为 null;SVN 菜单项显隐判定)。 */
@@ -30,6 +30,11 @@ interface FeedEntry {
30
30
  onRefresh: (() => void) | null
31
31
  seq: number
32
32
  inflight: boolean
33
+ queued?: boolean
34
+ queuedSkipStale?: boolean
35
+ request?: Promise<void>
36
+ controller?: AbortController
37
+ onVisible?: () => void
33
38
  }
34
39
 
35
40
  const feeds = new Map<string, FeedEntry>()
@@ -80,12 +85,21 @@ function notifyRecords(entry: FeedEntry): void {
80
85
  */
81
86
  export function refreshFeed(sessionId: string, skipStale = false): Promise<void> {
82
87
  const entry = entryOf(sessionId)
88
+ entry.queuedSkipStale = entry.queuedSkipStale === undefined ? skipStale : entry.queuedSkipStale && skipStale
89
+ if (typeof document !== 'undefined' && document.hidden) { entry.queued = true; return Promise.resolve() }
90
+ if (entry.inflight) {
91
+ entry.queued = true
92
+ return entry.request ?? Promise.resolve()
93
+ }
94
+ skipStale = entry.queuedSkipStale ?? skipStale
95
+ entry.queuedSkipStale = undefined
83
96
  const current = ++entry.seq
84
97
  entry.inflight = true
98
+ entry.controller = new AbortController()
85
99
  const payload: { sessionId: string; skipStale?: boolean } = { sessionId }
86
100
  if (skipStale) payload.skipStale = true
87
101
 
88
- return rpc('edrv.list', payload)
102
+ entry.request = rpc('edrv.list', payload, entry.controller.signal)
89
103
  .then((res) => {
90
104
  if (current !== entry.seq) return
91
105
  if (!res || !res.ok || !Array.isArray(res.records)) return
@@ -105,8 +119,14 @@ export function refreshFeed(sessionId: string, skipStale = false): Promise<void>
105
119
  }
106
120
  })
107
121
  .finally(() => {
108
- if (current === entry.seq) entry.inflight = false
122
+ if (current !== entry.seq) return
123
+ entry.inflight = false
124
+ if (entry.queued) {
125
+ entry.queued = false
126
+ if (feeds.get(sessionId) === entry) return refreshFeed(sessionId, entry.queuedSkipStale ?? false)
127
+ }
109
128
  })
129
+ return entry.request
110
130
  }
111
131
 
112
132
  /**
@@ -118,6 +138,7 @@ export function refreshFeed(sessionId: string, skipStale = false): Promise<void>
118
138
  function ensureTimer(sessionId: string, entry: FeedEntry): void {
119
139
  if (entry.timer === null) {
120
140
  entry.timer = setInterval(() => {
141
+ if (typeof document !== 'undefined' && document.hidden) return
121
142
  const tickers = Array.from(entry.tickers)
122
143
  for (const tick of tickers) {
123
144
  try {
@@ -130,6 +151,14 @@ function ensureTimer(sessionId: string, entry: FeedEntry): void {
130
151
  if (entry.listeners.size > 0 || entry.errorListeners.size > 0) void refreshFeed(sessionId)
131
152
  }, FEED_INTERVAL_MS)
132
153
  }
154
+ if (!entry.onVisible && typeof document !== 'undefined') {
155
+ entry.onVisible = () => {
156
+ if (document.hidden) return
157
+ for (const tick of entry.tickers) { try { tick() } catch { /* Isolated observer. */ } }
158
+ if (entry.listeners.size || entry.errorListeners.size) { entry.queued = false; void refreshFeed(sessionId) }
159
+ }
160
+ document.addEventListener('visibilitychange', entry.onVisible)
161
+ }
133
162
  if (entry.onRefresh === null) {
134
163
  const handler = () => {
135
164
  void refreshFeed(sessionId)
@@ -147,12 +176,16 @@ function ensureTimer(sessionId: string, entry: FeedEntry): void {
147
176
  * @param entry 订阅槽位
148
177
  */
149
178
  function releaseIfIdle(sessionId: string, entry: FeedEntry): void {
150
- if (entry.listeners.size || entry.tickers.size || entry.errorListeners.size) return
179
+ if (feeds.get(sessionId) !== entry || entry.listeners.size || entry.tickers.size || entry.errorListeners.size) return
151
180
  if (entry.timer !== null) clearInterval(entry.timer)
152
181
  if (entry.onRefresh !== null) {
153
182
  const target = win()
154
183
  if (target) target.removeEventListener('edrv:refresh', entry.onRefresh)
155
184
  }
185
+ entry.seq++
186
+ entry.queued = false
187
+ entry.controller?.abort()
188
+ if (entry.onVisible && typeof document !== 'undefined') document.removeEventListener('visibilitychange', entry.onVisible)
156
189
  feeds.delete(sessionId)
157
190
  }
158
191
 
@@ -237,5 +270,9 @@ export function disposeFeed(sessionId: string): void {
237
270
  const target = win()
238
271
  if (target) target.removeEventListener('edrv:refresh', entry.onRefresh)
239
272
  }
273
+ entry.seq++
274
+ entry.queued = false
275
+ entry.controller?.abort()
276
+ if (entry.onVisible && typeof document !== 'undefined') document.removeEventListener('visibilitychange', entry.onVisible)
240
277
  feeds.delete(sessionId)
241
278
  }
@@ -4,7 +4,7 @@ import type { ReviewPage } from '../../shared/review.js'
4
4
 
5
5
  export interface ReviewFeed { page: ReviewPage | null; error: string }
6
6
  type Listener = (feed: ReviewFeed) => void
7
- interface Entry { listeners: Set<Listener>; value: ReviewFeed; controller: AbortController; timer?: ReturnType<typeof setTimeout>; loading: boolean; again: boolean; onRefresh: () => void }
7
+ interface Entry { listeners: Set<Listener>; value: ReviewFeed; controller: AbortController; timer?: ReturnType<typeof setTimeout>; loading: boolean; again: boolean; onRefresh: () => void; onVisible?: () => void }
8
8
  const feeds = new Map<string, Entry>()
9
9
 
10
10
  /** @private @author ddj 2026年10月26号 @param entry Shared feed. Notify mounted observers only. */
@@ -13,6 +13,7 @@ function notify(entry: Entry): void { for (const listener of entry.listeners) li
13
13
  /** @private @author ddj 2026年10月26号 @param sessionId Scope. @param entry Current generation. @returns First-page metadata update; no overlap or late publication. */
14
14
  async function refresh(sessionId: string, entry: Entry, force = false): Promise<void> {
15
15
  if (entry.controller.signal.aborted) return
16
+ if (typeof document !== 'undefined' && document.hidden) { entry.again = true; return }
16
17
  if (entry.loading) { entry.again = true; return }
17
18
  clearTimeout(entry.timer)
18
19
  entry.loading = true
@@ -46,14 +47,20 @@ export function subscribeReview(sessionId: string, listener: Listener): () => vo
46
47
  entry = { listeners: new Set(), value: { page: null, error: '' }, controller: new AbortController(), loading: false, again: false, onRefresh }
47
48
  feeds.set(sessionId, entry)
48
49
  if (typeof window !== 'undefined') window.addEventListener('edrv:refresh', onRefresh)
50
+ if (typeof document !== 'undefined') {
51
+ const current = entry
52
+ entry.onVisible = () => { if (!document.hidden) void refresh(sessionId, current) }
53
+ document.addEventListener('visibilitychange', entry.onVisible)
54
+ }
49
55
  void refresh(sessionId, entry)
50
56
  }
51
57
  entry.listeners.add(listener); listener(entry.value)
52
58
  return () => {
53
59
  entry.listeners.delete(listener)
54
- if (entry.listeners.size) return
60
+ if (entry.listeners.size || feeds.get(sessionId) !== entry) return
55
61
  entry.controller.abort(); clearTimeout(entry.timer); feeds.delete(sessionId)
56
62
  if (typeof window !== 'undefined') window.removeEventListener('edrv:refresh', entry.onRefresh)
63
+ if (entry.onVisible && typeof document !== 'undefined') document.removeEventListener('visibilitychange', entry.onVisible)
57
64
  }
58
65
  }
59
66
 
@@ -48,6 +48,7 @@ export interface TabMenuState {
48
48
  canSplit?: boolean
49
49
  canFloat?: boolean
50
50
  canReturn?: boolean
51
+ forgeReady?: boolean
51
52
  }
52
53
 
53
54
  /** 一条页签菜单项(ContextMenu 的展示形状 + 动作 id)。 */
@@ -122,6 +123,10 @@ export function buildTabMenu(state: TabMenuState): TabMenuEntry[] {
122
123
  label: '添加到对话',
123
124
  disabled: !(hasSession && canAddToConversation),
124
125
  },
126
+ ...(state.forgeReady ? [
127
+ { id: 'forge-open', label: '打开 Forge' },
128
+ { id: 'forge-local', label: '查看本地变更(Forge)' },
129
+ ] : []),
125
130
  { id: 'close', label: '关闭', separator: true, ...(closeChord ? { hint: closeChord } : {}) },
126
131
  { id: 'close-others', label: '关闭其他', disabled: nothingToClose(closeOthers(tabs, path, active), tabs) },
127
132
  { id: 'close-right', label: '关闭右侧标签页', disabled: nothingToClose(closeRight(tabs, path, active), tabs) },
@@ -8,7 +8,7 @@
8
8
  * 作者 ddj 2026年09月10号
9
9
  */
10
10
  import React from 'react'
11
- import { forwardToEditor, parseOfficialFileAddress, resolveNavLine } from '../officialSidebar.js'
11
+ import { forwardToEditor, parseOfficialFileAddress, resolveNavLine, resolveNavColumn } from '../officialSidebar.js'
12
12
  import { log } from '../log.js'
13
13
 
14
14
  /** 转发尝试次数(含首次)与重试间隔。 */
@@ -68,17 +68,22 @@ export function createClaimRouter(spec) {
68
68
  const doneRef = React.useRef(-1)
69
69
 
70
70
  React.useEffect(() => {
71
- if (revision === doneRef.current) return
72
- doneRef.current = revision
71
+ const intent = JSON.stringify([props?.sessionId, tab?.id, revision, navigation?.address])
72
+ if (intent === doneRef.current) return
73
73
  const parsed = parseOfficialFileAddress(navigation?.address)
74
74
  if (!parsed) {
75
75
  setFailed(true)
76
76
  return
77
77
  }
78
78
  const line = resolveNavLine(navigation?.params)
79
+ const column = resolveNavColumn(navigation?.params)
80
+ let alive = true
81
+ setFailed(false)
79
82
  let left = FORWARD_ATTEMPTS
80
83
  const attempt = () => {
81
- if (forwardToEditor(service, parsed.path, line, navigation?.params?.preview)) {
84
+ if (!alive || tab?.signal?.aborted) return
85
+ if (forwardToEditor(typeof tab?.actions?.openTab === 'function' ? tab.actions : service, parsed.path, line, navigation?.params?.preview, column)) {
86
+ doneRef.current = intent
82
87
  closeTab(tab)
83
88
  return
84
89
  }
@@ -87,7 +92,8 @@ export function createClaimRouter(spec) {
87
92
  else setFailed(true)
88
93
  }
89
94
  attempt()
90
- }, [revision])
95
+ return () => { alive = false }
96
+ }, [revision, props?.sessionId, tab?.id, navigation?.address])
91
97
 
92
98
  if (!failed || typeof fallback !== 'function') return null
93
99
  return React.createElement(fallback, props)
@@ -43,7 +43,15 @@ export function EditorPane(props: PaneProps): React.ReactElement {
43
43
  /** @private @author ddj 2026年10月14号 @param event Close press. Never start a drag. */
44
44
  function stop(event: React.PointerEvent): void { event.stopPropagation() }
45
45
  /** @private @author ddj 2026年10月14号 @param event Keyboard activation. */
46
- function key(event: React.KeyboardEvent): void { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(group.id, tab.path) } }
46
+ function key(event: React.KeyboardEvent): void {
47
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
48
+ event.preventDefault(); event.stopPropagation()
49
+ const rect = event.currentTarget.getBoundingClientRect()
50
+ props.onMenu(tab.path, { clientX: rect.left, clientY: rect.bottom } as React.MouseEvent)
51
+ return
52
+ }
53
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(group.id, tab.path) }
54
+ }
47
55
  return React.createElement('div', {
48
56
  key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected': selected,
49
57
  className: 'edrv-tab' + (selected ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),