pi-hashline-edit-pro 4.4.4 → 4.5.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 +44 -11
- package/index.ts +19 -4
- package/package.json +4 -3
- package/prompts/copy-guidelines.md +1 -0
- package/prompts/copy-snippet.md +1 -0
- package/prompts/copy.md +3 -0
- package/prompts/grep.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +1 -0
- package/prompts/move-snippet.md +1 -0
- package/prompts/move.md +3 -0
- package/prompts/replace-guidelines.md +0 -2
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/batch.ts +28 -6
- package/src/commit.ts +17 -4
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +1 -1
- package/src/copy-move.ts +793 -0
- package/src/edit-common.ts +19 -0
- package/src/file-reader.ts +4 -0
- package/src/hash-store/validation.ts +22 -0
- package/src/hash-store.ts +32 -4
- package/src/hashline/apply.ts +8 -3
- package/src/hashline/index.ts +3 -0
- package/src/hashline/parse.ts +22 -10
- package/src/hashline/resolve.ts +23 -8
- package/src/insert.ts +28 -10
- package/src/line-endings.ts +151 -0
- package/src/paths.ts +18 -2
- package/src/payload-contract.ts +29 -1
- package/src/replace-render.ts +4 -4
- package/src/replace-undo.ts +9 -2
- package/src/replace.ts +17 -5
- package/src/utils.ts +40 -5
package/src/copy-move.ts
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReqParams, type ReplaceDetails } from "./replace";
|
|
6
|
+
import { commitEdit } from "./commit";
|
|
7
|
+
import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
|
|
8
|
+
import {
|
|
9
|
+
assertRangeServed,
|
|
10
|
+
lineChecksum,
|
|
11
|
+
lineHashes,
|
|
12
|
+
MAX_HASH_LINES,
|
|
13
|
+
parseHashRef,
|
|
14
|
+
resolveAnchorLine,
|
|
15
|
+
stripAnchorRow,
|
|
16
|
+
type Anchor,
|
|
17
|
+
} from "./hashline";
|
|
18
|
+
import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths, withAnchorSession } from "./anchor-registry";
|
|
19
|
+
import { loadP, loadGuide } from "./prompts";
|
|
20
|
+
import { assertTransferReq, normReq, type TransferReq } from "./payload-contract";
|
|
21
|
+
import { abortIf, assertByteLimit, isRec, splitLines } from "./utils";
|
|
22
|
+
import { toDisplayPath } from "./paths";
|
|
23
|
+
import { getDiffContextLines, readConfig } from "./config";
|
|
24
|
+
import { resolveInCwd, writeAtomic } from "./fs-write";
|
|
25
|
+
import { restoreEndings, type LineEnding } from "./normalize";
|
|
26
|
+
import { applyEndingOverrides, endingsForRange, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
27
|
+
import { saveUndo, type UndoEntry } from "./replace-undo";
|
|
28
|
+
import { buildChanged, buildMetrics, type TResult } from "./replace-response";
|
|
29
|
+
import { servedHashesFromDiff, serveRows } from "./served";
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_EDIT_FLAGS,
|
|
32
|
+
editRenderResultWrapper,
|
|
33
|
+
editToolBase,
|
|
34
|
+
queuedEdit,
|
|
35
|
+
resolveEditTarget,
|
|
36
|
+
throwIfStrictInput,
|
|
37
|
+
tryResolveEditTarget,
|
|
38
|
+
withTransferPrompts,
|
|
39
|
+
type EditToolFlags,
|
|
40
|
+
} from "./edit-common";
|
|
41
|
+
import { makeRenderCall, type RPreview, type RRState } from "./replace-render";
|
|
42
|
+
|
|
43
|
+
export type TransferKind = "copy" | "move";
|
|
44
|
+
|
|
45
|
+
interface TransferRefs {
|
|
46
|
+
sourceFrom: Anchor;
|
|
47
|
+
sourceTo: Anchor;
|
|
48
|
+
insertAfter: Anchor;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TransferPlan {
|
|
52
|
+
editParams: ReqParams;
|
|
53
|
+
foldedAnchorLines: number;
|
|
54
|
+
anchorCarry?: number;
|
|
55
|
+
servedOverride?: ReadonlyMap<string, string>;
|
|
56
|
+
endingOverrides?: (LineEnding | undefined)[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface PairFileCommit {
|
|
60
|
+
pipe: PipelineResult;
|
|
61
|
+
displayPath: string;
|
|
62
|
+
absolutePath: string;
|
|
63
|
+
mutationTargetPath: string;
|
|
64
|
+
foldedAnchorLines: number;
|
|
65
|
+
endingOverrides?: (LineEnding | undefined)[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertRangeVerified(
|
|
69
|
+
fileLines: string[],
|
|
70
|
+
fileHashes: string[],
|
|
71
|
+
start: number,
|
|
72
|
+
end: number,
|
|
73
|
+
served: ReadonlyMap<string, string> | undefined,
|
|
74
|
+
displayPath: string,
|
|
75
|
+
): void {
|
|
76
|
+
if (served === undefined) return;
|
|
77
|
+
assertRangeServed(
|
|
78
|
+
{
|
|
79
|
+
content_lines: [],
|
|
80
|
+
hash_bounds: [
|
|
81
|
+
{ line: start, hash: fileHashes[start - 1]! },
|
|
82
|
+
{ line: end, hash: fileHashes[end - 1]! },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
fileLines,
|
|
86
|
+
fileHashes,
|
|
87
|
+
served,
|
|
88
|
+
displayPath,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function trustSpan(
|
|
93
|
+
fileLines: string[],
|
|
94
|
+
fileHashes: string[],
|
|
95
|
+
served: ReadonlyMap<string, string> | undefined,
|
|
96
|
+
start: number,
|
|
97
|
+
end: number,
|
|
98
|
+
): ReadonlyMap<string, string> | undefined {
|
|
99
|
+
if (served === undefined) return undefined;
|
|
100
|
+
const merged = new Map(served);
|
|
101
|
+
for (let line = start; line <= end; line++) {
|
|
102
|
+
merged.set(fileHashes[line - 1]!, lineChecksum(fileLines[line - 1]!));
|
|
103
|
+
}
|
|
104
|
+
return merged;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dedupeWarnings(warnings: string[]): string[] {
|
|
108
|
+
const seen = new Set<string>();
|
|
109
|
+
const out: string[] = [];
|
|
110
|
+
for (const warning of warnings) {
|
|
111
|
+
if (seen.has(warning)) continue;
|
|
112
|
+
seen.add(warning);
|
|
113
|
+
out.push(warning);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseTransferAnchors(req: TransferReq): { refs: TransferRefs; warnings: string[] } {
|
|
119
|
+
const warnings: string[] = [];
|
|
120
|
+
const from = stripAnchorRow(req.source_from.trim(), "source_from entry", warnings);
|
|
121
|
+
const to = stripAnchorRow(req.source_to.trim(), "source_to entry", warnings);
|
|
122
|
+
const after = stripAnchorRow(req.insert_after.trim(), "insert_after entry", warnings);
|
|
123
|
+
return {
|
|
124
|
+
refs: {
|
|
125
|
+
sourceFrom: parseHashRef(from),
|
|
126
|
+
sourceTo: parseHashRef(to),
|
|
127
|
+
insertAfter: parseHashRef(after),
|
|
128
|
+
},
|
|
129
|
+
warnings,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function offsetEndings(offset: number, endings: (LineEnding | undefined)[]): (LineEnding | undefined)[] {
|
|
134
|
+
return [...new Array<LineEnding | undefined>(offset).fill(undefined), ...endings];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildTransferEdit(input: {
|
|
138
|
+
kind: TransferKind;
|
|
139
|
+
refs: TransferRefs;
|
|
140
|
+
preload: NormFile;
|
|
141
|
+
displayPath: string;
|
|
142
|
+
served?: ReadonlyMap<string, string>;
|
|
143
|
+
}): TransferPlan {
|
|
144
|
+
const { kind, refs, preload, displayPath, served } = input;
|
|
145
|
+
if (preload.normalized.length === 0) {
|
|
146
|
+
throw new Error("[E_BAD_SHAPE] The file is empty, so there are no lines to copy or move; use insert or replace to seed content.");
|
|
147
|
+
}
|
|
148
|
+
const fileLines = splitLines(preload.normalized);
|
|
149
|
+
const fromLine = resolveAnchorLine(refs.sourceFrom, fileLines, preload.fileHashes, displayPath);
|
|
150
|
+
const toLine = resolveAnchorLine(refs.sourceTo, fileLines, preload.fileHashes, displayPath);
|
|
151
|
+
const sourceStart = Math.min(fromLine, toLine);
|
|
152
|
+
const sourceEnd = Math.max(fromLine, toLine);
|
|
153
|
+
const insertLine = resolveAnchorLine(refs.insertAfter, fileLines, preload.fileHashes, displayPath);
|
|
154
|
+
if (insertLine >= sourceStart && insertLine < sourceEnd) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`[E_BAD_SHAPE] "insert_after" resolves to line ${insertLine}, inside the source range (lines ${sourceStart}-${sourceEnd}). Use a line before source_from, or source_to to place the block right after itself.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
assertRangeVerified(fileLines, preload.fileHashes, insertLine, insertLine, served, displayPath);
|
|
160
|
+
assertRangeVerified(fileLines, preload.fileHashes, sourceStart, sourceEnd, served, displayPath);
|
|
161
|
+
const sourceLines = fileLines.slice(sourceStart - 1, sourceEnd);
|
|
162
|
+
const sourceEndings = endingsForRange(preload.endingSeparators, sourceStart, sourceEnd);
|
|
163
|
+
if (kind === "copy") {
|
|
164
|
+
return {
|
|
165
|
+
editParams: {
|
|
166
|
+
remove_from: preload.fileHashes[insertLine - 1]!,
|
|
167
|
+
remove_to: preload.fileHashes[insertLine - 1]!,
|
|
168
|
+
replacement_lines: [fileLines[insertLine - 1]!, ...sourceLines],
|
|
169
|
+
},
|
|
170
|
+
foldedAnchorLines: 1,
|
|
171
|
+
anchorCarry: 0,
|
|
172
|
+
endingOverrides: [undefined, ...sourceEndings],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (insertLine < sourceStart) {
|
|
176
|
+
const replacedStart = insertLine + 1;
|
|
177
|
+
const replacedEnd = sourceEnd;
|
|
178
|
+
return {
|
|
179
|
+
editParams: {
|
|
180
|
+
remove_from: preload.fileHashes[replacedStart - 1]!,
|
|
181
|
+
remove_to: preload.fileHashes[replacedEnd - 1]!,
|
|
182
|
+
replacement_lines: [...sourceLines, ...fileLines.slice(insertLine, sourceStart - 1)],
|
|
183
|
+
},
|
|
184
|
+
foldedAnchorLines: 0,
|
|
185
|
+
servedOverride: trustSpan(fileLines, preload.fileHashes, served, replacedStart, replacedEnd),
|
|
186
|
+
endingOverrides: sourceEndings,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const replacedStart = sourceStart;
|
|
190
|
+
const replacedEnd = insertLine;
|
|
191
|
+
return {
|
|
192
|
+
editParams: {
|
|
193
|
+
remove_from: preload.fileHashes[replacedStart - 1]!,
|
|
194
|
+
remove_to: preload.fileHashes[replacedEnd - 1]!,
|
|
195
|
+
replacement_lines: [...fileLines.slice(sourceEnd, insertLine), ...sourceLines],
|
|
196
|
+
},
|
|
197
|
+
foldedAnchorLines: 0,
|
|
198
|
+
servedOverride: trustSpan(fileLines, preload.fileHashes, served, replacedStart, replacedEnd),
|
|
199
|
+
endingOverrides: offsetEndings(insertLine - sourceEnd, sourceEndings),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function resolveTransferTargets(req: TransferReq, cwd: string): Promise<{ sourcePath: string; destinationPath: string }> {
|
|
204
|
+
const { requirePath } = await readConfig();
|
|
205
|
+
if (!requirePath && req.path !== undefined) {
|
|
206
|
+
throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.");
|
|
207
|
+
}
|
|
208
|
+
if (requirePath && (typeof req.path !== "string" || req.path.length === 0)) {
|
|
209
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the source or destination file the anchors were served for.');
|
|
210
|
+
}
|
|
211
|
+
const sourcePath = resolveEditTarget(req.source_from, req.source_to);
|
|
212
|
+
const destinationPath = resolveEditTarget(req.insert_after);
|
|
213
|
+
if (requirePath) {
|
|
214
|
+
const { resolved } = await resolveInCwd(req.path as string, cwd);
|
|
215
|
+
if (resolved !== sourcePath && resolved !== destinationPath) {
|
|
216
|
+
throw new Error(`[E_BAD_SHAPE] Provided "path" "${req.path}" does not match the source file "${sourcePath}" or the destination file "${destinationPath}".`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { sourcePath, destinationPath };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface CrossTransferPreparation {
|
|
223
|
+
sourcePreload: NormFile;
|
|
224
|
+
destinationPreload: NormFile;
|
|
225
|
+
sourceDisplay: string;
|
|
226
|
+
destinationDisplay: string;
|
|
227
|
+
destinationEdit: ReqParams;
|
|
228
|
+
destinationFolded: number;
|
|
229
|
+
endingOverrides: (LineEnding | undefined)[];
|
|
230
|
+
sourceEdit?: ReqParams;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function prepareCrossTransfer(input: {
|
|
234
|
+
kind: TransferKind;
|
|
235
|
+
refs: TransferRefs;
|
|
236
|
+
sourcePath: string;
|
|
237
|
+
destinationPath: string;
|
|
238
|
+
cwd: string;
|
|
239
|
+
signal?: AbortSignal;
|
|
240
|
+
noPersist: boolean;
|
|
241
|
+
}): Promise<CrossTransferPreparation> {
|
|
242
|
+
const { kind, refs, sourcePath, destinationPath, cwd, signal, noPersist } = input;
|
|
243
|
+
const common = { signal, maxLines: MAX_HASH_LINES };
|
|
244
|
+
const sourceAccessMode = kind === "move" ? constants.R_OK | constants.W_OK : constants.R_OK;
|
|
245
|
+
const sourcePreload = await readNormFile(sourcePath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: sourceAccessMode });
|
|
246
|
+
const destinationPreload = await readNormFile(destinationPath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: constants.R_OK | constants.W_OK });
|
|
247
|
+
const sourceDisplay = toDisplayPath(cwd, sourcePreload.absolutePath, sourcePath);
|
|
248
|
+
const destinationDisplay = toDisplayPath(cwd, destinationPreload.absolutePath, destinationPath);
|
|
249
|
+
const sourceServed = servedForPath(sourcePreload.absolutePath);
|
|
250
|
+
const destinationServed = servedForPath(destinationPreload.absolutePath);
|
|
251
|
+
const sourceLines = splitLines(sourcePreload.normalized);
|
|
252
|
+
const destinationLines = splitLines(destinationPreload.normalized);
|
|
253
|
+
|
|
254
|
+
const adopt = async (path: string, error: unknown): Promise<void> => {
|
|
255
|
+
if (!noPersist) await noteAnchorError(path, error);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (sourcePreload.normalized.length === 0) {
|
|
259
|
+
throw new Error("[E_BAD_SHAPE] The source file is empty, so there are no lines to copy or move; use insert or replace to seed content.");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let sourceStart = 0;
|
|
263
|
+
let sourceEnd = 0;
|
|
264
|
+
try {
|
|
265
|
+
const fromLine = resolveAnchorLine(refs.sourceFrom, sourceLines, sourcePreload.fileHashes, sourceDisplay);
|
|
266
|
+
const toLine = resolveAnchorLine(refs.sourceTo, sourceLines, sourcePreload.fileHashes, sourceDisplay);
|
|
267
|
+
sourceStart = Math.min(fromLine, toLine);
|
|
268
|
+
sourceEnd = Math.max(fromLine, toLine);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
await adopt(sourcePreload.absolutePath, error);
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
assertRangeVerified(sourceLines, sourcePreload.fileHashes, sourceStart, sourceEnd, sourceServed, sourceDisplay);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
await adopt(sourcePreload.absolutePath, error);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let insertLine = 0;
|
|
281
|
+
try {
|
|
282
|
+
insertLine = resolveAnchorLine(refs.insertAfter, destinationLines, destinationPreload.fileHashes, destinationDisplay);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
await adopt(destinationPreload.absolutePath, error);
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
assertRangeVerified(destinationLines, destinationPreload.fileHashes, insertLine, insertLine, destinationServed, destinationDisplay);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
await adopt(destinationPreload.absolutePath, error);
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const moved = sourceLines.slice(sourceStart - 1, sourceEnd);
|
|
295
|
+
const destinationEdit: ReqParams = destinationPreload.normalized.length === 0
|
|
296
|
+
? {
|
|
297
|
+
remove_from: destinationPreload.fileHashes[0]!,
|
|
298
|
+
remove_to: destinationPreload.fileHashes[0]!,
|
|
299
|
+
replacement_lines: [...moved],
|
|
300
|
+
}
|
|
301
|
+
: {
|
|
302
|
+
remove_from: destinationPreload.fileHashes[insertLine - 1]!,
|
|
303
|
+
remove_to: destinationPreload.fileHashes[insertLine - 1]!,
|
|
304
|
+
replacement_lines: [destinationLines[insertLine - 1]!, ...moved],
|
|
305
|
+
};
|
|
306
|
+
const destinationFolded = destinationPreload.normalized.length === 0 ? 0 : 1;
|
|
307
|
+
const sourceEndings = endingsForRange(sourcePreload.endingSeparators, sourceStart, sourceEnd);
|
|
308
|
+
const endingOverrides = destinationFolded === 1 ? offsetEndings(1, sourceEndings) : sourceEndings;
|
|
309
|
+
if (kind === "copy") {
|
|
310
|
+
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, endingOverrides };
|
|
311
|
+
}
|
|
312
|
+
const sourceEdit: ReqParams = {
|
|
313
|
+
remove_from: sourcePreload.fileHashes[sourceStart - 1]!,
|
|
314
|
+
remove_to: sourcePreload.fileHashes[sourceEnd - 1]!,
|
|
315
|
+
replacement_lines: [],
|
|
316
|
+
};
|
|
317
|
+
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, sourceEdit, endingOverrides };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function pairEntry(pipe: PipelineResult, separators: LineEnding[] | undefined): UndoEntry {
|
|
321
|
+
return {
|
|
322
|
+
content: pipe.originalNormalized,
|
|
323
|
+
bom: pipe.bom,
|
|
324
|
+
originalEnding: pipe.originalEnding,
|
|
325
|
+
separators: pipe.originalSeparators,
|
|
326
|
+
hashes: pipe.originalHashes,
|
|
327
|
+
resultContent: pipe.result,
|
|
328
|
+
...(separators !== undefined ? { resultSeparators: separators } : {}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function commitMovePair(input: {
|
|
333
|
+
source: PairFileCommit;
|
|
334
|
+
destination: PairFileCommit;
|
|
335
|
+
signal?: AbortSignal;
|
|
336
|
+
warnings: string[];
|
|
337
|
+
}): Promise<TResult> {
|
|
338
|
+
const warnings = dedupeWarnings([
|
|
339
|
+
...input.warnings,
|
|
340
|
+
...input.destination.pipe.warnings,
|
|
341
|
+
...input.source.pipe.warnings,
|
|
342
|
+
]);
|
|
343
|
+
const destinationSpan = input.destination.pipe.spans?.[0];
|
|
344
|
+
const sourceSpan = input.source.pipe.spans?.[0];
|
|
345
|
+
const destinationSeparators = destinationSpan
|
|
346
|
+
? separatorsForSpans(input.destination.pipe.originalSeparators, input.destination.pipe.originalHashes.length, [destinationSpan], input.destination.pipe.result, input.destination.pipe.originalEnding)
|
|
347
|
+
: undefined;
|
|
348
|
+
if (destinationSeparators !== undefined && destinationSpan !== undefined) {
|
|
349
|
+
applyEndingOverrides(destinationSeparators, destinationSpan.start, input.destination.endingOverrides);
|
|
350
|
+
}
|
|
351
|
+
const sourceSeparators = sourceSpan
|
|
352
|
+
? separatorsForSpans(input.source.pipe.originalSeparators, input.source.pipe.originalHashes.length, [sourceSpan], input.source.pipe.result, input.source.pipe.originalEnding)
|
|
353
|
+
: undefined;
|
|
354
|
+
const destinationBytes = input.destination.pipe.bom + (destinationSeparators !== undefined
|
|
355
|
+
? joinSeparators(input.destination.pipe.result, destinationSeparators)
|
|
356
|
+
: restoreEndings(input.destination.pipe.result, input.destination.pipe.originalEnding));
|
|
357
|
+
const sourceBytes = input.source.pipe.bom + (sourceSeparators !== undefined
|
|
358
|
+
? joinSeparators(input.source.pipe.result, sourceSeparators)
|
|
359
|
+
: restoreEndings(input.source.pipe.result, input.source.pipe.originalEnding));
|
|
360
|
+
assertByteLimit(destinationBytes, input.destination.displayPath);
|
|
361
|
+
assertByteLimit(sourceBytes, input.source.displayPath);
|
|
362
|
+
|
|
363
|
+
if (input.destination.pipe.hadUtf8DecodeErrors || input.source.pipe.hadUtf8DecodeErrors) {
|
|
364
|
+
warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const undoDestination = await saveUndo(input.destination.mutationTargetPath, pairEntry(input.destination.pipe, destinationSeparators));
|
|
368
|
+
if (!undoDestination.persisted) {
|
|
369
|
+
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${input.destination.displayPath}. Nothing was written.`);
|
|
370
|
+
}
|
|
371
|
+
const undoSource = await saveUndo(input.source.mutationTargetPath, pairEntry(input.source.pipe, sourceSeparators));
|
|
372
|
+
if (!undoSource.persisted) {
|
|
373
|
+
await undoDestination.restore();
|
|
374
|
+
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${input.source.displayPath}. Nothing was written.`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const restoreUndoRecords = async (): Promise<void> => {
|
|
378
|
+
await undoDestination.restore();
|
|
379
|
+
await undoSource.restore();
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
abortIf(input.signal);
|
|
384
|
+
await writeAtomic(input.destination.absolutePath, destinationBytes, input.destination.pipe.identity);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
await restoreUndoRecords();
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
abortIf(input.signal);
|
|
391
|
+
await writeAtomic(input.source.absolutePath, sourceBytes, input.source.pipe.identity);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
try {
|
|
394
|
+
const destinationOriginal = input.destination.pipe.bom + joinSeparators(input.destination.pipe.originalNormalized, input.destination.pipe.originalSeparators);
|
|
395
|
+
await writeAtomic(input.destination.absolutePath, destinationOriginal);
|
|
396
|
+
} catch (rollbackError) {
|
|
397
|
+
console.error("Failed to roll back the destination after a cross-file move failed:", rollbackError);
|
|
398
|
+
}
|
|
399
|
+
await restoreUndoRecords();
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const destinationSnapshot = await safeSnapId(input.destination.absolutePath, "post-edit");
|
|
404
|
+
const sourceSnapshot = await safeSnapId(input.source.absolutePath, "post-edit");
|
|
405
|
+
let destinationHashes: string[];
|
|
406
|
+
let sourceHashes: string[];
|
|
407
|
+
try {
|
|
408
|
+
destinationHashes = await lineHashes(input.destination.pipe.result, input.destination.mutationTargetPath, {
|
|
409
|
+
content: input.destination.pipe.originalNormalized,
|
|
410
|
+
hashes: input.destination.pipe.originalHashes,
|
|
411
|
+
...(destinationSpan !== undefined ? { spans: [destinationSpan] } : {}),
|
|
412
|
+
});
|
|
413
|
+
sourceHashes = await lineHashes(input.source.pipe.result, input.source.mutationTargetPath, {
|
|
414
|
+
content: input.source.pipe.originalNormalized,
|
|
415
|
+
hashes: input.source.pipe.originalHashes,
|
|
416
|
+
...(sourceSpan !== undefined ? { spans: [sourceSpan] } : {}),
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
420
|
+
throw new Error(`${detail} Both files were written; one undo per file reverts the move. Call read for fresh anchors.`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
424
|
+
if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
|
|
425
|
+
|
|
426
|
+
const contextLines = await getDiffContextLines();
|
|
427
|
+
const destinationChanged = buildChanged({
|
|
428
|
+
path: input.destination.displayPath,
|
|
429
|
+
originalNormalized: input.destination.pipe.originalNormalized,
|
|
430
|
+
originalHashes: input.destination.pipe.originalHashes,
|
|
431
|
+
result: input.destination.pipe.result,
|
|
432
|
+
resultHashes: destinationHashes,
|
|
433
|
+
warnings,
|
|
434
|
+
snapshotId: destinationSnapshot,
|
|
435
|
+
editMeta: {
|
|
436
|
+
editsAttempted: 1,
|
|
437
|
+
noopEditsCount: 0,
|
|
438
|
+
firstChangedLine: input.destination.pipe.firstChangedLine,
|
|
439
|
+
lastChangedLine: input.destination.pipe.lastChangedLine,
|
|
440
|
+
addedLines: Math.max(0, input.destination.pipe.totalAddedLines - input.destination.foldedAnchorLines),
|
|
441
|
+
removedLines: input.destination.pipe.totalRemovedLines,
|
|
442
|
+
},
|
|
443
|
+
...(destinationSpan !== undefined ? { spans: [destinationSpan] } : {}),
|
|
444
|
+
}, "moved", contextLines);
|
|
445
|
+
const sourceChanged = buildChanged({
|
|
446
|
+
path: input.source.displayPath,
|
|
447
|
+
originalNormalized: input.source.pipe.originalNormalized,
|
|
448
|
+
originalHashes: input.source.pipe.originalHashes,
|
|
449
|
+
result: input.source.pipe.result,
|
|
450
|
+
resultHashes: sourceHashes,
|
|
451
|
+
warnings,
|
|
452
|
+
snapshotId: sourceSnapshot,
|
|
453
|
+
editMeta: {
|
|
454
|
+
editsAttempted: 1,
|
|
455
|
+
noopEditsCount: 0,
|
|
456
|
+
firstChangedLine: input.source.pipe.firstChangedLine,
|
|
457
|
+
lastChangedLine: input.source.pipe.lastChangedLine,
|
|
458
|
+
addedLines: 0,
|
|
459
|
+
removedLines: input.source.pipe.totalRemovedLines,
|
|
460
|
+
},
|
|
461
|
+
...(sourceSpan !== undefined ? { spans: [sourceSpan] } : {}),
|
|
462
|
+
}, "moved", contextLines);
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
serveRows(input.destination.mutationTargetPath, destinationHashes, splitLines(input.destination.pipe.result), servedHashesFromDiff(destinationChanged.details.diff));
|
|
466
|
+
serveRows(input.source.mutationTargetPath, sourceHashes, splitLines(input.source.pipe.result), servedHashesFromDiff(sourceChanged.details.diff));
|
|
467
|
+
} catch (error) {
|
|
468
|
+
console.error("Failed to mark cross-file move diff served:", error);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const movedLines = input.source.pipe.totalRemovedLines;
|
|
472
|
+
const warningBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
473
|
+
const rawDiff = `--- ${input.source.displayPath} ---\n${sourceChanged.details.diff}\n\n--- ${input.destination.displayPath} ---\n${destinationChanged.details.diff}`;
|
|
474
|
+
const diffTruncation = truncateHead(rawDiff, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
475
|
+
const diff = diffTruncation.truncated
|
|
476
|
+
? `${diffTruncation.content}\n[Cross-file move diff truncated at ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}; use read to see each file.]`
|
|
477
|
+
: diffTruncation.content;
|
|
478
|
+
const rawPatch = [sourceChanged.details.patch, destinationChanged.details.patch]
|
|
479
|
+
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
|
480
|
+
.join("\n");
|
|
481
|
+
const patchTruncation = truncateHead(rawPatch, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
482
|
+
const patch = patchTruncation.truncated
|
|
483
|
+
? `${patchTruncation.content}\n... [cross-file move patch truncated at ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}; the patch cannot be applied as-is. Use read to see the full files.]`
|
|
484
|
+
: rawPatch;
|
|
485
|
+
const patchTruncated = patchTruncation.truncated || sourceChanged.details.patchTruncated === true || destinationChanged.details.patchTruncated === true;
|
|
486
|
+
return {
|
|
487
|
+
content: [
|
|
488
|
+
{
|
|
489
|
+
type: "text",
|
|
490
|
+
text: `Successfully moved ${movedLines} line(s) from ${input.source.displayPath} to ${input.destination.displayPath}.${warningBlock}`,
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
details: {
|
|
494
|
+
diff,
|
|
495
|
+
patch,
|
|
496
|
+
...(patchTruncated ? { patchTruncated: true as const } : {}),
|
|
497
|
+
firstChangedLine: input.destination.pipe.firstChangedLine,
|
|
498
|
+
snapshotId: destinationSnapshot,
|
|
499
|
+
metrics: buildMetrics({
|
|
500
|
+
classification: "applied",
|
|
501
|
+
editsAttempted: 1,
|
|
502
|
+
noopEditsCount: 0,
|
|
503
|
+
warningsCount: warnings.length,
|
|
504
|
+
addedLines: movedLines,
|
|
505
|
+
removedLines: movedLines,
|
|
506
|
+
}),
|
|
507
|
+
...(warnings.length > 0 ? { warnings: [...warnings] } : {}),
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function queuedEditPair<T>(
|
|
513
|
+
sourcePath: string,
|
|
514
|
+
destinationPath: string,
|
|
515
|
+
cwd: string,
|
|
516
|
+
signal: AbortSignal | undefined,
|
|
517
|
+
work: (source: { absolute: string; resolved: string }, destination: { absolute: string; resolved: string }) => Promise<T>,
|
|
518
|
+
): Promise<T> {
|
|
519
|
+
abortIf(signal);
|
|
520
|
+
const source = await resolveInCwd(sourcePath, cwd);
|
|
521
|
+
const destination = await resolveInCwd(destinationPath, cwd);
|
|
522
|
+
const ordered = source.resolved <= destination.resolved ? [source, destination] : [destination, source];
|
|
523
|
+
return withFileMutationQueue(ordered[0]!.resolved, async () => {
|
|
524
|
+
abortIf(signal);
|
|
525
|
+
return withFileMutationQueue(ordered[1]!.resolved, async () => {
|
|
526
|
+
abortIf(signal);
|
|
527
|
+
return work(source, destination);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function executeCrossFile(
|
|
533
|
+
kind: TransferKind,
|
|
534
|
+
refs: TransferRefs,
|
|
535
|
+
anchorWarnings: string[],
|
|
536
|
+
sourcePath: string,
|
|
537
|
+
destinationPath: string,
|
|
538
|
+
cwd: string,
|
|
539
|
+
signal?: AbortSignal,
|
|
540
|
+
): Promise<TResult> {
|
|
541
|
+
return queuedEditPair(sourcePath, destinationPath, cwd, signal, async (source, destination) => {
|
|
542
|
+
const prepared = await prepareCrossTransfer({ kind, refs, sourcePath, destinationPath, cwd, signal, noPersist: false });
|
|
543
|
+
const destinationPipe = await execPipeline(destinationPath, prepared.destinationEdit, cwd, {
|
|
544
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
545
|
+
signal,
|
|
546
|
+
preloadedNorm: prepared.destinationPreload,
|
|
547
|
+
});
|
|
548
|
+
if (kind === "copy") {
|
|
549
|
+
return commitEdit(destinationPipe, {
|
|
550
|
+
path: destinationPipe.path,
|
|
551
|
+
absolutePath: destination.absolute,
|
|
552
|
+
mutationTargetPath: destination.resolved,
|
|
553
|
+
editAnchors: [prepared.destinationEdit.remove_from, prepared.destinationEdit.remove_to],
|
|
554
|
+
...(prepared.destinationFolded === 1 ? { anchorCarry: 0 } : {}),
|
|
555
|
+
endingOverrides: prepared.endingOverrides,
|
|
556
|
+
signal,
|
|
557
|
+
verb: "copied",
|
|
558
|
+
noopNoun: "Copy",
|
|
559
|
+
foldedAnchorLines: prepared.destinationFolded,
|
|
560
|
+
prefixWarnings: anchorWarnings,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const sourcePipe = await execPipeline(sourcePath, prepared.sourceEdit!, cwd, {
|
|
564
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
565
|
+
signal,
|
|
566
|
+
preloadedNorm: prepared.sourcePreload,
|
|
567
|
+
allowEmpty: true,
|
|
568
|
+
});
|
|
569
|
+
return commitMovePair({
|
|
570
|
+
source: { pipe: sourcePipe, displayPath: prepared.sourceDisplay, absolutePath: source.absolute, mutationTargetPath: source.resolved, foldedAnchorLines: 0 },
|
|
571
|
+
destination: {
|
|
572
|
+
pipe: destinationPipe,
|
|
573
|
+
displayPath: prepared.destinationDisplay,
|
|
574
|
+
absolutePath: destination.absolute,
|
|
575
|
+
mutationTargetPath: destination.resolved,
|
|
576
|
+
foldedAnchorLines: prepared.destinationFolded,
|
|
577
|
+
endingOverrides: prepared.endingOverrides,
|
|
578
|
+
},
|
|
579
|
+
signal,
|
|
580
|
+
warnings: anchorWarnings,
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function transferPreview(kind: TransferKind, request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
586
|
+
try {
|
|
587
|
+
const normalized = normReq(request);
|
|
588
|
+
assertTransferReq(normalized);
|
|
589
|
+
const req = normalized;
|
|
590
|
+
const { refs, warnings } = parseTransferAnchors(req);
|
|
591
|
+
await throwIfStrictInput(warnings);
|
|
592
|
+
const { sourcePath, destinationPath } = await resolveTransferTargets(req, cwd);
|
|
593
|
+
if (sourcePath === destinationPath) {
|
|
594
|
+
const preload = await readNormFile(sourcePath, cwd, {
|
|
595
|
+
accessMode: constants.R_OK,
|
|
596
|
+
maxLines: MAX_HASH_LINES,
|
|
597
|
+
noPersist: true,
|
|
598
|
+
allocation: "shadow",
|
|
599
|
+
signal,
|
|
600
|
+
});
|
|
601
|
+
const plan = buildTransferEdit({
|
|
602
|
+
kind,
|
|
603
|
+
refs,
|
|
604
|
+
preload,
|
|
605
|
+
displayPath: toDisplayPath(cwd, preload.absolutePath, sourcePath),
|
|
606
|
+
served: servedForPath(preload.absolutePath),
|
|
607
|
+
});
|
|
608
|
+
const pipe = await execPipeline(sourcePath, plan.editParams, cwd, {
|
|
609
|
+
accessMode: constants.R_OK,
|
|
610
|
+
noPersist: true,
|
|
611
|
+
preloadedNorm: preload,
|
|
612
|
+
served: plan.servedOverride,
|
|
613
|
+
signal,
|
|
614
|
+
});
|
|
615
|
+
return previewFromPipe(pipe);
|
|
616
|
+
}
|
|
617
|
+
const prepared = await prepareCrossTransfer({ kind, refs, sourcePath, destinationPath, cwd, signal, noPersist: true });
|
|
618
|
+
const destinationPipe = await execPipeline(destinationPath, prepared.destinationEdit, cwd, {
|
|
619
|
+
accessMode: constants.R_OK,
|
|
620
|
+
noPersist: true,
|
|
621
|
+
preloadedNorm: prepared.destinationPreload,
|
|
622
|
+
signal,
|
|
623
|
+
});
|
|
624
|
+
if (kind === "copy") return previewFromPipe(destinationPipe);
|
|
625
|
+
const sourcePipe = await execPipeline(sourcePath, prepared.sourceEdit!, cwd, {
|
|
626
|
+
accessMode: constants.R_OK,
|
|
627
|
+
noPersist: true,
|
|
628
|
+
preloadedNorm: prepared.sourcePreload,
|
|
629
|
+
allowEmpty: true,
|
|
630
|
+
signal,
|
|
631
|
+
});
|
|
632
|
+
const destinationPreview = previewFromPipe(destinationPipe);
|
|
633
|
+
const sourcePreview = previewFromPipe(sourcePipe);
|
|
634
|
+
if ("error" in destinationPreview) return destinationPreview;
|
|
635
|
+
if ("error" in sourcePreview) return sourcePreview;
|
|
636
|
+
return {
|
|
637
|
+
diff: `--- ${prepared.sourceDisplay} ---\n${sourcePreview.diff}\n\n--- ${prepared.destinationDisplay} ---\n${destinationPreview.diff}`,
|
|
638
|
+
path: prepared.destinationDisplay,
|
|
639
|
+
};
|
|
640
|
+
} catch (error: unknown) {
|
|
641
|
+
if (signal?.aborted) throw error;
|
|
642
|
+
return previewError(error);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function getTransferInput(args: unknown): { path?: string; source_from?: string; source_to?: string; insert_after?: string } | null {
|
|
647
|
+
let normalized: unknown;
|
|
648
|
+
try {
|
|
649
|
+
normalized = normReq(args);
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
if (!isRec(normalized)) return null;
|
|
654
|
+
if (
|
|
655
|
+
typeof normalized.source_from !== "string" ||
|
|
656
|
+
typeof normalized.source_to !== "string" ||
|
|
657
|
+
typeof normalized.insert_after !== "string"
|
|
658
|
+
) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
|
|
663
|
+
source_from: normalized.source_from,
|
|
664
|
+
source_to: normalized.source_to,
|
|
665
|
+
insert_after: normalized.insert_after,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const transferSourceFromSchema = Type.String({
|
|
670
|
+
description:
|
|
671
|
+
"Bare 4-char anchor from a served anchor│content row (the text before the `│` separator), never the row content. Marks the FIRST source line (inclusive); this anchor and `source_to` resolve the source file.",
|
|
672
|
+
});
|
|
673
|
+
const transferSourceToSchema = Type.String({
|
|
674
|
+
description:
|
|
675
|
+
"Bare 4-char anchor from a served anchor│content row (the text before the `│` separator), never the row content. Marks the LAST source line (inclusive); this anchor and `source_from` resolve the source file.",
|
|
676
|
+
});
|
|
677
|
+
const transferInsertAfterSchema = Type.String({
|
|
678
|
+
description:
|
|
679
|
+
"Bare 4-char anchor of the destination line after which the block goes; it may live in another file than the source, and the destination file is the one this anchor belongs to. The anchor line is preserved.",
|
|
680
|
+
});
|
|
681
|
+
const transferPathRequiredSchema = Type.String({
|
|
682
|
+
description:
|
|
683
|
+
"Path to the source or destination file the anchors were served for; required and must match anchor ownership. The anchors still resolve both files.",
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const transferToolSchema = Type.Object(
|
|
687
|
+
{
|
|
688
|
+
source_from: transferSourceFromSchema,
|
|
689
|
+
source_to: transferSourceToSchema,
|
|
690
|
+
insert_after: transferInsertAfterSchema,
|
|
691
|
+
},
|
|
692
|
+
{ additionalProperties: true },
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
export function buildTransferToolSchema(requirePath: boolean): typeof transferToolSchema {
|
|
696
|
+
if (!requirePath) return transferToolSchema;
|
|
697
|
+
return Type.Object(
|
|
698
|
+
{
|
|
699
|
+
path: transferPathRequiredSchema,
|
|
700
|
+
source_from: transferSourceFromSchema,
|
|
701
|
+
source_to: transferSourceToSchema,
|
|
702
|
+
insert_after: transferInsertAfterSchema,
|
|
703
|
+
},
|
|
704
|
+
{ additionalProperties: true },
|
|
705
|
+
) as typeof transferToolSchema;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
type TransferToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
|
|
709
|
+
|
|
710
|
+
export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): TransferToolDef {
|
|
711
|
+
const prompted = withTransferPrompts(
|
|
712
|
+
{
|
|
713
|
+
description: loadP(`../prompts/${kind}.md`),
|
|
714
|
+
snippet: loadP(`../prompts/${kind}-snippet.md`),
|
|
715
|
+
guidelines: loadGuide(`../prompts/${kind}-guidelines.md`),
|
|
716
|
+
},
|
|
717
|
+
flags,
|
|
718
|
+
kind,
|
|
719
|
+
);
|
|
720
|
+
return {
|
|
721
|
+
name: kind,
|
|
722
|
+
label: kind === "copy" ? "Copy" : "Move",
|
|
723
|
+
description: prompted.description,
|
|
724
|
+
promptSnippet: prompted.snippet,
|
|
725
|
+
promptGuidelines: prompted.guidelines,
|
|
726
|
+
...editToolBase,
|
|
727
|
+
parameters: buildTransferToolSchema(flags.requirePath),
|
|
728
|
+
renderCall: makeRenderCall(
|
|
729
|
+
(request, cwd, signal) => transferPreview(kind, request, cwd, signal),
|
|
730
|
+
{
|
|
731
|
+
getInput: getTransferInput,
|
|
732
|
+
toolName: kind,
|
|
733
|
+
resolveTarget: (input) => (typeof input.source_from === "string" ? tryResolveEditTarget(input.source_from, input.source_to) : undefined),
|
|
734
|
+
},
|
|
735
|
+
),
|
|
736
|
+
renderResult: editRenderResultWrapper,
|
|
737
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
738
|
+
return withAnchorSession(ctx, async () => {
|
|
739
|
+
const canonical = normReq(params);
|
|
740
|
+
assertTransferReq(canonical);
|
|
741
|
+
const req = canonical;
|
|
742
|
+
const { refs, warnings } = parseTransferAnchors(req);
|
|
743
|
+
await throwIfStrictInput(warnings);
|
|
744
|
+
const { sourcePath, destinationPath } = await resolveTransferTargets(req, ctx.cwd);
|
|
745
|
+
if (sourcePath !== destinationPath) {
|
|
746
|
+
return executeCrossFile(kind, refs, warnings, sourcePath, destinationPath, ctx.cwd, signal);
|
|
747
|
+
}
|
|
748
|
+
return queuedEdit(sourcePath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
749
|
+
const preload = await readNormFile(sourcePath, ctx.cwd, {
|
|
750
|
+
signal,
|
|
751
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
752
|
+
maxLines: MAX_HASH_LINES,
|
|
753
|
+
});
|
|
754
|
+
const displayPath = toDisplayPath(ctx.cwd, preload.absolutePath, sourcePath);
|
|
755
|
+
let plan: TransferPlan;
|
|
756
|
+
try {
|
|
757
|
+
plan = buildTransferEdit({ kind, refs, preload, displayPath, served: servedForPath(preload.absolutePath) });
|
|
758
|
+
} catch (error) {
|
|
759
|
+
await noteAnchorError(preload.absolutePath, error);
|
|
760
|
+
throw error;
|
|
761
|
+
}
|
|
762
|
+
const pipe = await execPipeline(sourcePath, plan.editParams, ctx.cwd, {
|
|
763
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
764
|
+
signal,
|
|
765
|
+
preloadedNorm: preload,
|
|
766
|
+
served: plan.servedOverride,
|
|
767
|
+
});
|
|
768
|
+
return commitEdit(pipe, {
|
|
769
|
+
path: pipe.path,
|
|
770
|
+
absolutePath,
|
|
771
|
+
mutationTargetPath,
|
|
772
|
+
editAnchors: [plan.editParams.remove_from, plan.editParams.remove_to],
|
|
773
|
+
...(plan.anchorCarry !== undefined ? { anchorCarry: plan.anchorCarry } : {}),
|
|
774
|
+
...(plan.endingOverrides !== undefined ? { endingOverrides: plan.endingOverrides } : {}),
|
|
775
|
+
signal,
|
|
776
|
+
verb: kind === "copy" ? "copied" : "moved",
|
|
777
|
+
noopNoun: kind === "copy" ? "Copy" : "Move",
|
|
778
|
+
foldedAnchorLines: plan.foldedAnchorLines,
|
|
779
|
+
prefixWarnings: warnings,
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
},
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export function regCopy(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
788
|
+
pi.registerTool(buildTransferToolDef("copy", flags));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export function regMove(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
|
|
792
|
+
pi.registerTool(buildTransferToolDef("move", flags));
|
|
793
|
+
}
|