dsh-vscode-mode 0.1.7 → 0.1.11
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 +12 -0
- package/lib/client.js +725 -89
- package/lib/client.js.map +1 -1
- package/lib/index.js +1003 -94
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +44 -8
- package/src/client/index.ts +11 -2
- package/src/client/monaco/diffRender.ts +17 -2
- package/src/client/monaco/loader.ts +56 -9
- package/src/client/state/records.ts +5 -2
- package/src/client/state/regions.ts +26 -20
- package/src/client/styles/editor.css +16 -2
- package/src/client/styles/mcp.css +87 -0
- package/src/client/ui/DiffBox.ts +1 -0
- package/src/client/ui/EditorView.ts +70 -8
- package/src/client/ui/McpSettings.ts +233 -0
- package/src/index.ts +4 -2
- package/src/mcp.ts +123 -0
- package/src/mcpIsolation.ts +160 -0
- package/src/mcpProject.ts +258 -0
- package/src/model.ts +66 -34
- package/src/revert.ts +10 -3
- package/src/rpc.ts +44 -0
- package/src/shared/diff.ts +201 -0
- package/src/shared/mcp.ts +53 -0
- package/src/shared/rpc.ts +21 -0
- package/src/shared/types.ts +15 -0
- package/src/store.ts +29 -31
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode shared diff primitives — 快照指纹、hunk 规范化与安全定位。
|
|
3
|
+
* 不依赖 Node/React,host 与 browser 共用,避免各层用不同 indexOf 语义。
|
|
4
|
+
* 作者 ddj 2026-08-24
|
|
5
|
+
*/
|
|
6
|
+
import type { Hunk, RecordBase } from './types.js'
|
|
7
|
+
|
|
8
|
+
/** 文件读取状态,区分真实空文件与无法读取/过大的文件。 */
|
|
9
|
+
export type ReadState =
|
|
10
|
+
| { kind: 'content'; content: string }
|
|
11
|
+
| { kind: 'missing' }
|
|
12
|
+
| { kind: 'unavailable' }
|
|
13
|
+
|
|
14
|
+
/** 文本中的一个半开区间 [start,end)。 */
|
|
15
|
+
export interface TextRange {
|
|
16
|
+
start: number
|
|
17
|
+
end: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 一条 hunk 在当前内容中的定位结果。 */
|
|
21
|
+
export interface HunkLocation extends TextRange {
|
|
22
|
+
idx: number
|
|
23
|
+
hunk: Hunk
|
|
24
|
+
matched: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 统一取对应 hunk;callHunk 只用于旧记录缺失 hunks 时兼容。 */
|
|
28
|
+
export function preciseHunk(record: Pick<RecordBase, 'hunks' | 'callHunk'>, idx: number): Hunk | null {
|
|
29
|
+
return hunkOf(record, idx)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 轻量、确定性的字符串指纹;不引入 Node crypto。 */
|
|
33
|
+
export function fingerprint(text: string | null | undefined): string | null {
|
|
34
|
+
if (typeof text !== 'string') return null
|
|
35
|
+
let hash = 2166136261
|
|
36
|
+
for (let i = 0; i < text.length; i++) {
|
|
37
|
+
hash ^= text.charCodeAt(i)
|
|
38
|
+
hash = Math.imul(hash, 16777619)
|
|
39
|
+
}
|
|
40
|
+
return 'fnv1a:' + (hash >>> 0).toString(16) + ':' + text.length
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 取记录第 idx 个有效 hunk;只有记录缺 hunk 时才回退 callHunk。 */
|
|
44
|
+
export function hunkOf(record: Pick<RecordBase, 'hunks' | 'callHunk'>, idx: number): Hunk | null {
|
|
45
|
+
const hunk = Array.isArray(record.hunks) ? record.hunks[idx] : undefined
|
|
46
|
+
if (hunk && typeof hunk.newText === 'string') return hunk
|
|
47
|
+
if (idx === 0 && record.callHunk && typeof record.callHunk.newText === 'string') return record.callHunk
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** hunk 是否表示没有实际内容变化。 */
|
|
52
|
+
export function isNoopHunk(hunk: Hunk | null | undefined): boolean {
|
|
53
|
+
return !!hunk && hunk.oldText !== null && hunk.oldText === hunk.newText
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** splitLines 的空文本语义:真实空文件没有一行变更内容。 */
|
|
57
|
+
export function splitLines(text: string): string[] {
|
|
58
|
+
return text.length ? text.split('\n') : []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 为执行后 hunk 写入快照坐标;空 newText 通过 before/after 公共边界定位删除点。 */
|
|
62
|
+
export function annotateHunk(hunk: Hunk, before: string | null, after: string | null): Hunk {
|
|
63
|
+
return annotateHunks([hunk], before, after)[0]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 按文件顺序为多 hunk 写入不重复的 after 坐标。 */
|
|
67
|
+
export function annotateHunks(hunks: Hunk[], before: string | null, after: string | null): Hunk[] {
|
|
68
|
+
if (typeof after !== 'string') return hunks
|
|
69
|
+
let cursor = 0
|
|
70
|
+
return hunks.map((hunk) => {
|
|
71
|
+
if (hunk.newText.length > 0) {
|
|
72
|
+
const start = after.indexOf(hunk.newText, cursor)
|
|
73
|
+
if (start >= 0) {
|
|
74
|
+
cursor = start + hunk.newText.length
|
|
75
|
+
return { ...hunk, afterStart: start, afterEnd: cursor }
|
|
76
|
+
}
|
|
77
|
+
return hunk
|
|
78
|
+
}
|
|
79
|
+
if (hunks.length !== 1 || typeof before !== 'string' || before === after || hunk.oldText === null) return hunk
|
|
80
|
+
const prefix = commonPrefix(before, after)
|
|
81
|
+
const suffix = commonSuffix(before, after, prefix)
|
|
82
|
+
const removedStart = prefix
|
|
83
|
+
const removedEnd = Math.max(removedStart, before.length - suffix)
|
|
84
|
+
return { ...hunk, afterStart: removedStart, afterEnd: removedStart, beforeStart: removedStart, beforeEnd: removedEnd }
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 计算两个文本的最长公共前缀长度。 */
|
|
89
|
+
function commonPrefix(left: string, right: string): number {
|
|
90
|
+
const limit = Math.min(left.length, right.length)
|
|
91
|
+
let i = 0
|
|
92
|
+
while (i < limit && left.charCodeAt(i) === right.charCodeAt(i)) i++
|
|
93
|
+
return i
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 计算不与 prefix 重叠的最长公共后缀长度。 */
|
|
97
|
+
function commonSuffix(left: string, right: string, prefix: number): number {
|
|
98
|
+
const limit = Math.min(left.length, right.length) - prefix
|
|
99
|
+
let i = 0
|
|
100
|
+
while (i < limit && left.charCodeAt(left.length - 1 - i) === right.charCodeAt(right.length - 1 - i)) i++
|
|
101
|
+
return i
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 计算 index 前的 0 基行数。 */
|
|
105
|
+
export function lineBefore(text: string, index: number): number {
|
|
106
|
+
let n = 0
|
|
107
|
+
const end = Math.max(0, Math.min(index, text.length))
|
|
108
|
+
for (let i = 0; i < end; i++) if (text.charCodeAt(i) === 10) n++
|
|
109
|
+
return n
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 找出文本的全部非重叠出现位置。 */
|
|
113
|
+
function allMatches(content: string, needle: string): TextRange[] {
|
|
114
|
+
if (!needle.length) return []
|
|
115
|
+
const out: TextRange[] = []
|
|
116
|
+
let from = 0
|
|
117
|
+
while (from <= content.length - needle.length) {
|
|
118
|
+
const at = content.indexOf(needle, from)
|
|
119
|
+
if (at < 0) break
|
|
120
|
+
out.push({ start: at, end: at + needle.length })
|
|
121
|
+
from = at + Math.max(1, needle.length)
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 从 after 快照定位所有 hunk,按 hunk 顺序优先选未占用且靠近前一块的候选。
|
|
128
|
+
* 若 hunk 带 afterStart/afterEnd,则优先验证快照坐标,避免重复文本错配。
|
|
129
|
+
* @author ddj 2026年08月24号
|
|
130
|
+
* @param content 当前文件内容
|
|
131
|
+
* @param hunks 待定位 hunk
|
|
132
|
+
* @returns 每个 hunk 一个结果,matched=false 表示冲突/stale
|
|
133
|
+
*/
|
|
134
|
+
export function locateHunks(content: string, hunks: Hunk[]): HunkLocation[] {
|
|
135
|
+
const locations: HunkLocation[] = []
|
|
136
|
+
let cursor = 0
|
|
137
|
+
const occupied: TextRange[] = []
|
|
138
|
+
for (let idx = 0; idx < hunks.length; idx++) {
|
|
139
|
+
const hunk = hunks[idx]
|
|
140
|
+
const newText = hunk.newText
|
|
141
|
+
const snapshot = Number.isInteger(hunk.afterStart) && Number.isInteger(hunk.afterEnd)
|
|
142
|
+
? { start: hunk.afterStart as number, end: hunk.afterEnd as number }
|
|
143
|
+
: null
|
|
144
|
+
const snapshotMatches = snapshot && snapshot.start >= 0 && snapshot.end >= snapshot.start
|
|
145
|
+
&& (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText)
|
|
146
|
+
? snapshot
|
|
147
|
+
: null
|
|
148
|
+
const candidates = snapshotMatches ? [snapshotMatches] : allMatches(content, newText)
|
|
149
|
+
const candidate = candidates.find((range) => range.start >= cursor && !occupied.some((used) => overlaps(range, used)))
|
|
150
|
+
?? candidates.find((range) => !occupied.some((used) => overlaps(range, used)))
|
|
151
|
+
if (candidate) {
|
|
152
|
+
locations.push({ idx, hunk, ...candidate, matched: true })
|
|
153
|
+
occupied.push(candidate)
|
|
154
|
+
cursor = Math.max(cursor, candidate.end)
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (newText.length === 0 && hunk.afterStart === undefined && hunk.afterEnd === undefined && content.length === 0) {
|
|
158
|
+
const point = { start: 0, end: 0 }
|
|
159
|
+
locations.push({ idx, hunk, ...point, matched: true })
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (newText.length === 0 && hunk.afterStart !== undefined && hunk.afterEnd !== undefined
|
|
163
|
+
&& hunk.afterStart === hunk.afterEnd && hunk.afterStart >= 0 && hunk.afterStart <= content.length) {
|
|
164
|
+
const point = { start: hunk.afterStart, end: hunk.afterEnd }
|
|
165
|
+
if (!occupied.some((used) => overlaps(point, used))) {
|
|
166
|
+
locations.push({ idx, hunk, ...point, matched: true })
|
|
167
|
+
occupied.push(point)
|
|
168
|
+
cursor = Math.max(cursor, point.end)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
locations.push({ idx, hunk, start: -1, end: -1, matched: false })
|
|
173
|
+
}
|
|
174
|
+
return locations
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 判断两个半开区间是否重叠。零长度区间只与同一位置的零长度区间重叠。 */
|
|
178
|
+
function overlaps(left: TextRange, right: TextRange): boolean {
|
|
179
|
+
if (left.start === left.end || right.start === right.end) return left.start === right.start && left.end === right.end
|
|
180
|
+
return left.start < right.end && right.start < left.end
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 在可定位区间上执行一次 hunk 反向替换。 */
|
|
184
|
+
export function replaceRange(content: string, location: HunkLocation, reverse = false): string | null {
|
|
185
|
+
if (!location.matched || location.start < 0) return null
|
|
186
|
+
const replacement = reverse ? (location.hunk.oldText ?? '') : location.hunk.newText
|
|
187
|
+
return content.slice(0, location.start) + replacement + content.slice(location.end)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 按当前内容中出现的位置从后向前应用 hunk,避免前块长度变化影响后块。 */
|
|
191
|
+
export function applyLocations(content: string, locations: HunkLocation[], reverse = false): { content: string; stale: number[] } {
|
|
192
|
+
const stale = locations.filter((location) => !location.matched).map((location) => location.idx)
|
|
193
|
+
let out = content
|
|
194
|
+
const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start)
|
|
195
|
+
for (const location of matched) {
|
|
196
|
+
const next = replaceRange(out, location, reverse)
|
|
197
|
+
if (next === null) stale.push(location.idx)
|
|
198
|
+
else out = next
|
|
199
|
+
}
|
|
200
|
+
return { content: out, stale: stale.sort((a, b) => a - b) }
|
|
201
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** dsh-vscode-mode MCP 管理共享数据契约。作者 ddj 2026年08月22号 */
|
|
2
|
+
|
|
3
|
+
export type MpcTransport = 'stdio' | 'streamable-http'
|
|
4
|
+
export type MpcStatus = 'connected' | 'connecting' | 'error' | 'disabled'
|
|
5
|
+
|
|
6
|
+
export interface MpcTool {
|
|
7
|
+
name: string
|
|
8
|
+
description?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MpcConfig {
|
|
12
|
+
serverName: string
|
|
13
|
+
transport: MpcTransport
|
|
14
|
+
command?: string
|
|
15
|
+
args?: string[]
|
|
16
|
+
cwd?: string
|
|
17
|
+
env?: Record<string, string>
|
|
18
|
+
url?: string
|
|
19
|
+
headers?: Record<string, string>
|
|
20
|
+
toolCallTimeoutMs?: number
|
|
21
|
+
failOnStartupError?: boolean
|
|
22
|
+
reconnect?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MpcServer {
|
|
26
|
+
id: string
|
|
27
|
+
serverName: string
|
|
28
|
+
enabled: boolean
|
|
29
|
+
transport: MpcTransport
|
|
30
|
+
config: MpcConfig
|
|
31
|
+
status: MpcStatus
|
|
32
|
+
toolCount: number
|
|
33
|
+
tools: MpcTool[]
|
|
34
|
+
error?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 项目级 MCP:绑定到一个 workspace 路径,配置存于该项目根 .mcp.json。 */
|
|
38
|
+
export interface MpcProject {
|
|
39
|
+
workspacePath: string
|
|
40
|
+
title: string
|
|
41
|
+
servers: MpcServer[]
|
|
42
|
+
source: 'project'
|
|
43
|
+
missingDir?: boolean
|
|
44
|
+
/** .mcp.json 解析失败时携带错误文案(不覆盖文件)。 */
|
|
45
|
+
fileError?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 项目级 MCP 保存入参:config 为该 serverName 的传输配置(不含归属信息)。 */
|
|
49
|
+
export interface MpcProjectSaveInput {
|
|
50
|
+
workspacePath: string
|
|
51
|
+
serverName: string
|
|
52
|
+
config: MpcConfig
|
|
53
|
+
}
|
package/src/shared/rpc.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
RecordView,
|
|
13
13
|
StaleHunk,
|
|
14
14
|
} from './types.js'
|
|
15
|
+
import type { MpcConfig, MpcProject, MpcProjectSaveInput, MpcServer } from './mcp.js'
|
|
15
16
|
|
|
16
17
|
/** webServer 精确路由。 */
|
|
17
18
|
export const RPC_PATH = '/edrv/rpc'
|
|
@@ -32,6 +33,16 @@ export interface RpcRequestMap {
|
|
|
32
33
|
'edrv.rollback': { sessionId?: string; path: string; batch?: number }
|
|
33
34
|
'edrv.debug': { sessionId?: string; text: string }
|
|
34
35
|
'edrv.searchFiles': { sessionId?: string; query: string }
|
|
36
|
+
'mcp.list': {}
|
|
37
|
+
'mcp.save': { config: MpcConfig }
|
|
38
|
+
'mcp.remove': { id: string }
|
|
39
|
+
'mcp.toggle': { id: string; enabled: boolean }
|
|
40
|
+
'mcp.refresh': { id: string }
|
|
41
|
+
'mcp.projects': {}
|
|
42
|
+
'mcp.projectSave': MpcProjectSaveInput
|
|
43
|
+
'mcp.projectRemove': { workspacePath: string; serverName: string }
|
|
44
|
+
'mcp.projectToggle': { workspacePath: string; serverName: string; enabled: boolean }
|
|
45
|
+
'mcp.projectRefresh': { workspacePath: string; serverName: string }
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
export type RpcMethod = keyof RpcRequestMap
|
|
@@ -49,6 +60,16 @@ export interface RpcOkMap {
|
|
|
49
60
|
'edrv.rollback': { path: string; batch: number | null }
|
|
50
61
|
'edrv.debug': object
|
|
51
62
|
'edrv.searchFiles': { files: string[]; truncated: boolean }
|
|
63
|
+
'mcp.list': { servers: MpcServer[] }
|
|
64
|
+
'mcp.save': { server: MpcServer }
|
|
65
|
+
'mcp.remove': object
|
|
66
|
+
'mcp.toggle': { server: MpcServer }
|
|
67
|
+
'mcp.refresh': { server: MpcServer }
|
|
68
|
+
'mcp.projects': { projects: MpcProject[] }
|
|
69
|
+
'mcp.projectSave': { project: MpcProject }
|
|
70
|
+
'mcp.projectRemove': { project: MpcProject }
|
|
71
|
+
'mcp.projectToggle': { project: MpcProject }
|
|
72
|
+
'mcp.projectRefresh': { project: MpcProject }
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
/** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
|
package/src/shared/types.ts
CHANGED
|
@@ -15,6 +15,12 @@ export type Decision = 'pending' | 'accepted' | 'rejected'
|
|
|
15
15
|
export interface Hunk {
|
|
16
16
|
oldText: string | null
|
|
17
17
|
newText: string
|
|
18
|
+
/** 执行后快照中的精确范围(可选,旧记录没有)。 */
|
|
19
|
+
afterStart?: number
|
|
20
|
+
afterEnd?: number
|
|
21
|
+
/** 执行前快照中的精确范围(可选,旧记录没有)。 */
|
|
22
|
+
beforeStart?: number
|
|
23
|
+
beforeEnd?: number
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
/** 单条 edit 调用的精确 old/new 串(edit 工具参数原样)。 */
|
|
@@ -41,6 +47,15 @@ export interface RecordBase {
|
|
|
41
47
|
note: string | null
|
|
42
48
|
superseded: boolean
|
|
43
49
|
at: string
|
|
50
|
+
/** 完整执行后快照;旧 sidecar 缺失时为 undefined。 */
|
|
51
|
+
after?: string | null
|
|
52
|
+
/** 执行前/后快照指纹,用于保守冲突判断。 */
|
|
53
|
+
baseFingerprint?: string | null
|
|
54
|
+
afterFingerprint?: string | null
|
|
55
|
+
/** 当前磁盘内容无法安全匹配时保留记录并标记冲突。 */
|
|
56
|
+
conflict?: boolean
|
|
57
|
+
/** 记录来自没有快照字段的旧 sidecar。 */
|
|
58
|
+
legacy?: boolean
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
/** host 内部完整记录(含持久化专用字段)。 */
|
package/src/store.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* 作者 ddj 2026-08-20
|
|
5
5
|
*/
|
|
6
6
|
import type { ArchiveBatch, ArchiveData, DiffRecord, SidecarData } from './shared/types.js'
|
|
7
|
+
import type { ReadState } from './shared/diff.js'
|
|
8
|
+
import { fingerprint, isNoopHunk, locateHunks, preciseHunk } from './shared/diff.js'
|
|
7
9
|
import { archiveEntryFor, groupByBatch, normalizeRecord } from './model.js'
|
|
8
10
|
|
|
9
11
|
export const SIDECAR = '.dsh-edit-review.json'
|
|
@@ -105,21 +107,23 @@ export async function saveBucket(ctx: Ctx, cwd: string, recsMap: Map<string, Dif
|
|
|
105
107
|
* @author ddj 2026年08月20号
|
|
106
108
|
* @param cache path → 内容(null=缺失/读取失败,''=过大跳过)
|
|
107
109
|
*/
|
|
108
|
-
export async function readForCache(ctx: Ctx, session: Session, cache: Map<string,
|
|
109
|
-
|
|
110
|
+
export async function readForCache(ctx: Ctx, session: Session, cache: Map<string, ReadState>, path: string): Promise<ReadState> {
|
|
111
|
+
const cached = cache.get(path)
|
|
112
|
+
if (cached) return cached
|
|
110
113
|
const fs = ctx.get('fs')
|
|
111
|
-
if (!fs) { cache.set(path,
|
|
114
|
+
if (!fs) { const state: ReadState = { kind: 'unavailable' }; cache.set(path, state); return state }
|
|
112
115
|
try {
|
|
113
116
|
const target = await resolveTarget(ctx, session, path)
|
|
114
117
|
const info = await fs.stat(target)
|
|
115
|
-
if (!info || info.type !== 'file') { cache.set(path,
|
|
116
|
-
if ((info.size ?? 0) > READ_CAP) { cache.set(path,
|
|
117
|
-
const
|
|
118
|
-
cache.set(path,
|
|
119
|
-
return
|
|
118
|
+
if (!info || info.type !== 'file') { const state: ReadState = { kind: 'missing' }; cache.set(path, state); return state }
|
|
119
|
+
if ((info.size ?? 0) > READ_CAP) { const state: ReadState = { kind: 'unavailable' }; cache.set(path, state); return state }
|
|
120
|
+
const state: ReadState = { kind: 'content', content: await fs.readText(target) }
|
|
121
|
+
cache.set(path, state)
|
|
122
|
+
return state
|
|
120
123
|
} catch (error) {
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
const state: ReadState = { kind: 'unavailable' }
|
|
125
|
+
cache.set(path, state)
|
|
126
|
+
return state
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
129
|
|
|
@@ -128,28 +132,22 @@ export async function readForCache(ctx: Ctx, session: Session, cache: Map<string
|
|
|
128
132
|
* 或新建文件已不存在,或全为空差异。满足则应由 host 自动归档。
|
|
129
133
|
* @author ddj 2026年08月20号
|
|
130
134
|
*/
|
|
131
|
-
export async function recordIsStale(ctx: Ctx, session: Session, cache: Map<string,
|
|
135
|
+
export async function recordIsStale(ctx: Ctx, session: Session, cache: Map<string, ReadState>, rec: DiffRecord): Promise<boolean> {
|
|
132
136
|
if (rec.superseded === true) return true
|
|
133
137
|
if (!Array.isArray(rec.hunks) || !rec.hunks.length) return true
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (oldText !== null && oldText === newText) continue
|
|
148
|
-
if (newText === null || newText === undefined) continue
|
|
149
|
-
const content = await readForCache(ctx, session, cache, rec.path)
|
|
150
|
-
if (content !== null && content.indexOf(newText) >= 0) { hasLocatablePending = true; break }
|
|
151
|
-
}
|
|
152
|
-
return !hasLocatablePending
|
|
138
|
+
const state = await readForCache(ctx, session, cache, rec.path)
|
|
139
|
+
if (state.kind === 'unavailable') return false
|
|
140
|
+
if (state.kind === 'missing') return true
|
|
141
|
+
if (rec.create === true) return false
|
|
142
|
+
const pending = rec.hunks.map((_, idx) => ({ idx, hunk: preciseHunk(rec, idx), status: rec.decisions.perHunk[idx] ?? rec.decisions.call }))
|
|
143
|
+
.filter((item) => item.status === 'pending' && item.hunk && !isNoopHunk(item.hunk))
|
|
144
|
+
if (!pending.length) return true
|
|
145
|
+
const currentFingerprint = fingerprint(state.content)
|
|
146
|
+
if (rec.afterFingerprint && currentFingerprint === rec.afterFingerprint) { rec.conflict = false; return false }
|
|
147
|
+
const locations = locateHunks(state.content, pending.map((item) => item.hunk!))
|
|
148
|
+
if (locations.some((location) => location.matched)) { rec.conflict = false; return false }
|
|
149
|
+
rec.conflict = true
|
|
150
|
+
return false
|
|
153
151
|
}
|
|
154
152
|
|
|
155
153
|
/**
|
|
@@ -161,7 +159,7 @@ export async function recordIsStale(ctx: Ctx, session: Session, cache: Map<strin
|
|
|
161
159
|
export async function autoArchiveStale(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>): Promise<number> {
|
|
162
160
|
const fs = ctx.get('fs')
|
|
163
161
|
if (!fs) return 0
|
|
164
|
-
const cache = new Map<string,
|
|
162
|
+
const cache = new Map<string, ReadState>()
|
|
165
163
|
const stale: DiffRecord[] = []
|
|
166
164
|
for (const rec of bucket.values()) {
|
|
167
165
|
if (rec.archived || rec.superseded === true) continue
|