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.
- package/.github/workflows/release.yml +8 -0
- package/bun.lock +23 -0
- package/dist/App.js +225 -471
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +444 -78
- package/dist/core/GitStateManager.js +169 -93
- package/dist/git/diff.js +4 -0
- package/dist/index.js +54 -53
- package/dist/state/UIState.js +17 -4
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/FileFinder.js +232 -0
- package/dist/ui/widgets/CompareListView.js +86 -64
- package/dist/ui/widgets/DiffView.js +19 -17
- package/dist/ui/widgets/ExplorerContent.js +15 -28
- package/dist/ui/widgets/ExplorerView.js +140 -31
- package/dist/ui/widgets/Footer.js +6 -2
- package/dist/ui/widgets/Header.js +3 -46
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +4 -1
- package/dist/utils/ansiToBlessed.js +0 -125
- package/dist/utils/mouseCoordinates.js +0 -165
- 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
|
-
|
|
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 =
|
|
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
|
|
56
|
+
* Set filtering options and reload tree.
|
|
48
57
|
*/
|
|
49
58
|
async setOptions(options) {
|
|
50
59
|
this.options = { ...this.options, ...options };
|
|
51
|
-
await this.
|
|
60
|
+
await this.loadTree();
|
|
52
61
|
}
|
|
53
62
|
/**
|
|
54
|
-
*
|
|
63
|
+
* Update git status map and refresh display.
|
|
55
64
|
*/
|
|
56
|
-
|
|
57
|
-
this.
|
|
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) =>
|
|
63
|
-
// Get ignored files
|
|
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
|
-
|
|
68
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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 =
|
|
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
|
-
|
|
417
|
+
const rows = this._state.displayRows;
|
|
418
|
+
if (index < 0 || index >= rows.length)
|
|
186
419
|
return;
|
|
187
|
-
const
|
|
420
|
+
const row = rows[index];
|
|
188
421
|
this.updateState({ selectedIndex: index });
|
|
189
|
-
if (
|
|
190
|
-
await this.loadFile(
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
459
|
+
* Toggle expand/collapse for selected directory, or go to parent if ".." would be selected.
|
|
233
460
|
*/
|
|
234
|
-
async
|
|
235
|
-
const
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
485
|
+
this.refreshDisplayRows();
|
|
249
486
|
}
|
|
250
487
|
/**
|
|
251
|
-
*
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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.
|