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.
- package/.dependency-cruiser.cjs +67 -0
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +15 -0
- package/.github/workflows/release.yml +8 -0
- package/README.md +43 -35
- package/bun.lock +82 -3
- package/dist/App.js +555 -552
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +228 -0
- package/dist/MouseHandlers.js +192 -0
- package/dist/core/ExplorerStateManager.js +423 -78
- package/dist/core/GitStateManager.js +260 -119
- package/dist/git/diff.js +102 -17
- package/dist/git/status.js +16 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +60 -53
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +39 -4
- package/dist/ui/PaneRenderers.js +76 -0
- package/dist/ui/modals/FileFinder.js +193 -0
- package/dist/ui/modals/HotkeysModal.js +12 -3
- package/dist/ui/modals/ThemePicker.js +1 -2
- package/dist/ui/widgets/CommitPanel.js +1 -1
- package/dist/ui/widgets/CompareListView.js +123 -80
- package/dist/ui/widgets/DiffView.js +228 -180
- package/dist/ui/widgets/ExplorerContent.js +15 -28
- package/dist/ui/widgets/ExplorerView.js +148 -43
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -11
- package/dist/ui/widgets/Header.js +17 -52
- package/dist/ui/widgets/fileRowFormatters.js +73 -0
- package/dist/utils/ansiTruncate.js +0 -1
- package/dist/utils/displayRows.js +101 -21
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +15 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/metrics/v0.2.2.json +229 -0
- package/package.json +9 -2
- 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,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
|
|
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 {
|
|
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
|
-
if (ignoredFiles.has(entryPath)) {
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
183
|
+
if (this.options.hideGitignored && ignoredFiles.has(entryPath)) {
|
|
184
|
+
continue;
|
|
80
185
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 =
|
|
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
|
-
|
|
396
|
+
const rows = this._state.displayRows;
|
|
397
|
+
if (index < 0 || index >= rows.length)
|
|
186
398
|
return;
|
|
187
|
-
const
|
|
399
|
+
const row = rows[index];
|
|
188
400
|
this.updateState({ selectedIndex: index });
|
|
189
|
-
if (
|
|
190
|
-
await this.loadFile(
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
438
|
+
* Toggle expand/collapse for selected directory, or go to parent if ".." would be selected.
|
|
233
439
|
*/
|
|
234
|
-
async
|
|
235
|
-
const
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
|
482
|
+
* Go to parent directory - navigate up and collapse the directory we left.
|
|
252
483
|
*/
|
|
253
484
|
async goUp() {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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.
|