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/browser.ts
ADDED
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_BROWSER_HEIGHT,
|
|
10
|
+
getResponsivePanelHeight,
|
|
11
|
+
OVERLAY_MAX_HEIGHT_RATIO,
|
|
12
|
+
LINE_COUNT_BATCH_DELAY_MS,
|
|
13
|
+
LINE_COUNT_BATCH_SIZE,
|
|
14
|
+
MAX_BROWSER_HEIGHT,
|
|
15
|
+
MAX_LINE_COUNT_BYTES,
|
|
16
|
+
MAX_TREE_DEPTH,
|
|
17
|
+
MIN_PANEL_HEIGHT,
|
|
18
|
+
POLL_INTERVAL_MS,
|
|
19
|
+
SCAN_BATCH_DELAY_MS,
|
|
20
|
+
SCAN_BATCH_SIZE,
|
|
21
|
+
SAFE_MODE_ENTRY_THRESHOLD,
|
|
22
|
+
} from "./constants";
|
|
23
|
+
import { getGitBranch, getGitDiffStats, getGitFileList, getGitStatus, isGitRepo } from "./git";
|
|
24
|
+
import { buildFileTreeFromPaths, flattenTree, getIgnoredNames, sortChildren, updateTreeStats } from "./file-tree";
|
|
25
|
+
import type { DiffStats, FileNode, FlatNode } from "./types";
|
|
26
|
+
import { isIgnoredStatus, isUntrackedStatus } from "./utils";
|
|
27
|
+
import { createViewer, type CommentPayload, type ViewerAction } from "./viewer";
|
|
28
|
+
import { createTextInputBuffer } from "./input-utils";
|
|
29
|
+
|
|
30
|
+
export interface BrowserController {
|
|
31
|
+
render(width: number): string[];
|
|
32
|
+
handleInput(data: string): void;
|
|
33
|
+
invalidate(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface BrowserStats {
|
|
37
|
+
totalLines?: number;
|
|
38
|
+
additions: number;
|
|
39
|
+
deletions: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ScanMode = "full" | "safe" | "none";
|
|
43
|
+
|
|
44
|
+
interface ScanState {
|
|
45
|
+
mode: ScanMode;
|
|
46
|
+
isScanning: boolean;
|
|
47
|
+
isPartial: boolean;
|
|
48
|
+
pending: number;
|
|
49
|
+
spinnerIndex: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface BrowserState {
|
|
53
|
+
root: FileNode | null;
|
|
54
|
+
flatList: FlatNode[];
|
|
55
|
+
fullList: FlatNode[];
|
|
56
|
+
stats: BrowserStats;
|
|
57
|
+
nodeByPath: Map<string, FileNode>;
|
|
58
|
+
scanState: ScanState;
|
|
59
|
+
selectedIndex: number;
|
|
60
|
+
searchQuery: string;
|
|
61
|
+
searchMode: boolean;
|
|
62
|
+
showOnlyChanged: boolean;
|
|
63
|
+
expandedChangedView: boolean;
|
|
64
|
+
expandedForChangedView: Set<string>;
|
|
65
|
+
focusFirstChildOf: string | null;
|
|
66
|
+
errorMessage: string | null;
|
|
67
|
+
browserHeight: number;
|
|
68
|
+
lastPollTime: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ChangedFile {
|
|
72
|
+
file: FileNode;
|
|
73
|
+
ancestors: FileNode[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
77
|
+
|
|
78
|
+
function findNodeByPath(root: FileNode | null, path: string): FileNode | null {
|
|
79
|
+
if (!root) return null;
|
|
80
|
+
if (root.path === path) return root;
|
|
81
|
+
if (!root.children) return null;
|
|
82
|
+
|
|
83
|
+
for (const child of root.children) {
|
|
84
|
+
const found = findNodeByPath(child, path);
|
|
85
|
+
if (found) return found;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function indexNodes(root: FileNode | null, map: Map<string, FileNode>): void {
|
|
92
|
+
map.clear();
|
|
93
|
+
if (!root) return;
|
|
94
|
+
const stack: FileNode[] = [root];
|
|
95
|
+
while (stack.length > 0) {
|
|
96
|
+
const node = stack.pop();
|
|
97
|
+
if (!node) continue;
|
|
98
|
+
map.set(node.path, node);
|
|
99
|
+
if (node.children) {
|
|
100
|
+
for (const child of node.children) {
|
|
101
|
+
stack.push(child);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getNodeDepth(node: FileNode, root: string): number {
|
|
108
|
+
if (node.path === root) return 0;
|
|
109
|
+
const rel = relative(root, node.path);
|
|
110
|
+
if (!rel) return 0;
|
|
111
|
+
return rel.split(sep).length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatRootPath(path: string): string {
|
|
115
|
+
const home = homedir();
|
|
116
|
+
if (!home) return path;
|
|
117
|
+
if (path === home) return "~";
|
|
118
|
+
if (path.startsWith(home + sep)) return "~" + path.slice(home.length);
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function safeRealPathSync(path: string): string {
|
|
123
|
+
try {
|
|
124
|
+
return realpathSync(path);
|
|
125
|
+
} catch {
|
|
126
|
+
return resolve(path);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getPathInfoSync(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
|
|
131
|
+
try {
|
|
132
|
+
const linkStat = lstatSync(path);
|
|
133
|
+
const isSymlink = linkStat.isSymbolicLink();
|
|
134
|
+
const targetStat = isSymlink ? statSync(path) : linkStat;
|
|
135
|
+
return {
|
|
136
|
+
isDirectory: targetStat.isDirectory(),
|
|
137
|
+
isSymlink,
|
|
138
|
+
realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
return { isDirectory: false, isSymlink: false };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function getPathInfo(path: string, isSymlink: boolean): Promise<{ isDirectory: boolean; isSymlink: boolean; realPath?: string }> {
|
|
146
|
+
try {
|
|
147
|
+
const targetStat = await stat(path);
|
|
148
|
+
return {
|
|
149
|
+
isDirectory: targetStat.isDirectory(),
|
|
150
|
+
isSymlink,
|
|
151
|
+
realPath: targetStat.isDirectory() ? await realpath(path).catch(() => resolve(path)) : undefined,
|
|
152
|
+
};
|
|
153
|
+
} catch {
|
|
154
|
+
return { isDirectory: false, isSymlink };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasAncestorRealPath(node: FileNode | undefined, realPath: string): boolean {
|
|
159
|
+
let current = node;
|
|
160
|
+
while (current) {
|
|
161
|
+
if (current.realPath === realPath) return true;
|
|
162
|
+
current = current.parent;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function shouldSafeMode(path: string): boolean {
|
|
168
|
+
const resolved = resolve(path);
|
|
169
|
+
const home = resolve(homedir());
|
|
170
|
+
const root = resolve(sep);
|
|
171
|
+
return resolved === home || resolved === root;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function collectChangedFiles(node: FileNode, ancestors: FileNode[] = []): ChangedFile[] {
|
|
175
|
+
const results: ChangedFile[] = [];
|
|
176
|
+
|
|
177
|
+
if (!node.isDirectory && (node.gitStatus || node.agentModified)) {
|
|
178
|
+
results.push({ file: node, ancestors: [...ancestors] });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (node.children) {
|
|
182
|
+
for (const child of node.children) {
|
|
183
|
+
results.push(...collectChangedFiles(child, [...ancestors, node]));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getTreeStats(root: FileNode | null): BrowserStats {
|
|
191
|
+
if (!root) {
|
|
192
|
+
return { totalLines: undefined, additions: 0, deletions: 0 };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
totalLines: root.lineCountComplete ? root.totalLines ?? 0 : undefined,
|
|
197
|
+
additions: root.totalAdditions ?? 0,
|
|
198
|
+
deletions: root.totalDeletions ?? 0,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function formatNodeStatus(node: FileNode, theme: Theme): string {
|
|
203
|
+
if (isIgnoredStatus(node.gitStatus)) return "";
|
|
204
|
+
if (node.agentModified) return theme.fg("accent", " 🤖");
|
|
205
|
+
if (node.gitStatus === "M" || node.gitStatus === "MM") return theme.fg("warning", " M");
|
|
206
|
+
if (isUntrackedStatus(node.gitStatus)) return theme.fg("dim", " ?");
|
|
207
|
+
if (node.gitStatus === "A") return theme.fg("success", " A");
|
|
208
|
+
if (node.gitStatus === "D") return theme.fg("error", " D");
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formatNodeMeta(node: FileNode, theme: Theme): string {
|
|
213
|
+
if (isIgnoredStatus(node.gitStatus)) return "";
|
|
214
|
+
|
|
215
|
+
const parts: string[] = [];
|
|
216
|
+
|
|
217
|
+
if (node.isDirectory && !node.expanded) {
|
|
218
|
+
if (node.totalAdditions && node.totalAdditions > 0) {
|
|
219
|
+
parts.push(theme.fg("success", `+${node.totalAdditions}`));
|
|
220
|
+
}
|
|
221
|
+
if (node.totalDeletions && node.totalDeletions > 0) {
|
|
222
|
+
parts.push(theme.fg("error", `-${node.totalDeletions}`));
|
|
223
|
+
}
|
|
224
|
+
if (node.totalLines && node.lineCountComplete !== false) {
|
|
225
|
+
parts.push(theme.fg("dim", `${node.totalLines}L`));
|
|
226
|
+
}
|
|
227
|
+
} else if (!node.isDirectory) {
|
|
228
|
+
if (node.diffStats) {
|
|
229
|
+
if (node.diffStats.additions > 0) {
|
|
230
|
+
parts.push(theme.fg("success", `+${node.diffStats.additions}`));
|
|
231
|
+
}
|
|
232
|
+
if (node.diffStats.deletions > 0) {
|
|
233
|
+
parts.push(theme.fg("error", `-${node.diffStats.deletions}`));
|
|
234
|
+
}
|
|
235
|
+
} else if (isUntrackedStatus(node.gitStatus) && node.lineCount !== undefined) {
|
|
236
|
+
parts.push(theme.fg("success", `+${node.lineCount}`));
|
|
237
|
+
}
|
|
238
|
+
if (node.lineCount !== undefined) {
|
|
239
|
+
parts.push(theme.fg("dim", `${node.lineCount}L`));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function withSymlinkMarker(label: string, node: FileNode, theme: Theme): string {
|
|
247
|
+
return node.isSymlink ? `${label}${theme.fg("dim", " ↗")}` : label;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatNodeName(node: FileNode, theme: Theme): string {
|
|
251
|
+
if (isIgnoredStatus(node.gitStatus)) return withSymlinkMarker(theme.fg("dim", node.name), node, theme);
|
|
252
|
+
if (node.isDirectory) {
|
|
253
|
+
const label = node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
|
|
254
|
+
const rendered = withSymlinkMarker(label, node, theme);
|
|
255
|
+
return node.loading ? `${rendered}${theme.fg("dim", " ⏳")}` : rendered;
|
|
256
|
+
}
|
|
257
|
+
if (node.gitStatus) return withSymlinkMarker(theme.fg("warning", node.name), node, theme);
|
|
258
|
+
return withSymlinkMarker(node.name, node, theme);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
|
|
262
|
+
if (node.isDirectory) {
|
|
263
|
+
node.expanded = keep.has(node);
|
|
264
|
+
if (node.children) {
|
|
265
|
+
for (const child of node.children) {
|
|
266
|
+
collapseAllExcept(child, keep);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function createFileBrowser(
|
|
273
|
+
initialPath: string,
|
|
274
|
+
agentModifiedFiles: Set<string>,
|
|
275
|
+
theme: Theme,
|
|
276
|
+
onClose: () => void,
|
|
277
|
+
requestComment: (payload: CommentPayload, comment: string) => void,
|
|
278
|
+
requestRender: () => void,
|
|
279
|
+
projectCwd: string = initialPath
|
|
280
|
+
): BrowserController {
|
|
281
|
+
const ignored = getIgnoredNames();
|
|
282
|
+
|
|
283
|
+
let rootPath = resolve(initialPath);
|
|
284
|
+
const initialRoot = rootPath;
|
|
285
|
+
let repo = false;
|
|
286
|
+
let gitStatus = new Map<string, string>();
|
|
287
|
+
let diffStats = new Map<string, DiffStats>();
|
|
288
|
+
let gitBranch = "";
|
|
289
|
+
|
|
290
|
+
const viewer = createViewer({ getRoot: () => rootPath, projectCwd }, theme, requestComment);
|
|
291
|
+
const textInput = createTextInputBuffer();
|
|
292
|
+
|
|
293
|
+
const scanState: ScanState = {
|
|
294
|
+
mode: "none",
|
|
295
|
+
isScanning: false,
|
|
296
|
+
isPartial: false,
|
|
297
|
+
pending: 0,
|
|
298
|
+
spinnerIndex: 0,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const browser: BrowserState = {
|
|
302
|
+
root: null,
|
|
303
|
+
flatList: [],
|
|
304
|
+
fullList: [],
|
|
305
|
+
stats: { totalLines: undefined, additions: 0, deletions: 0 },
|
|
306
|
+
nodeByPath: new Map<string, FileNode>(),
|
|
307
|
+
scanState,
|
|
308
|
+
selectedIndex: 0,
|
|
309
|
+
searchQuery: "",
|
|
310
|
+
searchMode: false,
|
|
311
|
+
showOnlyChanged: false,
|
|
312
|
+
expandedChangedView: false,
|
|
313
|
+
expandedForChangedView: new Set<string>(),
|
|
314
|
+
focusFirstChildOf: null,
|
|
315
|
+
errorMessage: null,
|
|
316
|
+
browserHeight: getResponsivePanelHeight(DEFAULT_BROWSER_HEIGHT, MAX_BROWSER_HEIGHT, 9),
|
|
317
|
+
lastPollTime: Date.now(),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const lineCountCache = new Map<string, { size: number; mtimeMs: number; count: number }>();
|
|
321
|
+
const lineCountQueue: FileNode[] = [];
|
|
322
|
+
const lineCountPending = new Set<string>();
|
|
323
|
+
let lineCountTimer: ReturnType<typeof setTimeout> | null = null;
|
|
324
|
+
|
|
325
|
+
const scanQueue: Array<{ node: FileNode; depth: number }> = [];
|
|
326
|
+
const scanQueued = new Set<string>();
|
|
327
|
+
let scanTimer: ReturnType<typeof setTimeout> | null = null;
|
|
328
|
+
// Incremented on every (re-)root. In-flight async scan/line-count batches
|
|
329
|
+
// capture the value when they start and bail after each await if it changed,
|
|
330
|
+
// so work belonging to an old root can never mutate state for the new one.
|
|
331
|
+
let rootGeneration = 0;
|
|
332
|
+
|
|
333
|
+
const normalizeGitPath = (path: string): string => path.split(sep).join("/");
|
|
334
|
+
|
|
335
|
+
function refreshLists(): void {
|
|
336
|
+
browser.flatList = browser.root ? flattenTree(browser.root) : [];
|
|
337
|
+
browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function reportError(message: string): void {
|
|
341
|
+
browser.errorMessage ??= message;
|
|
342
|
+
requestRender();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function reportGitError(operation: string): void {
|
|
346
|
+
reportError(`${operation} unavailable`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function focusFirstChild(directory: FileNode): boolean {
|
|
350
|
+
const child = directory.children?.[0];
|
|
351
|
+
if (!child) return false;
|
|
352
|
+
const index = getDisplayList().findIndex(entry => entry.node.path === child.path);
|
|
353
|
+
if (index === -1) return false;
|
|
354
|
+
browser.selectedIndex = index;
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function queueLineCount(node: FileNode, force = false): void {
|
|
359
|
+
if (node.isDirectory) return;
|
|
360
|
+
if (!force && node.lineCount !== undefined) return;
|
|
361
|
+
if (lineCountPending.has(node.path)) return;
|
|
362
|
+
lineCountPending.add(node.path);
|
|
363
|
+
lineCountQueue.push(node);
|
|
364
|
+
if (!lineCountTimer) {
|
|
365
|
+
lineCountTimer = setTimeout(processLineCountBatch, LINE_COUNT_BATCH_DELAY_MS);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function queueLineCountsForDirectory(directory: FileNode | null): void {
|
|
370
|
+
if (!directory?.children) return;
|
|
371
|
+
for (const child of directory.children) {
|
|
372
|
+
if (!child.isDirectory) queueLineCount(child);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function updateLineCount(node: FileNode): Promise<void> {
|
|
377
|
+
try {
|
|
378
|
+
const fileStat = await stat(node.path);
|
|
379
|
+
if (fileStat.size > MAX_LINE_COUNT_BYTES) {
|
|
380
|
+
node.lineCount = undefined;
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const cached = lineCountCache.get(node.path);
|
|
384
|
+
if (cached && cached.size === fileStat.size && cached.mtimeMs === fileStat.mtimeMs) {
|
|
385
|
+
node.lineCount = cached.count;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const content = await readFile(node.path, "utf-8");
|
|
389
|
+
const count = content.split("\n").length;
|
|
390
|
+
node.lineCount = count;
|
|
391
|
+
lineCountCache.set(node.path, { size: fileStat.size, mtimeMs: fileStat.mtimeMs, count });
|
|
392
|
+
} catch {
|
|
393
|
+
node.lineCount = undefined;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function processLineCountBatch(): Promise<void> {
|
|
398
|
+
lineCountTimer = null;
|
|
399
|
+
if (!browser.root) return;
|
|
400
|
+
const generation = rootGeneration;
|
|
401
|
+
const batch = lineCountQueue.splice(0, LINE_COUNT_BATCH_SIZE);
|
|
402
|
+
if (batch.length === 0) return;
|
|
403
|
+
|
|
404
|
+
await Promise.all(
|
|
405
|
+
batch.map(async node => {
|
|
406
|
+
await updateLineCount(node);
|
|
407
|
+
if (generation === rootGeneration) {
|
|
408
|
+
lineCountPending.delete(node.path);
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
);
|
|
412
|
+
if (generation !== rootGeneration) return;
|
|
413
|
+
|
|
414
|
+
updateTreeStats(browser.root);
|
|
415
|
+
browser.stats = getTreeStats(browser.root);
|
|
416
|
+
refreshLists();
|
|
417
|
+
requestRender();
|
|
418
|
+
|
|
419
|
+
if (lineCountQueue.length > 0) {
|
|
420
|
+
lineCountTimer = setTimeout(processLineCountBatch, LINE_COUNT_BATCH_DELAY_MS);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function shouldAutoScan(depth: number): boolean {
|
|
425
|
+
// In git repos the main tree comes from git file lists, not from filesystem
|
|
426
|
+
// crawling. If the user expands a symlinked directory inside that tree, only
|
|
427
|
+
// scan one level on demand; nested directories stay lazy until explicitly
|
|
428
|
+
// expanded so links into large trees (iCloud/Drive/$HOME) don't trigger a
|
|
429
|
+
// broad recursive crawl.
|
|
430
|
+
if (repo) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
if (browser.scanState.mode === "safe") {
|
|
434
|
+
return depth <= 0;
|
|
435
|
+
}
|
|
436
|
+
return depth <= MAX_TREE_DEPTH;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function getScanBatchSize(): number {
|
|
440
|
+
return browser.scanState.mode === "safe" ? 1 : SCAN_BATCH_SIZE;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getScanDelay(): number {
|
|
444
|
+
return browser.scanState.mode === "safe" ? SCAN_BATCH_DELAY_MS * 4 : SCAN_BATCH_DELAY_MS;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function enqueueScan(node: FileNode, depth: number, force = false): void {
|
|
448
|
+
if (depth > MAX_TREE_DEPTH) return;
|
|
449
|
+
if (!force && browser.scanState.mode === "safe" && depth > 0) return;
|
|
450
|
+
if (node.children !== undefined || node.loading) return;
|
|
451
|
+
if (scanQueued.has(node.path)) return;
|
|
452
|
+
|
|
453
|
+
node.loading = true;
|
|
454
|
+
scanQueued.add(node.path);
|
|
455
|
+
scanQueue.push({ node, depth });
|
|
456
|
+
browser.scanState.pending = scanQueue.length;
|
|
457
|
+
browser.scanState.isScanning = true;
|
|
458
|
+
|
|
459
|
+
if (!scanTimer) {
|
|
460
|
+
scanTimer = setTimeout(processScanBatch, getScanDelay());
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function scanDirectory(node: FileNode, depth: number, generation: number): Promise<void> {
|
|
465
|
+
try {
|
|
466
|
+
const entries = await readdir(node.path, { withFileTypes: true });
|
|
467
|
+
if (generation !== rootGeneration) return;
|
|
468
|
+
const sorted = [...entries].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
469
|
+
|
|
470
|
+
if (node.path === rootPath && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
|
|
471
|
+
browser.scanState.mode = "safe";
|
|
472
|
+
browser.scanState.isPartial = true;
|
|
473
|
+
scanQueue.length = 0;
|
|
474
|
+
scanQueued.clear();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const dirs: FileNode[] = [];
|
|
478
|
+
const files: FileNode[] = [];
|
|
479
|
+
|
|
480
|
+
for (const entry of sorted) {
|
|
481
|
+
if (ignored.has(entry.name)) continue;
|
|
482
|
+
const fullPath = join(node.path, entry.name);
|
|
483
|
+
const childDepth = depth + 1;
|
|
484
|
+
|
|
485
|
+
if (entry.isDirectory()) {
|
|
486
|
+
const dirRealPath = await realpath(fullPath).catch(() => resolve(fullPath));
|
|
487
|
+
if (generation !== rootGeneration) return;
|
|
488
|
+
const dirNode: FileNode = {
|
|
489
|
+
name: entry.name,
|
|
490
|
+
path: fullPath,
|
|
491
|
+
isDirectory: true,
|
|
492
|
+
realPath: dirRealPath,
|
|
493
|
+
parent: node,
|
|
494
|
+
children: undefined,
|
|
495
|
+
expanded: childDepth < 1,
|
|
496
|
+
hasChangedChildren: false,
|
|
497
|
+
};
|
|
498
|
+
dirs.push(dirNode);
|
|
499
|
+
browser.nodeByPath.set(fullPath, dirNode);
|
|
500
|
+
if (shouldAutoScan(childDepth)) {
|
|
501
|
+
enqueueScan(dirNode, childDepth);
|
|
502
|
+
}
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (entry.isSymbolicLink()) {
|
|
507
|
+
const pathInfo = await getPathInfo(fullPath, true);
|
|
508
|
+
if (generation !== rootGeneration) return;
|
|
509
|
+
if (pathInfo.isDirectory) {
|
|
510
|
+
const isCycle = pathInfo.realPath ? hasAncestorRealPath(node, pathInfo.realPath) : false;
|
|
511
|
+
const dirNode: FileNode = {
|
|
512
|
+
name: entry.name,
|
|
513
|
+
path: fullPath,
|
|
514
|
+
isDirectory: true,
|
|
515
|
+
isSymlink: true,
|
|
516
|
+
realPath: pathInfo.realPath,
|
|
517
|
+
parent: node,
|
|
518
|
+
children: isCycle ? [] : undefined,
|
|
519
|
+
expanded: childDepth < 1,
|
|
520
|
+
hasChangedChildren: false,
|
|
521
|
+
};
|
|
522
|
+
dirs.push(dirNode);
|
|
523
|
+
browser.nodeByPath.set(fullPath, dirNode);
|
|
524
|
+
if (!isCycle && shouldAutoScan(childDepth)) {
|
|
525
|
+
enqueueScan(dirNode, childDepth);
|
|
526
|
+
}
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const symlinkFileNode: FileNode = {
|
|
531
|
+
name: entry.name,
|
|
532
|
+
path: fullPath,
|
|
533
|
+
isDirectory: false,
|
|
534
|
+
isSymlink: true,
|
|
535
|
+
parent: node,
|
|
536
|
+
agentModified: agentModifiedFiles.has(fullPath),
|
|
537
|
+
};
|
|
538
|
+
files.push(symlinkFileNode);
|
|
539
|
+
browser.nodeByPath.set(fullPath, symlinkFileNode);
|
|
540
|
+
queueLineCount(symlinkFileNode);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const fileNode: FileNode = {
|
|
545
|
+
name: entry.name,
|
|
546
|
+
path: fullPath,
|
|
547
|
+
isDirectory: false,
|
|
548
|
+
parent: node,
|
|
549
|
+
agentModified: agentModifiedFiles.has(fullPath),
|
|
550
|
+
};
|
|
551
|
+
files.push(fileNode);
|
|
552
|
+
browser.nodeByPath.set(fullPath, fileNode);
|
|
553
|
+
queueLineCount(fileNode);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
node.children = [...dirs, ...files];
|
|
557
|
+
} catch {
|
|
558
|
+
if (generation === rootGeneration) {
|
|
559
|
+
node.children = [];
|
|
560
|
+
reportError(`Unable to scan ${node === browser.root ? "directory" : node.name}`);
|
|
561
|
+
}
|
|
562
|
+
} finally {
|
|
563
|
+
if (generation === rootGeneration) {
|
|
564
|
+
node.loading = false;
|
|
565
|
+
scanQueued.delete(node.path);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function processScanBatch(): Promise<void> {
|
|
571
|
+
scanTimer = null;
|
|
572
|
+
if (!browser.root) return;
|
|
573
|
+
const generation = rootGeneration;
|
|
574
|
+
const batch = scanQueue.splice(0, getScanBatchSize());
|
|
575
|
+
if (batch.length === 0) {
|
|
576
|
+
browser.scanState.isScanning = false;
|
|
577
|
+
browser.scanState.pending = 0;
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
for (const item of batch) {
|
|
582
|
+
await scanDirectory(item.node, item.depth, generation);
|
|
583
|
+
if (generation !== rootGeneration) return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
browser.scanState.pending = scanQueue.length;
|
|
587
|
+
browser.scanState.isScanning = scanQueue.length > 0;
|
|
588
|
+
|
|
589
|
+
updateTreeStats(browser.root);
|
|
590
|
+
browser.stats = getTreeStats(browser.root);
|
|
591
|
+
refreshLists();
|
|
592
|
+
if (browser.focusFirstChildOf) {
|
|
593
|
+
const directory = browser.nodeByPath.get(browser.focusFirstChildOf);
|
|
594
|
+
if (directory && focusFirstChild(directory)) browser.focusFirstChildOf = null;
|
|
595
|
+
}
|
|
596
|
+
requestRender();
|
|
597
|
+
|
|
598
|
+
if (scanQueue.length > 0) {
|
|
599
|
+
scanTimer = setTimeout(processScanBatch, getScanDelay());
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function stopBackgroundTasks(): void {
|
|
604
|
+
if (lineCountTimer) {
|
|
605
|
+
clearTimeout(lineCountTimer);
|
|
606
|
+
lineCountTimer = null;
|
|
607
|
+
}
|
|
608
|
+
if (scanTimer) {
|
|
609
|
+
clearTimeout(scanTimer);
|
|
610
|
+
scanTimer = null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function applyAgentModified(): void {
|
|
615
|
+
for (const node of browser.nodeByPath.values()) {
|
|
616
|
+
if (!node.isDirectory) {
|
|
617
|
+
node.agentModified = agentModifiedFiles.has(node.path);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function applyGitUpdates(): void {
|
|
623
|
+
for (const node of browser.nodeByPath.values()) {
|
|
624
|
+
const relPath = normalizeGitPath(relative(rootPath, node.path));
|
|
625
|
+
node.gitStatus = gitStatus.get(relPath);
|
|
626
|
+
node.diffStats = diffStats.get(relPath);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function ensureNode(relPath: string): FileNode | null {
|
|
631
|
+
if (!browser.root) return null;
|
|
632
|
+
let normalized = relPath.trim();
|
|
633
|
+
if (!normalized) return null;
|
|
634
|
+
if (normalized.startsWith("./")) {
|
|
635
|
+
normalized = normalized.slice(2);
|
|
636
|
+
}
|
|
637
|
+
normalized = normalizeGitPath(normalized);
|
|
638
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
639
|
+
if (parts.length === 0) return null;
|
|
640
|
+
if (parts.length - 1 > MAX_TREE_DEPTH) return null;
|
|
641
|
+
|
|
642
|
+
let current = browser.root;
|
|
643
|
+
let currentRel = "";
|
|
644
|
+
|
|
645
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
646
|
+
const part = parts[i];
|
|
647
|
+
if (ignored.has(part)) return null;
|
|
648
|
+
currentRel = currentRel ? `${currentRel}/${part}` : part;
|
|
649
|
+
const dirPath = join(rootPath, currentRel);
|
|
650
|
+
let dirNode = browser.nodeByPath.get(dirPath);
|
|
651
|
+
if (!dirNode) {
|
|
652
|
+
const depth = i + 1;
|
|
653
|
+
dirNode = {
|
|
654
|
+
name: part,
|
|
655
|
+
path: dirPath,
|
|
656
|
+
isDirectory: true,
|
|
657
|
+
realPath: safeRealPathSync(dirPath),
|
|
658
|
+
parent: current,
|
|
659
|
+
children: [],
|
|
660
|
+
expanded: depth < 1,
|
|
661
|
+
hasChangedChildren: false,
|
|
662
|
+
};
|
|
663
|
+
current.children ??= [];
|
|
664
|
+
current.children.push(dirNode);
|
|
665
|
+
sortChildren(current);
|
|
666
|
+
browser.nodeByPath.set(dirPath, dirNode);
|
|
667
|
+
}
|
|
668
|
+
current = dirNode;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const fileName = parts[parts.length - 1];
|
|
672
|
+
if (ignored.has(fileName)) return null;
|
|
673
|
+
|
|
674
|
+
const filePath = join(rootPath, normalized);
|
|
675
|
+
const existing = browser.nodeByPath.get(filePath);
|
|
676
|
+
if (existing) return existing;
|
|
677
|
+
|
|
678
|
+
const pathInfo = getPathInfoSync(filePath);
|
|
679
|
+
if (pathInfo.isDirectory) {
|
|
680
|
+
const isCycle = pathInfo.realPath ? hasAncestorRealPath(current, pathInfo.realPath) : false;
|
|
681
|
+
const dirNode: FileNode = {
|
|
682
|
+
name: fileName,
|
|
683
|
+
path: filePath,
|
|
684
|
+
isDirectory: true,
|
|
685
|
+
isSymlink: pathInfo.isSymlink,
|
|
686
|
+
realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
|
|
687
|
+
parent: current,
|
|
688
|
+
children: pathInfo.isSymlink && !isCycle ? undefined : [],
|
|
689
|
+
expanded: false,
|
|
690
|
+
hasChangedChildren: false,
|
|
691
|
+
gitStatus: gitStatus.get(normalized),
|
|
692
|
+
diffStats: diffStats.get(normalized),
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
current.children ??= [];
|
|
696
|
+
current.children.push(dirNode);
|
|
697
|
+
sortChildren(current);
|
|
698
|
+
browser.nodeByPath.set(filePath, dirNode);
|
|
699
|
+
return dirNode;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const fileNode: FileNode = {
|
|
703
|
+
name: fileName,
|
|
704
|
+
path: filePath,
|
|
705
|
+
isDirectory: false,
|
|
706
|
+
isSymlink: pathInfo.isSymlink,
|
|
707
|
+
parent: current,
|
|
708
|
+
gitStatus: gitStatus.get(normalized),
|
|
709
|
+
agentModified: agentModifiedFiles.has(filePath),
|
|
710
|
+
diffStats: diffStats.get(normalized),
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
current.children ??= [];
|
|
714
|
+
current.children.push(fileNode);
|
|
715
|
+
sortChildren(current);
|
|
716
|
+
browser.nodeByPath.set(filePath, fileNode);
|
|
717
|
+
return fileNode;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function addUntrackedNodes(): void {
|
|
721
|
+
for (const [relPath, status] of gitStatus.entries()) {
|
|
722
|
+
if (!isUntrackedStatus(status)) continue;
|
|
723
|
+
const node = ensureNode(relPath);
|
|
724
|
+
if (node) {
|
|
725
|
+
node.gitStatus = status;
|
|
726
|
+
node.diffStats = diffStats.get(relPath);
|
|
727
|
+
if (!node.isDirectory) {
|
|
728
|
+
queueLineCount(node, true);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function refreshMetadata(): void {
|
|
735
|
+
if (!browser.root) return;
|
|
736
|
+
const previousDisplayList = getDisplayList();
|
|
737
|
+
const currentPath = previousDisplayList[browser.selectedIndex]?.node.path;
|
|
738
|
+
const viewingFile = viewer.getFile();
|
|
739
|
+
const viewingFilePath = viewingFile?.path;
|
|
740
|
+
|
|
741
|
+
if (repo) {
|
|
742
|
+
let gitMetadataFailed = false;
|
|
743
|
+
const reportRefreshGitError = (operation: string) => {
|
|
744
|
+
gitMetadataFailed = true;
|
|
745
|
+
reportGitError(operation);
|
|
746
|
+
};
|
|
747
|
+
gitStatus = getGitStatus(rootPath, {}, reportRefreshGitError);
|
|
748
|
+
diffStats = getGitDiffStats(rootPath, reportRefreshGitError);
|
|
749
|
+
if (!gitMetadataFailed && browser.errorMessage?.endsWith(" unavailable")) {
|
|
750
|
+
browser.errorMessage = null;
|
|
751
|
+
}
|
|
752
|
+
applyGitUpdates();
|
|
753
|
+
addUntrackedNodes();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
applyAgentModified();
|
|
757
|
+
updateTreeStats(browser.root);
|
|
758
|
+
browser.stats = getTreeStats(browser.root);
|
|
759
|
+
refreshLists();
|
|
760
|
+
|
|
761
|
+
const updatedDisplayList = getDisplayList();
|
|
762
|
+
if (currentPath) {
|
|
763
|
+
const newIdx = updatedDisplayList.findIndex(f => f.node.path === currentPath);
|
|
764
|
+
if (newIdx !== -1) {
|
|
765
|
+
browser.selectedIndex = newIdx;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
browser.selectedIndex = Math.min(browser.selectedIndex, Math.max(0, updatedDisplayList.length - 1));
|
|
770
|
+
|
|
771
|
+
if (viewingFilePath && browser.root) {
|
|
772
|
+
const newNode = browser.nodeByPath.get(viewingFilePath) ?? findNodeByPath(browser.root, viewingFilePath);
|
|
773
|
+
if (newNode) {
|
|
774
|
+
if (newNode.lineCount === undefined && viewingFile?.lineCount !== undefined) {
|
|
775
|
+
newNode.lineCount = viewingFile.lineCount;
|
|
776
|
+
}
|
|
777
|
+
viewer.updateFileRef(newNode);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function loadRoot(newRoot: string): void {
|
|
783
|
+
rootGeneration += 1;
|
|
784
|
+
rootPath = resolve(newRoot);
|
|
785
|
+
browser.errorMessage = null;
|
|
786
|
+
|
|
787
|
+
repo = isGitRepo(rootPath);
|
|
788
|
+
gitStatus = repo ? getGitStatus(rootPath, {}, reportGitError) : new Map<string, string>();
|
|
789
|
+
diffStats = repo ? getGitDiffStats(rootPath, reportGitError) : new Map<string, DiffStats>();
|
|
790
|
+
gitBranch = repo ? getGitBranch(rootPath) : "";
|
|
791
|
+
|
|
792
|
+
const newRootNode: FileNode = repo
|
|
793
|
+
? buildFileTreeFromPaths(rootPath, getGitFileList(rootPath, reportGitError), gitStatus, diffStats, ignored, agentModifiedFiles)
|
|
794
|
+
: {
|
|
795
|
+
name: ".",
|
|
796
|
+
path: rootPath,
|
|
797
|
+
isDirectory: true,
|
|
798
|
+
realPath: safeRealPathSync(rootPath),
|
|
799
|
+
children: undefined,
|
|
800
|
+
expanded: true,
|
|
801
|
+
hasChangedChildren: false,
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
browser.root = newRootNode;
|
|
805
|
+
|
|
806
|
+
const safeMode = !repo && shouldSafeMode(rootPath);
|
|
807
|
+
browser.scanState.mode = repo ? "none" : safeMode ? "safe" : "full";
|
|
808
|
+
browser.scanState.isScanning = false;
|
|
809
|
+
browser.scanState.isPartial = safeMode;
|
|
810
|
+
browser.scanState.pending = 0;
|
|
811
|
+
|
|
812
|
+
indexNodes(browser.root, browser.nodeByPath);
|
|
813
|
+
refreshLists();
|
|
814
|
+
browser.stats = getTreeStats(browser.root);
|
|
815
|
+
|
|
816
|
+
browser.selectedIndex = 0;
|
|
817
|
+
browser.searchQuery = "";
|
|
818
|
+
browser.searchMode = false;
|
|
819
|
+
browser.focusFirstChildOf = null;
|
|
820
|
+
textInput.reset();
|
|
821
|
+
browser.lastPollTime = Date.now();
|
|
822
|
+
|
|
823
|
+
if (repo) {
|
|
824
|
+
queueLineCountsForDirectory(browser.root);
|
|
825
|
+
} else if (browser.root) {
|
|
826
|
+
enqueueScan(browser.root, 0, true);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function setRoot(newRoot: string): void {
|
|
831
|
+
if (viewer.isOpen()) {
|
|
832
|
+
viewer.close();
|
|
833
|
+
}
|
|
834
|
+
stopBackgroundTasks();
|
|
835
|
+
scanQueue.length = 0;
|
|
836
|
+
scanQueued.clear();
|
|
837
|
+
lineCountQueue.length = 0;
|
|
838
|
+
lineCountPending.clear();
|
|
839
|
+
loadRoot(newRoot);
|
|
840
|
+
requestRender();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
loadRoot(initialRoot);
|
|
844
|
+
|
|
845
|
+
function getDisplayList(): FlatNode[] {
|
|
846
|
+
let list = browser.searchQuery ? browser.fullList : browser.flatList;
|
|
847
|
+
|
|
848
|
+
if (browser.showOnlyChanged) {
|
|
849
|
+
list = list.filter(f =>
|
|
850
|
+
f.node.gitStatus ||
|
|
851
|
+
f.node.agentModified ||
|
|
852
|
+
(f.node.isDirectory && f.node.hasChangedChildren)
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (browser.searchQuery) {
|
|
857
|
+
const q = browser.searchQuery.toLowerCase();
|
|
858
|
+
list = list.filter(f => f.node.name.toLowerCase().includes(q));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return list;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function navigateToChange(direction: 1 | -1): void {
|
|
865
|
+
if (!browser.root) return;
|
|
866
|
+
|
|
867
|
+
const changedFiles = collectChangedFiles(browser.root);
|
|
868
|
+
if (changedFiles.length === 0) return;
|
|
869
|
+
|
|
870
|
+
const displayList = getDisplayList();
|
|
871
|
+
const currentNode = displayList[browser.selectedIndex]?.node;
|
|
872
|
+
|
|
873
|
+
let currentIdx = -1;
|
|
874
|
+
if (currentNode && !currentNode.isDirectory) {
|
|
875
|
+
currentIdx = changedFiles.findIndex(c => c.file.path === currentNode.path);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
let nextIdx: number;
|
|
879
|
+
if (currentIdx === -1) {
|
|
880
|
+
nextIdx = direction === 1 ? 0 : changedFiles.length - 1;
|
|
881
|
+
} else {
|
|
882
|
+
nextIdx = currentIdx + direction;
|
|
883
|
+
if (nextIdx < 0) nextIdx = changedFiles.length - 1;
|
|
884
|
+
if (nextIdx >= changedFiles.length) nextIdx = 0;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const target = changedFiles[nextIdx];
|
|
888
|
+
|
|
889
|
+
const ancestorSet = new Set(target.ancestors);
|
|
890
|
+
collapseAllExcept(browser.root, ancestorSet);
|
|
891
|
+
|
|
892
|
+
for (const ancestor of target.ancestors) {
|
|
893
|
+
ancestor.expanded = true;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
browser.flatList = flattenTree(browser.root);
|
|
897
|
+
|
|
898
|
+
const newDisplayList = getDisplayList();
|
|
899
|
+
const targetIdx = newDisplayList.findIndex(f => f.node.path === target.file.path);
|
|
900
|
+
if (targetIdx !== -1) {
|
|
901
|
+
browser.selectedIndex = targetIdx;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function toggleDir(node: FileNode): void {
|
|
906
|
+
if (node.isDirectory) {
|
|
907
|
+
node.expanded = !node.expanded;
|
|
908
|
+
if (node.expanded && repo) queueLineCountsForDirectory(node);
|
|
909
|
+
if (node.expanded && node.children === undefined) {
|
|
910
|
+
enqueueScan(node, getNodeDepth(node, rootPath), true);
|
|
911
|
+
}
|
|
912
|
+
refreshLists();
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function expandChangedDirectories(node: FileNode): void {
|
|
917
|
+
if (!node.isDirectory || !node.hasChangedChildren) return;
|
|
918
|
+
if (!node.expanded) {
|
|
919
|
+
node.expanded = true;
|
|
920
|
+
browser.expandedForChangedView.add(node.path);
|
|
921
|
+
}
|
|
922
|
+
for (const child of node.children ?? []) {
|
|
923
|
+
expandChangedDirectories(child);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function restoreExpandedDirectories(node: FileNode): void {
|
|
928
|
+
if (browser.expandedForChangedView.delete(node.path)) node.expanded = false;
|
|
929
|
+
for (const child of node.children ?? []) {
|
|
930
|
+
restoreExpandedDirectories(child);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function disableExpandedChangedView(): void {
|
|
935
|
+
if (!browser.expandedChangedView) return;
|
|
936
|
+
if (browser.root) restoreExpandedDirectories(browser.root);
|
|
937
|
+
browser.expandedChangedView = false;
|
|
938
|
+
refreshLists();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function toggleExpandedChangedView(): void {
|
|
942
|
+
if (browser.expandedChangedView) {
|
|
943
|
+
disableExpandedChangedView();
|
|
944
|
+
browser.showOnlyChanged = false;
|
|
945
|
+
browser.selectedIndex = 0;
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (!browser.root) return;
|
|
949
|
+
updateTreeStats(browser.root);
|
|
950
|
+
expandChangedDirectories(browser.root);
|
|
951
|
+
browser.expandedChangedView = true;
|
|
952
|
+
browser.showOnlyChanged = true;
|
|
953
|
+
browser.selectedIndex = 0;
|
|
954
|
+
refreshLists();
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function openFile(node: FileNode): void {
|
|
958
|
+
viewer.setFile(node);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function renderBrowser(width: number): string[] {
|
|
962
|
+
const lines: string[] = [];
|
|
963
|
+
const pathDisplay = formatRootPath(rootPath);
|
|
964
|
+
const branchDisplay = gitBranch ? theme.fg("accent", ` (${gitBranch})`) : "";
|
|
965
|
+
const stats = browser.stats;
|
|
966
|
+
|
|
967
|
+
let statsDisplay = "";
|
|
968
|
+
if (stats.totalLines !== undefined) {
|
|
969
|
+
statsDisplay += theme.fg("dim", ` ${stats.totalLines}L`);
|
|
970
|
+
}
|
|
971
|
+
if (stats.additions > 0) statsDisplay += theme.fg("success", ` +${stats.additions}`);
|
|
972
|
+
if (stats.deletions > 0) statsDisplay += theme.fg("error", ` -${stats.deletions}`);
|
|
973
|
+
|
|
974
|
+
const hasActivity = browser.scanState.isScanning || lineCountPending.size > 0;
|
|
975
|
+
if (hasActivity) {
|
|
976
|
+
browser.scanState.spinnerIndex = (browser.scanState.spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
977
|
+
}
|
|
978
|
+
const spinner = SPINNER_FRAMES[browser.scanState.spinnerIndex];
|
|
979
|
+
const activityParts: string[] = [];
|
|
980
|
+
if (browser.scanState.isScanning) activityParts.push(`${spinner} scanning`);
|
|
981
|
+
if (lineCountPending.size > 0) activityParts.push(`${spinner} counts`);
|
|
982
|
+
const activityIndicator = activityParts.length > 0 ? theme.fg("dim", ` ${activityParts.join(" ")}`) : "";
|
|
983
|
+
const partialIndicator = browser.scanState.isPartial ? theme.fg("warning", " [partial]") : "";
|
|
984
|
+
const errorIndicator = browser.errorMessage ? theme.fg("error", ` [${browser.errorMessage}]`) : "";
|
|
985
|
+
|
|
986
|
+
const searchIndicator = browser.searchMode
|
|
987
|
+
? theme.fg("accent", ` /${browser.searchQuery}█`)
|
|
988
|
+
: "";
|
|
989
|
+
|
|
990
|
+
lines.push(
|
|
991
|
+
truncateToWidth(theme.bold(pathDisplay) + branchDisplay + statsDisplay + activityIndicator + partialIndicator + errorIndicator + searchIndicator, width)
|
|
992
|
+
);
|
|
993
|
+
lines.push(theme.fg("borderMuted", "─".repeat(width)));
|
|
994
|
+
|
|
995
|
+
const displayList = getDisplayList();
|
|
996
|
+
if (displayList.length === 0) {
|
|
997
|
+
const emptyLabel = browser.scanState.isScanning
|
|
998
|
+
? " (loading...)"
|
|
999
|
+
: " (no files" + (browser.searchQuery ? " matching '" + browser.searchQuery + "'" : "") + ")";
|
|
1000
|
+
lines.push(theme.fg("dim", emptyLabel));
|
|
1001
|
+
for (let i = 1; i < browser.browserHeight; i++) {
|
|
1002
|
+
lines.push("");
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
const start = Math.max(
|
|
1006
|
+
0,
|
|
1007
|
+
Math.min(browser.selectedIndex - Math.floor(browser.browserHeight / 2), displayList.length - browser.browserHeight)
|
|
1008
|
+
);
|
|
1009
|
+
const end = Math.min(displayList.length, start + browser.browserHeight);
|
|
1010
|
+
|
|
1011
|
+
for (let i = start; i < end; i++) {
|
|
1012
|
+
const { node, depth } = displayList[i];
|
|
1013
|
+
const isSelected = i === browser.selectedIndex;
|
|
1014
|
+
const indent = " ".repeat(depth);
|
|
1015
|
+
const icon = node.isDirectory
|
|
1016
|
+
? (node.expanded ? "▾ " : "▸ ")
|
|
1017
|
+
: " ";
|
|
1018
|
+
|
|
1019
|
+
const status = formatNodeStatus(node, theme);
|
|
1020
|
+
const meta = formatNodeMeta(node, theme);
|
|
1021
|
+
const name = formatNodeName(node, theme);
|
|
1022
|
+
|
|
1023
|
+
let line = `${indent}${icon}${name}${status}${meta}`;
|
|
1024
|
+
line = truncateToWidth(line, width);
|
|
1025
|
+
|
|
1026
|
+
if (isSelected) {
|
|
1027
|
+
line = theme.bg("selectedBg", line);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
lines.push(line);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const renderedCount = end - start;
|
|
1034
|
+
for (let i = renderedCount; i < browser.browserHeight; i++) {
|
|
1035
|
+
lines.push("");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const pct = displayList.length > 1
|
|
1039
|
+
? Math.round((browser.selectedIndex / (displayList.length - 1)) * 100)
|
|
1040
|
+
: 100;
|
|
1041
|
+
lines.push(theme.fg("dim", ` ${browser.selectedIndex + 1}/${displayList.length} (${pct}%)`));
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
lines.push(theme.fg("borderMuted", "─".repeat(width)));
|
|
1045
|
+
const changedIndicator = browser.showOnlyChanged ? theme.fg("warning", " [changed only]") : "";
|
|
1046
|
+
const help = browser.searchMode
|
|
1047
|
+
? theme.fg("dim", "Type to search ↑↓: nav Enter: confirm Esc: cancel")
|
|
1048
|
+
: theme.fg("dim", "j/k: nav u: up .: home c/C: toggle changed / expanded changed []: next/prev change /: search q: close") + changedIndicator;
|
|
1049
|
+
lines.push(truncateToWidth(help, width));
|
|
1050
|
+
|
|
1051
|
+
return lines;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function handleViewerInput(data: string): void {
|
|
1055
|
+
const action: ViewerAction = viewer.handleInput(data);
|
|
1056
|
+
if (action.type === "close") {
|
|
1057
|
+
viewer.close();
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (action.type === "navigate") {
|
|
1061
|
+
viewer.close();
|
|
1062
|
+
navigateToChange(action.direction);
|
|
1063
|
+
const displayList = getDisplayList();
|
|
1064
|
+
const item = displayList[browser.selectedIndex];
|
|
1065
|
+
if (item && !item.node.isDirectory) {
|
|
1066
|
+
openFile(item.node);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function handleBrowserInput(data: string): void {
|
|
1072
|
+
const displayList = getDisplayList();
|
|
1073
|
+
const maxIndex = Math.max(0, displayList.length - 1);
|
|
1074
|
+
|
|
1075
|
+
if (matchesKey(data, "q") && !browser.searchMode) {
|
|
1076
|
+
textInput.reset();
|
|
1077
|
+
stopBackgroundTasks();
|
|
1078
|
+
onClose();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (matchesKey(data, Key.escape)) {
|
|
1082
|
+
if (browser.searchMode) {
|
|
1083
|
+
browser.searchMode = false;
|
|
1084
|
+
browser.searchQuery = "";
|
|
1085
|
+
textInput.reset();
|
|
1086
|
+
} else {
|
|
1087
|
+
textInput.reset();
|
|
1088
|
+
stopBackgroundTasks();
|
|
1089
|
+
onClose();
|
|
1090
|
+
}
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (matchesKey(data, "/") && !browser.searchMode) {
|
|
1094
|
+
browser.searchMode = true;
|
|
1095
|
+
browser.searchQuery = "";
|
|
1096
|
+
textInput.reset();
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (browser.searchMode) {
|
|
1100
|
+
if (matchesKey(data, Key.enter)) {
|
|
1101
|
+
browser.searchMode = false;
|
|
1102
|
+
browser.selectedIndex = 0;
|
|
1103
|
+
textInput.reset();
|
|
1104
|
+
} else if (matchesKey(data, Key.backspace)) {
|
|
1105
|
+
browser.searchQuery = browser.searchQuery.slice(0, -1);
|
|
1106
|
+
browser.selectedIndex = 0;
|
|
1107
|
+
} else if (matchesKey(data, Key.down)) {
|
|
1108
|
+
browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
|
|
1109
|
+
} else if (matchesKey(data, Key.up)) {
|
|
1110
|
+
browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
|
|
1111
|
+
} else {
|
|
1112
|
+
const text = textInput.push(data);
|
|
1113
|
+
if (text) {
|
|
1114
|
+
browser.searchQuery += text;
|
|
1115
|
+
browser.selectedIndex = 0;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
if (matchesKey(data, "u")) {
|
|
1121
|
+
const parent = resolve(rootPath, "..");
|
|
1122
|
+
if (parent !== rootPath) {
|
|
1123
|
+
setRoot(parent);
|
|
1124
|
+
}
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (matchesKey(data, ".")) {
|
|
1128
|
+
if (rootPath !== initialRoot) {
|
|
1129
|
+
setRoot(initialRoot);
|
|
1130
|
+
}
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
|
|
1134
|
+
browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
|
|
1138
|
+
browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (matchesKey(data, Key.enter)) {
|
|
1142
|
+
const item = displayList[browser.selectedIndex];
|
|
1143
|
+
if (item) {
|
|
1144
|
+
if (item.node.isDirectory) {
|
|
1145
|
+
toggleDir(item.node);
|
|
1146
|
+
} else {
|
|
1147
|
+
openFile(item.node);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if (matchesKey(data, "l") || matchesKey(data, Key.right)) {
|
|
1153
|
+
const item = displayList[browser.selectedIndex];
|
|
1154
|
+
if (item?.node.isDirectory && !item.node.expanded) {
|
|
1155
|
+
toggleDir(item.node);
|
|
1156
|
+
if (!focusFirstChild(item.node)) browser.focusFirstChildOf = item.node.path;
|
|
1157
|
+
} else if (item && !item.node.isDirectory) {
|
|
1158
|
+
openFile(item.node);
|
|
1159
|
+
}
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (matchesKey(data, "h") || matchesKey(data, Key.left)) {
|
|
1163
|
+
const item = displayList[browser.selectedIndex];
|
|
1164
|
+
if (item?.node.isDirectory && item.node.expanded) {
|
|
1165
|
+
toggleDir(item.node);
|
|
1166
|
+
} else {
|
|
1167
|
+
const parent = item?.node.parent;
|
|
1168
|
+
if (parent && parent !== browser.root && parent.expanded) {
|
|
1169
|
+
parent.expanded = false;
|
|
1170
|
+
refreshLists();
|
|
1171
|
+
const parentIndex = getDisplayList().findIndex(entry => entry.node.path === parent.path);
|
|
1172
|
+
if (parentIndex !== -1) browser.selectedIndex = parentIndex;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
1178
|
+
browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + browser.browserHeight);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
1182
|
+
browser.selectedIndex = Math.max(0, browser.selectedIndex - browser.browserHeight);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (matchesKey(data, "+") || matchesKey(data, "=")) {
|
|
1186
|
+
const maximumHeight = getResponsivePanelHeight(MAX_BROWSER_HEIGHT, MAX_BROWSER_HEIGHT, 9, process.stdout.rows, OVERLAY_MAX_HEIGHT_RATIO);
|
|
1187
|
+
browser.browserHeight = Math.min(maximumHeight, browser.browserHeight + 5);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (matchesKey(data, "-") || matchesKey(data, "_")) {
|
|
1191
|
+
browser.browserHeight = Math.max(MIN_PANEL_HEIGHT, browser.browserHeight - 5);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (matchesKey(data, "shift+c")) {
|
|
1195
|
+
toggleExpandedChangedView();
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
if (matchesKey(data, "c")) {
|
|
1199
|
+
if (browser.showOnlyChanged) {
|
|
1200
|
+
const wasExpanded = browser.expandedChangedView;
|
|
1201
|
+
disableExpandedChangedView();
|
|
1202
|
+
browser.showOnlyChanged = wasExpanded;
|
|
1203
|
+
} else {
|
|
1204
|
+
browser.showOnlyChanged = true;
|
|
1205
|
+
}
|
|
1206
|
+
browser.selectedIndex = 0;
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (matchesKey(data, "]")) {
|
|
1210
|
+
navigateToChange(1);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (matchesKey(data, "[")) {
|
|
1214
|
+
navigateToChange(-1);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
return {
|
|
1220
|
+
render(width: number): string[] {
|
|
1221
|
+
const now = Date.now();
|
|
1222
|
+
if (repo && now - browser.lastPollTime > POLL_INTERVAL_MS) {
|
|
1223
|
+
browser.lastPollTime = now;
|
|
1224
|
+
refreshMetadata();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (viewer.isOpen()) {
|
|
1228
|
+
return viewer.render(width);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
return renderBrowser(width);
|
|
1232
|
+
},
|
|
1233
|
+
|
|
1234
|
+
handleInput(data: string): void {
|
|
1235
|
+
if (viewer.isOpen()) {
|
|
1236
|
+
handleViewerInput(data);
|
|
1237
|
+
} else {
|
|
1238
|
+
handleBrowserInput(data);
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
1241
|
+
|
|
1242
|
+
invalidate(): void {},
|
|
1243
|
+
};
|
|
1244
|
+
}
|