ftreeview 0.1.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/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/cli.js +1619 -0
- package/package.json +58 -0
- package/src/App.jsx +243 -0
- package/src/cli.js +228 -0
- package/src/components/StatusBar.jsx +270 -0
- package/src/components/TreeLine.jsx +190 -0
- package/src/components/TreeView.jsx +129 -0
- package/src/hooks/useChangedFiles.js +82 -0
- package/src/hooks/useGitStatus.js +347 -0
- package/src/hooks/useIgnore.js +182 -0
- package/src/hooks/useNavigation.js +247 -0
- package/src/hooks/useTree.js +508 -0
- package/src/hooks/useWatcher.js +129 -0
- package/src/index.js +22 -0
- package/src/lib/changeStatus.js +79 -0
- package/src/lib/connectors.js +233 -0
- package/src/lib/constants.js +64 -0
- package/src/lib/icons.js +658 -0
- package/src/lib/theme.js +102 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useNavigation - Navigation Hook for ftree
|
|
3
|
+
*
|
|
4
|
+
* Handles keyboard navigation, cursor management, and viewport control
|
|
5
|
+
* for the file tree view. Provides vim-style keybindings and auto-scrolling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
9
|
+
import { useInput, useApp } from 'ink';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Configuration constants for navigation behavior
|
|
13
|
+
*/
|
|
14
|
+
const VIEWPORT_MARGIN = 3; // Keep cursor 3 lines from viewport edges
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* React hook for managing navigation state and keyboard input
|
|
18
|
+
*
|
|
19
|
+
* @param {FileNode[]} flatList - Flattened list of visible nodes from useTree
|
|
20
|
+
* @param {Function} rebuildTree - Callback to rebuild tree with modified nodes
|
|
21
|
+
* @param {Function|null} refreshFlatList - Callback to refresh visible list without full rebuild
|
|
22
|
+
* @returns {{cursor: number, viewportStart: number, exit: Function, setCursorByPath: Function, setCursor: Function}} Navigation state
|
|
23
|
+
*/
|
|
24
|
+
export function useNavigation(flatList = [], rebuildTree, refreshFlatList = null) {
|
|
25
|
+
const { exit } = useApp();
|
|
26
|
+
|
|
27
|
+
// Calculate viewport height (terminal height minus header and status bar)
|
|
28
|
+
const viewportHeight = process.stdout.rows - 2;
|
|
29
|
+
|
|
30
|
+
// Navigation state
|
|
31
|
+
const [cursor, setCursor] = useState(0);
|
|
32
|
+
const [viewportStart, setViewportStart] = useState(0);
|
|
33
|
+
|
|
34
|
+
const refreshVisibleTree = useCallback(() => {
|
|
35
|
+
if (typeof refreshFlatList === 'function') {
|
|
36
|
+
refreshFlatList();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
rebuildTree(true);
|
|
40
|
+
}, [refreshFlatList, rebuildTree]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set cursor position by looking up a path in the flat list
|
|
44
|
+
* Useful for preserving cursor position across rebuilds
|
|
45
|
+
*
|
|
46
|
+
* @param {string} path - The path to find in the flat list
|
|
47
|
+
* @returns {number} The new cursor position, or -1 if not found
|
|
48
|
+
*/
|
|
49
|
+
const setCursorByPath = useCallback((path) => {
|
|
50
|
+
if (!path || flatList.length === 0) return -1;
|
|
51
|
+
|
|
52
|
+
// Find the node with matching path
|
|
53
|
+
const newIndex = flatList.findIndex((node) => node.path === path);
|
|
54
|
+
|
|
55
|
+
if (newIndex !== -1) {
|
|
56
|
+
setCursor(newIndex);
|
|
57
|
+
return newIndex;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return -1;
|
|
61
|
+
}, [flatList]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find the parent of the current node in the flat list
|
|
65
|
+
* Used by smartCollapse to jump to parent when already collapsed
|
|
66
|
+
*
|
|
67
|
+
* @param {number} currentIndex - Current cursor position
|
|
68
|
+
* @returns {number|null} Index of parent node or null
|
|
69
|
+
*/
|
|
70
|
+
const findParentIndex = useCallback((currentIndex) => {
|
|
71
|
+
if (currentIndex <= 0) return null;
|
|
72
|
+
|
|
73
|
+
const currentDepth = flatList[currentIndex]?.depth ?? 0;
|
|
74
|
+
|
|
75
|
+
// Search backwards for a node with shallower depth
|
|
76
|
+
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
77
|
+
if (flatList[i]?.depth < currentDepth) {
|
|
78
|
+
return i;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}, [flatList]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Move cursor up one line
|
|
87
|
+
*/
|
|
88
|
+
const moveUp = useCallback(() => {
|
|
89
|
+
setCursor((prev) => Math.max(0, prev - 1));
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Move cursor down one line
|
|
94
|
+
*/
|
|
95
|
+
const moveDown = useCallback(() => {
|
|
96
|
+
setCursor((prev) => Math.min(flatList.length - 1, prev + 1));
|
|
97
|
+
}, [flatList.length]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Expand the directory at current cursor position
|
|
101
|
+
* Only works on directories, refreshes visible nodes
|
|
102
|
+
*/
|
|
103
|
+
const expand = useCallback(() => {
|
|
104
|
+
if (flatList.length === 0) return;
|
|
105
|
+
|
|
106
|
+
const node = flatList[cursor];
|
|
107
|
+
if (!node) return;
|
|
108
|
+
|
|
109
|
+
// Only directories can be expanded
|
|
110
|
+
if (node.isDir && !node.expanded) {
|
|
111
|
+
node.expanded = true;
|
|
112
|
+
refreshVisibleTree();
|
|
113
|
+
}
|
|
114
|
+
}, [cursor, flatList, refreshVisibleTree]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Smart collapse: collapse if expanded, jump to parent if collapsed
|
|
118
|
+
*/
|
|
119
|
+
const smartCollapse = useCallback(() => {
|
|
120
|
+
if (flatList.length === 0) return;
|
|
121
|
+
|
|
122
|
+
const node = flatList[cursor];
|
|
123
|
+
if (!node) return;
|
|
124
|
+
|
|
125
|
+
if (node.expanded && node.isDir) {
|
|
126
|
+
// Collapse the current directory
|
|
127
|
+
node.expanded = false;
|
|
128
|
+
refreshVisibleTree();
|
|
129
|
+
} else {
|
|
130
|
+
// Jump to parent directory
|
|
131
|
+
const parentIndex = findParentIndex(cursor);
|
|
132
|
+
if (parentIndex !== null) {
|
|
133
|
+
setCursor(parentIndex);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}, [cursor, flatList, refreshVisibleTree, findParentIndex]);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Toggle expanded state of current directory
|
|
140
|
+
*/
|
|
141
|
+
const toggle = useCallback(() => {
|
|
142
|
+
if (flatList.length === 0) return;
|
|
143
|
+
|
|
144
|
+
const node = flatList[cursor];
|
|
145
|
+
if (!node || !node.isDir) return;
|
|
146
|
+
|
|
147
|
+
node.expanded = !node.expanded;
|
|
148
|
+
refreshVisibleTree();
|
|
149
|
+
}, [cursor, flatList, refreshVisibleTree]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Jump to the first item in the list
|
|
153
|
+
*/
|
|
154
|
+
const jumpToTop = useCallback(() => {
|
|
155
|
+
setCursor(0);
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Jump to the last item in the list
|
|
160
|
+
*/
|
|
161
|
+
const jumpToBottom = useCallback(() => {
|
|
162
|
+
setCursor(Math.max(0, flatList.length - 1));
|
|
163
|
+
}, [flatList.length]);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Refresh the tree by rebuilding it
|
|
167
|
+
*/
|
|
168
|
+
const refresh = useCallback(() => {
|
|
169
|
+
rebuildTree(true);
|
|
170
|
+
}, [rebuildTree]);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Handle keyboard input using Ink's useInput hook
|
|
174
|
+
*/
|
|
175
|
+
useInput((input, key) => {
|
|
176
|
+
// Vim-style and arrow key navigation
|
|
177
|
+
if (key.upArrow || input === 'k') {
|
|
178
|
+
moveUp();
|
|
179
|
+
} else if (key.downArrow || input === 'j') {
|
|
180
|
+
moveDown();
|
|
181
|
+
} else if (key.rightArrow || input === 'l' || key.return) {
|
|
182
|
+
expand();
|
|
183
|
+
} else if (key.leftArrow || input === 'h') {
|
|
184
|
+
smartCollapse();
|
|
185
|
+
} else if (key.ctrl && input === 'c') {
|
|
186
|
+
exit();
|
|
187
|
+
} else if (input === 'q') {
|
|
188
|
+
exit();
|
|
189
|
+
} else if (input === ' ') {
|
|
190
|
+
toggle();
|
|
191
|
+
} else if (input === 'g') {
|
|
192
|
+
jumpToTop();
|
|
193
|
+
} else if (input === 'G') {
|
|
194
|
+
jumpToBottom();
|
|
195
|
+
} else if (input === 'r') {
|
|
196
|
+
refresh();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Auto-scroll viewport to keep cursor in view
|
|
202
|
+
* Adjust viewportStart when cursor moves near edges
|
|
203
|
+
*/
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (flatList.length === 0) {
|
|
206
|
+
setViewportStart(0);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Ensure cursor is visible
|
|
211
|
+
if (cursor < viewportStart) {
|
|
212
|
+
// Cursor is above viewport, scroll up
|
|
213
|
+
setViewportStart(cursor);
|
|
214
|
+
} else if (cursor >= viewportStart + viewportHeight) {
|
|
215
|
+
// Cursor is below viewport, scroll down
|
|
216
|
+
setViewportStart(cursor - viewportHeight + 1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Keep cursor comfortably away from edges (margin)
|
|
220
|
+
if (cursor - viewportStart < VIEWPORT_MARGIN && viewportStart > 0) {
|
|
221
|
+
// Cursor is too close to top edge, scroll up
|
|
222
|
+
setViewportStart(Math.max(0, cursor - VIEWPORT_MARGIN));
|
|
223
|
+
} else if (viewportStart + viewportHeight - cursor < VIEWPORT_MARGIN) {
|
|
224
|
+
// Cursor is too close to bottom edge, scroll down
|
|
225
|
+
const maxStart = Math.max(0, flatList.length - viewportHeight);
|
|
226
|
+
setViewportStart(Math.min(maxStart, cursor - viewportHeight + VIEWPORT_MARGIN + 1));
|
|
227
|
+
}
|
|
228
|
+
}, [cursor, flatList.length, viewportHeight]);
|
|
229
|
+
|
|
230
|
+
// Reset cursor when flat list becomes empty
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (flatList.length === 0) {
|
|
233
|
+
setCursor(0);
|
|
234
|
+
setViewportStart(0);
|
|
235
|
+
}
|
|
236
|
+
}, [flatList.length]);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
cursor,
|
|
240
|
+
viewportStart,
|
|
241
|
+
exit,
|
|
242
|
+
setCursorByPath,
|
|
243
|
+
setCursor,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export default useNavigation;
|