dsh-vscode-mode 0.14.6 → 0.14.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/lib/client.js +818 -123
- package/lib/client.js.map +1 -1
- package/lib/index.js +1002 -19
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/client/events.ts +11 -5
- package/src/client/openFlow.ts +33 -3
- package/src/client/rpc.ts +10 -0
- package/src/client/sidebar/menuItems.ts +7 -6
- package/src/client/sidebar/panels/FileExplorer.ts +89 -72
- package/src/client/sidebar/panels/explorerView.ts +165 -0
- package/src/client/sidebar/panels/useExplorerSvn.ts +141 -0
- package/src/client/styles/editor.css +12 -0
- package/src/client/svnNotice.ts +24 -0
- package/src/client/ui/EditorView.ts +11 -10
- package/src/client/ui/SvnOverlay.ts +24 -0
- package/src/dap/manager.ts +65 -17
- package/src/index.ts +9 -1
- package/src/model.ts +112 -1
- package/src/openBridge.ts +13 -0
- package/src/rpc.ts +3 -1
- package/src/shared/rpc.ts +6 -1
- package/src/shared/svn.ts +55 -0
- package/src/shared/svnExplorer.ts +97 -0
- package/src/store.ts +36 -1
- package/src/svn.ts +108 -6
- package/src/svnExplorer.ts +56 -0
- package/src/svnExplorerCache.ts +304 -0
- package/src/svnStatusStream.ts +135 -0
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +58 -4
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +13 -2
- package/unity/com.dsh.editor/package.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.8",
|
|
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": [
|
package/src/capture.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { dirname } from 'node:path'
|
|
|
13
13
|
import type { DiffRecord, Hunk } from './shared/types.js'
|
|
14
14
|
import { annotateHunks, fingerprint } from './shared/diff.js'
|
|
15
15
|
import type { Ctx, Session } from './store.js'
|
|
16
|
-
import { READ_CAP, loadBucket, saveBucket } from './store.js'
|
|
16
|
+
import { READ_CAP, archiveCanceled, loadBucket, saveBucket } from './store.js'
|
|
17
17
|
import { fileMaxBatch, prune } from './model.js'
|
|
18
18
|
import type { Registry } from './registry.js'
|
|
19
19
|
import { cwdOf } from './registry.js'
|
|
@@ -253,6 +253,9 @@ async function persistRecord(ctx: Ctx, registry: Registry, session: Session, cwd
|
|
|
253
253
|
for (const [callId, rec] of prior) bucket.set(callId, rec)
|
|
254
254
|
throw error
|
|
255
255
|
}
|
|
256
|
+
// 先保存再独立归档:优化失败不得撤回已捕获的记录,下次列表维护可重试。
|
|
257
|
+
try { await archiveCanceled(ctx, session, cwd, bucket, record) }
|
|
258
|
+
catch (error) { log.warn('archive canceled edits failed: ' + String(error)) }
|
|
256
259
|
}
|
|
257
260
|
|
|
258
261
|
/**
|
package/src/client/events.ts
CHANGED
|
@@ -49,9 +49,13 @@ export function openDiffView(path: string): void {
|
|
|
49
49
|
}, 80)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
export interface RefreshNotice { scope?: string; paths?: string[]; eventId?: string; kind?: 'text' | 'structure' | 'svn' | 'unknown' }
|
|
53
|
+
let refreshSeq = 0
|
|
54
|
+
/** @public @author ddj 2026年10月10号 @param notice Scope/path/id; absent detail means legacy unknown/full. @returns Shared id for multi-channel deduplication. */
|
|
55
|
+
export function emitRefresh(notice: RefreshNotice = {}): string {
|
|
56
|
+
const eventId = notice.eventId ?? 'change-' + Date.now() + '-' + (++refreshSeq)
|
|
57
|
+
window.dispatchEvent(new CustomEvent('edrv:refresh', { detail: { ...notice, eventId } }))
|
|
58
|
+
return eventId
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/**
|
|
@@ -60,9 +64,11 @@ export function emitRefresh(): void {
|
|
|
60
64
|
* @author ddj 2026年09月15号
|
|
61
65
|
* @param path 发生变化的文件路径
|
|
62
66
|
*/
|
|
63
|
-
export function emitFileChanged(path: string):
|
|
67
|
+
export function emitFileChanged(path: string, notice: RefreshNotice = {}): string | undefined {
|
|
64
68
|
if (!path) return
|
|
65
|
-
|
|
69
|
+
const eventId = notice.eventId ?? 'change-' + Date.now() + '-' + (++refreshSeq)
|
|
70
|
+
window.dispatchEvent(new CustomEvent('edrv:file-changed', { detail: { ...notice, path, paths: notice.paths ?? [path], eventId } }))
|
|
71
|
+
return eventId
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/** 打开指定路径到编辑区页签。 */
|
package/src/client/openFlow.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { rpc } from './rpc.js'
|
|
|
10
10
|
import { chooseWorkspace } from './chooseDialog.js'
|
|
11
11
|
import { requestOpen } from './openReceipt.js'
|
|
12
12
|
import { toastDom } from './externalOpen.js'
|
|
13
|
-
import { createAddToConversation } from './addToConversation.js'
|
|
13
|
+
import { createAddToConversation, newDraftSession } from './addToConversation.js'
|
|
14
14
|
import type { AddOutcome, CtxLike } from './addToConversation.js'
|
|
15
15
|
import type { OpenParams } from '../shared/externalOpen.js'
|
|
16
16
|
|
|
@@ -307,12 +307,42 @@ function realDeps(ctx: unknown, overrides: Partial<OpenDeps> = {}): OpenDeps {
|
|
|
307
307
|
const snap = (service('sessions')?.list?.getSnapshot?.() ?? {}) as { ids?: string[]; byId?: Record<string, SessionRow>; phase?: string }
|
|
308
308
|
return { ids: snap.ids ?? [], byId: snap.byId ?? {}, ready: snap.phase !== 'pending' }
|
|
309
309
|
},
|
|
310
|
-
createSession: async (opts) =>
|
|
310
|
+
createSession: async (opts) => {
|
|
311
|
+
// 桌面端 sessions 服务面缺 create(实证缺 open;create 同代面可能缺)——
|
|
312
|
+
// 直连 create 不可用时回落 newDraftSession 的多通道建会话(宽松解析 id)。
|
|
313
|
+
const sessions = service('sessions') as { create?: (o: object) => unknown } | undefined
|
|
314
|
+
if (typeof sessions?.create === 'function') {
|
|
315
|
+
try {
|
|
316
|
+
const created = await sessions.create(opts)
|
|
317
|
+
const id = typeof created === 'string' && created ? created
|
|
318
|
+
: typeof (created as { id?: unknown } | null)?.id === 'string' ? (created as { id: string }).id
|
|
319
|
+
: typeof (created as { sessionId?: unknown } | null)?.sessionId === 'string' ? (created as { sessionId: string }).sessionId
|
|
320
|
+
: ''
|
|
321
|
+
if (id) return id
|
|
322
|
+
} catch { /* 回落下一通道 */ }
|
|
323
|
+
}
|
|
324
|
+
const draft = await newDraftSession(ctx as CtxLike, opts.cwd)
|
|
325
|
+
if (draft.ok && draft.id) return draft.id
|
|
326
|
+
throw new Error('新建会话失败')
|
|
327
|
+
},
|
|
311
328
|
createWorkspace: async (path) => {
|
|
312
329
|
const ws = await service('workspaces').create({ path })
|
|
313
330
|
return { workspaceId: ws.workspaceId, title: ws.title }
|
|
314
331
|
},
|
|
315
|
-
openSession: (id) =>
|
|
332
|
+
openSession: (id) => {
|
|
333
|
+
// 桌面端 sessions 服务面缺 .open(实证 TypeError)——按 startDraftSession 同款
|
|
334
|
+
// 多面切换:materializeScope 铸 scope → manager.select 切会话 → open 兜底,缺面静默。
|
|
335
|
+
try {
|
|
336
|
+
const sessions = service('sessions') as {
|
|
337
|
+
materializeScope?: (id: string) => unknown
|
|
338
|
+
manager?: { select?: (id: string) => void }
|
|
339
|
+
open?: (id: string) => void
|
|
340
|
+
} | undefined
|
|
341
|
+
sessions?.materializeScope?.(id)
|
|
342
|
+
void sessions?.manager?.select?.(id)
|
|
343
|
+
void sessions?.open?.(id)
|
|
344
|
+
} catch { /* 面能力缺失不致命:打开失败由 openChecked 抛错兜底 */ }
|
|
345
|
+
},
|
|
316
346
|
reference: async (sessionId, path, appearance) => {
|
|
317
347
|
const outcome: AddOutcome = await add.appendReference(sessionId, path, undefined, appearance)
|
|
318
348
|
// ok/busy(纯文本降级)都算已落点;unavailable/failed 交给 settleRef 继续重试
|
package/src/client/rpc.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 诊断开关运行时化:内存缓存 + localStorage 持久(日志弹窗开关即改即生效,免刷新)。
|
|
5
5
|
* 作者 ddj 2026-08-20 / 2026-09-17
|
|
6
6
|
*/
|
|
7
|
+
import { svnNotice } from './svnNotice.js'
|
|
7
8
|
import { appUrl } from '../shared/appUrl.js'
|
|
8
9
|
import { RPC_PATH } from '../shared/rpc.js'
|
|
9
10
|
import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
|
|
@@ -45,6 +46,15 @@ export function setDebugEnabled(on: boolean): void {
|
|
|
45
46
|
* @returns A validated business result; transport failures reject with a readable error.
|
|
46
47
|
*/
|
|
47
48
|
export async function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M], signal?: AbortSignal): Promise<RpcResult<M>> {
|
|
49
|
+
try {
|
|
50
|
+
const out = await rpcFetch(method, args, signal)
|
|
51
|
+
svnNotice(method, args, out.ok)
|
|
52
|
+
return out
|
|
53
|
+
} catch (error) { svnNotice(method, args, false); throw error }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @private @author ddj 2026年10月10号 @param method Typed method. @param args Request. @param signal Fetch lifetime. @returns Validated authenticated business response. @throws Transport/format errors. */
|
|
57
|
+
async function rpcFetch<M extends RpcMethod>(method: M, args: RpcRequestMap[M], signal?: AbortSignal): Promise<RpcResult<M>> {
|
|
48
58
|
const res = await fetch(appUrl(RPC_PATH), {
|
|
49
59
|
method: 'POST',
|
|
50
60
|
credentials: 'same-origin',
|
|
@@ -126,9 +126,10 @@ async function fsCall<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): P
|
|
|
126
126
|
* @param ctx 面板上下文
|
|
127
127
|
* @param okText 成功反馈文案
|
|
128
128
|
*/
|
|
129
|
-
|
|
129
|
+
let fsSequence = 0
|
|
130
|
+
function fsDone(ctx: SidebarCtx, okText: string, paths: string[]): void {
|
|
130
131
|
ctx.notify?.(okText)
|
|
131
|
-
emit('edrv:refresh', {})
|
|
132
|
+
emit('edrv:refresh', { scope: ctx.scope, paths, kind: 'structure', eventId: 'fs-' + Date.now() + '-' + (++fsSequence) })
|
|
132
133
|
ctx.refreshSvnChanges?.()
|
|
133
134
|
}
|
|
134
135
|
|
|
@@ -176,7 +177,7 @@ async function createEntry(target: TreeMenuTarget, ctx: SidebarCtx, kind: 'file'
|
|
|
176
177
|
return
|
|
177
178
|
}
|
|
178
179
|
if (kind === 'file') ctx.openFile(path)
|
|
179
|
-
fsDone(ctx, (kind === 'file' ? '已新建文件 ' : '已新建文件夹 ') + path)
|
|
180
|
+
fsDone(ctx, (kind === 'file' ? '已新建文件 ' : '已新建文件夹 ') + path, [path])
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
/**
|
|
@@ -205,7 +206,7 @@ async function renameEntry(target: TreeMenuTarget, ctx: SidebarCtx): Promise<voi
|
|
|
205
206
|
return
|
|
206
207
|
}
|
|
207
208
|
if (res.to !== res.from) emit('edrv:path-renamed', { from: res.from, to: res.to })
|
|
208
|
-
fsDone(ctx, '已重命名')
|
|
209
|
+
fsDone(ctx, '已重命名', [res.from, res.to])
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
/**
|
|
@@ -227,7 +228,7 @@ async function deleteEntry(target: TreeMenuTarget, ctx: SidebarCtx): Promise<voi
|
|
|
227
228
|
return
|
|
228
229
|
}
|
|
229
230
|
emit('edrv:path-deleted', { path: target.path })
|
|
230
|
-
fsDone(ctx, (dirTarget ? '已永久删除「' : '已删除「') + name + '」')
|
|
231
|
+
fsDone(ctx, (dirTarget ? '已永久删除「' : '已删除「') + name + '」', [target.path])
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
/**
|
|
@@ -252,7 +253,7 @@ async function pasteInto(target: TreeMenuTarget, ctx: SidebarCtx): Promise<void>
|
|
|
252
253
|
clearFileClip()
|
|
253
254
|
emit('edrv:path-renamed', { from: res.from, to: res.to })
|
|
254
255
|
}
|
|
255
|
-
fsDone(ctx, (cut ? '已移动到 ' : '已复制到 ') + (target.path || '工作区根目录'))
|
|
256
|
+
fsDone(ctx, (cut ? '已移动到 ' : '已复制到 ') + (target.path || '工作区根目录'), cut ? [res.from, res.to] : [res.to])
|
|
256
257
|
}
|
|
257
258
|
|
|
258
259
|
// --endregion
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* 保留旧条目强制后台重列(force);每次用户发起加载后预取 ≤4 个子目录(排除重型
|
|
7
7
|
* 目录、缓存新鲜跳过、不级联);10s 轻量跟随已展开目录(命中索引,RPC 近零成本)。
|
|
8
8
|
* 差异角标/右键菜单/展开状态持久化行为不变;行图标用官方原语(文件夹图标 + 文件类型图标)。
|
|
9
|
-
* SVN
|
|
10
|
-
*
|
|
9
|
+
* SVN 图标叠加:受控正常绿色对勾、本地修改红色感叹号;目录聚合后代状态。
|
|
10
|
+
* 远端更新箭头由显式只读检查提供,与本地状态并存,不执行更新。
|
|
11
|
+
* 作者 ddj 2026-08-26 / 2026-08-27 / 2026-08-31 / 2026-09-10 / 2026-09-16 / 2026-10-09
|
|
11
12
|
*/
|
|
12
13
|
import React from 'react'
|
|
13
14
|
import { IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, fileIconEl } from '../../ui/icons.js'
|
|
@@ -19,7 +20,10 @@ import { entriesCacheGet, entriesCacheIsFresh, entriesCachePut } from '../../sta
|
|
|
19
20
|
import { workspaceScopeOf } from '../../state/scopeStore.js'
|
|
20
21
|
import { ancestorDirsOf } from '../../tabActions.js'
|
|
21
22
|
import { refreshSvnChanges, refreshSvnStatus } from '../../svnStatus.js'
|
|
22
|
-
import {
|
|
23
|
+
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
24
|
+
import { emitRefresh } from '../../events.js'
|
|
25
|
+
import { svnOverlayEl, viewOverlayOf } from '../../ui/SvnOverlay.js'
|
|
26
|
+
import { useExplorerSvn } from './useExplorerSvn.js'
|
|
23
27
|
import type { SidebarCtx } from '../types.js'
|
|
24
28
|
|
|
25
29
|
const DIR_CAP = 4000
|
|
@@ -73,6 +77,26 @@ function refreshIconEl() {
|
|
|
73
77
|
}
|
|
74
78
|
// --endregion
|
|
75
79
|
|
|
80
|
+
/** @private @author ddj 2026年10月10号 @param props Stable primitive row fields and compact mark. @returns Official file icon with independently fresh SVN evidence. */
|
|
81
|
+
function rowView(props) {
|
|
82
|
+
const { path, name, type, depth, isDir, isOpen, pending, active, context, reveal, mark, remote, onSelect, onContext } = props
|
|
83
|
+
/** @private @author ddj 2026年10月10号 Handle row activation. */
|
|
84
|
+
function select() { onSelect(path, isDir) }
|
|
85
|
+
/** @private @author ddj 2026年10月10号 @param event Context trigger. */
|
|
86
|
+
function menu(event) { onContext(path, type, event) }
|
|
87
|
+
return React.createElement('div', {
|
|
88
|
+
className: 'edrv-tree-row' + (active ? ' edrv-tree-active' : '') + (context ? ' edrv-tree-context' : '')
|
|
89
|
+
+ (reveal ? ' edrv-tree-reveal' : '') + (mark?.stale ? ' edrv-tree-stale' : ''),
|
|
90
|
+
title: path + (mark?.stale ? ';SVN 状态陈旧,等待确认' : ''), 'data-edrv-path': path,
|
|
91
|
+
style: { paddingLeft: 6 + depth * 14 }, onClick: select, onContextMenu: menu,
|
|
92
|
+
},
|
|
93
|
+
React.createElement('span', { className: 'edrv-tree-chev' }, isDir ? (isOpen ? '▾' : '▸') : ''),
|
|
94
|
+
React.createElement('span', { className: 'edrv-tree-iconbox' }, rowIconEl({ name }, isDir, isOpen), svnOverlayEl(viewOverlayOf(mark), remote)),
|
|
95
|
+
React.createElement('span', { className: 'edrv-tree-name' + (type === 'other' ? ' edrv-tree-dim' : '') }, name),
|
|
96
|
+
pending > 0 ? React.createElement('span', { className: 'edrv-tree-badge' }, String(pending)) : null)
|
|
97
|
+
}
|
|
98
|
+
const ExplorerRow = React.memo(rowView)
|
|
99
|
+
|
|
76
100
|
/**
|
|
77
101
|
* 转义 CSS 属性选择器的值(路径含引号/反斜杠时避免选择器语法错误)。
|
|
78
102
|
* @author ddj 2026年09月11号
|
|
@@ -92,10 +116,11 @@ export function FileExplorer(props) {
|
|
|
92
116
|
const sessionId = ctx?.sessionId
|
|
93
117
|
// 状态作用域:展开树/条目缓存按工作区隔离(同工作区切对话恢复同一份),无 cwd 回退会话
|
|
94
118
|
const scope = ctx?.scope ?? workspaceScopeOf(null, sessionId)
|
|
119
|
+
const scopeRef = React.useRef(scope)
|
|
120
|
+
scopeRef.current = scope
|
|
95
121
|
const openFile = ctx?.openFile
|
|
96
122
|
const activePath = ctx?.activePath ?? null
|
|
97
123
|
const pendingByPath = ctx?.pendingByPath ?? {}
|
|
98
|
-
const svnChangeMap = ctx?.svnChangeMap ?? {}
|
|
99
124
|
const [root, setRoot] = React.useState(null)
|
|
100
125
|
const [dirs, setDirs] = React.useState({}) // rel → 最新条目(本会话内存态)
|
|
101
126
|
const [expanded, setExpanded] = React.useState({})
|
|
@@ -239,7 +264,9 @@ export function FileExplorer(props) {
|
|
|
239
264
|
}
|
|
240
265
|
revealInTreeRef.current = revealInTree
|
|
241
266
|
|
|
242
|
-
const refresh = () => {
|
|
267
|
+
const refresh = (event) => {
|
|
268
|
+
if (event?.detail?.scope && event.detail.scope !== scope) return
|
|
269
|
+
if (event?.detail?.paths?.length) { for (const path of event.detail.paths) onFileChanged(path); return }
|
|
243
270
|
const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
|
|
244
271
|
tokensRef.current = {}
|
|
245
272
|
setMenu(null)
|
|
@@ -274,7 +301,7 @@ export function FileExplorer(props) {
|
|
|
274
301
|
if (typeof path !== 'string' || !path) return
|
|
275
302
|
const rel = path.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
276
303
|
const parts = rel.split('/').filter(Boolean)
|
|
277
|
-
|
|
304
|
+
changedRelRef.current.add(parts.slice(0, -1).join('/'))
|
|
278
305
|
// 绝对路径(或已在更深层目录):补上所有已展开的祖先目录
|
|
279
306
|
for (const dir of ancestorDirsOf(rel)) {
|
|
280
307
|
if (expandedRef.current[dir] === true) changedRelRef.current.add(dir)
|
|
@@ -293,7 +320,10 @@ export function FileExplorer(props) {
|
|
|
293
320
|
|
|
294
321
|
// edrv:file-changed(外部改动同步):按变化路径强制重列对应目录(去抖合并)
|
|
295
322
|
React.useEffect(() => {
|
|
296
|
-
const handler = (event) =>
|
|
323
|
+
const handler = (event) => {
|
|
324
|
+
if (event?.detail?.scope && event.detail.scope !== scopeRef.current) return
|
|
325
|
+
for (const path of event?.detail?.paths ?? [event?.detail?.path]) onFileChanged(path)
|
|
326
|
+
}
|
|
297
327
|
window.addEventListener('edrv:file-changed', handler)
|
|
298
328
|
return () => {
|
|
299
329
|
window.removeEventListener('edrv:file-changed', handler)
|
|
@@ -343,7 +373,7 @@ export function FileExplorer(props) {
|
|
|
343
373
|
|
|
344
374
|
// edrv:refresh(差异决策/回滚后):保留旧条目,强制后台重列
|
|
345
375
|
React.useEffect(() => {
|
|
346
|
-
const onRefresh = () => refreshRef.current?.()
|
|
376
|
+
const onRefresh = (event) => refreshRef.current?.(event)
|
|
347
377
|
window.addEventListener('edrv:refresh', onRefresh)
|
|
348
378
|
return () => window.removeEventListener('edrv:refresh', onRefresh)
|
|
349
379
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -367,6 +397,7 @@ export function FileExplorer(props) {
|
|
|
367
397
|
React.useEffect(() => {
|
|
368
398
|
if (!scope) return
|
|
369
399
|
const timer = setInterval(() => {
|
|
400
|
+
if (document.visibilityState === 'hidden') return
|
|
370
401
|
const open = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
|
|
371
402
|
if (!open.length) return
|
|
372
403
|
for (const rel of open) loadDirRef.current?.(rel, {})
|
|
@@ -375,69 +406,49 @@ export function FileExplorer(props) {
|
|
|
375
406
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
376
407
|
}, [scope])
|
|
377
408
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
React.createElement('span', { className: 'edrv-tree-name' + (dim ? ' edrv-tree-dim' : '') },
|
|
409
|
-
e.name),
|
|
410
|
-
(svnLetter
|
|
411
|
-
? React.createElement('span', {
|
|
412
|
-
className: 'edrv-tree-svn edrv-svn-ch-' + (SVN_STATUS_TONE[svnEntry.status] ?? 'plain'),
|
|
413
|
-
title: 'SVN ' + svnBadgeTitle(svnEntry),
|
|
414
|
-
}, svnLetter)
|
|
415
|
-
: null),
|
|
416
|
-
(pending > 0
|
|
417
|
-
? React.createElement('span', { className: 'edrv-tree-badge' }, String(pending))
|
|
418
|
-
: null))
|
|
409
|
+
/** @private @author ddj 2026年10月10号 @returns Expanded tree descriptors; icon refreshes never re-walk the tree. */
|
|
410
|
+
function flatten() {
|
|
411
|
+
const result = []
|
|
412
|
+
/** @private @author ddj 2026年10月10号 @param rel Directory. @param depth Indent level. Appends only expanded rows and placeholders. */
|
|
413
|
+
function visit(rel, depth) {
|
|
414
|
+
const entries = dirs[rel] ?? entriesCacheGet(scope, rel)
|
|
415
|
+
if (!entries) { result.push({ key: rel + ':loading', depth, text: loading[rel] ? '加载中…' : '无法加载' }); return }
|
|
416
|
+
for (const e of entries) {
|
|
417
|
+
const isDir = e.type === 'directory', isOpen = expanded[e.path] === true
|
|
418
|
+
result.push({ key: e.path, e, depth, isDir, isOpen })
|
|
419
|
+
if (isDir && isOpen) visit(e.path, depth + 1)
|
|
420
|
+
}
|
|
421
|
+
if (entries.length >= DIR_CAP) result.push({ key: rel + ':cap', depth, text: '目录条目过多,仅显示前 ' + DIR_CAP + ' 项' })
|
|
422
|
+
}
|
|
423
|
+
visit('', 0)
|
|
424
|
+
return result
|
|
425
|
+
}
|
|
426
|
+
const treeRows = React.useMemo(flatten, [dirs, expanded, loading, scope])
|
|
427
|
+
/** @private @author ddj 2026年10月10号 @returns Stable RPC row projection, independent of icon-only renders. */
|
|
428
|
+
function projectRows() { return treeRows.filter((row) => row.e).map((row) => ({ path: row.e.path, isDir: row.isDir })) }
|
|
429
|
+
const svnRows = React.useMemo(projectRows, [treeRows])
|
|
430
|
+
const svn = useExplorerSvn(ctx, svnRows)
|
|
431
|
+
/** @private @author ddj 2026年10月10号 @param path Row path. @param isDir Row kind. Routes selection through latest callback. */
|
|
432
|
+
function selectRow(path, isDir) { if (isDir) toggle(path); else openFile?.(path) }
|
|
433
|
+
const selectRef = React.useRef(selectRow)
|
|
434
|
+
selectRef.current = selectRow
|
|
435
|
+
/** @private @author ddj 2026年10月10号 @param path Row path. @param type Entry type. @param event Context trigger. */
|
|
436
|
+
function contextRow(path, type, event) {
|
|
437
|
+
event.preventDefault(); event.stopPropagation()
|
|
438
|
+
setMenu({ x: event.clientX, y: event.clientY, target: { path, type } })
|
|
419
439
|
}
|
|
440
|
+
const onSelect = React.useCallback((path, isDir) => selectRef.current(path, isDir), [])
|
|
441
|
+
const onContext = React.useCallback(contextRow, [])
|
|
442
|
+
const rootName = root ? String(root).split(/[\\/]/).pop() || root : ''
|
|
420
443
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (!
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
for (let i = 0; i < cap; i++) {
|
|
430
|
-
const e = entries[i]
|
|
431
|
-
const isDir = e.type === 'directory'
|
|
432
|
-
const isOpen = expanded[e.path] === true
|
|
433
|
-
rows.push(rowEl(e, depth, isDir, isOpen))
|
|
434
|
-
if (isDir && isOpen) rows.push(...rowsOf(e.path, depth + 1))
|
|
435
|
-
}
|
|
436
|
-
if (cap >= DIR_CAP) {
|
|
437
|
-
rows.push(React.createElement('div', { key: rel + ':cap', className: 'edrv-tree-loading', style: { paddingLeft: 6 + depth * 14 } },
|
|
438
|
-
'目录条目过多,仅显示前 ' + DIR_CAP + ' 项'))
|
|
439
|
-
}
|
|
440
|
-
return rows
|
|
444
|
+
/** @private @author ddj 2026年10月10号 @param row Flat descriptor. @returns Memo row; unaffected status objects retain identity. */
|
|
445
|
+
function renderRow(row) {
|
|
446
|
+
if (!row.e) return React.createElement('div', { key: row.key, className: 'edrv-tree-loading', style: { paddingLeft: 6 + row.depth * 14 } }, row.text)
|
|
447
|
+
const { e, depth, isDir, isOpen } = row
|
|
448
|
+
return React.createElement(ExplorerRow, { key: e.path, path: e.path, name: e.name, type: e.type, depth, isDir, isOpen,
|
|
449
|
+
pending: isDir ? 0 : pendingByPath[e.path] ?? 0, active: !isDir && e.path === activePath,
|
|
450
|
+
context: Boolean(menu && menuEntries.length && menu.target.path === e.path), reveal: revealPath === e.path,
|
|
451
|
+
mark: svn.local?.get(e.path), remote: svn.remote?.get(e.path) ?? 0, onSelect, onContext })
|
|
441
452
|
}
|
|
442
453
|
|
|
443
454
|
// 面板区右键(空白处)→ 工作区根目录菜单;行内右键已在 rowEl 阻止冒泡。
|
|
@@ -467,14 +478,20 @@ export function FileExplorer(props) {
|
|
|
467
478
|
React.createElement('span', { className: 'edrv-side-title' }, '资源管理器'),
|
|
468
479
|
React.createElement('span', { className: 'edrv-side-root', title: root || '' }, rootName),
|
|
469
480
|
React.createElement('span', { style: { flex: 1 } }),
|
|
470
|
-
React.createElement(
|
|
481
|
+
ctx?.svn?.managed ? React.createElement(Button, {
|
|
482
|
+
variant: 'toolbar', size: 'sm', disabled: !svn.canCheck || svn.busy,
|
|
483
|
+
title: '检查仓库更新(只读;不会执行 svn update)', onClick: svn.checkRemote,
|
|
484
|
+
'aria-label': '检查仓库更新',
|
|
485
|
+
}, svn.busy ? '…' : '↓') : null,
|
|
486
|
+
React.createElement('button', { className: 'edrv-side-btn', title: '刷新目录树', onClick: () => emitRefresh({ scope, kind: 'unknown' }) }, refreshIconEl())),
|
|
487
|
+
svn.message ? React.createElement('div', { className: 'edrv-tree-status', role: 'status' }, svn.message) : null,
|
|
471
488
|
React.createElement('div', { className: 'edrv-tree', ref: treeRef },
|
|
472
489
|
(errorText
|
|
473
490
|
? React.createElement('div', { className: 'edrv-tree-error' },
|
|
474
491
|
React.createElement('span', null, String(errorText)),
|
|
475
|
-
React.createElement('button', { className: 'edrv-side-btn', onClick: () =>
|
|
492
|
+
React.createElement('button', { className: 'edrv-side-btn', onClick: () => emitRefresh({ scope, kind: 'unknown' }) }, '重试'))
|
|
476
493
|
: null),
|
|
477
|
-
|
|
494
|
+
treeRows.map(renderRow)),
|
|
478
495
|
(menu && menuEntries.length
|
|
479
496
|
? React.createElement(ContextMenu, { x: menu.x, y: menu.y, entries: menuEntries, onClose: () => setMenu(null) })
|
|
480
497
|
: null))
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** Compact explorer lifecycle, independently testable without browser/React. @author ddj 2026年10月10号 */
|
|
2
|
+
import type { SvnRefreshReason, SvnViewMark, SvnViewPayload, SvnViewRequest, SvnViewRow } from '../../../shared/svnExplorer.js'
|
|
3
|
+
|
|
4
|
+
export interface ExplorerNotice { scope?: string; paths?: string[]; eventId?: string; kind?: 'text' | 'structure' | 'svn' | 'unknown' }
|
|
5
|
+
export interface ExplorerView extends Omit<SvnViewPayload, 'rows'> { marks: Map<string, SvnViewMark> }
|
|
6
|
+
type ViewResult = ({ ok: true } & SvnViewPayload) | { ok: false; error: string }
|
|
7
|
+
type ViewFetch = (args: SvnViewRequest, signal: AbortSignal) => Promise<ViewResult>
|
|
8
|
+
const EMPTY: ExplorerView = { scopeId: '', prefix: '.', revision: 0, generation: 0, observedGeneration: 0, confirmedAt: 0,
|
|
9
|
+
complete: false, refreshing: false, scanMs: 0, error: '', mismatch: false, marks: new Map() }
|
|
10
|
+
let nextClient = 0
|
|
11
|
+
|
|
12
|
+
/** @public @author ddj 2026年10月10号 @param previous Stable row objects. @param rows Compact projection. @returns Same map when unchanged; otherwise replaces only changed marks. */
|
|
13
|
+
export function applyViewRows(previous: Map<string, SvnViewMark>, rows: readonly SvnViewMark[]): Map<string, SvnViewMark> {
|
|
14
|
+
const next = new Map<string, SvnViewMark>()
|
|
15
|
+
let changed = previous.size !== rows.length
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
const old = previous.get(row.path)
|
|
18
|
+
const equal = old && old.kind === row.kind && old.count === row.count && old.stale === row.stale
|
|
19
|
+
next.set(row.path, equal ? old : row)
|
|
20
|
+
if (!equal) changed = true
|
|
21
|
+
}
|
|
22
|
+
return changed ? next : previous
|
|
23
|
+
}
|
|
24
|
+
/** @private @author ddj 2026年10月10号 @param path Row. @param root Changed path. @returns Segment-bound overlap incl. ancestors. */
|
|
25
|
+
function overlaps(path: string, root: string): boolean {
|
|
26
|
+
const a = path.replace(/\\/g, '/').toLowerCase(), b = root.replace(/\\/g, '/').toLowerCase()
|
|
27
|
+
return a === b || a.startsWith(b + '/') || b.startsWith(a + '/')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --region Single-flight client projection
|
|
31
|
+
/** @public @author ddj 2026年10月10号 Owns fetch/debounce/calibration; does not own or cancel shared Host scans. */
|
|
32
|
+
export class ExplorerViewClient {
|
|
33
|
+
private view: ExplorerView = { ...EMPTY, marks: new Map() }
|
|
34
|
+
private rows: SvnViewRow[] = []
|
|
35
|
+
private paths = new Set<string>()
|
|
36
|
+
private reason: SvnRefreshReason = 'read'
|
|
37
|
+
private pending = false
|
|
38
|
+
private since = 0
|
|
39
|
+
private last = 0
|
|
40
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
41
|
+
private controller: AbortController | null = null
|
|
42
|
+
private stopped = false
|
|
43
|
+
private visible = true
|
|
44
|
+
private rowEpoch = 0
|
|
45
|
+
private sequence = 0
|
|
46
|
+
private readonly clientId = 'explorer-' + Date.now() + '-' + (++nextClient)
|
|
47
|
+
private readonly events = new Set<string>()
|
|
48
|
+
private lastNotice = 0
|
|
49
|
+
sessionId?: string
|
|
50
|
+
|
|
51
|
+
/** @public @author ddj 2026年10月10号 @param scope Workspace UI key. @param fetch Authenticated compact RPC. @param publish State callback. @param remoteDrop Evidence invalidation callback. */
|
|
52
|
+
constructor(readonly scope: string, private readonly fetch: ViewFetch, private readonly publish: (view: ExplorerView) => void,
|
|
53
|
+
private readonly remoteDrop: () => void) {}
|
|
54
|
+
|
|
55
|
+
/** @public @author ddj 2026年10月10号 @param rows Expanded/displayed paths. Updates projection only, never forces a scan. */
|
|
56
|
+
setRows(rows: readonly SvnViewRow[]): void {
|
|
57
|
+
this.rows = [...rows]; this.rowEpoch++
|
|
58
|
+
this.enqueue('read', 0)
|
|
59
|
+
}
|
|
60
|
+
/** @public @author ddj 2026年10月10号 @param notice Optional scope/path/id and evidence kind. Ignore another scope; repeated IDs do not dirty twice. */
|
|
61
|
+
notify(notice: ExplorerNotice = {}): void {
|
|
62
|
+
if (notice.scope && notice.scope !== this.scope) return
|
|
63
|
+
if (notice.eventId && this.events.has(notice.eventId)) return
|
|
64
|
+
if (notice.eventId) { this.events.add(notice.eventId); if (this.events.size > 512) this.events.delete(this.events.values().next().value!) }
|
|
65
|
+
this.lastNotice = Date.now()
|
|
66
|
+
if (notice.kind !== 'text') this.remoteDrop()
|
|
67
|
+
const paths = notice.paths?.filter(Boolean)
|
|
68
|
+
const invalid = paths?.some((path) => typeof path !== 'string' || path.length > 1024 || /^[\\/]|^[A-Za-z]:|[\x00-\x1f]/.test(path)
|
|
69
|
+
|| path.replace(/\\/g, '/').split('/').includes('..'))
|
|
70
|
+
if (!paths?.length || invalid) this.reason = 'full'
|
|
71
|
+
else for (const path of paths) this.paths.add(path)
|
|
72
|
+
if (this.paths.size > 256) this.reason = 'full'
|
|
73
|
+
if (this.reason !== 'full') this.reason = 'paths'
|
|
74
|
+
if (this.reason === 'full') this.paths.clear()
|
|
75
|
+
this.stale(this.reason === 'full' ? null : paths!)
|
|
76
|
+
this.enqueue(this.reason, 300)
|
|
77
|
+
}
|
|
78
|
+
/** @public @author ddj 2026年10月10号 Unknown-origin changes-list replacement requests one calibration, not reference-driven full scan. */
|
|
79
|
+
changes(): void { if (Date.now() - this.lastNotice > 1000) this.enqueue('calibrate', 300) }
|
|
80
|
+
/** @public @author ddj 2026年10月10号 @param visible Page visibility. Hide clears automatic work, show/focus calibrates only after five seconds. */
|
|
81
|
+
visibility(visible: boolean): void {
|
|
82
|
+
this.visible = visible
|
|
83
|
+
if (!visible) { if (this.timer) clearTimeout(this.timer); this.timer = null; return }
|
|
84
|
+
if (Date.now() - this.view.confirmedAt > 5000) this.enqueue('calibrate', 0)
|
|
85
|
+
else this.enqueue('read', 0)
|
|
86
|
+
}
|
|
87
|
+
/** @public @author ddj 2026年10月10号 Explicit offline full refresh; remote evidence no longer trusted. */
|
|
88
|
+
refresh(): void { this.notify({ kind: 'unknown' }) }
|
|
89
|
+
|
|
90
|
+
/** @private @author ddj 2026年10月10号 @param paths Dirty paths or unknown/full. Mark stale immediately, retain independent rows and anomalous evidence. */
|
|
91
|
+
private stale(paths: readonly string[] | null): void {
|
|
92
|
+
const rows = [...this.view.marks.values()].map((row) => !paths || paths.some((path) => overlaps(row.path, path)) ? { ...row, stale: true } : row)
|
|
93
|
+
this.view = { ...this.view, complete: false, refreshing: true, marks: applyViewRows(this.view.marks, rows) }
|
|
94
|
+
this.publish(this.view)
|
|
95
|
+
}
|
|
96
|
+
/** @private @author ddj 2026年10月10号 @param reason Requested work. @param delay Tail wait, bounded by one second. */
|
|
97
|
+
private enqueue(reason: SvnRefreshReason, delay: number): void {
|
|
98
|
+
if (this.stopped) return
|
|
99
|
+
if (this.reason === 'read' || (reason === 'full')) this.reason = reason
|
|
100
|
+
if (!this.pending) this.since = Date.now()
|
|
101
|
+
this.pending = true; this.last = Date.now()
|
|
102
|
+
if (this.timer) clearTimeout(this.timer)
|
|
103
|
+
this.timer = null
|
|
104
|
+
if (this.controller || !this.visible) return
|
|
105
|
+
const wait = Math.max(0, Math.min(this.last + delay, this.since + 1000) - Date.now())
|
|
106
|
+
this.timer = setTimeout(() => { this.timer = null; void this.read() }, wait)
|
|
107
|
+
}
|
|
108
|
+
/** @private @author ddj 2026年10月10号 @param args First page carries idempotent invalidation. @param signal Fetch only cancellation. @returns Version-consistent pages, bounded retries. */
|
|
109
|
+
private async pages(args: SvnViewRequest, signal: AbortSignal): Promise<SvnViewPayload> {
|
|
110
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
111
|
+
let snapshot: SvnViewPayload | null = null
|
|
112
|
+
const rows: SvnViewMark[] = []
|
|
113
|
+
for (let offset = 0; offset < Math.max(1, this.rows.length); offset += 1000) {
|
|
114
|
+
const out = await this.fetch({ ...args, rows: this.rows.slice(offset, offset + 1000),
|
|
115
|
+
revision: snapshot?.revision, generation: snapshot?.generation }, signal)
|
|
116
|
+
if (!out.ok) throw new Error(out.error)
|
|
117
|
+
if (out.mismatch) { snapshot = null; break }
|
|
118
|
+
if (snapshot && snapshot.scopeId !== out.scopeId) throw new Error('SVN 工作区范围发生变化')
|
|
119
|
+
snapshot = out; rows.push(...out.rows)
|
|
120
|
+
}
|
|
121
|
+
if (snapshot) return { ...snapshot, rows }
|
|
122
|
+
}
|
|
123
|
+
throw new Error('SVN 状态正在变化,稍后重新投影')
|
|
124
|
+
}
|
|
125
|
+
/** @private @author ddj 2026年10月10号 Apply compact results; notices during fetch retain dirtiness and receive one follow-up request. */
|
|
126
|
+
private async read(): Promise<void> {
|
|
127
|
+
if (this.stopped || this.controller) return
|
|
128
|
+
const controller = new AbortController(), epoch = this.rowEpoch
|
|
129
|
+
this.controller = controller
|
|
130
|
+
const reason = this.reason, paths = [...this.paths]
|
|
131
|
+
const args: SvnViewRequest = { sessionId: this.sessionId, rows: [], reason, paths,
|
|
132
|
+
clientId: this.clientId, eventId: String(++this.sequence) }
|
|
133
|
+
this.reason = 'read'; this.paths.clear(); this.pending = false
|
|
134
|
+
try {
|
|
135
|
+
const out = await this.pages(args, controller.signal)
|
|
136
|
+
if (this.stopped || controller.signal.aborted) return
|
|
137
|
+
if (epoch !== this.rowEpoch) { this.enqueue('read', 0); return }
|
|
138
|
+
this.view = { ...out, marks: applyViewRows(this.view.marks, out.rows) }
|
|
139
|
+
if (this.pending && this.reason !== 'read') this.stale(this.reason === 'paths' ? [...this.paths] : null)
|
|
140
|
+
else this.publish(this.view)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (this.stopped || controller.signal.aborted) return
|
|
143
|
+
this.stale(null)
|
|
144
|
+
this.view = { ...this.view, refreshing: false, error: String(error) }; this.publish(this.view)
|
|
145
|
+
} finally {
|
|
146
|
+
this.controller = null
|
|
147
|
+
if (!this.stopped) this.follow()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** @private @author ddj 2026年10月10号 Schedule merged notices, short read-only polls, or completion-based adaptive offline calibration; hidden pages never poll. */
|
|
151
|
+
private follow(): void {
|
|
152
|
+
if (this.pending) { this.enqueue(this.reason, 300); return }
|
|
153
|
+
if (!this.visible) return
|
|
154
|
+
const reason = this.view.refreshing ? 'read' : 'calibrate'
|
|
155
|
+
const delay = this.view.refreshing ? 250 : Math.max(30_000, 20 * this.view.scanMs)
|
|
156
|
+
this.timer = setTimeout(() => { this.timer = null; this.enqueue(reason, 0) }, delay)
|
|
157
|
+
}
|
|
158
|
+
/** @public @author ddj 2026年10月10号 Release UI work; does not terminate shared Host scan. */
|
|
159
|
+
dispose(): void {
|
|
160
|
+
this.stopped = true
|
|
161
|
+
if (this.timer) clearTimeout(this.timer)
|
|
162
|
+
this.controller?.abort(); this.events.clear(); this.paths.clear()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// --endregion
|