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,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatusBar Component for ftree
|
|
3
|
+
*
|
|
4
|
+
* Fixed bottom status bar showing helpful information about the current state.
|
|
5
|
+
* Displays version, path, file counts, watch status, git status, and keybind hints.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Inverse background for visibility
|
|
9
|
+
* - Truncated paths for narrow terminals
|
|
10
|
+
* - Optional keybind hints (second line)
|
|
11
|
+
* - Watch status indicator
|
|
12
|
+
* - Git integration indicator
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { Box, Text } from 'ink';
|
|
17
|
+
import { VSCODE_THEME } from '../lib/theme.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Truncates a path to fit within the available width
|
|
21
|
+
* Shows the beginning and end of the path with "..." in between if needed
|
|
22
|
+
*
|
|
23
|
+
* Examples:
|
|
24
|
+
* - "./very/long/path/to/project" → ".../path/to/project"
|
|
25
|
+
* - "./short" → "./short"
|
|
26
|
+
*
|
|
27
|
+
* @param {string} path - The path to truncate
|
|
28
|
+
* @param {number} maxWidth - Maximum width available
|
|
29
|
+
* @returns {string} Truncated path with ellipsis if needed
|
|
30
|
+
*/
|
|
31
|
+
function truncatePath(path, maxWidth) {
|
|
32
|
+
if (maxWidth >= path.length) {
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Reserve 3 chars for "..." separator
|
|
37
|
+
// Show at least 10 chars from the end
|
|
38
|
+
const endLength = Math.min(15, maxWidth - 3);
|
|
39
|
+
const startLength = Math.max(3, maxWidth - endLength - 3);
|
|
40
|
+
|
|
41
|
+
if (startLength + endLength + 3 > maxWidth) {
|
|
42
|
+
// Path is very short - just truncate with leading ellipsis
|
|
43
|
+
return '...' + path.slice(Math.max(0, path.length - maxWidth + 3));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return path.slice(0, startLength) + '...' + path.slice(path.length - endLength);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get a shortened path display
|
|
51
|
+
* For nested paths, shows just the last 2 segments with leading ...
|
|
52
|
+
* For short paths, shows as-is
|
|
53
|
+
*
|
|
54
|
+
* @param {string} path - Full path
|
|
55
|
+
* @param {number} maxWidth - Maximum available width
|
|
56
|
+
* @returns {string} Formatted path string
|
|
57
|
+
*/
|
|
58
|
+
function formatPath(path, maxWidth) {
|
|
59
|
+
const segments = path.split('/');
|
|
60
|
+
const segmentCount = segments.length;
|
|
61
|
+
|
|
62
|
+
// If path is short enough or only has 2-3 segments, show as-is
|
|
63
|
+
if (path.length <= maxWidth || segmentCount <= 3) {
|
|
64
|
+
return truncatePath(path, maxWidth);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// For deep paths, show first segment ... last 2 segments
|
|
68
|
+
// e.g., "project/.../src/components" from "project/a/b/c/src/components"
|
|
69
|
+
const first = segments[0];
|
|
70
|
+
const lastTwo = segments.slice(-2).join('/');
|
|
71
|
+
const ellipsisPath = `${first}/.../${lastTwo}`;
|
|
72
|
+
|
|
73
|
+
if (ellipsisPath.length <= maxWidth) {
|
|
74
|
+
return ellipsisPath;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return truncatePath(ellipsisPath, maxWidth);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Calculate git status summary from statusMap
|
|
82
|
+
* Returns a summary string like "clean" or "3M 1A"
|
|
83
|
+
*
|
|
84
|
+
* @param {Map<string, string>} statusMap - Map of file paths to git status codes
|
|
85
|
+
* @returns {{isRepo: boolean, summary: string}} Git repository status and summary
|
|
86
|
+
*/
|
|
87
|
+
export function calculateGitStatus(statusMap) {
|
|
88
|
+
if (!statusMap || statusMap.size === 0) {
|
|
89
|
+
return { isRepo: false, summary: '' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Count each status type
|
|
93
|
+
const counts = { M: 0, A: 0, D: 0, U: 0, R: 0 };
|
|
94
|
+
for (const status of statusMap.values()) {
|
|
95
|
+
if (counts.hasOwnProperty(status)) {
|
|
96
|
+
counts[status]++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Build summary string (non-zero counts only, in priority order)
|
|
101
|
+
const parts = [];
|
|
102
|
+
if (counts.D) parts.push(`${counts.D}D`);
|
|
103
|
+
if (counts.M) parts.push(`${counts.M}M`);
|
|
104
|
+
if (counts.A) parts.push(`${counts.A}A`);
|
|
105
|
+
if (counts.U) parts.push(`${counts.U}U`);
|
|
106
|
+
if (counts.R) parts.push(`${counts.R}R`);
|
|
107
|
+
|
|
108
|
+
const summary = parts.length > 0 ? parts.join(' ') : 'clean';
|
|
109
|
+
return { isRepo: true, summary };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* StatusBar Component Props:
|
|
114
|
+
* @property {string} version - Version string (e.g., "v0.1.0")
|
|
115
|
+
* @property {string} rootPath - Root directory path
|
|
116
|
+
* @property {number} fileCount - Total number of files
|
|
117
|
+
* @property {number} dirCount - Total number of directories
|
|
118
|
+
* @property {boolean} isWatching - File watcher active status
|
|
119
|
+
* @property {boolean} gitEnabled - Git integration enabled
|
|
120
|
+
* @property {boolean} isGitRepo - Whether current directory is a git repo
|
|
121
|
+
* @property {string} gitSummary - Git status summary (e.g., "clean" or "3M 1A")
|
|
122
|
+
* @property {boolean} showHelp - Show keybind hints
|
|
123
|
+
* @property {number} termWidth - Terminal width in columns
|
|
124
|
+
*/
|
|
125
|
+
export function StatusBar({
|
|
126
|
+
version = 'v0.1.0',
|
|
127
|
+
rootPath = '.',
|
|
128
|
+
fileCount = 0,
|
|
129
|
+
dirCount = 0,
|
|
130
|
+
isWatching = false,
|
|
131
|
+
gitEnabled = false,
|
|
132
|
+
isGitRepo = false,
|
|
133
|
+
gitSummary = '',
|
|
134
|
+
showHelp = true,
|
|
135
|
+
termWidth = 80,
|
|
136
|
+
iconSet = 'emoji',
|
|
137
|
+
theme = VSCODE_THEME,
|
|
138
|
+
}) {
|
|
139
|
+
const finalTheme = theme || VSCODE_THEME;
|
|
140
|
+
|
|
141
|
+
const barIcons = (() => {
|
|
142
|
+
if (iconSet === 'nerd') {
|
|
143
|
+
return {
|
|
144
|
+
file: '',
|
|
145
|
+
folder: '',
|
|
146
|
+
watch: '',
|
|
147
|
+
git: '',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (iconSet === 'ascii') {
|
|
151
|
+
return {
|
|
152
|
+
file: 'F',
|
|
153
|
+
folder: 'D',
|
|
154
|
+
watch: 'W',
|
|
155
|
+
git: 'git',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// emoji default
|
|
159
|
+
return {
|
|
160
|
+
file: '📄',
|
|
161
|
+
folder: '📁',
|
|
162
|
+
watch: '👁',
|
|
163
|
+
git: '🌿',
|
|
164
|
+
};
|
|
165
|
+
})();
|
|
166
|
+
|
|
167
|
+
const sep = ` ${finalTheme.statusBar.separator} `;
|
|
168
|
+
const paddingWidth = 2; // paddingX={1}
|
|
169
|
+
|
|
170
|
+
const versionText = `ftree ${version}`;
|
|
171
|
+
const countsText = `${barIcons.file} ${fileCount} ${barIcons.folder} ${dirCount}`;
|
|
172
|
+
const watchText = `${barIcons.watch} ${isWatching ? 'Watching' : 'Static'}`;
|
|
173
|
+
const gitText = gitEnabled && isGitRepo ? `${barIcons.git} ${gitSummary || 'clean'}` : '';
|
|
174
|
+
|
|
175
|
+
const fixedSegments = [versionText, countsText, watchText].concat(gitText ? [gitText] : []);
|
|
176
|
+
const fixedWidth = fixedSegments.reduce((sum, part) => sum + part.length, 0)
|
|
177
|
+
+ (fixedSegments.length /* separators count incl. path */) * sep.length
|
|
178
|
+
+ paddingWidth;
|
|
179
|
+
|
|
180
|
+
// Calculate available width for path section
|
|
181
|
+
const pathMaxWidth = Math.max(8, termWidth - fixedWidth);
|
|
182
|
+
|
|
183
|
+
// Format the path to fit
|
|
184
|
+
const displayPath = formatPath(rootPath, pathMaxWidth);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<Box flexDirection="column" width={termWidth}>
|
|
188
|
+
{/* Main status line */}
|
|
189
|
+
<Box
|
|
190
|
+
width={termWidth}
|
|
191
|
+
paddingX={1}
|
|
192
|
+
>
|
|
193
|
+
<Text color={finalTheme.colors.accent} bold>
|
|
194
|
+
ftree
|
|
195
|
+
</Text>
|
|
196
|
+
<Text color={finalTheme.colors.muted} dimColor>
|
|
197
|
+
{' '}{version}
|
|
198
|
+
</Text>
|
|
199
|
+
|
|
200
|
+
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
201
|
+
{sep}
|
|
202
|
+
</Text>
|
|
203
|
+
<Text color={finalTheme.colors.fg} bold>
|
|
204
|
+
{displayPath}
|
|
205
|
+
</Text>
|
|
206
|
+
|
|
207
|
+
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
208
|
+
{sep}
|
|
209
|
+
</Text>
|
|
210
|
+
<Text color={finalTheme.colors.muted}>
|
|
211
|
+
{countsText}
|
|
212
|
+
</Text>
|
|
213
|
+
|
|
214
|
+
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
215
|
+
{sep}
|
|
216
|
+
</Text>
|
|
217
|
+
<Text color={isWatching ? finalTheme.colors.success : finalTheme.colors.muted}>
|
|
218
|
+
{watchText}
|
|
219
|
+
</Text>
|
|
220
|
+
|
|
221
|
+
{gitEnabled && isGitRepo && (
|
|
222
|
+
<>
|
|
223
|
+
<Text {...finalTheme.statusBar.separatorStyle}>
|
|
224
|
+
{sep}
|
|
225
|
+
</Text>
|
|
226
|
+
<Text color={gitSummary === 'clean' ? finalTheme.colors.success : finalTheme.colors.warning}>
|
|
227
|
+
{gitText}
|
|
228
|
+
</Text>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</Box>
|
|
232
|
+
|
|
233
|
+
{/* Keybind hints line (optional) */}
|
|
234
|
+
{showHelp && (
|
|
235
|
+
<Box
|
|
236
|
+
width={termWidth}
|
|
237
|
+
paddingX={1}
|
|
238
|
+
>
|
|
239
|
+
<Text {...finalTheme.statusBar.hintStyle}>
|
|
240
|
+
q quit ↑↓/jk move ←→/hl expand space toggle r refresh
|
|
241
|
+
</Text>
|
|
242
|
+
</Box>
|
|
243
|
+
)}
|
|
244
|
+
</Box>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Calculate file and directory counts from flatList
|
|
250
|
+
*
|
|
251
|
+
* @param {FileNode[]} flatList - Flattened list of FileNodes
|
|
252
|
+
* @returns {{fileCount: number, dirCount: number}} Count of files and directories
|
|
253
|
+
*/
|
|
254
|
+
export function calculateCounts(flatList = []) {
|
|
255
|
+
let fileCount = 0;
|
|
256
|
+
let dirCount = 0;
|
|
257
|
+
|
|
258
|
+
// Only count items INSIDE directories (depth > 0), not root-level items
|
|
259
|
+
for (const node of flatList) {
|
|
260
|
+
if (node.depth > 0 && node.isDir) {
|
|
261
|
+
dirCount++;
|
|
262
|
+
} else if (node.depth > 0) {
|
|
263
|
+
fileCount++;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { fileCount, dirCount };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export default StatusBar;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeLine Component for ftree
|
|
3
|
+
*
|
|
4
|
+
* Renders a single line in the file tree display with:
|
|
5
|
+
* - Tree connector prefix (│, ├─, └─)
|
|
6
|
+
* - File/directory icon
|
|
7
|
+
* - Filename with appropriate color
|
|
8
|
+
* - Git status badge (right-aligned)
|
|
9
|
+
* - Cursor highlighting (inverse background)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { Box, Text } from 'ink';
|
|
14
|
+
|
|
15
|
+
import { getIcon, detectFileType } from '../lib/icons.js';
|
|
16
|
+
import { normalizeChangeStatus } from '../lib/changeStatus.js';
|
|
17
|
+
import { getPrefix } from '../lib/connectors.js';
|
|
18
|
+
import { VSCODE_THEME } from '../lib/theme.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Truncates a filename to fit within the available width
|
|
22
|
+
* Adds "…" ellipsis in the middle if truncation is needed
|
|
23
|
+
*
|
|
24
|
+
* @param {string} name - The filename to truncate
|
|
25
|
+
* @param {number} maxWidth - Maximum width available
|
|
26
|
+
* @returns {string} Truncated filename with ellipsis if needed
|
|
27
|
+
*/
|
|
28
|
+
function truncateFilename(name, maxWidth) {
|
|
29
|
+
if (maxWidth >= name.length) {
|
|
30
|
+
return name;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Need to truncate - show first part + … + last part
|
|
34
|
+
// Reserve 1 char for ellipsis and at least 4 chars for end
|
|
35
|
+
const endLength = Math.min(4, Math.floor(maxWidth / 2));
|
|
36
|
+
const startLength = maxWidth - endLength - 1;
|
|
37
|
+
|
|
38
|
+
if (startLength <= 0) {
|
|
39
|
+
// Not enough space - just show the end with ellipsis
|
|
40
|
+
return '…' + name.slice(Math.max(0, name.length - maxWidth + 1));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return name.slice(0, startLength) + '…' + name.slice(name.length - endLength);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the color for a git status badge
|
|
48
|
+
* Colors match git status conventions:
|
|
49
|
+
* - M (modified) → yellow
|
|
50
|
+
* - A (added) → green
|
|
51
|
+
* - D (deleted) → red
|
|
52
|
+
* - U (untracked) → green
|
|
53
|
+
* - R (renamed) → cyan
|
|
54
|
+
*
|
|
55
|
+
* @param {string} status - Single character git status code
|
|
56
|
+
* @returns {string} Chalk color name
|
|
57
|
+
*/
|
|
58
|
+
function getGitBadgeColor(status) {
|
|
59
|
+
const colorMap = {
|
|
60
|
+
'M': 'yellow',
|
|
61
|
+
'A': 'green',
|
|
62
|
+
'D': 'red',
|
|
63
|
+
'U': 'green',
|
|
64
|
+
'R': 'cyan',
|
|
65
|
+
};
|
|
66
|
+
return colorMap[status] || 'white';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get icon and color for the A/M change indicator.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} status - Change status (A/M)
|
|
73
|
+
* @returns {{icon: string, color: string}|null} Indicator display config
|
|
74
|
+
*/
|
|
75
|
+
function getChangeIndicator(status) {
|
|
76
|
+
const normalizedStatus = normalizeChangeStatus(status);
|
|
77
|
+
if (normalizedStatus === 'M') {
|
|
78
|
+
return { icon: '●', color: 'yellow' };
|
|
79
|
+
}
|
|
80
|
+
if (normalizedStatus === 'A') {
|
|
81
|
+
return { icon: '✚', color: 'green' };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* TreeLine Component Props:
|
|
88
|
+
* @property {Object} node - FileNode containing name, isDir, expanded, mode, isSymlink, depth
|
|
89
|
+
* @property {boolean} isLast - Is this the last child of its parent?
|
|
90
|
+
* @property {boolean[]} ancestorIsLast - Array of ancestor "is last" states
|
|
91
|
+
* @property {boolean} isCursor - Is the cursor on this line?
|
|
92
|
+
* @property {number} termWidth - Terminal width (for truncation)
|
|
93
|
+
* @property {boolean} showIcons - --no-icons flag (false means hide icons)
|
|
94
|
+
* @property {string} gitStatus - Git status code (empty, M, A, D, U, R)
|
|
95
|
+
* @property {string} changeStatus - Combined indicator status (A/M/empty)
|
|
96
|
+
*/
|
|
97
|
+
export function TreeLine({
|
|
98
|
+
node,
|
|
99
|
+
isLast = false,
|
|
100
|
+
ancestorIsLast = [],
|
|
101
|
+
isCursor = false,
|
|
102
|
+
termWidth = 80,
|
|
103
|
+
iconSet = 'emoji',
|
|
104
|
+
theme = VSCODE_THEME,
|
|
105
|
+
gitStatus = '',
|
|
106
|
+
changeStatus = '',
|
|
107
|
+
}) {
|
|
108
|
+
const finalTheme = theme || VSCODE_THEME;
|
|
109
|
+
|
|
110
|
+
// Compute the tree prefix (connectors)
|
|
111
|
+
const prefix = getPrefix(node, isLast, ancestorIsLast);
|
|
112
|
+
|
|
113
|
+
// Detect special file types from mode
|
|
114
|
+
const fileType = detectFileType(node.mode);
|
|
115
|
+
|
|
116
|
+
// Get icon (emoji/nerd/ascii)
|
|
117
|
+
const icon = getIcon(
|
|
118
|
+
node.name,
|
|
119
|
+
node.isDir,
|
|
120
|
+
node.expanded,
|
|
121
|
+
node.isSymlink,
|
|
122
|
+
node.hasError,
|
|
123
|
+
fileType,
|
|
124
|
+
{ iconSet }
|
|
125
|
+
);
|
|
126
|
+
const changeIndicator = getChangeIndicator(changeStatus);
|
|
127
|
+
|
|
128
|
+
const baseNameStyle = (() => {
|
|
129
|
+
if (node.hasError) return finalTheme.tree.error;
|
|
130
|
+
if (node.isSymlink) return finalTheme.tree.symlink;
|
|
131
|
+
if (node.name?.startsWith('.')) return finalTheme.tree.hidden;
|
|
132
|
+
if (node.isDir) return finalTheme.tree.dirName;
|
|
133
|
+
return finalTheme.tree.fileName;
|
|
134
|
+
})();
|
|
135
|
+
|
|
136
|
+
const cursorStyle = isCursor ? finalTheme.tree.cursor : null;
|
|
137
|
+
const badgeCursorStyle = cursorStyle?.backgroundColor ? { backgroundColor: cursorStyle.backgroundColor } : null;
|
|
138
|
+
const connectorStyle = finalTheme.tree.connector;
|
|
139
|
+
|
|
140
|
+
// Calculate available width for filename
|
|
141
|
+
// Reserve space for: prefix + icon + right badges + padding
|
|
142
|
+
const iconWidth = icon ? 2 : 0; // icon + space
|
|
143
|
+
const changeIndicatorWidth = changeIndicator ? 3 : 0; // space + indicator + space
|
|
144
|
+
const gitBadgeWidth = gitStatus ? 3 : 0; // space + status char + space
|
|
145
|
+
const reservedWidth = prefix.length + iconWidth + changeIndicatorWidth + gitBadgeWidth + 2;
|
|
146
|
+
const maxFilenameWidth = Math.max(4, termWidth - reservedWidth);
|
|
147
|
+
|
|
148
|
+
// Truncate filename if needed
|
|
149
|
+
const displayName = truncateFilename(node.name, maxFilenameWidth);
|
|
150
|
+
|
|
151
|
+
// Build the main content (prefix + icon + filename)
|
|
152
|
+
const mainContent = (
|
|
153
|
+
<Box>
|
|
154
|
+
<Text {...connectorStyle} {...(cursorStyle || {})}>{prefix}</Text>
|
|
155
|
+
{icon && <Text {...(cursorStyle || {})}>{icon} </Text>}
|
|
156
|
+
<Text
|
|
157
|
+
{...baseNameStyle}
|
|
158
|
+
{...(cursorStyle || {})}
|
|
159
|
+
>
|
|
160
|
+
{displayName}
|
|
161
|
+
</Text>
|
|
162
|
+
</Box>
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Add right-aligned badges if present
|
|
166
|
+
if (gitStatus || changeIndicator) {
|
|
167
|
+
return (
|
|
168
|
+
<Box justifyContent="spaceBetween" width={termWidth}>
|
|
169
|
+
{mainContent}
|
|
170
|
+
<Box>
|
|
171
|
+
{gitStatus && (
|
|
172
|
+
<Text color={getGitBadgeColor(gitStatus)} {...(badgeCursorStyle || {})}>
|
|
173
|
+
{' '}{gitStatus}{' '}
|
|
174
|
+
</Text>
|
|
175
|
+
)}
|
|
176
|
+
{changeIndicator && (
|
|
177
|
+
<Text color={changeIndicator.color} {...(badgeCursorStyle || {})}>
|
|
178
|
+
{' '}{changeIndicator.icon}{' '}
|
|
179
|
+
</Text>
|
|
180
|
+
)}
|
|
181
|
+
</Box>
|
|
182
|
+
</Box>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// No git badge - just return main content
|
|
187
|
+
return mainContent;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default TreeLine;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeView Component for ftree
|
|
3
|
+
*
|
|
4
|
+
* Main tree view component that renders the file tree with viewport support.
|
|
5
|
+
* Handles slicing the visible nodes, computing connector states, and managing
|
|
6
|
+
* the tree visualization.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Viewport rendering (only visible lines are rendered)
|
|
10
|
+
* - Tree connector state computation for proper tree structure
|
|
11
|
+
* - Empty directory handling
|
|
12
|
+
* - Cursor highlighting support
|
|
13
|
+
* - Icon display toggle (--no-icons flag)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React from 'react';
|
|
17
|
+
import { Box, Text } from 'ink';
|
|
18
|
+
|
|
19
|
+
import { TreeLine } from './TreeLine.jsx';
|
|
20
|
+
import { computeAncestorPrefixes } from '../lib/connectors.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Determines if a node at the given index is a last child of its parent.
|
|
24
|
+
*
|
|
25
|
+
* A node is a last child if there are no siblings following it in the
|
|
26
|
+
* flattened list before the depth decreases.
|
|
27
|
+
*
|
|
28
|
+
* @param {FileNode[]} flatList - Flattened list of FileNodes
|
|
29
|
+
* @param {number} index - Index of the node to check
|
|
30
|
+
* @returns {boolean} True if this node is the last child of its parent
|
|
31
|
+
*/
|
|
32
|
+
function isLastChild(flatList, index) {
|
|
33
|
+
if (index >= flatList.length - 1) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const currentNode = flatList[index];
|
|
38
|
+
const nextNode = flatList[index + 1];
|
|
39
|
+
|
|
40
|
+
// If next node is shallower or at same depth (not a child), this is a last child
|
|
41
|
+
// If next node is deeper (a child), this is NOT a last child
|
|
42
|
+
return nextNode.depth <= currentNode.depth;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Empty directory message component
|
|
47
|
+
* Displays a centered, dimmed message when the tree is empty
|
|
48
|
+
*/
|
|
49
|
+
function EmptyDirectory() {
|
|
50
|
+
return (
|
|
51
|
+
<Box justifyContent="center" paddingY={1}>
|
|
52
|
+
<Text dimColor>(empty directory)</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* TreeView Component Props:
|
|
59
|
+
* @property {FileNode[]} flatList - Flattened visible nodes from useTree
|
|
60
|
+
* @property {number} cursor - Current cursor index (in flatList coordinates)
|
|
61
|
+
* @property {number} viewportStart - First visible line index (in flatList coordinates)
|
|
62
|
+
* @property {number} viewportHeight - Number of visible lines
|
|
63
|
+
* @property {number} termWidth - Terminal width in columns
|
|
64
|
+
* @property {'emoji'|'nerd'|'ascii'} iconSet - Icon set selection
|
|
65
|
+
* @property {object} theme - Theme tokens
|
|
66
|
+
*/
|
|
67
|
+
export function TreeView({
|
|
68
|
+
flatList = [],
|
|
69
|
+
cursor = 0,
|
|
70
|
+
viewportStart = 0,
|
|
71
|
+
viewportHeight = 20,
|
|
72
|
+
termWidth = 80,
|
|
73
|
+
iconSet = 'emoji',
|
|
74
|
+
theme = null,
|
|
75
|
+
}) {
|
|
76
|
+
// Handle empty directory case
|
|
77
|
+
if (flatList.length === 0) {
|
|
78
|
+
return <EmptyDirectory />;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Compute the visible slice of the flat list
|
|
82
|
+
// Ensure we don't go out of bounds
|
|
83
|
+
const safeViewportStart = Math.max(0, Math.min(viewportStart, flatList.length - 1));
|
|
84
|
+
const safeViewportHeight = Math.min(viewportHeight, flatList.length - safeViewportStart);
|
|
85
|
+
const visibleNodes = flatList.slice(safeViewportStart, safeViewportStart + safeViewportHeight);
|
|
86
|
+
|
|
87
|
+
// DEBUG: Log what we're rendering
|
|
88
|
+
if (process.env.FTREE_DEBUG) {
|
|
89
|
+
console.log(`[DEBUG] TreeView rendering: flatList.length=${flatList.length}, viewportHeight=${viewportHeight}`);
|
|
90
|
+
console.log(`[DEBUG] TreeView: safeViewportStart=${safeViewportStart}, safeViewportHeight=${safeViewportHeight}`);
|
|
91
|
+
console.log(`[DEBUG] TreeView: visibleNodes count=${visibleNodes.length}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Box flexDirection="column" width={termWidth}>
|
|
96
|
+
{visibleNodes.map((node, visibleIndex) => {
|
|
97
|
+
// Calculate the actual index in the flat list
|
|
98
|
+
const actualIndex = safeViewportStart + visibleIndex;
|
|
99
|
+
|
|
100
|
+
// Determine if this node is the last child of its parent
|
|
101
|
+
const isLast = isLastChild(flatList, actualIndex);
|
|
102
|
+
|
|
103
|
+
// Compute ancestor prefixes for connector state
|
|
104
|
+
// ancestorIsLast array indicates which ancestors are last children
|
|
105
|
+
const ancestorIsLast = computeAncestorPrefixes(flatList, actualIndex);
|
|
106
|
+
|
|
107
|
+
// Check if cursor is on this line
|
|
108
|
+
const isCursor = actualIndex === cursor;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<TreeLine
|
|
112
|
+
key={node.path}
|
|
113
|
+
node={node}
|
|
114
|
+
isLast={isLast}
|
|
115
|
+
ancestorIsLast={ancestorIsLast}
|
|
116
|
+
isCursor={isCursor}
|
|
117
|
+
termWidth={termWidth}
|
|
118
|
+
iconSet={iconSet}
|
|
119
|
+
theme={theme}
|
|
120
|
+
gitStatus={node.gitStatus || ''}
|
|
121
|
+
changeStatus={node.changeStatus || ''}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</Box>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default TreeView;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useChangedFiles - Track recently changed files for visual highlighting
|
|
3
|
+
*
|
|
4
|
+
* Provides a map of file paths to A/M status values that changed recently
|
|
5
|
+
* due to file watcher events. Files are automatically removed
|
|
6
|
+
* from the changed set after a timeout period.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
mergeChangeStatus,
|
|
12
|
+
normalizeChangeStatus,
|
|
13
|
+
} from '../lib/changeStatus.js';
|
|
14
|
+
|
|
15
|
+
// How long to show the "changed" indicator (in milliseconds)
|
|
16
|
+
// Keep these fairly long to ensure users actually notice new items,
|
|
17
|
+
// especially on filesystems where watcher events arrive in bursts (add -> change).
|
|
18
|
+
const MODIFY_HIGHLIGHT_DURATION = 60000; // 60 seconds
|
|
19
|
+
const ADD_HIGHLIGHT_DURATION = 120000; // 120 seconds (helps empty new folders with no git signal)
|
|
20
|
+
|
|
21
|
+
export function useChangedFiles() {
|
|
22
|
+
const [changedFiles, setChangedFiles] = useState(new Map());
|
|
23
|
+
const timeoutMapRef = useRef(new Map());
|
|
24
|
+
|
|
25
|
+
const markChanged = useCallback((filePath, status = 'M') => {
|
|
26
|
+
const normalizedStatus = normalizeChangeStatus(status);
|
|
27
|
+
if (!filePath || !normalizedStatus) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let mergedStatus = normalizedStatus;
|
|
32
|
+
setChangedFiles(prev => {
|
|
33
|
+
const next = new Map(prev);
|
|
34
|
+
const existingStatus = next.get(filePath) || '';
|
|
35
|
+
mergedStatus = mergeChangeStatus(existingStatus, normalizedStatus);
|
|
36
|
+
next.set(filePath, mergedStatus);
|
|
37
|
+
return next;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const existingTimer = timeoutMapRef.current.get(filePath);
|
|
41
|
+
if (existingTimer) {
|
|
42
|
+
clearTimeout(existingTimer);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const durationMs = mergedStatus === 'A'
|
|
46
|
+
? ADD_HIGHLIGHT_DURATION
|
|
47
|
+
: MODIFY_HIGHLIGHT_DURATION;
|
|
48
|
+
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
setChangedFiles(prev => {
|
|
51
|
+
if (!prev.has(filePath)) {
|
|
52
|
+
return prev;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const next = new Map(prev);
|
|
56
|
+
next.delete(filePath);
|
|
57
|
+
return next;
|
|
58
|
+
});
|
|
59
|
+
timeoutMapRef.current.delete(filePath);
|
|
60
|
+
}, durationMs);
|
|
61
|
+
|
|
62
|
+
timeoutMapRef.current.set(filePath, timer);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Cleanup all pending timers on unmount
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
return () => {
|
|
68
|
+
for (const timer of timeoutMapRef.current.values()) {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
timeoutMapRef.current.clear();
|
|
72
|
+
};
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
changedFiles,
|
|
77
|
+
markChanged,
|
|
78
|
+
getStatus: (filePath) => normalizeChangeStatus(changedFiles.get(filePath) || ''),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default useChangedFiles;
|