diffstalker 0.2.0 → 0.2.2

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 (46) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/.github/workflows/release.yml +8 -0
  5. package/README.md +43 -35
  6. package/bun.lock +82 -3
  7. package/dist/App.js +555 -552
  8. package/dist/FollowMode.js +85 -0
  9. package/dist/KeyBindings.js +228 -0
  10. package/dist/MouseHandlers.js +192 -0
  11. package/dist/core/ExplorerStateManager.js +423 -78
  12. package/dist/core/GitStateManager.js +260 -119
  13. package/dist/git/diff.js +102 -17
  14. package/dist/git/status.js +16 -54
  15. package/dist/git/test-helpers.js +67 -0
  16. package/dist/index.js +60 -53
  17. package/dist/ipc/CommandClient.js +6 -7
  18. package/dist/state/UIState.js +39 -4
  19. package/dist/ui/PaneRenderers.js +76 -0
  20. package/dist/ui/modals/FileFinder.js +193 -0
  21. package/dist/ui/modals/HotkeysModal.js +12 -3
  22. package/dist/ui/modals/ThemePicker.js +1 -2
  23. package/dist/ui/widgets/CommitPanel.js +1 -1
  24. package/dist/ui/widgets/CompareListView.js +123 -80
  25. package/dist/ui/widgets/DiffView.js +228 -180
  26. package/dist/ui/widgets/ExplorerContent.js +15 -28
  27. package/dist/ui/widgets/ExplorerView.js +148 -43
  28. package/dist/ui/widgets/FileList.js +62 -95
  29. package/dist/ui/widgets/FlatFileList.js +65 -0
  30. package/dist/ui/widgets/Footer.js +25 -11
  31. package/dist/ui/widgets/Header.js +17 -52
  32. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  33. package/dist/utils/ansiTruncate.js +0 -1
  34. package/dist/utils/displayRows.js +101 -21
  35. package/dist/utils/fileCategories.js +37 -0
  36. package/dist/utils/fileTree.js +148 -0
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +15 -0
  40. package/metrics/.gitkeep +0 -0
  41. package/metrics/v0.2.1.json +268 -0
  42. package/metrics/v0.2.2.json +229 -0
  43. package/package.json +9 -2
  44. package/dist/utils/ansiToBlessed.js +0 -125
  45. package/dist/utils/mouseCoordinates.js +0 -165
  46. package/dist/utils/rowCalculations.js +0 -246
@@ -18,14 +18,17 @@ function isBinaryContent(buffer) {
18
18
  }
19
19
  /**
20
20
  * ExplorerStateManager manages file explorer state independent of React.
21
- * It handles directory loading, file selection, and navigation.
21
+ * It handles directory loading, file selection, and navigation with tree view support.
22
22
  */
