diffstalker 0.1.7 → 0.2.1

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.
Files changed (74) hide show
  1. package/.github/workflows/release.yml +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +89 -306
  4. package/dist/App.js +895 -520
  5. package/dist/FollowMode.js +85 -0
  6. package/dist/KeyBindings.js +178 -0
  7. package/dist/MouseHandlers.js +156 -0
  8. package/dist/core/ExplorerStateManager.js +632 -0
  9. package/dist/core/FilePathWatcher.js +133 -0
  10. package/dist/core/GitStateManager.js +221 -86
  11. package/dist/git/diff.js +4 -0
  12. package/dist/git/ignoreUtils.js +30 -0
  13. package/dist/git/status.js +2 -34
  14. package/dist/index.js +68 -53
  15. package/dist/ipc/CommandClient.js +165 -0
  16. package/dist/ipc/CommandServer.js +152 -0
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +195 -0
  19. package/dist/types/tabs.js +4 -0
  20. package/dist/ui/Layout.js +252 -0
  21. package/dist/ui/PaneRenderers.js +56 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/FileFinder.js +232 -0
  25. package/dist/ui/modals/HotkeysModal.js +209 -0
  26. package/dist/ui/modals/ThemePicker.js +107 -0
  27. package/dist/ui/widgets/CommitPanel.js +58 -0
  28. package/dist/ui/widgets/CompareListView.js +238 -0
  29. package/dist/ui/widgets/DiffView.js +281 -0
  30. package/dist/ui/widgets/ExplorerContent.js +89 -0
  31. package/dist/ui/widgets/ExplorerView.js +204 -0
  32. package/dist/ui/widgets/FileList.js +185 -0
  33. package/dist/ui/widgets/Footer.js +50 -0
  34. package/dist/ui/widgets/Header.js +68 -0
  35. package/dist/ui/widgets/HistoryView.js +69 -0
  36. package/dist/utils/displayRows.js +185 -6
  37. package/dist/utils/explorerDisplayRows.js +1 -1
  38. package/dist/utils/fileCategories.js +37 -0
  39. package/dist/utils/fileTree.js +148 -0
  40. package/dist/utils/languageDetection.js +56 -0
  41. package/dist/utils/pathUtils.js +27 -0
  42. package/dist/utils/wordDiff.js +50 -0
  43. package/eslint.metrics.js +16 -0
  44. package/metrics/.gitkeep +0 -0
  45. package/metrics/v0.2.1.json +268 -0
  46. package/package.json +14 -12
  47. package/dist/components/BaseBranchPicker.js +0 -60
  48. package/dist/components/BottomPane.js +0 -101
  49. package/dist/components/CommitPanel.js +0 -58
  50. package/dist/components/CompareListView.js +0 -110
  51. package/dist/components/ExplorerContentView.js +0 -80
  52. package/dist/components/ExplorerView.js +0 -37
  53. package/dist/components/FileList.js +0 -131
  54. package/dist/components/Footer.js +0 -6
  55. package/dist/components/Header.js +0 -107
  56. package/dist/components/HistoryView.js +0 -21
  57. package/dist/components/HotkeysModal.js +0 -108
  58. package/dist/components/Modal.js +0 -19
  59. package/dist/components/ScrollableList.js +0 -125
  60. package/dist/components/ThemePicker.js +0 -42
  61. package/dist/components/TopPane.js +0 -14
  62. package/dist/components/UnifiedDiffView.js +0 -115
  63. package/dist/hooks/useCommitFlow.js +0 -66
  64. package/dist/hooks/useCompareState.js +0 -123
  65. package/dist/hooks/useExplorerState.js +0 -248
  66. package/dist/hooks/useGit.js +0 -156
  67. package/dist/hooks/useHistoryState.js +0 -62
  68. package/dist/hooks/useKeymap.js +0 -167
  69. package/dist/hooks/useLayout.js +0 -154
  70. package/dist/hooks/useMouse.js +0 -87
  71. package/dist/hooks/useTerminalSize.js +0 -20
  72. package/dist/hooks/useWatcher.js +0 -137
  73. package/dist/utils/mouseCoordinates.js +0 -165
  74. package/dist/utils/rowCalculations.js +0 -209
