pi-files-widget-overlay 0.3.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/CHANGELOG.md +198 -0
- package/LICENSE +22 -0
- package/README.md +102 -0
- package/demo.png +0 -0
- package/package.json +38 -0
- package/publish-pi-files-widget-overlay.sh +228 -0
- package/src/activity.ts +10 -0
- package/src/browser.ts +1244 -0
- package/src/comment.ts +6 -0
- package/src/constants.ts +31 -0
- package/src/file-tree.ts +321 -0
- package/src/file-viewer.ts +181 -0
- package/src/git.ts +129 -0
- package/src/index.ts +147 -0
- package/src/input-utils.ts +96 -0
- package/src/types.ts +31 -0
- package/src/utils.ts +59 -0
- package/src/viewer.ts +729 -0
- package/tsconfig.json +13 -0
package/src/comment.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CommentPayload } from "./viewer";
|
|
2
|
+
|
|
3
|
+
export function formatCommentMessage(payload: CommentPayload, comment: string): string {
|
|
4
|
+
const subject = payload.isDiff ? `In the diff for \`${payload.relPath}\`` : `In \`${payload.relPath}\``;
|
|
5
|
+
return `${subject} (${payload.lineRange}):\n\`\`\`${payload.ext}\n${payload.selectedText}\n\`\`\`\n\nComment: ${comment}\n`;
|
|
6
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const MAX_TREE_DEPTH = 6;
|
|
2
|
+
export const POLL_INTERVAL_MS = 3000;
|
|
3
|
+
export const MAX_LINE_COUNT_BYTES = 256 * 1024;
|
|
4
|
+
export const LINE_COUNT_BATCH_SIZE = 8;
|
|
5
|
+
export const LINE_COUNT_BATCH_DELAY_MS = 30;
|
|
6
|
+
export const SCAN_BATCH_SIZE = 4;
|
|
7
|
+
export const SCAN_BATCH_DELAY_MS = 25;
|
|
8
|
+
export const SAFE_MODE_ENTRY_THRESHOLD = 200;
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_VIEWER_HEIGHT = 29;
|
|
11
|
+
export const DEFAULT_BROWSER_HEIGHT = 28;
|
|
12
|
+
|
|
13
|
+
export const MIN_PANEL_HEIGHT = 5;
|
|
14
|
+
export const MAX_VIEWER_HEIGHT = 50;
|
|
15
|
+
export const MAX_BROWSER_HEIGHT = 40;
|
|
16
|
+
export const INITIAL_PANEL_HEIGHT_RATIO = 0.85;
|
|
17
|
+
export const OVERLAY_MAX_HEIGHT_RATIO = 0.95;
|
|
18
|
+
export const OVERLAY_MAX_HEIGHT = "95%";
|
|
19
|
+
|
|
20
|
+
export function getResponsivePanelHeight(
|
|
21
|
+
fallback: number,
|
|
22
|
+
maximum: number,
|
|
23
|
+
chromeRows: number,
|
|
24
|
+
terminalRows = process.stdout.rows,
|
|
25
|
+
ratio = INITIAL_PANEL_HEIGHT_RATIO
|
|
26
|
+
): number {
|
|
27
|
+
if (!terminalRows || terminalRows <= 0) return fallback;
|
|
28
|
+
return Math.min(maximum, Math.max(MIN_PANEL_HEIGHT, Math.floor(terminalRows * ratio) - chromeRows));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const SEARCH_SCROLL_OFFSET = 3;
|
package/src/file-tree.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { MAX_TREE_DEPTH } from "./constants";
|
|
5
|
+
import type { DiffStats, FileNode, FlatNode } from "./types";
|
|
6
|
+
|
|
7
|
+
const collator = new Intl.Collator(undefined, { sensitivity: "base" });
|
|
8
|
+
|
|
9
|
+
function safeRealPathSync(path: string): string {
|
|
10
|
+
try {
|
|
11
|
+
return realpathSync(path);
|
|
12
|
+
} catch {
|
|
13
|
+
return path;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getPathInfo(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
|
|
18
|
+
try {
|
|
19
|
+
const linkStat = lstatSync(path);
|
|
20
|
+
const isSymlink = linkStat.isSymbolicLink();
|
|
21
|
+
const targetStat = isSymlink ? statSync(path) : linkStat;
|
|
22
|
+
return {
|
|
23
|
+
isDirectory: targetStat.isDirectory(),
|
|
24
|
+
isSymlink,
|
|
25
|
+
realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
|
|
26
|
+
};
|
|
27
|
+
} catch {
|
|
28
|
+
return { isDirectory: false, isSymlink: false };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compareNodes(a: FileNode, b: FileNode): number {
|
|
33
|
+
if (a.isDirectory !== b.isDirectory) {
|
|
34
|
+
return a.isDirectory ? -1 : 1;
|
|
35
|
+
}
|
|
36
|
+
return collator.compare(a.name, b.name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function shouldIgnoreSegment(segment: string, ignored: Set<string>): boolean {
|
|
40
|
+
return ignored.has(segment);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function sortChildren(node: FileNode): void {
|
|
44
|
+
if (!node.children || node.children.length === 0) return;
|
|
45
|
+
node.children.sort(compareNodes);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sortTree(node: FileNode): void {
|
|
49
|
+
sortChildren(node);
|
|
50
|
+
if (node.children) {
|
|
51
|
+
for (const child of node.children) {
|
|
52
|
+
if (child.isDirectory) {
|
|
53
|
+
sortTree(child);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function updateTreeStats(root: FileNode | null): void {
|
|
60
|
+
if (!root) return;
|
|
61
|
+
|
|
62
|
+
function traverse(node: FileNode): {
|
|
63
|
+
totalLines: number;
|
|
64
|
+
totalAdditions: number;
|
|
65
|
+
totalDeletions: number;
|
|
66
|
+
lineCountComplete: boolean;
|
|
67
|
+
hasChanges: boolean;
|
|
68
|
+
} {
|
|
69
|
+
if (!node.isDirectory) {
|
|
70
|
+
const totalLines = node.lineCount ?? 0;
|
|
71
|
+
const totalAdditions = node.diffStats?.additions ?? 0;
|
|
72
|
+
const totalDeletions = node.diffStats?.deletions ?? 0;
|
|
73
|
+
const lineCountComplete = node.lineCount !== undefined;
|
|
74
|
+
const hasChanges = Boolean(node.gitStatus || node.agentModified);
|
|
75
|
+
return { totalLines, totalAdditions, totalDeletions, lineCountComplete, hasChanges };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let totalLines = 0;
|
|
79
|
+
let totalAdditions = 0;
|
|
80
|
+
let totalDeletions = 0;
|
|
81
|
+
let lineCountComplete = true;
|
|
82
|
+
let hasChanges = false;
|
|
83
|
+
|
|
84
|
+
if (node.children) {
|
|
85
|
+
for (const child of node.children) {
|
|
86
|
+
const stats = traverse(child);
|
|
87
|
+
totalLines += stats.totalLines;
|
|
88
|
+
totalAdditions += stats.totalAdditions;
|
|
89
|
+
totalDeletions += stats.totalDeletions;
|
|
90
|
+
if (!stats.lineCountComplete) {
|
|
91
|
+
lineCountComplete = false;
|
|
92
|
+
}
|
|
93
|
+
if (stats.hasChanges) {
|
|
94
|
+
hasChanges = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
node.totalLines = totalLines;
|
|
100
|
+
node.totalAdditions = totalAdditions;
|
|
101
|
+
node.totalDeletions = totalDeletions;
|
|
102
|
+
node.lineCountComplete = lineCountComplete;
|
|
103
|
+
node.hasChangedChildren = hasChanges;
|
|
104
|
+
|
|
105
|
+
return { totalLines, totalAdditions, totalDeletions, lineCountComplete, hasChanges };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
traverse(root);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildFileTreeFromPaths(
|
|
112
|
+
cwd: string,
|
|
113
|
+
filePaths: string[],
|
|
114
|
+
gitStatus: Map<string, string>,
|
|
115
|
+
diffStats: Map<string, DiffStats>,
|
|
116
|
+
ignored: Set<string>,
|
|
117
|
+
agentModified: Set<string>
|
|
118
|
+
): FileNode {
|
|
119
|
+
const root: FileNode = {
|
|
120
|
+
name: ".",
|
|
121
|
+
path: cwd,
|
|
122
|
+
isDirectory: true,
|
|
123
|
+
realPath: safeRealPathSync(cwd),
|
|
124
|
+
children: [],
|
|
125
|
+
expanded: true,
|
|
126
|
+
hasChangedChildren: false,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const directoryMap = new Map<string, FileNode>();
|
|
130
|
+
directoryMap.set("", root);
|
|
131
|
+
const seenFiles = new Set<string>();
|
|
132
|
+
|
|
133
|
+
function addTruncatedDirectoryPath(parts: string[]): void {
|
|
134
|
+
let current = root;
|
|
135
|
+
let relPath = "";
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < MAX_TREE_DEPTH; i++) {
|
|
138
|
+
const part = parts[i];
|
|
139
|
+
if (!part || shouldIgnoreSegment(part, ignored)) return;
|
|
140
|
+
relPath = relPath ? `${relPath}/${part}` : part;
|
|
141
|
+
|
|
142
|
+
let dirNode = directoryMap.get(relPath);
|
|
143
|
+
if (!dirNode) {
|
|
144
|
+
const depth = i + 1;
|
|
145
|
+
dirNode = {
|
|
146
|
+
name: part,
|
|
147
|
+
path: join(cwd, relPath),
|
|
148
|
+
isDirectory: true,
|
|
149
|
+
realPath: safeRealPathSync(join(cwd, relPath)),
|
|
150
|
+
parent: current,
|
|
151
|
+
children: i === MAX_TREE_DEPTH - 1 ? undefined : [],
|
|
152
|
+
expanded: depth < 1,
|
|
153
|
+
hasChangedChildren: false,
|
|
154
|
+
};
|
|
155
|
+
directoryMap.set(relPath, dirNode);
|
|
156
|
+
current.children?.push(dirNode);
|
|
157
|
+
}
|
|
158
|
+
current = dirNode;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const rawPath of filePaths) {
|
|
162
|
+
let normalized = rawPath.trim();
|
|
163
|
+
if (!normalized) continue;
|
|
164
|
+
if (normalized.startsWith("./")) {
|
|
165
|
+
normalized = normalized.slice(2);
|
|
166
|
+
}
|
|
167
|
+
normalized = normalized.replace(/\\/g, "/");
|
|
168
|
+
|
|
169
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
170
|
+
if (parts.length === 0) continue;
|
|
171
|
+
const dirDepth = parts.length - 1;
|
|
172
|
+
const isChangedPath = gitStatus.has(normalized) || gitStatus.has(`${normalized}/`);
|
|
173
|
+
if (dirDepth > MAX_TREE_DEPTH && !isChangedPath) {
|
|
174
|
+
// Keep a navigable prefix for deep tracked paths instead of dropping their
|
|
175
|
+
// top-level directories from the initial Git tree.
|
|
176
|
+
addTruncatedDirectoryPath(parts);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let current = root;
|
|
181
|
+
let relPath = "";
|
|
182
|
+
let skip = false;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
185
|
+
const part = parts[i];
|
|
186
|
+
if (shouldIgnoreSegment(part, ignored)) {
|
|
187
|
+
skip = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
relPath = relPath ? `${relPath}/${part}` : part;
|
|
191
|
+
let dirNode = directoryMap.get(relPath);
|
|
192
|
+
if (!dirNode) {
|
|
193
|
+
const depth = i + 1;
|
|
194
|
+
dirNode = {
|
|
195
|
+
name: part,
|
|
196
|
+
path: join(cwd, relPath),
|
|
197
|
+
isDirectory: true,
|
|
198
|
+
realPath: safeRealPathSync(join(cwd, relPath)),
|
|
199
|
+
parent: current,
|
|
200
|
+
children: [],
|
|
201
|
+
expanded: depth < 1,
|
|
202
|
+
hasChangedChildren: false,
|
|
203
|
+
};
|
|
204
|
+
directoryMap.set(relPath, dirNode);
|
|
205
|
+
current.children?.push(dirNode);
|
|
206
|
+
}
|
|
207
|
+
current = dirNode;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (skip) continue;
|
|
211
|
+
|
|
212
|
+
const fileName = parts[parts.length - 1];
|
|
213
|
+
if (shouldIgnoreSegment(fileName, ignored)) continue;
|
|
214
|
+
|
|
215
|
+
const fileRelPath = parts.join("/");
|
|
216
|
+
if (seenFiles.has(fileRelPath)) continue;
|
|
217
|
+
|
|
218
|
+
const filePath = join(cwd, fileRelPath);
|
|
219
|
+
const fileGitStatus = gitStatus.get(fileRelPath) ?? gitStatus.get(`${fileRelPath}/`);
|
|
220
|
+
const fileDiffStats = diffStats.get(fileRelPath);
|
|
221
|
+
const existingDir = directoryMap.get(fileRelPath);
|
|
222
|
+
|
|
223
|
+
if (existingDir) {
|
|
224
|
+
if (fileGitStatus) {
|
|
225
|
+
existingDir.gitStatus = fileGitStatus;
|
|
226
|
+
}
|
|
227
|
+
if (fileDiffStats) {
|
|
228
|
+
existingDir.diffStats = fileDiffStats;
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const pathInfo = getPathInfo(filePath);
|
|
234
|
+
const isDirEntry = normalized.endsWith("/") || pathInfo.isDirectory;
|
|
235
|
+
if (isDirEntry) {
|
|
236
|
+
const depth = parts.length;
|
|
237
|
+
const dirNode: FileNode = {
|
|
238
|
+
name: fileName,
|
|
239
|
+
path: filePath,
|
|
240
|
+
isDirectory: true,
|
|
241
|
+
isSymlink: pathInfo.isSymlink,
|
|
242
|
+
realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
|
|
243
|
+
parent: current,
|
|
244
|
+
children: pathInfo.isSymlink ? undefined : [],
|
|
245
|
+
expanded: depth < 1,
|
|
246
|
+
hasChangedChildren: false,
|
|
247
|
+
gitStatus: fileGitStatus,
|
|
248
|
+
diffStats: fileDiffStats,
|
|
249
|
+
};
|
|
250
|
+
directoryMap.set(fileRelPath, dirNode);
|
|
251
|
+
current.children?.push(dirNode);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
seenFiles.add(fileRelPath);
|
|
256
|
+
|
|
257
|
+
current.children?.push({
|
|
258
|
+
name: fileName,
|
|
259
|
+
path: filePath,
|
|
260
|
+
isDirectory: false,
|
|
261
|
+
isSymlink: pathInfo.isSymlink,
|
|
262
|
+
parent: current,
|
|
263
|
+
gitStatus: fileGitStatus,
|
|
264
|
+
agentModified: agentModified.has(filePath),
|
|
265
|
+
diffStats: fileDiffStats,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
sortTree(root);
|
|
270
|
+
updateTreeStats(root);
|
|
271
|
+
return root;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function getIgnoredNames(): Set<string> {
|
|
275
|
+
return new Set([
|
|
276
|
+
"node_modules",
|
|
277
|
+
".git",
|
|
278
|
+
".DS_Store",
|
|
279
|
+
"__pycache__",
|
|
280
|
+
".pytest_cache",
|
|
281
|
+
".mypy_cache",
|
|
282
|
+
".next",
|
|
283
|
+
".nuxt",
|
|
284
|
+
"dist",
|
|
285
|
+
"build",
|
|
286
|
+
".venv",
|
|
287
|
+
"venv",
|
|
288
|
+
".env",
|
|
289
|
+
"coverage",
|
|
290
|
+
".nyc_output",
|
|
291
|
+
".turbo",
|
|
292
|
+
".cache",
|
|
293
|
+
]);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function flattenTree(
|
|
297
|
+
node: FileNode,
|
|
298
|
+
depth = 0,
|
|
299
|
+
isRoot = true,
|
|
300
|
+
includeCollapsed = false
|
|
301
|
+
): FlatNode[] {
|
|
302
|
+
const result: FlatNode[] = [];
|
|
303
|
+
|
|
304
|
+
// Skip the root "." node itself, just process its children
|
|
305
|
+
if (isRoot && node.name === ".") {
|
|
306
|
+
for (const child of node.children || []) {
|
|
307
|
+
result.push(...flattenTree(child, 0, false, includeCollapsed));
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
result.push({ node, depth });
|
|
313
|
+
|
|
314
|
+
if (node.isDirectory && node.children && (includeCollapsed || node.expanded)) {
|
|
315
|
+
for (const child of node.children) {
|
|
316
|
+
result.push(...flattenTree(child, depth + 1, false, includeCollapsed));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { getLanguageFromPath, getMarkdownTheme, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Markdown, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
import { isGitRepo } from "./git";
|
|
7
|
+
import { isMarkdownPath, stripLeadingEmptyLines } from "./utils";
|
|
8
|
+
|
|
9
|
+
type UnifiedDiffLine = {
|
|
10
|
+
kind: "add" | "remove" | "context";
|
|
11
|
+
lineNumber: number;
|
|
12
|
+
text: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type RenderedLines = {
|
|
16
|
+
lines: string[];
|
|
17
|
+
rowGroups: number[];
|
|
18
|
+
logicalLines: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function parseUnifiedDiff(diffOutput: string): UnifiedDiffLine[] {
|
|
22
|
+
const lines: UnifiedDiffLine[] = [];
|
|
23
|
+
let oldLine = 0;
|
|
24
|
+
let newLine = 0;
|
|
25
|
+
let inHunk = false;
|
|
26
|
+
|
|
27
|
+
for (const rawLine of diffOutput.split("\n")) {
|
|
28
|
+
const hunk = rawLine.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
29
|
+
if (hunk) {
|
|
30
|
+
oldLine = Number(hunk[1]);
|
|
31
|
+
newLine = Number(hunk[2]);
|
|
32
|
+
inHunk = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!inHunk || rawLine.startsWith("\")) continue;
|
|
36
|
+
|
|
37
|
+
const text = rawLine.slice(1);
|
|
38
|
+
switch (rawLine[0]) {
|
|
39
|
+
case " ":
|
|
40
|
+
lines.push({ kind: "context", lineNumber: newLine, text });
|
|
41
|
+
oldLine++;
|
|
42
|
+
newLine++;
|
|
43
|
+
break;
|
|
44
|
+
case "-":
|
|
45
|
+
lines.push({ kind: "remove", lineNumber: oldLine++, text });
|
|
46
|
+
break;
|
|
47
|
+
case "+":
|
|
48
|
+
lines.push({ kind: "add", lineNumber: newLine++, text });
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function wrapUnifiedDiffLine(line: string, width: number, wordWrap: boolean): string[] {
|
|
57
|
+
if (!wordWrap) return [line];
|
|
58
|
+
const separatorIndex = line.indexOf("│");
|
|
59
|
+
if (separatorIndex === -1 || width <= 0) return wrapTextWithAnsi(line, width);
|
|
60
|
+
|
|
61
|
+
const prefix = line.slice(0, separatorIndex + 2);
|
|
62
|
+
const contentWidth = Math.max(width - visibleWidth(prefix), 1);
|
|
63
|
+
const content = line.slice(separatorIndex + 2);
|
|
64
|
+
const continuationPrefix = prefix.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\d/g, token => token.startsWith("\x1b") ? token : " ");
|
|
65
|
+
return wrapTextWithAnsi(content, contentWidth).map((chunk, index) =>
|
|
66
|
+
(index === 0 ? prefix : continuationPrefix) + chunk
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderUnifiedDiff(diffOutput: string, width: number, theme: Theme, wordWrap: boolean): RenderedLines {
|
|
71
|
+
const parsed = parseUnifiedDiff(diffOutput);
|
|
72
|
+
if (parsed.length === 0) {
|
|
73
|
+
const lines = stripLeadingEmptyLines(diffOutput.split("\n"));
|
|
74
|
+
return { lines, rowGroups: lines.map((_, index) => index), logicalLines: lines };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lineNumberWidth = String(Math.max(...parsed.map(line => line.lineNumber))).length;
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
const rowGroups: number[] = [];
|
|
80
|
+
const logicalLines: string[] = [];
|
|
81
|
+
for (const [group, { kind, lineNumber, text }] of parsed.entries()) {
|
|
82
|
+
const marker = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
|
83
|
+
const color =
|
|
84
|
+
kind === "add" ? "toolDiffAdded" : kind === "remove" ? "toolDiffRemoved" : "toolDiffContext";
|
|
85
|
+
const rendered = theme.fg(color, `${marker} ${String(lineNumber).padStart(lineNumberWidth)} │ ${text}`);
|
|
86
|
+
logicalLines.push(rendered);
|
|
87
|
+
const wrapped = wrapUnifiedDiffLine(rendered, width, wordWrap);
|
|
88
|
+
lines.push(...wrapped);
|
|
89
|
+
rowGroups.push(...wrapped.map(() => group));
|
|
90
|
+
}
|
|
91
|
+
return { lines, rowGroups, logicalLines };
|
|
92
|
+
}
|
|
93
|
+
export interface LoadedFileContent extends RenderedLines {
|
|
94
|
+
renderedMarkdown: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface LoadFileContentOptions {
|
|
98
|
+
cwd: string;
|
|
99
|
+
diffMode: boolean;
|
|
100
|
+
hasChanges: boolean;
|
|
101
|
+
width?: number;
|
|
102
|
+
renderMarkdown: boolean;
|
|
103
|
+
wordWrap: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function loadFileContent(
|
|
107
|
+
filePath: string,
|
|
108
|
+
{ cwd, diffMode, hasChanges, width, renderMarkdown, wordWrap }: LoadFileContentOptions,
|
|
109
|
+
theme: Theme
|
|
110
|
+
): LoadedFileContent {
|
|
111
|
+
const isMarkdown = isMarkdownPath(filePath);
|
|
112
|
+
const termWidth = width || process.stdout.columns || 80;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
try {
|
|
116
|
+
if (statSync(filePath).isDirectory()) {
|
|
117
|
+
return { lines: ["Directory selected - expand it in the file tree instead of opening it."], rowGroups: [0], logicalLines: ["Directory selected - expand it in the file tree instead of opening it."], renderedMarkdown: false };
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Ignore stat errors and fall through to normal handling
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (diffMode && hasChanges && isGitRepo(cwd)) {
|
|
124
|
+
try {
|
|
125
|
+
// Try different diff strategies
|
|
126
|
+
let diffOutput = "";
|
|
127
|
+
|
|
128
|
+
// First try: unstaged changes
|
|
129
|
+
const unstaged = execSync(`git diff --no-color -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
|
|
130
|
+
if (unstaged.trim()) {
|
|
131
|
+
diffOutput = unstaged;
|
|
132
|
+
} else {
|
|
133
|
+
// Second try: staged changes
|
|
134
|
+
const staged = execSync(`git diff --no-color --cached -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
|
|
135
|
+
if (staged.trim()) {
|
|
136
|
+
diffOutput = staged;
|
|
137
|
+
} else {
|
|
138
|
+
// Third try: diff against HEAD (for new files that are staged)
|
|
139
|
+
const headDiff = execSync(`git diff --no-color HEAD -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
|
|
140
|
+
if (headDiff.trim()) {
|
|
141
|
+
diffOutput = headDiff;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!diffOutput.trim()) {
|
|
147
|
+
return { lines: ["No diff available - file may be untracked or unchanged"], rowGroups: [0], logicalLines: ["No diff available - file may be untracked or unchanged"], renderedMarkdown: false };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { ...renderUnifiedDiff(diffOutput, termWidth, theme, wordWrap), renderedMarkdown: false };
|
|
151
|
+
} catch (e: any) {
|
|
152
|
+
return { lines: [`Diff error: ${e.message}`], rowGroups: [0], logicalLines: [`Diff error: ${e.message}`], renderedMarkdown: false };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (isMarkdown && renderMarkdown) {
|
|
157
|
+
const markdown = new Markdown(readFileSync(filePath, "utf-8"), 0, 0, getMarkdownTheme());
|
|
158
|
+
const lines = markdown.render(wordWrap ? termWidth : 10_000).map(line => line.trimEnd());
|
|
159
|
+
return { lines, rowGroups: lines.map((_, index) => index), logicalLines: lines, renderedMarkdown: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
163
|
+
const lineNumberWidth = Math.max(4, String(raw.split("\n").length).length);
|
|
164
|
+
const contentWidth = Math.max(1, termWidth - lineNumberWidth - 3);
|
|
165
|
+
const highlighted = highlightCode(raw, getLanguageFromPath(filePath));
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
const rowGroups: number[] = [];
|
|
168
|
+
for (const [group, line] of highlighted.entries()) {
|
|
169
|
+
const lineNumber = theme.fg("dim", String(group + 1).padStart(lineNumberWidth));
|
|
170
|
+
const continuation = " ".repeat(lineNumberWidth);
|
|
171
|
+
const wrapped = wordWrap ? wrapTextWithAnsi(line, contentWidth) : [line];
|
|
172
|
+
lines.push(...wrapped.map((segment, segmentIndex) =>
|
|
173
|
+
`${segmentIndex === 0 ? lineNumber : continuation}${theme.fg("borderMuted", " │ ")}${segment}`
|
|
174
|
+
));
|
|
175
|
+
rowGroups.push(...wrapped.map(() => group));
|
|
176
|
+
}
|
|
177
|
+
return { lines, rowGroups, logicalLines: raw.split("\n"), renderedMarkdown: false };
|
|
178
|
+
} catch (e: any) {
|
|
179
|
+
return { lines: [`Error loading file: ${e.message}`], rowGroups: [0], logicalLines: [`Error loading file: ${e.message}`], renderedMarkdown: false };
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { DiffStats } from "./types";
|
|
4
|
+
|
|
5
|
+
const GIT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
type GitErrorReporter = (operation: string) => void;
|
|
8
|
+
|
|
9
|
+
export function isGitRepo(cwd: string): boolean {
|
|
10
|
+
try {
|
|
11
|
+
execSync("git rev-parse --is-inside-work-tree", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" });
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Path of `cwd` relative to the repository top-level (e.g. "app/"), or "" when
|
|
20
|
+
* `cwd` is the top-level itself. `git status --porcelain` reports paths relative
|
|
21
|
+
* to the repository root, while `git ls-files` (and this widget's node keys) are
|
|
22
|
+
* relative to `cwd` — this prefix lets us translate between the two.
|
|
23
|
+
*/
|
|
24
|
+
function getGitPathPrefix(cwd: string): string {
|
|
25
|
+
try {
|
|
26
|
+
return execSync("git rev-parse --show-prefix", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" }).trim();
|
|
27
|
+
} catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Convert a repo-root-relative path to a cwd-relative one; null if outside cwd. */
|
|
33
|
+
function stripPathPrefix(filePath: string, prefix: string): string | null {
|
|
34
|
+
if (!prefix) return filePath;
|
|
35
|
+
if (filePath.startsWith(prefix)) return filePath.slice(prefix.length);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getGitStatus(cwd: string, options: { includeIgnored?: boolean } = {}, onError?: GitErrorReporter): Map<string, string> {
|
|
40
|
+
const status = new Map<string, string>();
|
|
41
|
+
try {
|
|
42
|
+
const flags = ["--porcelain"];
|
|
43
|
+
if (options.includeIgnored !== false) flags.push("--ignored");
|
|
44
|
+
const prefix = getGitPathPrefix(cwd);
|
|
45
|
+
const output = execSync(`git status ${flags.join(" ")}`, { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe", maxBuffer: GIT_MAX_BUFFER });
|
|
46
|
+
for (const line of output.split("\n")) {
|
|
47
|
+
if (line.length < 3) continue;
|
|
48
|
+
const statusCode = line.slice(0, 2).trim() || "?";
|
|
49
|
+
const filePath = stripPathPrefix(line.slice(3), prefix)?.replace(/\/+$/, "");
|
|
50
|
+
if (filePath === null || !filePath) continue;
|
|
51
|
+
status.set(filePath, statusCode);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
onError?.("Git status");
|
|
55
|
+
}
|
|
56
|
+
return status;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getGitFileList(cwd: string, onError?: GitErrorReporter): string[] {
|
|
60
|
+
const files = new Set<string>();
|
|
61
|
+
try {
|
|
62
|
+
const tracked = execSync("git ls-files -z", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe", maxBuffer: GIT_MAX_BUFFER });
|
|
63
|
+
for (const entry of tracked.split("\0")) {
|
|
64
|
+
if (entry) files.add(entry);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
onError?.("tracked file list");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const prefix = getGitPathPrefix(cwd);
|
|
72
|
+
const statusOutput = execSync("git status --porcelain -uall -z", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe", maxBuffer: GIT_MAX_BUFFER });
|
|
73
|
+
const entries = statusOutput.split("\0");
|
|
74
|
+
for (let i = 0; i < entries.length; i++) {
|
|
75
|
+
const entry = entries[i];
|
|
76
|
+
if (!entry) continue;
|
|
77
|
+
const statusCode = entry.slice(0, 2).trim();
|
|
78
|
+
let filePath = entry.slice(3);
|
|
79
|
+
if ((statusCode.startsWith("R") || statusCode.startsWith("C")) && entries[i + 1]) {
|
|
80
|
+
i += 1;
|
|
81
|
+
filePath = entries[i];
|
|
82
|
+
} else if (filePath.includes(" -> ")) {
|
|
83
|
+
filePath = filePath.split(" -> ").pop() || filePath;
|
|
84
|
+
}
|
|
85
|
+
const relPath = filePath ? stripPathPrefix(filePath, prefix)?.replace(/\/+$/, "") : null;
|
|
86
|
+
if (relPath) files.add(relPath);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
onError?.("Git status");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Array.from(files);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getGitBranch(cwd: string): string {
|
|
96
|
+
try {
|
|
97
|
+
return execSync("git branch --show-current", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" }).trim();
|
|
98
|
+
} catch {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getGitDiffStats(cwd: string, onError?: GitErrorReporter): Map<string, DiffStats> {
|
|
104
|
+
const stats = new Map<string, DiffStats>();
|
|
105
|
+
try {
|
|
106
|
+
// Get diff stats for modified files. --relative keeps paths relative to cwd
|
|
107
|
+
// (and scoped to it) so they match the widget's cwd-relative node keys even
|
|
108
|
+
// when cwd is a subdirectory of the repository.
|
|
109
|
+
const output = execSync("git diff --relative --numstat HEAD", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe", maxBuffer: GIT_MAX_BUFFER });
|
|
110
|
+
for (const line of output.split("\n")) {
|
|
111
|
+
const parts = line.split("\t");
|
|
112
|
+
if (parts.length < 3) continue;
|
|
113
|
+
stats.set(parts[2], { additions: parseInt(parts[0], 10) || 0, deletions: parseInt(parts[1], 10) || 0 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const stagedOutput = execSync("git diff --relative --numstat --cached", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe", maxBuffer: GIT_MAX_BUFFER });
|
|
117
|
+
for (const line of stagedOutput.split("\n")) {
|
|
118
|
+
const parts = line.split("\t");
|
|
119
|
+
if (parts.length < 3) continue;
|
|
120
|
+
const additions = parseInt(parts[0], 10) || 0;
|
|
121
|
+
const deletions = parseInt(parts[1], 10) || 0;
|
|
122
|
+
const existing = stats.get(parts[2]);
|
|
123
|
+
stats.set(parts[2], existing ? { additions: existing.additions + additions, deletions: existing.deletions + deletions } : { additions, deletions });
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
onError?.("Git diff statistics");
|
|
127
|
+
}
|
|
128
|
+
return stats;
|
|
129
|
+
}
|