23
23
  export class ExplorerStateManager extends EventEmitter {
24
24
  repoPath;
25
25
  options;
26
+ expandedPaths = new Set();
27
+ gitStatusMap = { files: new Map(), directories: new Set() };
26
28
  _state = {
27
29
  currentPath: '',
28
- items: [],
30
+ tree: null,
31
+ displayRows: [],
29
32
  selectedIndex: 0,
30
33
  selectedFile: null,
31
34
  isLoading: false,
@@ -34,7 +37,13 @@ export class ExplorerStateManager extends EventEmitter {
34
37
  constructor(repoPath, options) {
35
38
  super();
36
39
  this.repoPath = repoPath;
37
- this.options = options;
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('');
38
47
  }
39
48
  get state() {
40
49
  return this._state;
@@ -44,77 +53,279 @@ export class ExplorerStateManager extends EventEmitter {
44
53
  this.emit('state-change', this._state);
45
54
  }
46
55
  /**
47
- * Set filtering options and reload directory.
56
+ * Set filtering options and reload tree.
48
57
  */
49
58
  async setOptions(options) {
50
59
  this.options = { ...this.options, ...options };
51
- await this.loadDirectory(this._state.currentPath);
60
+ await this.loadTree();
52
61
  }
53
62
  /**
54
- * Load a directory's contents.
63
+ * Update git status map and refresh display.
55
64
  */
56
- async loadDirectory(relativePath) {
57
- this.updateState({ isLoading: true, error: null, currentPath: relativePath });
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) {
58
127
  try {
59
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 {
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);
60
168
  const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
61
169
  // Build list of paths for gitignore check
62
- const pathsToCheck = entries.map((e) => relativePath ? path.join(relativePath, e.name) : e.name);
63
- // Get ignored files (only if we need to filter them)
170
+ const pathsToCheck = entries.map((e) => (node.path ? path.join(node.path, e.name) : e.name));
171
+ // Get ignored files
64
172
  const ignoredFiles = this.options.hideGitignored
65
173
  ? await getIgnoredFiles(this.repoPath, pathsToCheck)
66
174
  : new Set();
67
- // Filter and map entries
68
- const explorerItems = entries
69
- .filter((entry) => {
175
+ const children = [];
176
+ for (const entry of entries) {
70
177
  // Filter dot-prefixed hidden files
71
178
  if (this.options.hideHidden && entry.name.startsWith('.')) {
72
- return false;
179
+ continue;
73
180
  }
181
+ const entryPath = node.path ? path.join(node.path, entry.name) : entry.name;
74
182
  // Filter gitignored files
75
- if (this.options.hideGitignored) {
76
- const entryPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
77
- if (ignoredFiles.has(entryPath)) {
78
- return false;
79
- }
183
+ if (this.options.hideGitignored && ignoredFiles.has(entryPath)) {
184
+ continue;
80
185
  }
81
- return true;
82
- })
83
- .map((entry) => ({
84
- name: entry.name,
85
- path: relativePath ? path.join(relativePath, entry.name) : entry.name,
86
- isDirectory: entry.isDirectory(),
87
- }));
88
- // Sort: directories first (alphabetical), then files (alphabetical)
89
- explorerItems.sort((a, b) => {
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) => {
90
204
  if (a.isDirectory && !b.isDirectory)
91
205
  return -1;
92
206
  if (!a.isDirectory && b.isDirectory)
93
207
  return 1;
94
208
  return a.name.localeCompare(b.name);
95
209
  });
96
- // Add ".." at the beginning if not at root
97
- if (relativePath) {
98
- explorerItems.unshift({
99
- name: '..',
100
- path: path.dirname(relativePath) || '',
101
- isDirectory: true,
102
- });
210
+ // Collapse single-child directory chains
211
+ this.collapseNode(node, children);
212
+ node.childrenLoaded = true;
213
+ }
214
+ catch {
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
+ }
103
237
  }
104
- this.updateState({
105
- items: explorerItems,
106
- selectedIndex: 0,
107
- selectedFile: null,
108
- isLoading: false,
109
- });
110
238
  }
111
- catch (err) {
112
- this.updateState({
113
- error: err instanceof Error ? err.message : 'Failed to read directory',
114
- items: [],
115
- isLoading: false,
116
- });
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
+ }
117
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
+ shouldIncludeNode(node) {
266
+ if (!this.options.showOnlyChanges)
267
+ return true;
268
+ if (node.isDirectory)
269
+ return !!node.hasChangedChildren;
270
+ return !!node.gitStatus;
271
+ }
272
+ flattenTree(root) {
273
+ const rows = [];
274
+ const traverseChildren = (node, depth, parentIsLast) => {
275
+ for (let i = 0; i < node.children.length; i++) {
276
+ const child = node.children[i];
277
+ const isLast = i === node.children.length - 1;
278
+ if (!this.shouldIncludeNode(child))
279
+ continue;
280
+ rows.push({
281
+ node: child,
282
+ depth,
283
+ isLast,
284
+ parentIsLast: [...parentIsLast],
285
+ });
286
+ if (child.isDirectory && child.expanded) {
287
+ traverseChildren(child, depth + 1, [...parentIsLast, isLast]);
288
+ }
289
+ }
290
+ };
291
+ // Start from root's children at depth 0 (root itself is not displayed)
292
+ traverseChildren(root, 0, []);
293
+ return rows;
294
+ }
295
+ /**
296
+ * Refresh display rows without reloading tree.
297
+ * Maintains selection by path, not by index.
298
+ */
299
+ refreshDisplayRows() {
300
+ if (!this._state.tree)
301
+ return;
302
+ // Remember the currently selected path
303
+ const currentSelectedPath = this._state.displayRows[this._state.selectedIndex]?.node.path ?? null;
304
+ const displayRows = this.flattenTree(this._state.tree);
305
+ // Find the same path in the new rows
306
+ let selectedIndex = 0;
307
+ if (currentSelectedPath !== null) {
308
+ const foundIndex = displayRows.findIndex((row) => row.node.path === currentSelectedPath);
309
+ if (foundIndex >= 0) {
310
+ selectedIndex = foundIndex;
311
+ }
312
+ }
313
+ // Clamp to valid range
314
+ selectedIndex = Math.min(selectedIndex, Math.max(0, displayRows.length - 1));
315
+ this.updateState({ displayRows, selectedIndex });
316
+ }
317
+ /**
318
+ * Get repo name from path.
319
+ */
320
+ getRepoName() {
321
+ return path.basename(this.repoPath) || 'repo';
322
+ }
323
+ /**
324
+ * Load a directory's contents (legacy method, now wraps loadTree).
325
+ */
326
+ async loadDirectory(relativePath) {
327
+ this._state.currentPath = relativePath;
328
+ await this.loadTree();
118
329
  }
119
330
  /**
120
331
  * Load a file's contents.
@@ -149,7 +360,7 @@ export class ExplorerStateManager extends EventEmitter {
149
360
  let truncated = false;
150
361
  // Warn about large files
151
362
  if (stats.size > WARN_FILE_SIZE) {
152
- const warning = `⚠ Large file (${(stats.size / 1024).toFixed(1)} KB)\n\n`;
363
+ const warning = `Warning: Large file (${(stats.size / 1024).toFixed(1)} KB)\n\n`;
153
364
  content = warning + content;
154
365
  }
155
366
  // Truncate if needed
@@ -182,12 +393,13 @@ export class ExplorerStateManager extends EventEmitter {
182
393
  * Select an item by index.
183
394
  */
184
395
  async selectIndex(index) {
185
- if (index < 0 || index >= this._state.items.length)
396
+ const rows = this._state.displayRows;
397
+ if (index < 0 || index >= rows.length)
186
398
  return;
187
- const selected = this._state.items[index];
399
+ const row = rows[index];
188
400
  this.updateState({ selectedIndex: index });
189
- if (selected && !selected.isDirectory) {
190
- await this.loadFile(selected.path);
401
+ if (row && !row.node.isDirectory) {
402
+ await this.loadFile(row.node.path);
191
403
  }
192
404
  else {
193
405
  this.updateState({ selectedFile: null });
@@ -195,15 +407,12 @@ export class ExplorerStateManager extends EventEmitter {
195
407
  }
196
408
  /**
197
409
  * Navigate to previous item.
198
- * Returns the new scroll offset if scrolling is needed, or null if not.
199
410
  */
200
411
  navigateUp(currentScrollOffset) {
201
412
  const newIndex = Math.max(0, this._state.selectedIndex - 1);
202
413
  if (newIndex === this._state.selectedIndex)
203
414
  return null;
204
- // Don't await - fire and forget for responsiveness
205
415
  this.selectIndex(newIndex);
206
- // Return new scroll offset if we need to scroll up
207
416
  if (newIndex < currentScrollOffset) {
208
417
  return newIndex;
209
418
  }
@@ -211,16 +420,13 @@ export class ExplorerStateManager extends EventEmitter {
211
420
  }
212
421
  /**
213
422
  * Navigate to next item.
214
- * Returns the new scroll offset if scrolling is needed, or null if not.
215
423
  */
216
424
  navigateDown(currentScrollOffset, visibleHeight) {
217
- const newIndex = Math.min(this._state.items.length - 1, this._state.selectedIndex + 1);
425
+ const newIndex = Math.min(this._state.displayRows.length - 1, this._state.selectedIndex + 1);
218
426
  if (newIndex === this._state.selectedIndex)
219
427
  return null;
220
- // Don't await - fire and forget for responsiveness
221
428
  this.selectIndex(newIndex);
222
- // Calculate visible area accounting for scroll indicators
223
- const needsScrolling = this._state.items.length > visibleHeight;
429
+ const needsScrolling = this._state.displayRows.length > visibleHeight;
224
430
  const availableHeight = needsScrolling ? visibleHeight - 2 : visibleHeight;
225
431
  const visibleEnd = currentScrollOffset + availableHeight;
226
432
  if (newIndex >= visibleEnd) {
@@ -229,33 +435,172 @@ export class ExplorerStateManager extends EventEmitter {
229
435
  return null;
230
436
  }
231
437
  /**
232
- * Enter the selected directory or go to parent if ".." is selected.
438
+ * Toggle expand/collapse for selected directory, or go to parent if ".." would be selected.
233
439
  */
234
- async enterDirectory() {
235
- const selected = this._state.items[this._state.selectedIndex];
236
- if (!selected)
440
+ async toggleExpand() {
441
+ const rows = this._state.displayRows;
442
+ const index = this._state.selectedIndex;
443
+ if (index < 0 || index >= rows.length)
237
444
  return;
238
- if (selected.isDirectory) {
239
- if (selected.name === '..') {
240
- const parent = path.dirname(this._state.currentPath);
241
- // path.dirname returns "." for top-level paths, normalize to ""
242
- await this.loadDirectory(parent === '.' ? '' : parent);
243
- }
244
- else {
245
- await this.loadDirectory(selected.path);
445
+ const row = rows[index];
446
+ if (!row.node.isDirectory)
447
+ return;
448
+ const node = row.node;
449
+ if (node.expanded) {
450
+ // Collapse
451
+ this.expandedPaths.delete(node.path);
452
+ node.expanded = false;
453
+ }
454
+ else {
455
+ // Expand
456
+ this.expandedPaths.add(node.path);
457
+ node.expanded = true;
458
+ // Load children if not loaded
459
+ if (!node.childrenLoaded) {
460
+ await this.loadChildrenForNode(node);
461
+ this.applyGitStatusToTree(node);
246
462
  }
247
463
  }
248
- // If it's a file, do nothing (file content is already shown)
464
+ this.refreshDisplayRows();
465
+ }
466
+ /**
467
+ * Enter the selected directory (expand) or open parent directory.
468
+ * This is called when Enter is pressed.
469
+ */
470
+ async enterDirectory() {
471
+ const rows = this._state.displayRows;
472
+ const index = this._state.selectedIndex;
473
+ if (index < 0 || index >= rows.length)
474
+ return;
475
+ const row = rows[index];
476
+ if (row.node.isDirectory) {
477
+ await this.toggleExpand();
478
+ }
479
+ // For files, do nothing (file content is already shown)
249
480
  }
250
481
  /**
251
- * Go to parent directory (backspace navigation).
482
+ * Go to parent directory - navigate up and collapse the directory we left.
252
483
  */
253
484
  async goUp() {
254
- if (this._state.currentPath) {
255
- const parent = path.dirname(this._state.currentPath);
256
- // path.dirname returns "." for top-level paths, normalize to ""
257
- await this.loadDirectory(parent === '.' ? '' : parent);
485
+ const rows = this._state.displayRows;
486
+ const index = this._state.selectedIndex;
487
+ if (index < 0 || index >= rows.length)
488
+ return;
489
+ const row = rows[index];
490
+ const currentPath = row.node.path;
491
+ // Find the parent directory path
492
+ const parentPath = path.dirname(currentPath);
493
+ if (parentPath === '.' || parentPath === '') {
494
+ // Already at root level - nothing to do
495
+ return;
496
+ }
497
+ // If we're inside an expanded directory, collapse it
498
+ // The "inside" directory is the first expanded ancestor of our current selection
499
+ const pathParts = currentPath.split('/');
500
+ for (let i = pathParts.length - 1; i > 0; i--) {
501
+ const ancestorPath = pathParts.slice(0, i).join('/');
502
+ if (this.expandedPaths.has(ancestorPath)) {
503
+ // Collapse this ancestor and select it
504
+ this.expandedPaths.delete(ancestorPath);
505
+ // Find this ancestor in the tree and set expanded = false
506
+ const ancestor = this.findNodeByPath(ancestorPath);
507
+ if (ancestor) {
508
+ ancestor.expanded = false;
509
+ }
510
+ this.refreshDisplayRows();
511
+ // Select the collapsed ancestor (use selectIndex to clear file preview)
512
+ const newRows = this._state.displayRows;
513
+ const ancestorIndex = newRows.findIndex((r) => r.node.path === ancestorPath);
514
+ if (ancestorIndex >= 0) {
515
+ // Update selected index and clear file preview since we're selecting a directory
516
+ this.updateState({ selectedIndex: ancestorIndex, selectedFile: null });
517
+ }
518
+ return;
519
+ }
520
+ }
521
+ }
522
+ /**
523
+ * Find a node by its path in the tree.
524
+ */
525
+ findNodeByPath(targetPath) {
526
+ if (!this._state.tree)
527
+ return null;
528
+ const search = (node) => {
529
+ if (node.path === targetPath)
530
+ return node;
531
+ for (const child of node.children) {
532
+ const found = search(child);
533
+ if (found)
534
+ return found;
535
+ }
536
+ return null;
537
+ };
538
+ return search(this._state.tree);
539
+ }
540
+ /**
541
+ * Get all file paths in the repo (for file finder).
542
+ * Scans the filesystem directly to get all files, not just expanded ones.
543
+ */
544
+ async getAllFilePaths() {
545
+ const paths = [];
546
+ const scanDir = async (dirPath) => {
547
+ try {
548
+ const fullPath = path.join(this.repoPath, dirPath);
549
+ const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
550
+ // Build list of paths for gitignore check
551
+ const pathsToCheck = entries.map((e) => (dirPath ? path.join(dirPath, e.name) : e.name));
552
+ // Get ignored files
553
+ const ignoredFiles = this.options.hideGitignored
554
+ ? await getIgnoredFiles(this.repoPath, pathsToCheck)
555
+ : new Set();
556
+ for (const entry of entries) {
557
+ // Filter dot-prefixed hidden files
558
+ if (this.options.hideHidden && entry.name.startsWith('.')) {
559
+ continue;
560
+ }
561
+ const entryPath = dirPath ? path.join(dirPath, entry.name) : entry.name;
562
+ // Filter gitignored files
563
+ if (this.options.hideGitignored && ignoredFiles.has(entryPath)) {
564
+ continue;
565
+ }
566
+ if (entry.isDirectory()) {
567
+ await scanDir(entryPath);
568
+ }
569
+ else {
570
+ paths.push(entryPath);
571
+ }
572
+ }
573
+ }
574
+ catch {
575
+ // Ignore errors for individual directories
576
+ }
577
+ };
578
+ await scanDir('');
579
+ return paths;
580
+ }
581
+ /**
582
+ * Navigate to a specific file path in the tree.
583
+ * Expands parent directories as needed.
584
+ */
585
+ async navigateToPath(filePath) {
586
+ if (!this._state.tree)
587
+ return false;
588
+ // Expand all parent directories
589
+ const parts = filePath.split('/');
590
+ let currentPath = '';
591
+ for (let i = 0; i < parts.length - 1; i++) {
592
+ currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
593
+ this.expandedPaths.add(currentPath);
594
+ }
595
+ // Reload tree with new expanded state
596
+ await this.loadTree();
597
+ // Find the file in display rows
598
+ const index = this._state.displayRows.findIndex((r) => r.node.path === filePath);
599
+ if (index >= 0) {
600
+ await this.selectIndex(index);
601
+ return true;
258
602
  }
603
+ return false;
259
604
  }
260
605
  /**
261
606
  * Clean up resources.