@ladbabynpm/picc-edit 0.1.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/LICENSE +21 -0
- package/README.md +59 -0
- package/index.ts +342 -0
- package/package.json +60 -0
- package/src/constants.ts +15 -0
- package/src/diff.ts +209 -0
- package/src/edit.ts +283 -0
- package/src/editUtils.ts +203 -0
- package/src/file.ts +162 -0
- package/src/path.ts +62 -0
- package/src/prompt.ts +39 -0
- package/src/readState.ts +52 -0
- package/src/renderDiff.ts +185 -0
- package/src/windowsPaths.ts +31 -0
package/src/file.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picc-edit — src/file.ts
|
|
3
|
+
//
|
|
4
|
+
// Adaptors vs upstream:
|
|
5
|
+
// - `getFsImplementation()` → plain `node:fs`.
|
|
6
|
+
// - `safeResolvePath` is dropped (the caller passes an absolute path).
|
|
7
|
+
// - `writeFileSyncAndFlush_DEPRECATED` → `fs.writeFileSync`.
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import {
|
|
17
|
+
basename,
|
|
18
|
+
dirname,
|
|
19
|
+
extname,
|
|
20
|
+
join,
|
|
21
|
+
} from "node:path";
|
|
22
|
+
|
|
23
|
+
export type LineEndingType = "CRLF" | "LF";
|
|
24
|
+
|
|
25
|
+
function isEnoent(e: unknown): boolean {
|
|
26
|
+
return (
|
|
27
|
+
typeof e === "object" &&
|
|
28
|
+
e !== null &&
|
|
29
|
+
(e as NodeJS.ErrnoException).code === "ENOENT"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the normalized modification time of a file in milliseconds.
|
|
35
|
+
*/
|
|
36
|
+
export function getFileModificationTime(filePath: string): number {
|
|
37
|
+
return Math.floor(statSync(filePath).mtimeMs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect the file encoding from its leading bytes. Empty files default to
|
|
42
|
+
* utf8 so that writing emoji/CJK to empty files is not corrupted.
|
|
43
|
+
*/
|
|
44
|
+
export function detectEncodingForResolvedPath(
|
|
45
|
+
resolvedPath: string,
|
|
46
|
+
): BufferEncoding {
|
|
47
|
+
const buffer = readFileSync(resolvedPath);
|
|
48
|
+
const bytesRead = buffer.length;
|
|
49
|
+
|
|
50
|
+
if (bytesRead === 0) {
|
|
51
|
+
return "utf8";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (bytesRead >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
|
55
|
+
return "utf16le";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
bytesRead >= 3 &&
|
|
60
|
+
buffer[0] === 0xef &&
|
|
61
|
+
buffer[1] === 0xbb &&
|
|
62
|
+
buffer[2] === 0xbf
|
|
63
|
+
) {
|
|
64
|
+
return "utf8";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return "utf8";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Detect the dominant line-ending style in a string. */
|
|
71
|
+
export function detectLineEndingsForString(content: string): LineEndingType {
|
|
72
|
+
let crlfCount = 0;
|
|
73
|
+
let lfCount = 0;
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < content.length; i++) {
|
|
76
|
+
if (content[i] === "\n") {
|
|
77
|
+
if (i > 0 && content[i - 1] === "\r") {
|
|
78
|
+
crlfCount++;
|
|
79
|
+
} else {
|
|
80
|
+
lfCount++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return crlfCount > lfCount ? "CRLF" : "LF";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a file, returning its CRLF-normalized content plus detected encoding
|
|
90
|
+
* and line-ending style in one pass.
|
|
91
|
+
*/
|
|
92
|
+
export function readFileSyncWithMetadata(filePath: string): {
|
|
93
|
+
content: string;
|
|
94
|
+
encoding: BufferEncoding;
|
|
95
|
+
lineEndings: LineEndingType;
|
|
96
|
+
} {
|
|
97
|
+
const encoding = detectEncodingForResolvedPath(filePath);
|
|
98
|
+
const raw = readFileSync(filePath, { encoding });
|
|
99
|
+
const lineEndings = detectLineEndingsForString(raw.slice(0, 4096));
|
|
100
|
+
return {
|
|
101
|
+
content: raw.replaceAll("\r\n", "\n"),
|
|
102
|
+
encoding,
|
|
103
|
+
lineEndings,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Write `content` to `filePath` with `endings` normalization applied.
|
|
109
|
+
*
|
|
110
|
+
* - `'LF'`: writes `content` verbatim (the model's sent line endings are
|
|
111
|
+
* respected as-is; no resampling of the repo).
|
|
112
|
+
* - `'CRLF'`: normalizes any existing CRLF to LF first, then re-joins with
|
|
113
|
+
* CRLF so a `content` that already contains `\r\n` does not become `\r\r\n`.
|
|
114
|
+
*/
|
|
115
|
+
export function writeTextContent(
|
|
116
|
+
filePath: string,
|
|
117
|
+
content: string,
|
|
118
|
+
encoding: BufferEncoding,
|
|
119
|
+
endings: LineEndingType,
|
|
120
|
+
): void {
|
|
121
|
+
let toWrite = content;
|
|
122
|
+
if (endings === "CRLF") {
|
|
123
|
+
toWrite = content.replaceAll("\r\n", "\n").split("\n").join("\r\n");
|
|
124
|
+
}
|
|
125
|
+
writeFileSync(filePath, toWrite, { encoding });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Convert leading tabs on each line to two spaces. Used only for the
|
|
130
|
+
* *display* patch — the written content is left untouched.
|
|
131
|
+
*/
|
|
132
|
+
export function convertLeadingTabsToSpaces(content: string): string {
|
|
133
|
+
if (!content.includes("\t")) return content;
|
|
134
|
+
return content.replace(/^\t+/gm, (m) => " ".repeat(m.length));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Find files with the same base name but a different extension in the same
|
|
139
|
+
* directory, to suggest a fix for a file-not-found edit. Returns the bare
|
|
140
|
+
* filename of the first match, or undefined.
|
|
141
|
+
*/
|
|
142
|
+
export function findSimilarFile(filePath: string): string | undefined {
|
|
143
|
+
try {
|
|
144
|
+
const dir = dirname(filePath);
|
|
145
|
+
const fileBaseName = basename(filePath, extname(filePath));
|
|
146
|
+
|
|
147
|
+
const files = readdirSync(dir);
|
|
148
|
+
const similarFiles = files.filter(
|
|
149
|
+
(file) =>
|
|
150
|
+
basename(file, extname(file)) === fileBaseName &&
|
|
151
|
+
join(dir, file) !== filePath,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const firstMatch = similarFiles[0];
|
|
155
|
+
return firstMatch ? firstMatch : undefined;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!isEnoent(error)) {
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/path.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picc-write — src/path.ts
|
|
3
|
+
//
|
|
4
|
+
// Adaptors vs upstream:
|
|
5
|
+
// - `getCwd()` / `getFsImplementation().cwd()` → `process.cwd()`
|
|
6
|
+
// - `getPlatform()` → `process.platform`
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { isAbsolute, join, normalize, resolve } from "node:path";
|
|
11
|
+
import { posixPathToWindowsPath } from "./windowsPaths.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Expand `~` to the home directory, convert Windows POSIX-style paths, and
|
|
15
|
+
* resolve relative paths against `baseDir`.
|
|
16
|
+
*
|
|
17
|
+
* Ported from `utils/path.ts:expandPath`.
|
|
18
|
+
*/
|
|
19
|
+
export function expandPath(path: string, baseDir?: string): string {
|
|
20
|
+
const actualBaseDir = baseDir ?? process.cwd();
|
|
21
|
+
|
|
22
|
+
if (typeof path !== "string") {
|
|
23
|
+
throw new TypeError(`Path must be a string, received ${typeof path}`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof actualBaseDir !== "string") {
|
|
26
|
+
throw new TypeError(
|
|
27
|
+
`Base directory must be a string, received ${typeof actualBaseDir}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (path.includes("\0") || actualBaseDir.includes("\0")) {
|
|
32
|
+
throw new Error("Path contains null bytes");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const trimmedPath = path.trim();
|
|
36
|
+
if (!trimmedPath) {
|
|
37
|
+
return normalize(actualBaseDir).normalize("NFC");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (trimmedPath === "~") {
|
|
41
|
+
return homedir().normalize("NFC");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (trimmedPath.startsWith("~/")) {
|
|
45
|
+
return join(homedir(), trimmedPath.slice(2)).normalize("NFC");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let processedPath = trimmedPath;
|
|
49
|
+
if (process.platform === "win32" && trimmedPath.match(/^\/[a-z]\//i)) {
|
|
50
|
+
try {
|
|
51
|
+
processedPath = posixPathToWindowsPath(trimmedPath);
|
|
52
|
+
} catch {
|
|
53
|
+
processedPath = trimmedPath;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (isAbsolute(processedPath)) {
|
|
58
|
+
return normalize(processedPath).normalize("NFC");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return resolve(actualBaseDir, processedPath).normalize("NFC");
|
|
62
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picc-edit — src/prompt.ts
|
|
3
|
+
//
|
|
4
|
+
// Adaptors vs upstream:
|
|
5
|
+
// - `isCompactLinePrefixEnabled()` is hardcoded to `true` (picc-read always
|
|
6
|
+
// uses the compact `line-number + tab` prefix).
|
|
7
|
+
// - The `USER_TYPE === 'ant'` minimal-uniqueness hint is dropped.
|
|
8
|
+
// - `FILE_READ_TOOL_NAME` is fixed to `Read` (the read tool is configured
|
|
9
|
+
// independently of this extension).
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
function getPreReadInstruction(): string {
|
|
13
|
+
return `\n- You must use your \`read\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. `;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getEditToolDescription(): string {
|
|
17
|
+
return getDefaultEditDescription();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getDefaultEditDescription(): string {
|
|
21
|
+
const prefixFormat = "line number + tab";
|
|
22
|
+
return `Performs exact string replacements in files.
|
|
23
|
+
|
|
24
|
+
Usage:${getPreReadInstruction()}
|
|
25
|
+
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: ${prefixFormat}. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
|
|
26
|
+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
27
|
+
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
|
28
|
+
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
|
|
29
|
+
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Faithful success messages (from Claude Code's `mapToolResultToToolResultBlockParam`). */
|
|
33
|
+
export function replaceAllMessage(filePath: string): string {
|
|
34
|
+
return `The file ${filePath} has been updated. All occurrences were successfully replaced.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function singleEditMessage(filePath: string): string {
|
|
38
|
+
return `The file ${filePath} has been updated successfully.`;
|
|
39
|
+
}
|
package/src/readState.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picc-edit — src/readState.ts
|
|
3
|
+
//
|
|
4
|
+
// Session-scoped "has this file been read (and when)" map, mirroring Claude
|
|
5
|
+
// Code's `readFileState`. pi has no shared read-state, so each of picc-write
|
|
6
|
+
// and picc-edit owns its own: the entry point populates it from `tool_result`
|
|
7
|
+
// events for any file tool that establishes known contents (read/write/edit)
|
|
8
|
+
// and clears it on session start.
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
/** A recorded read of a file. `offset`/`limit` present ⇒ partial view. */
|
|
12
|
+
export type ReadEntry = {
|
|
13
|
+
content: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
offset?: number;
|
|
16
|
+
limit?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tool names whose successful `tool_result` means the file's contents are
|
|
21
|
+
* known and can seed the read-state. Mirrors Claude Code, where Read, Write,
|
|
22
|
+
* and Edit each refresh the shared `readFileState` — so a file the agent just
|
|
23
|
+
* wrote (or edited) is immediately editable/writable without a redundant
|
|
24
|
+
* re-read. Both pi's lowercase built-ins and the capitalized picc ports are
|
|
25
|
+
* accepted, since the active name depends on each extension's config.
|
|
26
|
+
*/
|
|
27
|
+
const KNOWN_FILE_TOOL_NAMES = new Set([
|
|
28
|
+
"read",
|
|
29
|
+
"Read",
|
|
30
|
+
"write",
|
|
31
|
+
"Write",
|
|
32
|
+
"edit",
|
|
33
|
+
"Edit",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export function fileStateToolName(name: string): boolean {
|
|
37
|
+
return KNOWN_FILE_TOOL_NAMES.has(name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const state = new Map<string, ReadEntry>();
|
|
41
|
+
|
|
42
|
+
export function readStateGet(filePath: string): ReadEntry | undefined {
|
|
43
|
+
return state.get(filePath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readStateSet(filePath: string, entry: ReadEntry): void {
|
|
47
|
+
state.set(filePath, entry);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readStateClear(): void {
|
|
51
|
+
state.clear();
|
|
52
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picc-edit — src/renderDiff.ts
|
|
3
|
+
//
|
|
4
|
+
// Local port of pi's built-in `renderDiff`
|
|
5
|
+
// (`modes/interactive/components/diff.js`) with one difference: added lines
|
|
6
|
+
// are drawn in a fixed truecolor green (#207c36) instead of the theme's
|
|
7
|
+
// `toolDiffAdded` color (see `renderAddedLine`). Everything else — line
|
|
8
|
+
// parsing, tab replacement, intra-line word diff with inverse highlighting —
|
|
9
|
+
// mirrors the built-in so the TUI output matches pi's standard diff viewer.
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
import { type Change, diffWords } from "diff";
|
|
13
|
+
|
|
14
|
+
/** Fixed truecolor for newly added lines (overrides the theme color). */
|
|
15
|
+
const ADDED_LINE_FG = "\x1b[38;2;32;124;54m";
|
|
16
|
+
/** Reset only the foreground color — same convention as `Theme.fg`. */
|
|
17
|
+
const FG_RESET = "\x1b[39m";
|
|
18
|
+
|
|
19
|
+
type RenderDiffTheme = {
|
|
20
|
+
fg: (color: "toolDiffRemoved" | "toolDiffContext", text: string) => string;
|
|
21
|
+
inverse: (text: string) => string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Render an added line in the fixed #207c36 color. */
|
|
25
|
+
function renderAddedLine(line: string): string {
|
|
26
|
+
return `${ADDED_LINE_FG}${line}${FG_RESET}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Parse diff line to extract prefix, line number, and content.
|
|
30
|
+
* Format: "+123 content" or "-123 content" or " 123 content" or " ..." */
|
|
31
|
+
function parseDiffLine(line: string): {
|
|
32
|
+
prefix: string;
|
|
33
|
+
lineNum: string;
|
|
34
|
+
content: string;
|
|
35
|
+
} | null {
|
|
36
|
+
const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
|
|
37
|
+
if (!match) return null;
|
|
38
|
+
return { prefix: match[1], lineNum: match[2], content: match[3] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Replace tabs with spaces for consistent rendering. */
|
|
42
|
+
function replaceTabs(text: string): string {
|
|
43
|
+
return text.replace(/\t/g, " ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute word-level diff and render with inverse on changed parts.
|
|
48
|
+
* Removed parts use the theme's inverse; added parts use the theme's inverse
|
|
49
|
+
* wrapped in the fixed added-line color (the inverse flips fg/bg, so the
|
|
50
|
+
* inverse spans still read as the #207c36 swap).
|
|
51
|
+
*/
|
|
52
|
+
function renderIntraLineDiff(
|
|
53
|
+
oldContent: string,
|
|
54
|
+
newContent: string,
|
|
55
|
+
theme: RenderDiffTheme,
|
|
56
|
+
): { removedLine: string; addedLine: string } {
|
|
57
|
+
const wordDiff: Change[] = diffWords(oldContent, newContent);
|
|
58
|
+
let removedLine = "";
|
|
59
|
+
let addedLine = "";
|
|
60
|
+
let isFirstRemoved = true;
|
|
61
|
+
let isFirstAdded = true;
|
|
62
|
+
for (const part of wordDiff) {
|
|
63
|
+
if (part.removed) {
|
|
64
|
+
let value = part.value;
|
|
65
|
+
// Strip leading whitespace from the first removed part
|
|
66
|
+
if (isFirstRemoved) {
|
|
67
|
+
const leadingWs = value.match(/^(\s*)/)?.[1] || "";
|
|
68
|
+
value = value.slice(leadingWs.length);
|
|
69
|
+
removedLine += leadingWs;
|
|
70
|
+
isFirstRemoved = false;
|
|
71
|
+
}
|
|
72
|
+
if (value) {
|
|
73
|
+
removedLine += theme.inverse(value);
|
|
74
|
+
}
|
|
75
|
+
} else if (part.added) {
|
|
76
|
+
let value = part.value;
|
|
77
|
+
// Strip leading whitespace from the first added part
|
|
78
|
+
if (isFirstAdded) {
|
|
79
|
+
const leadingWs = value.match(/^(\s*)/)?.[1] || "";
|
|
80
|
+
value = value.slice(leadingWs.length);
|
|
81
|
+
addedLine += leadingWs;
|
|
82
|
+
isFirstAdded = false;
|
|
83
|
+
}
|
|
84
|
+
if (value) {
|
|
85
|
+
addedLine += theme.inverse(value);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
removedLine += part.value;
|
|
89
|
+
addedLine += part.value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { removedLine, addedLine };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Render a diff string with colored lines and intra-line change highlighting.
|
|
97
|
+
* - Context lines: dim/gray
|
|
98
|
+
* - Removed lines: red, with inverse on changed tokens
|
|
99
|
+
* - Added lines: fixed #207c36, with inverse on changed tokens
|
|
100
|
+
*/
|
|
101
|
+
export function renderDiff(
|
|
102
|
+
diffText: string,
|
|
103
|
+
theme: RenderDiffTheme,
|
|
104
|
+
): string {
|
|
105
|
+
const lines = diffText.split("\n");
|
|
106
|
+
const result: string[] = [];
|
|
107
|
+
let i = 0;
|
|
108
|
+
while (i < lines.length) {
|
|
109
|
+
const line = lines[i];
|
|
110
|
+
const parsed = parseDiffLine(line);
|
|
111
|
+
if (!parsed) {
|
|
112
|
+
result.push(theme.fg("toolDiffContext", line));
|
|
113
|
+
i++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (parsed.prefix === "-") {
|
|
117
|
+
// Collect consecutive removed lines
|
|
118
|
+
const removedLines: { lineNum: string; content: string }[] = [];
|
|
119
|
+
while (i < lines.length) {
|
|
120
|
+
const p = parseDiffLine(lines[i]);
|
|
121
|
+
if (p?.prefix !== "-") break;
|
|
122
|
+
removedLines.push({ lineNum: p.lineNum, content: p.content });
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
// Collect consecutive added lines
|
|
126
|
+
const addedLines: { lineNum: string; content: string }[] = [];
|
|
127
|
+
while (i < lines.length) {
|
|
128
|
+
const p = parseDiffLine(lines[i]);
|
|
129
|
+
if (p?.prefix !== "+") break;
|
|
130
|
+
addedLines.push({ lineNum: p.lineNum, content: p.content });
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
// Only do intra-line diffing when there's exactly one removed and
|
|
134
|
+
// one added line (indicating a single line modification).
|
|
135
|
+
// Otherwise, show lines as-is.
|
|
136
|
+
if (removedLines.length === 1 && addedLines.length === 1) {
|
|
137
|
+
const removed = removedLines[0];
|
|
138
|
+
const added = addedLines[0];
|
|
139
|
+
const { removedLine, addedLine } = renderIntraLineDiff(
|
|
140
|
+
replaceTabs(removed.content),
|
|
141
|
+
replaceTabs(added.content),
|
|
142
|
+
theme,
|
|
143
|
+
);
|
|
144
|
+
result.push(
|
|
145
|
+
theme.fg(
|
|
146
|
+
"toolDiffRemoved",
|
|
147
|
+
`-${removed.lineNum} ${removedLine}`,
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
result.push(renderAddedLine(`+${added.lineNum} ${addedLine}`));
|
|
151
|
+
} else {
|
|
152
|
+
// Show all removed lines first, then all added lines
|
|
153
|
+
for (const removed of removedLines) {
|
|
154
|
+
result.push(
|
|
155
|
+
theme.fg(
|
|
156
|
+
"toolDiffRemoved",
|
|
157
|
+
`-${removed.lineNum} ${replaceTabs(removed.content)}`,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
for (const added of addedLines) {
|
|
162
|
+
result.push(
|
|
163
|
+
renderAddedLine(`+${added.lineNum} ${replaceTabs(added.content)}`),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} else if (parsed.prefix === "+") {
|
|
168
|
+
// Standalone added line
|
|
169
|
+
result.push(
|
|
170
|
+
renderAddedLine(`+${parsed.lineNum} ${replaceTabs(parsed.content)}`),
|
|
171
|
+
);
|
|
172
|
+
i++;
|
|
173
|
+
} else {
|
|
174
|
+
// Context line
|
|
175
|
+
result.push(
|
|
176
|
+
theme.fg(
|
|
177
|
+
"toolDiffContext",
|
|
178
|
+
` ${parsed.lineNum} ${replaceTabs(parsed.content)}`,
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
i++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return result.join("\n");
|
|
185
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picc-write — src/windowsPaths.ts
|
|
3
|
+
//
|
|
4
|
+
// On Windows, Git Bash (MSYS2) passes POSIX-style paths like `/c/Users/...`.
|
|
5
|
+
// This module converts that to a real Windows path so file APIs work.
|
|
6
|
+
// =============================================================================
|
|
7
|
+
|
|
8
|
+
/** Convert a POSIX path to a Windows path using pure JS. */
|
|
9
|
+
export function posixPathToWindowsPath(posixPath: string): string {
|
|
10
|
+
// Handle UNC paths: //server/share -> \\server\share
|
|
11
|
+
if (posixPath.startsWith("//")) {
|
|
12
|
+
return posixPath.replace(/\//g, "\\");
|
|
13
|
+
}
|
|
14
|
+
// Handle /cygdrive/c/... format
|
|
15
|
+
const cygdriveMatch = posixPath.match(/^\/cygdrive\/([A-Za-z])(\/|$)/);
|
|
16
|
+
if (cygdriveMatch) {
|
|
17
|
+
const driveLetter = cygdriveMatch[1]!.toUpperCase();
|
|
18
|
+
const rest = posixPath
|
|
19
|
+
.slice(("/cygdrive/" + cygdriveMatch[1]).length);
|
|
20
|
+
return driveLetter + ":" + (rest || "\\").replace(/\//g, "\\");
|
|
21
|
+
}
|
|
22
|
+
// Handle /c/... format (MSYS2/Git Bash)
|
|
23
|
+
const driveMatch = posixPath.match(/^\/([A-Za-z])(\/|$)/);
|
|
24
|
+
if (driveMatch) {
|
|
25
|
+
const driveLetter = driveMatch[1]!.toUpperCase();
|
|
26
|
+
const rest = posixPath.slice(2);
|
|
27
|
+
return driveLetter + ":" + (rest || "\\").replace(/\//g, "\\");
|
|
28
|
+
}
|
|
29
|
+
// Already Windows or relative — just flip slashes
|
|
30
|
+
return posixPath.replace(/\//g, "\\");
|
|
31
|
+
}
|