dsh-vscode-mode 0.15.0 → 0.15.3
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 +7 -3
- package/lib/client.js +335 -30
- package/lib/client.js.map +1 -1
- package/lib/index.js +221 -8
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/sidebar/panels/SearchPanel.ts +58 -1
- package/src/client/styles/editor.css +4 -0
- package/src/client/svnNotice.ts +1 -1
- package/src/client/ui/SvnUpdateDialog.ts +143 -29
- package/src/client/ui/svnUpdateModel.ts +45 -1
- package/src/rpc.ts +1 -0
- package/src/search/content.ts +6 -2
- package/src/shared/rpc.ts +6 -2
- package/src/shared/svn.ts +10 -0
- package/src/svn.ts +70 -5
- package/src/svnResolve.ts +33 -0
package/src/svn.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { attrOf, numAttrOf, scanXmlTags, textOf, unescapeXml } from './svnXml.js
|
|
|
19
19
|
import { TEXT_OUTPUT_CAP, batchResultOf, tailOfText, updateResultOf } from './svnText.js'
|
|
20
20
|
import { LOG_CAP, LOG_DEFAULT_LIMIT, SVN_LOG_SHOW_ALL_CAP, parseLogXml, parseSvnInfoRevision } from './svnLog.js'
|
|
21
21
|
import { SvnUpdateJobs } from './svnUpdateJob.js'
|
|
22
|
+
import { conflictOf, hasConflict, pegTarget, validAccept, validTarget } from './svnResolve.js'
|
|
22
23
|
import { EXPLORER_CAP, explorerViewOf, scopeEntriesOf, statusXmlValid, viewArgsValid } from './svnExplorer.js'
|
|
23
24
|
import { ExplorerScopes, explorerKey } from './svnExplorerCache.js'
|
|
24
25
|
import type { ExplorerScan } from './svnExplorerCache.js'
|
|
@@ -503,6 +504,14 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
503
504
|
const now = deps.now ?? (() => Date.now())
|
|
504
505
|
const findRoot = deps.findRoot ?? ((cwd: string) => findSvnRoot(cwd))
|
|
505
506
|
const findTortoise = deps.findTortoise ?? ((dir: string) => findTortoiseProc(dir))
|
|
507
|
+
const conflictWrites = new Set<string>()
|
|
508
|
+
/** @private @author ddj 2026年10月14号 @param root WC root. @returns Canonical lock identity across sessions. */
|
|
509
|
+
const writeKey = (root: string): string => (deps.platform ?? process.platform) === 'win32' ? root.toLowerCase() : root
|
|
510
|
+
/** @private @author ddj 2026年10月14号 @param root WC root. @returns Whether a live update or conflict write owns the WC. */
|
|
511
|
+
const writeBusy = (root: string): boolean => {
|
|
512
|
+
const job = updateJobs.active(root)
|
|
513
|
+
return conflictWrites.has(writeKey(root)) || Boolean(job && ['starting', 'running', 'cancelling'].includes(job.phase))
|
|
514
|
+
}
|
|
506
515
|
const statusCache = new Map<string, { at: number; payload: SvnStatusPayload }>()
|
|
507
516
|
/** 运行中更新按会话目录复用工作副本根;TTL 到期才重新扫描 .svn。 */
|
|
508
517
|
const updateRootCache = new Map<string, { at: number; root: string }>()
|
|
@@ -622,7 +631,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
622
631
|
* @param stdoutCap stdout 收集上限(缺省 1MB;大载荷场景如日志拉取需显式放大)
|
|
623
632
|
* @returns 退出码与输出
|
|
624
633
|
*/
|
|
625
|
-
const runSvn = async (cwd: string, argv: string[], graceMs: number, stdoutCap: number = OUTPUT_CAP): Promise<SvnRunOutcome> => {
|
|
634
|
+
const runSvn = async (cwd: string, argv: string[], graceMs: number, stdoutCap: number = OUTPUT_CAP, allowFallback = true): Promise<SvnRunOutcome> => {
|
|
626
635
|
const spawn = subprocess()
|
|
627
636
|
if (!spawn) throw new Error('缺少 subprocess 服务')
|
|
628
637
|
const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | null | undefined
|
|
@@ -643,7 +652,8 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
643
652
|
const stdout = handle.collected?.stdout?.readFrom(0).text ?? ''
|
|
644
653
|
const stderr = handle.collected?.stderr?.readFrom(0).text ?? ''
|
|
645
654
|
return { code: outcome?.exitCode ?? outcome?.code ?? null, stdout, stderr }
|
|
646
|
-
} catch {
|
|
655
|
+
} catch (error) {
|
|
656
|
+
if (!allowFallback) throw error
|
|
647
657
|
// 受管环境管道 EPERM:回退 inherit(只拿退出码,输出为空)
|
|
648
658
|
const handle = attempt('inherit')
|
|
649
659
|
const outcome = await handle.done
|
|
@@ -651,6 +661,15 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
651
661
|
}
|
|
652
662
|
}
|
|
653
663
|
|
|
664
|
+
/** @private @author ddj 2026年10月14号 @param cwd Session workspace. @param root WC root. @param path Validated target. @returns Exact conflict evidence without recursive scanning. */
|
|
665
|
+
const readConflict = async (cwd: string, root: string, path: string) => {
|
|
666
|
+
const target = svnPathOf(cwd, root, path)
|
|
667
|
+
if (!target) throw new Error('目标不在 SVN 工作副本内')
|
|
668
|
+
const result = await runSvn(root, ['status', '--xml', '--verbose', '--depth', 'empty', '--', pegTarget(target)], READ_GRACE_MS, OUTPUT_CAP, false)
|
|
669
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || 'SVN 状态读取失败')
|
|
670
|
+
return conflictOf(result.stdout, path)
|
|
671
|
+
}
|
|
672
|
+
|
|
654
673
|
/** 发射 TortoiseProc(GUI 长驻:不 await done,no-op catch 防未处理 rejection)。 */
|
|
655
674
|
const launchTortoise = (exe: string, action: SvnAction, absPath: string): void => {
|
|
656
675
|
const spawn = subprocess()
|
|
@@ -738,7 +757,9 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
738
757
|
if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
|
|
739
758
|
if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
|
|
740
759
|
try {
|
|
741
|
-
const
|
|
760
|
+
const targets = rels.map((path) => svnPathOf(sc.cwd, status.wcRoot!, path))
|
|
761
|
+
if (targets.some((path) => path === null)) return { ok: false as const, error: '目标不在 SVN 工作副本内' }
|
|
762
|
+
const outcome = await runSvn(status.wcRoot, [...verbArgs, '--', ...targets.map((path) => pegTarget(path!))], MUTATE_GRACE_MS, OUTPUT_CAP, !verbArgs.includes('--depth'))
|
|
742
763
|
const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, verb)
|
|
743
764
|
// 变更清单已过期(revert/add 改工作副本状态):主动作废缓存,下次拉取即为最新
|
|
744
765
|
changesCache.delete(status.wcRoot)
|
|
@@ -1089,8 +1110,51 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
1089
1110
|
return { ok: false as const, error: '读取基线失败:' + String(error) }
|
|
1090
1111
|
}
|
|
1091
1112
|
},
|
|
1092
|
-
'svn.
|
|
1093
|
-
|
|
1113
|
+
'svn.conflictStatus': async (args: { sessionId?: string; paths?: string[] }) => {
|
|
1114
|
+
const scope = await updateScope(args.sessionId, true)
|
|
1115
|
+
if ('err' in scope) return { ok: false as const, error: scope.err }
|
|
1116
|
+
if (!scope.cli) return { ok: false as const, error: 'svn 命令不可用' }
|
|
1117
|
+
if (!Array.isArray(args.paths) || args.paths.length > BATCH_PATHS_CAP) return { ok: false as const, error: '冲突路径列表不合法或超限' }
|
|
1118
|
+
if (!args.paths.every(validTarget)) return { ok: false as const, error: '路径不合法' }
|
|
1119
|
+
const paths = [...new Set(args.paths.map(safeRel))]
|
|
1120
|
+
if (paths.some((path) => !path)) return { ok: false as const, error: '路径不合法' }
|
|
1121
|
+
try {
|
|
1122
|
+
const states = []
|
|
1123
|
+
for (const path of paths) states.push(await readConflict(scope.cwd, scope.root, path!))
|
|
1124
|
+
return { ok: true as const, states }
|
|
1125
|
+
} catch (error) { return { ok: false as const, error: String(error) } }
|
|
1126
|
+
},
|
|
1127
|
+
'svn.resolve': async (args: { sessionId?: string; path?: string; accept?: unknown }) => {
|
|
1128
|
+
if (!validAccept(args.accept)) return { ok: false as const, error: '解决策略不合法' }
|
|
1129
|
+
if (!validTarget(args.path)) return { ok: false as const, error: '路径不合法' }
|
|
1130
|
+
const path = safeRel(args.path)
|
|
1131
|
+
if (!path) return { ok: false as const, error: '路径不合法' }
|
|
1132
|
+
const scope = await updateScope(args.sessionId, true)
|
|
1133
|
+
if ('err' in scope) return { ok: false as const, error: scope.err }
|
|
1134
|
+
if (!scope.cli) return { ok: false as const, error: 'svn 命令不可用' }
|
|
1135
|
+
if (writeBusy(scope.root)) return { ok: false as const, error: '工作副本正在更新或解决冲突,请等待完成' }
|
|
1136
|
+
conflictWrites.add(writeKey(scope.root))
|
|
1137
|
+
try {
|
|
1138
|
+
const evidence = await readConflict(scope.cwd, scope.root, path)
|
|
1139
|
+
if (!hasConflict(evidence)) return { ok: false as const, error: '目标已无冲突,请刷新状态' }
|
|
1140
|
+
if (evidence.tree && args.accept !== 'working') return { ok: false as const, error: '树冲突不支持通用完整版本选择,请针对性处理' }
|
|
1141
|
+
const target = svnPathOf(scope.cwd, scope.root, path)!
|
|
1142
|
+
const outcome = await runSvn(scope.root, ['resolve', '--accept', args.accept, '--depth', 'empty', '--', pegTarget(target)], MUTATE_GRACE_MS, OUTPUT_CAP, false)
|
|
1143
|
+
if (outcome.code !== 0) return { ok: false as const, error: outcome.stderr || outcome.stdout || 'SVN 解决冲突失败' }
|
|
1144
|
+
return { ok: true as const, summary: 'SVN 解决命令已执行,请核对剩余冲突', output: outcome.stdout }
|
|
1145
|
+
} catch (error) { return { ok: false as const, error: String(error) } }
|
|
1146
|
+
finally { conflictWrites.delete(writeKey(scope.root)); changesCache.delete(scope.root); explorerCache.clear() }
|
|
1147
|
+
},
|
|
1148
|
+
'svn.revert': async (args: { sessionId?: string; paths?: string[]; depth?: unknown }) => {
|
|
1149
|
+
if (args.depth === undefined) return mutate('还原', ['revert', '-R'], args.sessionId, args.paths)
|
|
1150
|
+
if (args.depth !== 'empty') return { ok: false as const, error: '还原深度不合法' }
|
|
1151
|
+
if (!Array.isArray(args.paths) || !args.paths.length || !args.paths.every(validTarget)) return { ok: false as const, error: '路径不合法' }
|
|
1152
|
+
const scope = await updateScope(args.sessionId, true)
|
|
1153
|
+
if ('err' in scope) return { ok: false as const, error: scope.err }
|
|
1154
|
+
if (writeBusy(scope.root)) return { ok: false as const, error: '工作副本正在更新或解决冲突,请等待完成' }
|
|
1155
|
+
conflictWrites.add(writeKey(scope.root))
|
|
1156
|
+
try { return await mutate('还原', ['revert', '--depth', 'empty'], args.sessionId, args.paths) }
|
|
1157
|
+
finally { conflictWrites.delete(writeKey(scope.root)); changesCache.delete(scope.root); explorerCache.clear() }
|
|
1094
1158
|
},
|
|
1095
1159
|
'svn.add': async (args: { sessionId?: string; paths?: string[] }) => {
|
|
1096
1160
|
return mutate('加入版本控制', ['add'], args.sessionId, args.paths)
|
|
@@ -1546,6 +1610,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
1546
1610
|
const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | undefined
|
|
1547
1611
|
const cwd = fs?.resolve && fs?.processPath
|
|
1548
1612
|
? fs.processPath(await fs.resolve(scope.cwd, {})) : scope.cwd
|
|
1613
|
+
if (conflictWrites.has(writeKey(scope.root))) return { ok: false as const, error: '工作副本正在解决冲突,请等待完成' }
|
|
1549
1614
|
return { ok: true as const, ...updateJobs.start({ cwd, root: scope.root, path: rel, exe: scope.exe }) }
|
|
1550
1615
|
} catch (error) {
|
|
1551
1616
|
return { ok: false as const, error: '启动 SVN 更新失败:' + String(error) }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SvnConflictState, SvnResolveAccept } from './shared/svn.js'
|
|
2
|
+
import { attrOf, scanXmlTags } from './svnXml.js'
|
|
3
|
+
|
|
4
|
+
/** @public @author ddj 2026年10月14号 @param value Requested strategy. @returns Whether SVN accepts this explicit strategy. */
|
|
5
|
+
export function validAccept(value: unknown): value is SvnResolveAccept {
|
|
6
|
+
return value === 'mine-full' || value === 'theirs-full' || value === 'working'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** @public @author ddj 2026年10月14号 @param value Untrusted relative target. @returns Whether the path names exactly one non-root workspace target. */
|
|
10
|
+
export function validTarget(value: unknown): value is string {
|
|
11
|
+
if (typeof value !== 'string' || !value || /[\u0000-\u001f]/.test(value) || /^[A-Za-z]:|^[\\/]/.test(value)) return false
|
|
12
|
+
return value.replace(/\\/g, '/').split('/').every((part) => Boolean(part) && part !== '.' && part !== '..' && part.toLowerCase() !== '.svn')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @public @author ddj 2026年10月14号 @param path Exact relative target. @returns Peg-safe SVN argument. */
|
|
16
|
+
export function pegTarget(path: string): string { return path.includes('@') ? path + '@' : path }
|
|
17
|
+
|
|
18
|
+
/** @public @author ddj 2026年10月14号 @param xml Verbose depth-empty status. @param path Client target identity. @returns Confirmed conflict evidence. @throws Missing or incomplete status evidence. */
|
|
19
|
+
export function conflictOf(xml: string, path: string): SvnConflictState {
|
|
20
|
+
if (!/^\s*(?:<\?xml[^>]*>\s*)?<status\b/.test(xml) || !/<\/status>\s*$/.test(xml)) {
|
|
21
|
+
throw new Error('SVN 冲突状态输出为空或不完整,状态未确认')
|
|
22
|
+
}
|
|
23
|
+
const statuses = [...scanXmlTags(xml, ['wc-status'])].filter((hit) => !hit.closing)
|
|
24
|
+
if (statuses.length !== 1) throw new Error('SVN 未返回唯一目标状态,状态未确认')
|
|
25
|
+
const attrs = statuses[0].attrs
|
|
26
|
+
const item = attrOf(attrs, 'item')
|
|
27
|
+
if (!item) throw new Error('SVN 目标状态缺失,状态未确认')
|
|
28
|
+
return { path, text: item === 'conflicted', props: attrOf(attrs, 'props') === 'conflicted',
|
|
29
|
+
tree: attrOf(attrs, 'tree-conflicted') === 'true', versioned: !['unversioned', 'ignored', 'external', 'none'].includes(item) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @public @author ddj 2026年10月14号 @param state Confirmed target evidence. @returns Whether any conflict remains. */
|
|
33
|
+
export function hasConflict(state: SvnConflictState): boolean { return state.text || state.props || state.tree }
|