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/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
+ }
@@ -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;
@@ -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
+ }