diffstalker 0.2.0 → 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.
@@ -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,78 +53,301 @@ 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 (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);
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;
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);
80
199
  }
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) => {
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
- });
103
- }
104
- this.updateState({
105
- items: explorerItems,
106
- selectedIndex: 0,
107
- selectedFile: null,
108
- isLoading: false,
109
- });
210
+ // Collapse single-child directory chains
211
+ this.collapseNode(node, children);
212
+ node.childrenLoaded = true;
110
213
  }
111
214
  catch (err) {
112
- this.updateState({
113
- error: err instanceof Error ? err.message : 'Failed to read directory',
114
- items: [],
115
- isLoading: false,
116
- });
215
+ node.childrenLoaded = true;
216
+ node.children = [];
117
217
  }
118
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
+ }
119
351
  /**
120
352
  * Load a file's contents.
121
353
  */
@@ -149,7 +381,7 @@ export class ExplorerStateManager extends EventEmitter {
149
381
  let truncated = false;
150
382
  // Warn about large files
151
383
  if (stats.size > WARN_FILE_SIZE) {
152
- const warning = `⚠ Large file (${(stats.size / 1024).toFixed(1)} KB)\n\n`;
384
+ const warning = `Warning: Large file (${(stats.size / 1024).toFixed(1)} KB)\n\n`;
153
385
  content = warning + content;
154
386
  }
155
387
  // Truncate if needed
@@ -182,12 +414,13 @@ export class ExplorerStateManager extends EventEmitter {
182
414
  * Select an item by index.
183
415
  */
184
416
  async selectIndex(index) {
185
- if (index < 0 || index >= this._state.items.length)
417
+ const rows = this._state.displayRows;
418
+ if (index < 0 || index >= rows.length)
186
419
  return;
187
- const selected = this._state.items[index];
420
+ const row = rows[index];
188
421
  this.updateState({ selectedIndex: index });
189
- if (selected && !selected.isDirectory) {
190
- await this.loadFile(selected.path);
422
+ if (row && !row.node.isDirectory) {
423
+ await this.loadFile(row.node.path);
191
424
  }
192
425
  else {
193
426
  this.updateState({ selectedFile: null });
@@ -195,15 +428,12 @@ export class ExplorerStateManager extends EventEmitter {
195
428
  }
196
429
  /**
197
430
  * Navigate to previous item.
198
- * Returns the new scroll offset if scrolling is needed, or null if not.
199
431
  */
200
432
  navigateUp(currentScrollOffset) {
201
433
  const newIndex = Math.max(0, this._state.selectedIndex - 1);
202
434
  if (newIndex === this._state.selectedIndex)
203
435
  return null;
204
- // Don't await - fire and forget for responsiveness
205
436
  this.selectIndex(newIndex);
206
- // Return new scroll offset if we need to scroll up
207
437
  if (newIndex < currentScrollOffset) {
208
438
  return newIndex;
209
439
  }
@@ -211,16 +441,13 @@ export class ExplorerStateManager extends EventEmitter {
211
441
  }
212
442
  /**
213
443
  * Navigate to next item.
214
- * Returns the new scroll offset if scrolling is needed, or null if not.
215
444
  */
216
445
  navigateDown(currentScrollOffset, visibleHeight) {
217
- const newIndex = Math.min(this._state.items.length - 1, this._state.selectedIndex + 1);
446
+ const newIndex = Math.min(this._state.displayRows.length - 1, this._state.selectedIndex + 1);
218
447
  if (newIndex === this._state.selectedIndex)
219
448
  return null;
220
- // Don't await - fire and forget for responsiveness
221
449
  this.selectIndex(newIndex);
222
- // Calculate visible area accounting for scroll indicators
223
- const needsScrolling = this._state.items.length > visibleHeight;
450
+ const needsScrolling = this._state.displayRows.length > visibleHeight;
224
451
  const availableHeight = needsScrolling ? visibleHeight - 2 : visibleHeight;
225
452
  const visibleEnd = currentScrollOffset + availableHeight;
226
453
  if (newIndex >= visibleEnd) {
@@ -229,33 +456,172 @@ export class ExplorerStateManager extends EventEmitter {
229
456
  return null;
230
457
  }
231
458
  /**
232
- * Enter the selected directory or go to parent if ".." is selected.
459
+ * Toggle expand/collapse for selected directory, or go to parent if ".." would be selected.
233
460
  */
234
- async enterDirectory() {
235
- const selected = this._state.items[this._state.selectedIndex];
236
- if (!selected)
461
+ async toggleExpand() {
462
+ const rows = this._state.displayRows;
463
+ const index = this._state.selectedIndex;
464
+ if (index < 0 || index >= rows.length)
237
465
  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);
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);
246
483
  }
247
484
  }
248
- // If it's a file, do nothing (file content is already shown)
485
+ this.refreshDisplayRows();
249
486
  }
250
487
  /**
251
- * Go to parent directory (backspace navigation).
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.
252
504
  */
253
505
  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);
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;
258
623
  }
624
+ return false;
259
625
  }
260
626
  /**
261
627
  * Clean up resources.