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/README.md +23 -11
- package/lib/client.js +487 -35
- package/lib/client.js.map +2 -2
- package/lib/index.js +398 -183
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/compatSummary.ts +4 -0
- package/src/client/contextMenusOfficial.ts +80 -0
- package/src/client/forgeBridge.ts +28 -0
- package/src/client/index.ts +18 -3
- package/src/client/monaco/lsp/lspClient.ts +25 -7
- package/src/client/officialSidebar.ts +15 -3
- package/src/client/sidebar/menuItems.ts +6 -0
- package/src/client/sidebar/panels/FileExplorer.ts +26 -1
- package/src/client/sidebar/types.ts +3 -0
- package/src/client/state/recordsFeed.ts +40 -3
- package/src/client/state/reviewFeed.ts +9 -2
- package/src/client/tabMenu.ts +5 -0
- package/src/client/ui/ClaimRouter.ts +11 -5
- package/src/client/ui/EditorPane.ts +9 -1
- package/src/client/ui/EditorView.ts +47 -10
- package/src/client/ui/OfficialSideTab.ts +13 -5
- package/src/pending/capture.ts +96 -47
- package/src/pending/store.ts +95 -57
- package/src/pendingCompat.ts +21 -29
- package/src/pendingRpc.ts +5 -6
- package/src/reviewCapture.ts +64 -23
- package/src/reviewEvidence.ts +1 -1
- package/src/reviewRpc.ts +6 -7
- package/src/toolTargets.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/client/tabMenu.ts
CHANGED
|
@@ -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
|
-
|
|
72
|
-
doneRef.current
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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' : ''),
|