dsh-vscode-mode 0.5.3 → 0.7.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 +72 -2
- package/lib/client.js +12082 -6420
- package/lib/client.js.map +1 -1
- package/lib/index.js +2750 -147
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/copyText.ts +28 -0
- package/src/client/dap/BpWidget.ts +262 -0
- package/src/client/dap/bpMenu.ts +91 -0
- package/src/client/dap/bpRowMenu.ts +53 -0
- package/src/client/dap/breakpoints.ts +222 -0
- package/src/client/dap/decorate.ts +128 -0
- package/src/client/dap/hintLine.ts +29 -0
- package/src/client/dap/hover.ts +166 -0
- package/src/client/dap/hoverMode.ts +159 -0
- package/src/client/dap/hoverTree.ts +722 -0
- package/src/client/dap/launchInsert.ts +229 -0
- package/src/client/dap/launchSnippetProvider.ts +206 -0
- package/src/client/dap/modelPath.ts +23 -0
- package/src/client/dap/panelSplit.ts +105 -0
- package/src/client/dap/pidPick.ts +24 -0
- package/src/client/dap/store.ts +571 -0
- package/src/client/dap/toolbarDrag.ts +51 -0
- package/src/client/dap/trace.ts +22 -0
- package/src/client/dap/variableTree.ts +62 -0
- package/src/client/fileClipboard.ts +46 -0
- package/src/client/index.ts +12 -3
- package/src/client/monaco/lsp/index.ts +15 -0
- package/src/client/monaco/lsp/providers.ts +2 -0
- package/src/client/monaco/theme.ts +15 -0
- package/src/client/openWithDialog.ts +66 -0
- package/src/client/searchSeed.ts +36 -0
- package/src/client/sidebar/contextMenu.ts +51 -10
- package/src/client/sidebar/menuItems.ts +473 -50
- package/src/client/sidebar/panels/DebugPanel.ts +613 -0
- package/src/client/sidebar/panels/SearchPanel.ts +24 -3
- package/src/client/sidebar/panels/SvnPanel.ts +167 -23
- package/src/client/sidebar/panels/index.ts +19 -0
- package/src/client/sidebar/types.ts +8 -0
- package/src/client/styles/editor.css +176 -0
- package/src/client/svnActions.ts +18 -1
- package/src/client/svnLog.ts +3 -3
- package/src/client/svnStatus.ts +140 -2
- package/src/client/tabActions.ts +22 -10
- package/src/client/ui/EditorView.ts +625 -23
- package/src/client/ui/OfficialSideTab.ts +1 -0
- package/src/client/ui/PromptDialog.ts +63 -0
- package/src/client/ui/SideBySideDiff.tsx +214 -29
- package/src/client/ui/SideEditorTab.ts +1 -0
- package/src/client/ui/SvnDiffPanel.ts +21 -8
- package/src/client/ui/SvnLogDialog.ts +44 -2
- package/src/client/ui/SvnPatchDialog.ts +63 -0
- package/src/client/ui/SvnSumDialog.ts +77 -0
- package/src/client/ui/commandCatalog.ts +63 -0
- package/src/client/ui/svnExport.ts +71 -0
- package/src/dap/configSnippets.ts +269 -0
- package/src/dap/discovery.ts +198 -0
- package/src/dap/launchConfig.ts +127 -0
- package/src/dap/manager.ts +588 -0
- package/src/dap/processList.ts +76 -0
- package/src/dap/protocol.ts +80 -0
- package/src/dap/provider.ts +49 -0
- package/src/dap/rpc.ts +192 -0
- package/src/dap/sourcePath.ts +82 -0
- package/src/index.ts +7 -3
- package/src/lsp/providers.ts +3 -2
- package/src/reveal.ts +31 -20
- package/src/rpc.ts +209 -5
- package/src/search/ripgrep.ts +127 -11
- package/src/shared/dap.ts +262 -0
- package/src/shared/fsNames.ts +115 -0
- package/src/shared/keybindings.ts +9 -0
- package/src/shared/rpc.ts +52 -2
- package/src/shared/svn.ts +147 -0
- package/src/shared/svnActions.ts +20 -0
- package/src/svn.ts +320 -11
- package/src/workspace.ts +0 -75
package/src/shared/svnActions.ts
CHANGED
|
@@ -131,6 +131,26 @@ export const SVN_ACTIONS: readonly SvnActionDef[] = [
|
|
|
131
131
|
targets: ['file', 'directory'],
|
|
132
132
|
gates: ['versioned'],
|
|
133
133
|
},
|
|
134
|
+
{
|
|
135
|
+
id: 'create-patch',
|
|
136
|
+
commandId: 'edrv.svnCreatePatch',
|
|
137
|
+
label: '生成补丁…',
|
|
138
|
+
order: 20,
|
|
139
|
+
surfaces: ['palette'],
|
|
140
|
+
capability: 'cli',
|
|
141
|
+
targets: ['file', 'directory'],
|
|
142
|
+
needsEditorModel: true,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'diff-summarize',
|
|
146
|
+
commandId: 'edrv.svnDiffSum',
|
|
147
|
+
label: '目录对比(两修订)…',
|
|
148
|
+
order: 21,
|
|
149
|
+
surfaces: ['palette'],
|
|
150
|
+
capability: 'cli',
|
|
151
|
+
// 目录对比以活动文件所在目录为默认目标(无活动文件 = 工作副本根),不限制目标类型
|
|
152
|
+
needsEditorModel: false,
|
|
153
|
+
},
|
|
134
154
|
{
|
|
135
155
|
id: 'cleanup',
|
|
136
156
|
commandId: 'edrv.svnCleanup',
|
package/src/svn.ts
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
* TortoiseProc 为长驻 GUI 进程:发射后不 await done(只挂 no-op catch 防未处理 rejection)。
|
|
7
7
|
* 作者 ddj 2026年09月16号
|
|
8
8
|
*/
|
|
9
|
-
import { stat } from 'node:fs/promises'
|
|
10
|
-
import { dirname, join } from 'node:path'
|
|
9
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
10
|
+
import { basename, dirname, join } from 'node:path'
|
|
11
11
|
import { tmpdir } from 'node:os'
|
|
12
12
|
import type { Ctx, Session } from './store.js'
|
|
13
13
|
import { cwdOf, sessionOf } from './registry.js'
|
|
14
|
-
import type { SvnAction, SvnChangeEntry, SvnFeature, SvnItemStatus, SvnLogEntry, SvnStatusPayload } from './shared/svn.js'
|
|
15
|
-
import { SVN_CLI_DEFAULT, TORTOISE_EXE, tortoiseLaunchArgv } from './shared/svn.js'
|
|
16
|
-
import { attrOf, numAttrOf, scanXmlTags, textOf } from './svnXml.js'
|
|
14
|
+
import type { SvnAction, SvnChangeEntry, SvnConflictArtifact, SvnFeature, SvnItemStatus, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry } from './shared/svn.js'
|
|
15
|
+
import { SVN_CLI_DEFAULT, TORTOISE_EXE, contentGuardOf, tortoiseLaunchArgv } from './shared/svn.js'
|
|
16
|
+
import { attrOf, numAttrOf, scanXmlTags, textOf, unescapeXml } from './svnXml.js'
|
|
17
17
|
import { TEXT_OUTPUT_CAP, batchResultOf, tailOfText, updateResultOf } from './svnText.js'
|
|
18
18
|
import { LOG_CAP, LOG_DEFAULT_LIMIT, SVN_LOG_SHOW_ALL_CAP, parseLogXml, parseSvnInfoRevision } from './svnLog.js'
|
|
19
19
|
export { batchResultOf, commitResultOf, updateResultOf } from './svnText.js'
|
|
@@ -58,6 +58,23 @@ const MUTATE_GRACE_MS = 120_000
|
|
|
58
58
|
export const CHANGES_TTL_MS = 5_000
|
|
59
59
|
/** 文本读取上限(对齐 rpc.ts READ_CAP:>8MB 拒绝整文件读取/差异)。 */
|
|
60
60
|
const READ_CAP = 8 * 1024 * 1024
|
|
61
|
+
/**
|
|
62
|
+
* W2-1 补丁文本输出上限(16MB)。实测教训复用(PROGRESS §六.1):DSH stdout 收集器
|
|
63
|
+
* 超限**保留尾部**——补丁被截断时头部 `Index:` 会丢失,host 以「不以 Index: 开头」
|
|
64
|
+
* 标记 truncated,客户端明示「不完整」而非让用户拿半份补丁去用。
|
|
65
|
+
*/
|
|
66
|
+
const PATCH_OUTPUT_CAP = 16 * 1024 * 1024
|
|
67
|
+
/** W2-2 目录对比 summarize 输出上限(8MB;大目录长区间的变更条目 XML)。 */
|
|
68
|
+
const SUM_OUTPUT_CAP = 8 * 1024 * 1024
|
|
69
|
+
/**
|
|
70
|
+
* W2-3 远端检查超时(15s)。M3 实测:无凭据/离线时 `status -u` 会长时间挂起
|
|
71
|
+
* (受限沙箱内 120s 无输出),远端检查必须短超时 + 失败降级,绝不阻塞面板。
|
|
72
|
+
*/
|
|
73
|
+
const REMOTE_GRACE_MS = 15_000
|
|
74
|
+
/** W2-2 summarize 服务器往返预算(大目录长区间可能较慢)。 */
|
|
75
|
+
const SUM_GRACE_MS = 60_000
|
|
76
|
+
/** W2-5 fileSizes 单次查询路径上限(配对候选是少数条目,防滥用)。 */
|
|
77
|
+
export const FILE_SIZES_CAP = 200
|
|
61
78
|
|
|
62
79
|
/** 文件存在性探测(可注入替身)。 */
|
|
63
80
|
export type ExistsFn = (path: string) => Promise<boolean>
|
|
@@ -227,6 +244,120 @@ export function parseStatusXml(xml: string, cap: number = CHANGES_CAP): { entrie
|
|
|
227
244
|
return { entries, truncated }
|
|
228
245
|
}
|
|
229
246
|
|
|
247
|
+
/**
|
|
248
|
+
* 绝对路径 → 工作副本相对路径(W2-2/W2-3:M3/M5 实测 XML 中 path 为绝对路径)。
|
|
249
|
+
* Windows 大小写不敏感文件系统:前缀匹配按小写比较,剥离后保留原大小写。
|
|
250
|
+
* @author ddj 2026年09月20号
|
|
251
|
+
* @param abs 绝对路径(`/` 或 `\` 分隔均可)
|
|
252
|
+
* @param wcRoot 工作副本根绝对路径
|
|
253
|
+
* @returns 相对路径(`/` 分隔);不在根下返回 null
|
|
254
|
+
*/
|
|
255
|
+
export function relPathOfAbs(abs: string, wcRoot: string): string | null {
|
|
256
|
+
const norm = (p: string) => String(p ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
257
|
+
const a = norm(abs)
|
|
258
|
+
const r = norm(wcRoot)
|
|
259
|
+
if (!r || !a.toLowerCase().startsWith(r.toLowerCase() + '/')) return null
|
|
260
|
+
return a.slice(r.length + 1)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* W2-1 补丁的 `-x` 扩展选项组合(纯函数)。
|
|
265
|
+
* 冒烟实测修正(2026-09-20):本机 svn 内部 diff 的上下文行数只认 `-U<N>`,
|
|
266
|
+
* `--unified=N` 报 E200016 erroneous argument(07 计划 §1 当年只核对了 help 文案,未实测)。
|
|
267
|
+
* @author ddj 2026年09月20号
|
|
268
|
+
* @param opts 空白语义 / EOL 忽略 / 上下文行数
|
|
269
|
+
* @returns 空格连接的单个 `-x` 值;无选项时空串
|
|
270
|
+
*/
|
|
271
|
+
export function patchExtOf(opts: { whitespace?: string; ignoreEol?: boolean; unified?: number }): string {
|
|
272
|
+
const parts: string[] = []
|
|
273
|
+
if (opts.whitespace === 'b') parts.push('-b')
|
|
274
|
+
if (opts.whitespace === 'w') parts.push('-w')
|
|
275
|
+
if (opts.ignoreEol === true) parts.push('--ignore-eol-style')
|
|
276
|
+
const unified = Math.floor(Number(opts.unified))
|
|
277
|
+
if (Number.isFinite(unified) && unified >= 0 && unified <= 100 && unified !== 3) parts.push('-U' + unified)
|
|
278
|
+
return parts.join(' ')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 解析 `svn diff --summarize --xml` 输出(W2-2;M5 实测形态,svn 1.14.5):
|
|
283
|
+
* `<diff><paths><path item="modified" kind="file" props="none">ABS:\\PATH</path>...`
|
|
284
|
+
* - path 的**文本内容**是绝对路径(非属性)→ relPathOfAbs 归一为工作副本相对;
|
|
285
|
+
* - 属性输出顺序不稳定(与 M1 log 同款教训),只按字段名取值、不做字符串快照;
|
|
286
|
+
* - 区间无变更时 `<paths>` 为空 → entries 为空数组(非错误)。
|
|
287
|
+
* @author ddj 2026年09月20号
|
|
288
|
+
* @param xml summarize --xml 的 stdout
|
|
289
|
+
* @param wcRoot 工作副本根(路径归一锚点)
|
|
290
|
+
* @param cap 条目上限(超出标记 truncated)
|
|
291
|
+
* @returns 变更条目与是否截断
|
|
292
|
+
*/
|
|
293
|
+
export function parseSummarizeXml(xml: string, wcRoot: string, cap: number = CHANGES_CAP): { entries: SvnSumEntry[]; truncated: boolean } {
|
|
294
|
+
const entries: SvnSumEntry[] = []
|
|
295
|
+
let truncated = false
|
|
296
|
+
if (typeof xml !== 'string' || !xml) return { entries, truncated }
|
|
297
|
+
const re = /<path\b([^>]*?)>([\s\S]*?)<\/path>/g
|
|
298
|
+
let match: RegExpExecArray | null
|
|
299
|
+
while ((match = re.exec(xml)) !== null) {
|
|
300
|
+
const abs = unescapeXml(match[2].trim())
|
|
301
|
+
// 路径形态双兼容(冒烟实测 2026-09-20):绝对路径目标 → svn 回显绝对路径;相对路径目标
|
|
302
|
+
// (host 常态,cwd=wcRoot)→ svn 回显相对路径。先按绝对剥离前缀,失败按相对归一。
|
|
303
|
+
const rel = relPathOfAbs(abs, wcRoot) ?? changePathOf(abs)
|
|
304
|
+
if (!rel) continue
|
|
305
|
+
if (entries.length >= cap) { truncated = true; break }
|
|
306
|
+
entries.push({
|
|
307
|
+
path: rel,
|
|
308
|
+
item: attrOf(match[1], 'item') || 'none',
|
|
309
|
+
kind: attrOf(match[1], 'kind') || 'none',
|
|
310
|
+
props: attrOf(match[1], 'props') || 'none',
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
return { entries, truncated }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 解析 `svn status -u --xml` 输出(W2-3;M3 实测形态,svn 1.14.5):
|
|
318
|
+
* `<entry path="ABS"><wc-status ...>...</wc-status><repos-status item="modified" props="none"/></entry>` + `<against revision="N"/>`
|
|
319
|
+
* - 落后锚点 = 存在 `<repos-status>` 且 `item !== 'none'`(实测落后文件为 modified);
|
|
320
|
+
* 远端无变化的条目**没有** repos-status 元素(省略,非 'none' 值);
|
|
321
|
+
* - 离线/无凭据时会长时间挂起(M3 实测 120s+),调用方必须用 REMOTE_GRACE_MS 短超时。
|
|
322
|
+
* @author ddj 2026年09月20号
|
|
323
|
+
* @param xml status -u --xml 的 stdout
|
|
324
|
+
* @param wcRoot 工作副本根(路径归一锚点)
|
|
325
|
+
* @param cap 条目上限(超出标记 truncated)
|
|
326
|
+
* @returns 落后清单 + against 修订
|
|
327
|
+
*/
|
|
328
|
+
export function parseRemoteStatusXml(
|
|
329
|
+
xml: string,
|
|
330
|
+
wcRoot: string,
|
|
331
|
+
cap: number = CHANGES_CAP,
|
|
332
|
+
): { outdated: SvnRemoteOutdatedEntry[]; againstRev: number | null; truncated: boolean } {
|
|
333
|
+
const outdated: SvnRemoteOutdatedEntry[] = []
|
|
334
|
+
let againstRev: number | null = null
|
|
335
|
+
let truncated = false
|
|
336
|
+
if (typeof xml !== 'string' || !xml) return { outdated, againstRev, truncated }
|
|
337
|
+
// 开标签属性截取(M3 实测长形态:`<repos-status\n item="...">\n</repos-status>`,
|
|
338
|
+
// 开标签 `>` 与闭标签之间可能有换行,故只截开标签而非要求紧邻闭标签)
|
|
339
|
+
const against = /<against\b([^>]*?)\/?>/.exec(xml)
|
|
340
|
+
if (against) {
|
|
341
|
+
const rev = numAttrOf(against[1], 'revision')
|
|
342
|
+
if (rev !== undefined) againstRev = rev
|
|
343
|
+
}
|
|
344
|
+
const re = /<entry\b([^>]*?)>([\s\S]*?)<\/entry>/g
|
|
345
|
+
let match: RegExpExecArray | null
|
|
346
|
+
while ((match = re.exec(xml)) !== null) {
|
|
347
|
+
const repos = /<repos-status\b([^>]*?)\/?>/.exec(match[2])
|
|
348
|
+
if (!repos) continue
|
|
349
|
+
const item = attrOf(repos[1], 'item')
|
|
350
|
+
if (!item || item === 'none') continue
|
|
351
|
+
// 路径形态双兼容(同 parseSummarizeXml:绝对/相对取决于调用目标形态)
|
|
352
|
+
const rawPath = attrOf(match[1], 'path')
|
|
353
|
+
const rel = relPathOfAbs(rawPath, wcRoot) ?? changePathOf(rawPath)
|
|
354
|
+
if (!rel) continue
|
|
355
|
+
if (outdated.length >= cap) { truncated = true; break }
|
|
356
|
+
outdated.push({ path: rel, item })
|
|
357
|
+
}
|
|
358
|
+
return { outdated, againstRev, truncated }
|
|
359
|
+
}
|
|
360
|
+
|
|
230
361
|
// --endregion
|
|
231
362
|
|
|
232
363
|
/** 一次 svn CLI 运行结果(输出可能因 stdio 回退而为空)。 */
|
|
@@ -639,6 +770,42 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
639
770
|
}
|
|
640
771
|
}
|
|
641
772
|
|
|
773
|
+
/**
|
|
774
|
+
* 扫描冲突副本文件(W2-4;svn 冲突时的标准后缀 `.mine`/`.working`/`.rN`,纯本地 fs 只读)。
|
|
775
|
+
* @author ddj 2026年09月20号
|
|
776
|
+
* @param wcRoot 工作副本根
|
|
777
|
+
* @param rel 冲突文件的工作副本相对路径
|
|
778
|
+
* @returns 副本清单(rev 升序 → working → mine;默认对比对 = 首个 .rN ↔ .mine)
|
|
779
|
+
*/
|
|
780
|
+
const conflictArtifactsOf = async (wcRoot: string, rel: string): Promise<SvnConflictArtifact[]> => {
|
|
781
|
+
const abs = join(wcRoot, rel)
|
|
782
|
+
const base = basename(abs)
|
|
783
|
+
const names = await readdir(dirname(abs))
|
|
784
|
+
const rankOf = (kind: SvnConflictArtifact['kind']) => (kind === 'rev' ? 0 : kind === 'working' ? 1 : 2)
|
|
785
|
+
const artifacts: SvnConflictArtifact[] = []
|
|
786
|
+
for (const name of names) {
|
|
787
|
+
let kind: SvnConflictArtifact['kind'] | null = null
|
|
788
|
+
let rev: number | undefined
|
|
789
|
+
if (name === base + '.mine') kind = 'mine'
|
|
790
|
+
else if (name === base + '.working') kind = 'working'
|
|
791
|
+
else if (name.startsWith(base + '.r') && /^\d+$/.test(name.slice(base.length + 2))) {
|
|
792
|
+
kind = 'rev'
|
|
793
|
+
rev = Number.parseInt(name.slice(base.length + 2), 10)
|
|
794
|
+
}
|
|
795
|
+
if (kind === null) continue
|
|
796
|
+
const artifactPath = relPathOfAbs(join(dirname(abs), name), wcRoot)
|
|
797
|
+
if (!artifactPath) continue
|
|
798
|
+
artifacts.push({ path: artifactPath, name, kind, ...(rev !== undefined ? { rev } : {}) })
|
|
799
|
+
}
|
|
800
|
+
return artifacts.sort((a, b) => rankOf(a.kind) - rankOf(b.kind) || (a.rev ?? 0) - (b.rev ?? 0) || a.name.localeCompare(b.name))
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** W2-6:双侧内容护栏标记(干净时返回空对象,展开进载荷不添字段)。 */
|
|
804
|
+
const guardSides = (a: string | null, b: string | null): { binary?: boolean; encodingHint?: boolean } => {
|
|
805
|
+
const merged = { ...contentGuardOf(a ?? ''), ...contentGuardOf(b ?? '') }
|
|
806
|
+
return merged.binary || merged.encodingHint ? merged : {}
|
|
807
|
+
}
|
|
808
|
+
|
|
642
809
|
const handlers = {
|
|
643
810
|
'svn.status': async (args: { sessionId?: string; force?: boolean }) => {
|
|
644
811
|
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
@@ -685,9 +852,9 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
685
852
|
// added/未提交条目没有 pristine:非致命,按原因返回(客户端提示而非报错面板)
|
|
686
853
|
const text = (outcome.stderr || outcome.stdout).trim()
|
|
687
854
|
const reason = /pristine|E200009/i.test(text) ? 'no-pristine' : 'cat-failed'
|
|
688
|
-
return { ok: true as const, base: null, working, reason, error: tailOfText(text) }
|
|
855
|
+
return { ok: true as const, base: null, working, reason, error: tailOfText(text), ...guardSides(null, working) }
|
|
689
856
|
}
|
|
690
|
-
return { ok: true as const, base: outcome.stdout, working }
|
|
857
|
+
return { ok: true as const, base: outcome.stdout, working, ...guardSides(outcome.stdout, working) }
|
|
691
858
|
} catch (error) {
|
|
692
859
|
return { ok: false as const, error: '读取基线失败:' + String(error) }
|
|
693
860
|
}
|
|
@@ -731,7 +898,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
731
898
|
if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
|
|
732
899
|
try {
|
|
733
900
|
const result = await diffRevSides(status.wcRoot, rel, Math.floor(revision))
|
|
734
|
-
return { ok: true as const, ...result }
|
|
901
|
+
return { ok: true as const, ...result, ...guardSides(result.left, result.right) }
|
|
735
902
|
} catch (error) {
|
|
736
903
|
return { ok: false as const, error: '读取版本差异失败:' + String(error) }
|
|
737
904
|
}
|
|
@@ -751,7 +918,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
751
918
|
if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
|
|
752
919
|
try {
|
|
753
920
|
const result = await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB))
|
|
754
|
-
return { ok: true as const, ...result }
|
|
921
|
+
return { ok: true as const, ...result, ...guardSides(result.left, result.right) }
|
|
755
922
|
} catch (error) {
|
|
756
923
|
return { ok: false as const, error: '读取版本差异失败:' + String(error) }
|
|
757
924
|
}
|
|
@@ -782,13 +949,155 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
782
949
|
const leftOutcome = await runSvn(status.wcRoot, ['cat', '-r', String(Math.floor(revision)), '--', rel], READ_GRACE_MS)
|
|
783
950
|
if (leftOutcome.code !== 0) {
|
|
784
951
|
const text = (leftOutcome.stderr || leftOutcome.stdout).trim()
|
|
785
|
-
return { ok: true as const, left: null, right: working, reason: 'not-exist', error: tailOfText(text) }
|
|
952
|
+
return { ok: true as const, left: null, right: working, reason: 'not-exist', error: tailOfText(text), ...guardSides(null, working) }
|
|
786
953
|
}
|
|
787
|
-
return { ok: true as const, left: leftOutcome.stdout, right: working }
|
|
954
|
+
return { ok: true as const, left: leftOutcome.stdout, right: working, ...guardSides(leftOutcome.stdout, working) }
|
|
788
955
|
} catch (error) {
|
|
789
956
|
return { ok: false as const, error: '读取工作副本差异失败:' + String(error) }
|
|
790
957
|
}
|
|
791
958
|
},
|
|
959
|
+
'svn.patchText': async (args: { sessionId?: string; path?: string; whitespace?: 'none' | 'b' | 'w'; ignoreEol?: boolean; unified?: number }) => {
|
|
960
|
+
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
961
|
+
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
962
|
+
const rel = safeRel(args.path)
|
|
963
|
+
if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
|
|
964
|
+
const status = await statusOf(sc.cwd)
|
|
965
|
+
if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
|
|
966
|
+
if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
|
|
967
|
+
try {
|
|
968
|
+
// W2-1:`-x` 单值承载全部扩展选项(-b/-w/--ignore-eol-style/--unified=N,patchExtOf 组合)
|
|
969
|
+
const ext = patchExtOf({ whitespace: args.whitespace, ignoreEol: args.ignoreEol === true, unified: args.unified })
|
|
970
|
+
const argv = ['diff', ...(ext ? ['-x', ext] : []), '--', rel]
|
|
971
|
+
const outcome = await runSvn(status.wcRoot, argv, READ_GRACE_MS, PATCH_OUTPUT_CAP)
|
|
972
|
+
if (outcome.code !== 0) {
|
|
973
|
+
return { ok: false as const, error: '生成补丁失败:' + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')) }
|
|
974
|
+
}
|
|
975
|
+
const text = outcome.stdout
|
|
976
|
+
// 截断判定(PROGRESS §六.1):收集器超限保留尾部 → 头部丢失;补丁恒以 Index:/Property changes on: 开头
|
|
977
|
+
const head = text.slice(0, 200)
|
|
978
|
+
const truncated = !head.startsWith('Index:') && !head.startsWith('Property changes on:')
|
|
979
|
+
// M4 实测:svn diff 对二进制只输出 "Cannot display" 通知(几百字节),如实标记给客户端
|
|
980
|
+
return { ok: true as const, text, truncated, binary: text.includes('Cannot display:') }
|
|
981
|
+
} catch (error) {
|
|
982
|
+
return { ok: false as const, error: '生成补丁失败:' + String(error) }
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
'svn.diffSum': async (args: { sessionId?: string; path?: string; revA?: number; revB?: number }) => {
|
|
986
|
+
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
987
|
+
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
988
|
+
const rel = safeRel(args.path)
|
|
989
|
+
if (rel === null) return { ok: false as const, error: '路径不合法' }
|
|
990
|
+
const revA = Number(args.revA)
|
|
991
|
+
const revB = Number(args.revB)
|
|
992
|
+
if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) {
|
|
993
|
+
return { ok: false as const, error: '版本号不合法' }
|
|
994
|
+
}
|
|
995
|
+
const status = await statusOf(sc.cwd)
|
|
996
|
+
if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
|
|
997
|
+
if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
|
|
998
|
+
try {
|
|
999
|
+
// W2-2:M5 实测 —— summarize 输出的 path 是绝对路径,parseSummarizeXml 内归一为相对
|
|
1000
|
+
const argv = ['diff', '--summarize', '--xml', '-r', String(Math.floor(revA)) + ':' + String(Math.floor(revB))]
|
|
1001
|
+
if (rel) argv.push('--', rel)
|
|
1002
|
+
const outcome = await runSvn(status.wcRoot, argv, SUM_GRACE_MS, SUM_OUTPUT_CAP)
|
|
1003
|
+
if (outcome.code !== 0) {
|
|
1004
|
+
return { ok: false as const, error: '目录对比失败:' + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')) }
|
|
1005
|
+
}
|
|
1006
|
+
return { ok: true as const, ...parseSummarizeXml(outcome.stdout, status.wcRoot) }
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
return { ok: false as const, error: '目录对比失败:' + String(error) }
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
1011
|
+
'svn.remoteStatus': async (args: { sessionId?: string; path?: string }) => {
|
|
1012
|
+
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
1013
|
+
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
1014
|
+
const rel = safeRel(args.path)
|
|
1015
|
+
if (rel === null) return { ok: false as const, error: '路径不合法' }
|
|
1016
|
+
const status = await statusOf(sc.cwd)
|
|
1017
|
+
if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
|
|
1018
|
+
if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
|
|
1019
|
+
try {
|
|
1020
|
+
// W2-3:M3 实测 —— 离线/无凭据会长时间挂起,REMOTE_GRACE_MS 短超时是硬约束
|
|
1021
|
+
const argv = ['status', '-u', '--xml']
|
|
1022
|
+
if (rel) argv.push('--', rel)
|
|
1023
|
+
const outcome = await runSvn(status.wcRoot, argv, REMOTE_GRACE_MS, SUM_OUTPUT_CAP)
|
|
1024
|
+
if (outcome.code !== 0) {
|
|
1025
|
+
return { ok: false as const, error: '远端检查失败:' + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')) }
|
|
1026
|
+
}
|
|
1027
|
+
return { ok: true as const, ...parseRemoteStatusXml(outcome.stdout, status.wcRoot) }
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
return { ok: false as const, error: '远端检查失败:' + String(error) }
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1032
|
+
'svn.conflictArtifacts': async (args: { sessionId?: string; path?: string }) => {
|
|
1033
|
+
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
1034
|
+
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
1035
|
+
const rel = safeRel(args.path)
|
|
1036
|
+
if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
|
|
1037
|
+
const status = await statusOf(sc.cwd)
|
|
1038
|
+
if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
|
|
1039
|
+
try {
|
|
1040
|
+
// W2-4:纯本地 fs 只读扫描,不跑 svn、不触碰 resolve
|
|
1041
|
+
const artifacts = await conflictArtifactsOf(status.wcRoot, rel)
|
|
1042
|
+
return { ok: true as const, artifacts }
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
return { ok: false as const, error: '扫描冲突副本失败:' + String(error) }
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
'svn.diffLocalPair': async (args: { sessionId?: string; left?: string; right?: string }) => {
|
|
1048
|
+
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
1049
|
+
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
1050
|
+
// W2-4:双侧均为本地文件(冲突副本对比);safeRel 兜底防越界,与 diffBase 同一安全口径
|
|
1051
|
+
const left = safeRel(args.left)
|
|
1052
|
+
const right = safeRel(args.right)
|
|
1053
|
+
if (!left || !right) return { ok: false as const, error: '路径不合法(需工作副本内相对路径)' }
|
|
1054
|
+
const status = await statusOf(sc.cwd)
|
|
1055
|
+
if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
|
|
1056
|
+
const fs = ctx.get('fs') as {
|
|
1057
|
+
resolve?: (p: string, o: object) => Promise<unknown>
|
|
1058
|
+
stat?: (t: unknown) => Promise<{ type?: string; size?: number } | null>
|
|
1059
|
+
readText?: (t: unknown) => Promise<string>
|
|
1060
|
+
} | null | undefined
|
|
1061
|
+
if (!fs?.resolve || !fs?.stat || !fs?.readText) return { ok: false as const, error: '缺少 fs' }
|
|
1062
|
+
try {
|
|
1063
|
+
const sides: string[] = []
|
|
1064
|
+
for (const rel of [left, right]) {
|
|
1065
|
+
const resolved = await fs.resolve(rel, { cwd: sc.cwd })
|
|
1066
|
+
const info = await fs.stat(resolved)
|
|
1067
|
+
if (!info || info.type !== 'file') return { ok: false as const, error: '文件不存在:' + rel }
|
|
1068
|
+
if ((info.size ?? 0) > READ_CAP) return { ok: false as const, error: '文件过大(>8MB):' + rel }
|
|
1069
|
+
sides.push(await fs.readText(resolved))
|
|
1070
|
+
}
|
|
1071
|
+
return { ok: true as const, left: sides[0], right: sides[1], ...guardSides(sides[0], sides[1]) }
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
return { ok: false as const, error: '读取冲突副本失败:' + String(error) }
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1076
|
+
'svn.fileSizes': async (args: { sessionId?: string; paths?: string[] }) => {
|
|
1077
|
+
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
1078
|
+
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
1079
|
+
// W2-5:配对候选是少数条目;上限护栏 + 单条失败按 null(未知)处理,不因个别路径失败整体报错
|
|
1080
|
+
const list = Array.isArray(args.paths) ? args.paths.slice(0, FILE_SIZES_CAP) : []
|
|
1081
|
+
if (!list.length) return { ok: true as const, sizes: [] }
|
|
1082
|
+
const fs = ctx.get('fs') as {
|
|
1083
|
+
resolve?: (p: string, o: object) => Promise<unknown>
|
|
1084
|
+
stat?: (t: unknown) => Promise<{ type?: string; size?: number } | null>
|
|
1085
|
+
} | null | undefined
|
|
1086
|
+
if (!fs?.resolve || !fs?.stat) return { ok: false as const, error: '缺少 fs' }
|
|
1087
|
+
const sizes: Array<{ path: string; size: number | null }> = []
|
|
1088
|
+
for (const raw of list) {
|
|
1089
|
+
const rel = safeRel(raw)
|
|
1090
|
+
if (rel === null || rel === '') { sizes.push({ path: String(raw ?? ''), size: null }); continue }
|
|
1091
|
+
try {
|
|
1092
|
+
const resolved = await fs.resolve(rel, { cwd: sc.cwd })
|
|
1093
|
+
const info = await fs.stat(resolved)
|
|
1094
|
+
sizes.push({ path: rel, size: info && info.type === 'file' ? (info.size ?? null) : null })
|
|
1095
|
+
} catch {
|
|
1096
|
+
sizes.push({ path: rel, size: null })
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return { ok: true as const, sizes }
|
|
1100
|
+
},
|
|
792
1101
|
'svn.wcRev': async (args: { sessionId?: string; path?: string }) => {
|
|
793
1102
|
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
794
1103
|
if ('err' in sc) return { ok: false as const, error: sc.err }
|
package/src/workspace.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-vscode-mode host — 工作区文件清单扫描与缓存(快速打开/搜索用)。
|
|
3
|
-
* 迁移自原 src/index.ts 的 listWorkspaceFiles,语义不改。
|
|
4
|
-
* 作者 ddj 2026-08-20
|
|
5
|
-
*/
|
|
6
|
-
import type { Ctx, Session } from './store.js'
|
|
7
|
-
import { policyOf } from './store.js'
|
|
8
|
-
import { log } from './log.js'
|
|
9
|
-
|
|
10
|
-
/** 文件清单缓存 TTL 与扫描上限(避免大工作区反复全量扫描)。 */
|
|
11
|
-
const FILE_INDEX_TTL = 60_000
|
|
12
|
-
const SCAN_CAP = 6000
|
|
13
|
-
/** cwd → { at, files }(随会话销毁清理)。 */
|
|
14
|
-
const fileIndex = new Map<string, { at: number; files: string[] }>()
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 扫描工作区文件清单(快速打开/搜索用):subprocess(Windows powershell / POSIX find),
|
|
18
|
-
* 排除常见重目录,容量上限 SCAN_CAP;结果缓存(TTL FILE_INDEX_TTL)。失败返回 null。
|
|
19
|
-
* @author ddj 2026年08月20号
|
|
20
|
-
* @param ctx DSH 上下文
|
|
21
|
-
* @param session 会话(取工作区根)
|
|
22
|
-
* @param cwd 工作区目录
|
|
23
|
-
* @returns 绝对路径清单;不可用(无 subprocess/fs)或扫描失败返回 null
|
|
24
|
-
*/
|
|
25
|
-
export async function listWorkspaceFiles(ctx: Ctx, session: Session, cwd: string): Promise<string[] | null> {
|
|
26
|
-
const cached = fileIndex.get(cwd)
|
|
27
|
-
if (cached && Date.now() - cached.at < FILE_INDEX_TTL) return cached.files
|
|
28
|
-
const sub = ctx.get('subprocess')
|
|
29
|
-
const fs = ctx.get('fs')
|
|
30
|
-
if (!sub || !fs) return null
|
|
31
|
-
const policy = policyOf(ctx, session)
|
|
32
|
-
let root: string
|
|
33
|
-
try {
|
|
34
|
-
const rootTarget = await fs.resolve(policy?.workspaceRoot ?? '.', {})
|
|
35
|
-
root = fs.processPath(rootTarget)
|
|
36
|
-
} catch (error) {
|
|
37
|
-
root = cwd
|
|
38
|
-
}
|
|
39
|
-
const lines: string[] = []
|
|
40
|
-
const run = async (argv: string[]): Promise<void> => {
|
|
41
|
-
const handle = sub.spawn({
|
|
42
|
-
argv,
|
|
43
|
-
stdio: { stdout: { maxBytes: 8 << 20 }, stderr: { maxBytes: 1 << 16 }, stdin: 'ignore' },
|
|
44
|
-
graceMs: 20000,
|
|
45
|
-
})
|
|
46
|
-
const outcome = await handle.done
|
|
47
|
-
const code = outcome?.exitCode ?? outcome?.code
|
|
48
|
-
if (code !== 0) throw new Error('scan exit ' + code)
|
|
49
|
-
const text = handle.collected?.stdout?.readFrom(0).text ?? ''
|
|
50
|
-
for (const ln of text.split(/\r?\n/)) {
|
|
51
|
-
const s = ln.trim()
|
|
52
|
-
if (s) lines.push(s)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
try {
|
|
56
|
-
if (process.platform === 'win32') {
|
|
57
|
-
const cmd = 'Get-ChildItem -LiteralPath "' + String(root).replace(/"/g, '""') + '" -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch \'(node_modules|\\.git|\\.tmp|\\.cache|dist|build|vendor|coverage|__pycache__)\' } | Select-Object -First ' + SCAN_CAP + ' | ForEach-Object { $_.FullName }'
|
|
58
|
-
await run(['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd])
|
|
59
|
-
} else {
|
|
60
|
-
const cmd = 'find ' + JSON.stringify(root) + ' -type f -not -path \'*/node_modules/*\' -not -path \'*/.git/*\' -not -path \'*/.tmp/*\' -not -path \'*/.cache/*\' -not -path \'*/dist/*\' -not -path \'*/build/*\' -not -path \'*/vendor/*\' -not -path \'*/coverage/*\' 2>/dev/null | head -n ' + SCAN_CAP
|
|
61
|
-
await run(['/bin/sh', '-c', cmd])
|
|
62
|
-
}
|
|
63
|
-
} catch (error) {
|
|
64
|
-
log.error('scan failed: ' + String(error))
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
const files = lines.slice(0, SCAN_CAP)
|
|
68
|
-
if (files.length) fileIndex.set(cwd, { at: Date.now(), files })
|
|
69
|
-
return files
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** 会话销毁时清理文件索引缓存。 */
|
|
73
|
-
export function dropFileIndex(cwd: string): void {
|
|
74
|
-
fileIndex.delete(cwd)
|
|
75
|
-
}
|