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