@@ -0,0 +1,632 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { EventEmitter } from 'node:events';
4
+ import { getIgnoredFiles } from '../git/ignoreUtils.js';
5
+ const MAX_FILE_SIZE = 1024 * 1024; // 1MB
6
+ const WARN_FILE_SIZE = 100 * 1024; // 100KB
7
+ /**
8
+ * Check if content appears to be binary.
9
+ */
10
+ function isBinaryContent(buffer) {
11
+ // Check first 8KB for null bytes (common in binary files)
12
+ const checkLength = Math.min(buffer.length, 8192);
13
+ for (let i = 0; i < checkLength; i++) {
14
+ if (buffer[i] === 0)
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ /**
20
+ * ExplorerStateManager manages file explorer state independent of React.
21
+ * It handles directory loading, file selection, and navigation with tree view support.
22
+ */
23
+ export class ExplorerStateManager extends EventEmitter {
24
+ repoPath;
25
+ options;
26
+ expandedPaths = new Set();
27
+ gitStatusMap = { files: new Map(), directories: new Set() };
28
+ _state = {
29
+ currentPath: '',
30
+ tree: null,
31
+ displayRows: [],
32
+ selectedIndex: 0,
33
+ selectedFile: null,
34
+ isLoading: false,
35
+ error: null,
36
+ };
37
+ constructor(repoPath, options) {
38
+ super();
39
+ this.repoPath = repoPath;
40
+ this.options = {
41
+ hideHidden: options.hideHidden ?? true,
42
+ hideGitignored: options.hideGitignored ?? true,
43
+ showOnlyChanges: options.showOnlyChanges ?? false,
44
+ };
45
+ // Expand root by default
46
+ this.expandedPaths.add('');
47
+ }
48
+ get state() {
49
+ return this._state;
50
+ }
51
+ updateState(partial) {
52
+ this._state = { ...this._state, ...partial };
53
+ this.emit('state-change', this._state);
54
+ }
55
+ /**
56
+ * Set filtering options and reload tree.
57
+ */
58
+ async setOptions(options) {
59
+ this.options = { ...this.options, ...options };
60
+ await this.loadTree();
61
+ }
62
+ /**
63
+ * Update git status map and refresh display.
64
+ */
65
+ setGitStatus(statusMap) {
66
+ this.gitStatusMap = statusMap;
67
+ // Refresh display to show updated status
68
+ if (this._state.tree) {
69
+ this.applyGitStatusToTree(this._state.tree);
70
+ this.refreshDisplayRows();
71
+ }
72
+ }
73
+ /**
74
+ * Toggle showOnlyChanges filter.
75
+ */
76
+ async toggleShowOnlyChanges() {
77
+ this.options.showOnlyChanges = !this.options.showOnlyChanges;
78
+ this.refreshDisplayRows();
79
+ }
80
+ /**
81
+ * Check if showOnlyChanges is enabled.
82
+ */
83
+ get showOnlyChanges() {
84
+ return this.options.showOnlyChanges;
85
+ }
86
+ /**
87
+ * Load the full tree structure.
88
+ */
89
+ async loadTree() {
90
+ this.updateState({ isLoading: true, error: null });
91
+ try {
92
+ const tree = await this.buildTreeNode('', 0);
93
+ if (tree) {
94
+ tree.expanded = true; // Root is always expanded
95
+ this.applyGitStatusToTree(tree);
96
+ const displayRows = this.flattenTree(tree);
97
+ this.updateState({
98
+ tree,
99
+ displayRows,
100
+ selectedIndex: 0,
101
+ selectedFile: null,
102
+ isLoading: false,
103
+ });
104
+ }
105
+ else {
106
+ this.updateState({
107
+ tree: null,
108
+ displayRows: [],
109
+ isLoading: false,
110
+ error: 'Failed to load directory',
111
+ });
112
+ }
113
+ }
114
+ catch (err) {
115
+ this.updateState({
116
+ error: err instanceof Error ? err.message : 'Failed to read directory',
117
+ tree: null,
118
+ displayRows: [],
119
+ isLoading: false,
120
+ });
121
+ }
122
+ }
123
+ /**
124
+ * Build a tree node for a directory path.
125
+ */
126
+ async buildTreeNode(relativePath, depth) {
127
+ try {
128
+ const fullPath = path.join(this.repoPath, relativePath);
129
+ const stats = await fs.promises.stat(fullPath);
130
+ if (!stats.isDirectory()) {
131
+ // It's a file
132
+ return {
133
+ name: path.basename(relativePath) || this.getRepoName(),
134
+ path: relativePath,
135
+ isDirectory: false,
136
+ expanded: false,
137
+ children: [],
138
+ childrenLoaded: true,
139
+ };
140
+ }
141
+ const isExpanded = this.expandedPaths.has(relativePath);
142
+ const node = {
143
+ name: path.basename(relativePath) || this.getRepoName(),
144
+ path: relativePath,
145
+ isDirectory: true,
146
+ expanded: isExpanded,
147
+ children: [],
148
+ childrenLoaded: false,
149
+ };
150
+ // Always load children for root, or if expanded
151
+ if (relativePath === '' || isExpanded) {
152
+ await this.loadChildrenForNode(node);
153
+ }
154
+ return node;
155
+ }
156
+ catch (err) {
157
+ return null;
158
+ }
159
+ }
160
+ /**
161
+ * Load children for a directory node.
162
+ */
163
+ async loadChildrenForNode(node) {
164
+ if (node.childrenLoaded)
165
+ return;
166
+ try {
167
+ const fullPath = path.join(this.repoPath, node.path);
168
+ const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
169
+ // Build list of paths for gitignore check
170
+ const pathsToCheck = entries.map((e) => (node.path ? path.join(node.path, e.name) : e.name));
171
+ // Get ignored files
172
+ const ignoredFiles = this.options.hideGitignored
173
+ ? await getIgnoredFiles(this.repoPath, pathsToCheck)
174
+ : new Set();
175
+ const children = [];
176
+ for (const entry of entries) {
177
+ // Filter dot-prefixed hidden files
178
+ if (this.options.hideHidden && entry.name.startsWith('.')) {
179
+ continue;
180
+ }
181
+ const entryPath = node.path ? path.join(node.path, entry.name) : entry.name;
182
+ // Filter gitignored files
183
+ if (this.options.hideGitignored && ignoredFiles.has(entryPath)) {
184
+ continue;
185
+ }
186
+ const isDir = entry.isDirectory();
187
+ const isExpanded = this.expandedPaths.has(entryPath);
188
+ const childNode = {
189
+ name: entry.name,
190
+ path: entryPath,
191
+ isDirectory: isDir,
192
+ expanded: isExpanded,
193
+ children: [],
194
+ childrenLoaded: !isDir,
195
+ };
196
+ // Recursively load if expanded
197
+ if (isDir && isExpanded) {
198
+ await this.loadChildrenForNode(childNode);
199
+ }
200
+ children.push(childNode);
201
+ }
202
+ // Sort: directories first, then alphabetically
203
+ children.sort((a, b) => {
204
+ if (a.isDirectory && !b.isDirectory)
205
+ return -1;
206
+ if (!a.isDirectory && b.isDirectory)
207
+ return 1;
208
+ return a.name.localeCompare(b.name);
209
+ });
210
+ // Collapse single-child directory chains
211
+ this.collapseNode(node, children);
212
+ node.childrenLoaded = true;
213
+ }
214
+ catch (err) {
215
+ node.childrenLoaded = true;
216
+ node.children = [];
217
+ }
218
+ }
219
+ /**
220
+ * Collapse single-child directory chains.
221
+ * e.g., a -> b -> c -> file becomes "a/b/c" -> file
222
+ */
223
+ collapseNode(parent, children) {
224
+ for (const child of children) {
225
+ if (child.isDirectory && child.childrenLoaded) {
226
+ // Collapse if: single child that is also a directory
227
+ while (child.children.length === 1 &&
228
+ child.children[0].isDirectory &&
229
+ child.children[0].childrenLoaded) {
230
+ const grandchild = child.children[0];
231
+ child.name = `${child.name}/${grandchild.name}`;
232
+ child.path = grandchild.path;
233
+ child.children = grandchild.children;
234
+ // Inherit expanded state from the collapsed path
235
+ child.expanded = this.expandedPaths.has(child.path);
236
+ }
237
+ }
238
+ }
239
+ parent.children = children;
240
+ }
241
+ /**
242
+ * Apply git status to tree nodes.
243
+ */
244
+ applyGitStatusToTree(node) {
245
+ if (!node.isDirectory) {
246
+ const status = this.gitStatusMap.files.get(node.path);
247
+ if (status) {
248
+ node.gitStatus = status.status;
249
+ }
250
+ else {
251
+ node.gitStatus = undefined;
252
+ }
253
+ }
254
+ else {
255
+ // Check if directory contains any changed files
256
+ node.hasChangedChildren = this.gitStatusMap.directories.has(node.path);
257
+ for (const child of node.children) {
258
+ this.applyGitStatusToTree(child);
259
+ }
260
+ }
261
+ }
262
+ /**
263
+ * Flatten tree into display rows.
264
+ */
265
+ flattenTree(root) {
266
+ const rows = [];
267
+ const traverse = (node, depth, parentIsLast) => {
268
+ // Skip root node in display (but process its children)
269
+ if (depth === 0) {
270
+ for (let i = 0; i < node.children.length; i++) {
271
+ const child = node.children[i];
272
+ const isLast = i === node.children.length - 1;
273
+ // Apply filter if showOnlyChanges is enabled
274
+ if (this.options.showOnlyChanges) {
275
+ if (child.isDirectory && !child.hasChangedChildren)
276
+ continue;
277
+ if (!child.isDirectory && !child.gitStatus)
278
+ continue;
279
+ }
280
+ rows.push({
281
+ node: child,
282
+ depth: 0,
283
+ isLast,
284
+ parentIsLast: [],
285
+ });
286
+ if (child.isDirectory && child.expanded) {
287
+ traverse(child, 1, [isLast]);
288
+ }
289
+ }
290
+ return;
291
+ }
292
+ for (let i = 0; i < node.children.length; i++) {
293
+ const child = node.children[i];
294
+ const isLast = i === node.children.length - 1;
295
+ // Apply filter if showOnlyChanges is enabled
296
+ if (this.options.showOnlyChanges) {
297
+ if (child.isDirectory && !child.hasChangedChildren)
298
+ continue;
299
+ if (!child.isDirectory && !child.gitStatus)
300
+ continue;
301
+ }
302
+ rows.push({
303
+ node: child,
304
+ depth,
305
+ isLast,
306
+ parentIsLast: [...parentIsLast],
307
+ });
308
+ if (child.isDirectory && child.expanded) {
309
+ traverse(child, depth + 1, [...parentIsLast, isLast]);
310
+ }
311
+ }
312
+ };
313
+ traverse(root, 0, []);
314
+ return rows;
315
+ }
316
+ /**
317
+ * Refresh display rows without reloading tree.
318
+ * Maintains selection by path, not by index.
319
+ */
320
+ refreshDisplayRows() {
321
+ if (!this._state.tree)
322
+ return;
323
+ // Remember the currently selected path
324
+ const currentSelectedPath = this._state.displayRows[this._state.selectedIndex]?.node.path ?? null;
325
+ const displayRows = this.flattenTree(this._state.tree);
326
+ // Find the same path in the new rows
327
+ let selectedIndex = 0;
328
+ if (currentSelectedPath !== null) {
329
+ const foundIndex = displayRows.findIndex((row) => row.node.path === currentSelectedPath);
330
+ if (foundIndex >= 0) {
331
+ selectedIndex = foundIndex;
332
+ }
333
+ }
334
+ // Clamp to valid range
335
+ selectedIndex = Math.min(selectedIndex, Math.max(0, displayRows.length - 1));
336
+ this.updateState({ displayRows, selectedIndex });
337
+ }
338
+ /**
339
+ * Get repo name from path.
340
+ */
341
+ getRepoName() {
342
+ return path.basename(this.repoPath) || 'repo';
343
+ }
344
+ /**
345
+ * Load a directory's contents (legacy method, now wraps loadTree).
346
+ */
347
+ async loadDirectory(relativePath) {
348
+ this._state.currentPath = relativePath;
349
+ await this.loadTree();
350
+ }
351
+ /**
352
+ * Load a file's contents.
353
+ */
354
+ async loadFile(itemPath) {
355
+ try {
356
+ const fullPath = path.join(this.repoPath, itemPath);
357
+ const stats = await fs.promises.stat(fullPath);
358
+ // Check file size
359
+ if (stats.size > MAX_FILE_SIZE) {
360
+ this.updateState({
361
+ selectedFile: {
362
+ path: itemPath,
363
+ content: `File too large to display (${(stats.size / 1024 / 1024).toFixed(2)} MB).\nMaximum size: 1 MB`,
364
+ truncated: true,
365
+ },
366
+ });
367
+ return;
368
+ }
369
+ const buffer = await fs.promises.readFile(fullPath);
370
+ // Check if binary
371
+ if (isBinaryContent(buffer)) {
372
+ this.updateState({
373
+ selectedFile: {
374
+ path: itemPath,
375
+ content: 'Binary file - cannot display',
376
+ },
377
+ });
378
+ return;
379
+ }
380
+ let content = buffer.toString('utf-8');
381
+ let truncated = false;
382
+ // Warn about large files
383
+ if (stats.size > WARN_FILE_SIZE) {
384
+ const warning = `Warning: Large file (${(stats.size / 1024).toFixed(1)} KB)\n\n`;
385
+ content = warning + content;
386
+ }
387
+ // Truncate if needed
388
+ const maxLines = 5000;
389
+ const lines = content.split('\n');
390
+ if (lines.length > maxLines) {
391
+ content =
392
+ lines.slice(0, maxLines).join('\n') +
393
+ `\n\n... (truncated, ${lines.length - maxLines} more lines)`;
394
+ truncated = true;
395
+ }
396
+ this.updateState({
397
+ selectedFile: {
398
+ path: itemPath,
399
+ content,
400
+ truncated,
401
+ },
402
+ });
403
+ }
404
+ catch (err) {
405
+ this.updateState({
406
+ selectedFile: {
407
+ path: itemPath,
408
+ content: err instanceof Error ? `Error: ${err.message}` : 'Failed to read file',
409
+ },
410
+ });
411
+ }
412
+ }
413
+ /**
414
+ * Select an item by index.
415
+ */
416
+ async selectIndex(index) {
417
+ const rows = this._state.displayRows;
418
+ if (index < 0 || index >= rows.length)
419
+ return;
420
+ const row = rows[index];
421
+ this.updateState({ selectedIndex: index });
422
+ if (row && !row.node.isDirectory) {
423
+ await this.loadFile(row.node.path);
424
+ }
425
+ else {
426
+ this.updateState({ selectedFile: null });
427
+ }
428
+ }
429
+ /**
430
+ * Navigate to previous item.
431
+ */
432
+ navigateUp(currentScrollOffset) {
433
+ const newIndex = Math.max(0, this._state.selectedIndex - 1);
434
+ if (newIndex === this._state.selectedIndex)
435
+ return null;
436
+ this.selectIndex(newIndex);
437
+ if (newIndex < currentScrollOffset) {
438
+ return newIndex;
439
+ }
440
+ return null;
441
+ }
442
+ /**
443
+ * Navigate to next item.
444
+ */
445
+ navigateDown(currentScrollOffset, visibleHeight) {
446
+ const newIndex = Math.min(this._state.displayRows.length - 1, this._state.selectedIndex + 1);
447
+ if (newIndex === this._state.selectedIndex)
448
+ return null;
449
+ this.selectIndex(newIndex);
450
+ const needsScrolling = this._state.displayRows.length > visibleHeight;
451
+ const availableHeight = needsScrolling ? visibleHeight - 2 : visibleHeight;
452
+ const visibleEnd = currentScrollOffset + availableHeight;
453
+ if (newIndex >= visibleEnd) {
454
+ return currentScrollOffset + 1;
455
+ }
456
+ return null;
457
+ }
458
+ /**
459
+ * Toggle expand/collapse for selected directory, or go to parent if ".." would be selected.
460
+ */
461
+ async toggleExpand() {
462
+ const rows = this._state.displayRows;
463
+ const index = this._state.selectedIndex;
464
+ if (index < 0 || index >= rows.length)
465
+ return;
466
+ const row = rows[index];
467
+ if (!row.node.isDirectory)
468
+ return;
469
+ const node = row.node;
470
+ if (node.expanded) {
471
+ // Collapse
472
+ this.expandedPaths.delete(node.path);
473
+ node.expanded = false;
474
+ }
475
+ else {
476
+ // Expand
477
+ this.expandedPaths.add(node.path);
478
+ node.expanded = true;
479
+ // Load children if not loaded
480
+ if (!node.childrenLoaded) {
481
+ await this.loadChildrenForNode(node);
482
+ this.applyGitStatusToTree(node);
483
+ }
484
+ }
485
+ this.refreshDisplayRows();
486
+ }
487
+ /**
488
+ * Enter the selected directory (expand) or open parent directory.
489
+ * This is called when Enter is pressed.
490
+ */
491
+ async enterDirectory() {
492
+ const rows = this._state.displayRows;
493
+ const index = this._state.selectedIndex;
494
+ if (index < 0 || index >= rows.length)
495
+ return;
496
+ const row = rows[index];
497
+ if (row.node.isDirectory) {
498
+ await this.toggleExpand();
499
+ }
500
+ // For files, do nothing (file content is already shown)
501
+ }
502
+ /**
503
+ * Go to parent directory - navigate up and collapse the directory we left.
504
+ */
505
+ async goUp() {
506
+ const rows = this._state.displayRows;
507
+ const index = this._state.selectedIndex;
508
+ if (index < 0 || index >= rows.length)
509
+ return;
510
+ const row = rows[index];
511
+ const currentPath = row.node.path;
512
+ // Find the parent directory path
513
+ const parentPath = path.dirname(currentPath);
514
+ if (parentPath === '.' || parentPath === '') {
515
+ // Already at root level - nothing to do
516
+ return;
517
+ }
518
+ // If we're inside an expanded directory, collapse it
519
+ // The "inside" directory is the first expanded ancestor of our current selection
520
+ const pathParts = currentPath.split('/');
521
+ for (let i = pathParts.length - 1; i > 0; i--) {
522
+ const ancestorPath = pathParts.slice(0, i).join('/');
523
+ if (this.expandedPaths.has(ancestorPath)) {
524
+ // Collapse this ancestor and select it
525
+ this.expandedPaths.delete(ancestorPath);
526
+ // Find this ancestor in the tree and set expanded = false
527
+ const ancestor = this.findNodeByPath(ancestorPath);
528
+ if (ancestor) {
529
+ ancestor.expanded = false;
530
+ }
531
+ this.refreshDisplayRows();
532
+ // Select the collapsed ancestor (use selectIndex to clear file preview)
533
+ const newRows = this._state.displayRows;
534
+ const ancestorIndex = newRows.findIndex((r) => r.node.path === ancestorPath);
535
+ if (ancestorIndex >= 0) {
536
+ // Update selected index and clear file preview since we're selecting a directory
537
+ this.updateState({ selectedIndex: ancestorIndex, selectedFile: null });
538
+ }
539
+ return;
540
+ }
541
+ }
542
+ }
543
+ /**
544
+ * Find a node by its path in the tree.
545
+ */
546
+ findNodeByPath(targetPath) {
547
+ if (!this._state.tree)
548
+ return null;
549
+ const search = (node) => {
550
+ if (node.path === targetPath)
551
+ return node;
552
+ for (const child of node.children) {
553
+ const found = search(child);
554
+ if (found)
555
+ return found;
556
+ }
557
+ return null;
558
+ };
559
+ return search(this._state.tree);
560
+ }
561
+ /**
562
+ * Get all file paths in the repo (for file finder).
563
+ * Scans the filesystem directly to get all files, not just expanded ones.
564
+ */
565
+ async getAllFilePaths() {
566
+ const paths = [];
567
+ const scanDir = async (dirPath) => {
568
+ try {
569
+ const fullPath = path.join(this.repoPath, dirPath);
570
+ const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
571
+ // Build list of paths for gitignore check
572
+ const pathsToCheck = entries.map((e) => (dirPath ? path.join(dirPath, e.name) : e.name));
573
+ // Get ignored files
574
+ const ignoredFiles = this.options.hideGitignored
575
+ ? await getIgnoredFiles(this.repoPath, pathsToCheck)
576
+ : new Set();
577
+ for (const entry of entries) {
578
+ // Filter dot-prefixed hidden files
579
+ if (this.options.hideHidden && entry.name.startsWith('.')) {
580
+ continue;
581
+ }
582
+ const entryPath = dirPath ? path.join(dirPath, entry.name) : entry.name;
583
+ // Filter gitignored files
584
+ if (this.options.hideGitignored && ignoredFiles.has(entryPath)) {
585
+ continue;
586
+ }
587
+ if (entry.isDirectory()) {
588
+ await scanDir(entryPath);
589
+ }
590
+ else {
591
+ paths.push(entryPath);
592
+ }
593
+ }
594
+ }
595
+ catch (err) {
596
+ // Ignore errors for individual directories
597
+ }
598
+ };
599
+ await scanDir('');
600
+ return paths;
601
+ }
602
+ /**
603
+ * Navigate to a specific file path in the tree.
604
+ * Expands parent directories as needed.
605
+ */
606
+ async navigateToPath(filePath) {
607
+ if (!this._state.tree)
608
+ return false;
609
+ // Expand all parent directories
610
+ const parts = filePath.split('/');
611
+ let currentPath = '';
612
+ for (let i = 0; i < parts.length - 1; i++) {
613
+ currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
614
+ this.expandedPaths.add(currentPath);
615
+ }
616
+ // Reload tree with new expanded state
617
+ await this.loadTree();
618
+ // Find the file in display rows
619
+ const index = this._state.displayRows.findIndex((r) => r.node.path === filePath);
620
+ if (index >= 0) {
621
+ await this.selectIndex(index);
622
+ return true;
623
+ }
624
+ return false;
625
+ }
626
+ /**
627
+ * Clean up resources.
628
+ */
629
+ dispose() {
630
+ this.removeAllListeners();
631
+ }
632
+ }