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.
@@ -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;