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/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "ftreeview",
3
+ "version": "0.1.1",
4
+ "description": "Terminal file explorer with git awareness and live updates",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "files": [
8
+ "dist",
9
+ "src",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "bin": {
14
+ "ftree": "dist/cli.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "scripts": {
20
+ "build": "node build.js",
21
+ "dev": "node build.js --run",
22
+ "prepublishOnly": "npm run build",
23
+ "test": "echo \"Error: no test specified\" && exit 1"
24
+ },
25
+ "keywords": [
26
+ "cli",
27
+ "file",
28
+ "explorer",
29
+ "tree",
30
+ "terminal",
31
+ "tui",
32
+ "watcher",
33
+ "git"
34
+ ],
35
+ "author": "ftree contributors",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/chaitanyame/watchout.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/chaitanyame/watchout/issues"
43
+ },
44
+ "homepage": "https://github.com/chaitanyame/watchout#readme",
45
+ "dependencies": {
46
+ "chalk": "^5.4.1",
47
+ "chokidar": "^4.0.3",
48
+ "ignore": "^6.0.2",
49
+ "ink": "^5.0.1",
50
+ "react": "^18.3.1"
51
+ },
52
+ "devDependencies": {
53
+ "esbuild": "^0.27.3"
54
+ },
55
+ "exports": {
56
+ ".": "./src/index.js"
57
+ }
58
+ }
package/src/App.jsx ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * App Component - Root Component for ftree
3
+ *
4
+ * Orchestrates between:
5
+ * - useTree: Manages the file tree data structure
6
+ * - useNavigation: Handles keyboard navigation and cursor state
7
+ * - TreeView: Renders the visible portion of the tree
8
+ * - StatusBar: Shows helpful information (T9)
9
+ * - useGitStatus: Provides git status integration
10
+ * - useWatcher: Monitors file system changes
11
+ *
12
+ * This component uses React hooks to manage state and coordinates
13
+ * between the data flow, navigation state, and rendering.
14
+ */
15
+ import React, { useEffect, useCallback, useMemo, useState, useRef } from 'react';
16
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
17
+ import { Box } from 'ink';
18
+ import TreeView from './components/TreeView.jsx';
19
+ import StatusBar, { calculateCounts, calculateGitStatus } from './components/StatusBar.jsx';
20
+ import { useTree, applyGitStatus, applyChangeIndicators } from './hooks/useTree.js';
21
+ import { useNavigation } from './hooks/useNavigation.js';
22
+ import { useGitStatus } from './hooks/useGitStatus.js';
23
+ import { useWatcher } from './hooks/useWatcher.js';
24
+ import { useChangedFiles } from './hooks/useChangedFiles.js';
25
+ import { mapWatcherEventToChangeStatus } from './lib/changeStatus.js';
26
+ import { getTheme, resolveIconSet, resolveThemeName } from './lib/theme.js';
27
+
28
+ /**
29
+ * Clean exit handler - ensures proper terminal state on exit
30
+ * Prints a newline to avoid leaving cursor on status line
31
+ */
32
+ function setupCleanExit() {
33
+ const onExit = () => {
34
+ // Print newline for clean exit
35
+ process.stdout.write('\n');
36
+ };
37
+
38
+ // Handle normal exit
39
+ process.on('exit', onExit);
40
+
41
+ // Handle SIGINT (Ctrl+C)
42
+ process.on('SIGINT', () => {
43
+ onExit();
44
+ process.exit(0);
45
+ });
46
+
47
+ // Return cleanup function
48
+ return () => {
49
+ process.off('exit', onExit);
50
+ process.off('SIGINT');
51
+ };
52
+ }
53
+
54
+ /**
55
+ * App Component Props
56
+ * @property {string} rootPath - Root directory path to explore
57
+ * @property {object} options - Configuration options from CLI
58
+ */
59
+ export default function App({ rootPath, options = {}, version }) {
60
+ const rootAbsPath = resolve(rootPath);
61
+ const iconSet = resolveIconSet({ cliIconSet: options.iconSet, noIcons: options.noIcons });
62
+ const theme = getTheme(resolveThemeName({ cliTheme: options.theme }));
63
+
64
+ // Track selected path to preserve cursor position across rebuilds
65
+ const [selectedPath, setSelectedPath] = useState(null);
66
+
67
+ // Setup clean exit handler - ensures proper terminal state on exit
68
+ useEffect(() => setupCleanExit(), []);
69
+
70
+ // Get tree data and rebuild function
71
+ const { tree, flatList, refreshFlatList, rebuildTree } = useTree(rootPath, options);
72
+
73
+ // Get git status (enabled unless --no-git flag is set)
74
+ const { statusMap, isGitRepo, refresh: refreshGit } = useGitStatus(
75
+ rootPath,
76
+ !options.noGit
77
+ );
78
+
79
+ // Get navigation state (cursor, viewport)
80
+ const { cursor, viewportStart, setCursor } = useNavigation(
81
+ flatList,
82
+ rebuildTree,
83
+ refreshFlatList
84
+ );
85
+ const cursorRef = useRef(cursor);
86
+ cursorRef.current = cursor;
87
+
88
+ // Track recently changed files for visual highlighting
89
+ const { changedFiles, markChanged } = useChangedFiles();
90
+
91
+ // Apply git/change indicators during render (memoized) to avoid effect-driven update loops.
92
+ useMemo(() => {
93
+ if (!tree) {
94
+ return;
95
+ }
96
+
97
+ const gitStatuses = isGitRepo ? statusMap : new Map();
98
+ applyGitStatus(tree, gitStatuses);
99
+ applyChangeIndicators(tree, gitStatuses, changedFiles);
100
+ }, [statusMap, tree, isGitRepo, changedFiles]);
101
+
102
+ // Terminal size state (for resize handling)
103
+ const [termSize, setTermSize] = useState({
104
+ width: process.stdout.columns,
105
+ height: process.stdout.rows,
106
+ });
107
+
108
+ // Listen for terminal resize events
109
+ useEffect(() => {
110
+ const handleResize = () => {
111
+ setTermSize({
112
+ width: process.stdout.columns,
113
+ height: process.stdout.rows,
114
+ });
115
+ };
116
+
117
+ // Listen for resize events
118
+ process.stdout.on('resize', handleResize);
119
+
120
+ // Cleanup listener on unmount
121
+ return () => {
122
+ process.stdout.off('resize', handleResize);
123
+ };
124
+ }, []);
125
+
126
+ // Update selected path when cursor changes
127
+ useEffect(() => {
128
+ if (flatList.length > 0 && cursor >= 0 && cursor < flatList.length) {
129
+ const currentNode = flatList[cursor];
130
+ if (currentNode) {
131
+ setSelectedPath((prev) => (prev === currentNode.path ? prev : currentNode.path));
132
+ }
133
+ }
134
+ }, [cursor, flatList]);
135
+
136
+ // Restore cursor position after rebuild
137
+ useEffect(() => {
138
+ if (!selectedPath || flatList.length === 0) {
139
+ return;
140
+ }
141
+
142
+ const newIndex = flatList.findIndex((node) => node.path === selectedPath);
143
+ if (newIndex === -1) {
144
+ setCursor((prev) => Math.min(prev, flatList.length - 1));
145
+ return;
146
+ }
147
+
148
+ if (newIndex !== cursorRef.current) {
149
+ setCursor(newIndex);
150
+ }
151
+ }, [flatList, selectedPath, setCursor]);
152
+
153
+ // Convert watcher path to tree-relative path format
154
+ const toTreeRelativePath = useCallback((watchPath) => {
155
+ if (!watchPath) {
156
+ return '';
157
+ }
158
+
159
+ const absolutePath = isAbsolute(watchPath)
160
+ ? watchPath
161
+ : resolve(rootAbsPath, watchPath);
162
+ const relativePath = relative(rootAbsPath, absolutePath);
163
+
164
+ if (!relativePath || relativePath.startsWith('..')) {
165
+ return '';
166
+ }
167
+
168
+ return relativePath.split(sep).join('/');
169
+ }, [rootAbsPath]);
170
+
171
+ // File watcher - rebuilds tree and refreshes git status on file changes
172
+ // Enabled unless --no-watch flag is set
173
+ const handleTreeChange = useCallback((eventsOrEvent, watchPath) => {
174
+ const batch = Array.isArray(eventsOrEvent)
175
+ ? eventsOrEvent
176
+ : [{ event: eventsOrEvent, path: watchPath }];
177
+
178
+ for (const item of batch) {
179
+ if (!item) continue;
180
+ const changeStatus = mapWatcherEventToChangeStatus(item.event);
181
+ const relativePath = toTreeRelativePath(item.path);
182
+
183
+ if (changeStatus && relativePath) {
184
+ markChanged(relativePath, changeStatus);
185
+ }
186
+ }
187
+
188
+ rebuildTree(true);
189
+ if (isGitRepo) {
190
+ refreshGit();
191
+ }
192
+ }, [rebuildTree, isGitRepo, refreshGit, markChanged, toTreeRelativePath]);
193
+
194
+ const { isWatching } = useWatcher(rootPath, handleTreeChange, {
195
+ noWatch: options.noWatch,
196
+ });
197
+
198
+ // Calculate viewport dimensions
199
+ // Reserve space: 1 line for status bar (2 if help enabled)
200
+ const helpEnabled = options.help !== false; // Show help by default
201
+ const statusBarHeight = helpEnabled ? 2 : 1;
202
+ const viewportHeight = termSize.height - statusBarHeight;
203
+ const termWidth = termSize.width;
204
+
205
+ // Calculate file and directory counts
206
+ const { fileCount, dirCount } = calculateCounts(flatList);
207
+
208
+ // Calculate git status summary
209
+ const { summary: gitSummary } = calculateGitStatus(statusMap);
210
+
211
+ return (
212
+ <Box flexDirection="column" height="100%">
213
+ {/* Main tree view area */}
214
+ <Box flexGrow={1}>
215
+ <TreeView
216
+ flatList={flatList}
217
+ cursor={cursor}
218
+ viewportStart={viewportStart}
219
+ viewportHeight={viewportHeight}
220
+ termWidth={termWidth}
221
+ iconSet={iconSet}
222
+ theme={theme}
223
+ />
224
+ </Box>
225
+
226
+ {/* StatusBar */}
227
+ <StatusBar
228
+ version={version}
229
+ rootPath={rootPath}
230
+ fileCount={fileCount}
231
+ dirCount={dirCount}
232
+ isWatching={isWatching}
233
+ gitEnabled={!options.noGit}
234
+ isGitRepo={isGitRepo}
235
+ gitSummary={gitSummary}
236
+ showHelp={helpEnabled}
237
+ termWidth={termWidth}
238
+ iconSet={iconSet}
239
+ theme={theme}
240
+ />
241
+ </Box>
242
+ );
243
+ }
package/src/cli.js ADDED
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ftree CLI entry point
5
+ * Terminal file explorer with git awareness and live updates
6
+ */
7
+
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+ import { resolve, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { statSync, readdirSync } from 'node:fs';
12
+ import { render } from 'ink';
13
+ import React from 'react';
14
+
15
+ import App from './App.jsx';
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+
19
+ // Get version from package.json
20
+ const pkgPath = resolve(__dirname, '../package.json');
21
+ const { version } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
22
+
23
+ /**
24
+ * Print help message
25
+ */
26
+ function printHelp() {
27
+ console.log(`
28
+ ftree - Terminal file explorer with git awareness and live updates
29
+
30
+ USAGE:
31
+ ftree [path]
32
+
33
+ ARGUMENTS:
34
+ [path] Path to directory to explore (default: current directory)
35
+
36
+ OPTIONS:
37
+ -h, --help Print this help message
38
+ -v, --version Print version information
39
+ --no-git Disable git integration
40
+ --no-icons Use ASCII icons (disable emoji/nerd icons)
41
+ --no-watch Disable file watching
42
+ --icon-set <emoji|nerd|ascii> Icon set (default: emoji). You can also set FTREE_ICON_SET.
43
+ --theme <vscode> UI theme (default: vscode). You can also set FTREE_THEME.
44
+
45
+ EXAMPLES:
46
+ ftree # Explore current directory
47
+ ftree ~/projects # Explore specific directory
48
+ ftree /path/to/project # Explore project path
49
+
50
+ EXIT CODES:
51
+ 0 - Success
52
+ 1 - Invalid path or error
53
+ `);
54
+ }
55
+
56
+ /**
57
+ * Print version information
58
+ */
59
+ function printVersion() {
60
+ console.log(`ftree v${version}`);
61
+ }
62
+
63
+ /**
64
+ * Validate a directory path
65
+ * @param {string} path - Path to validate
66
+ * @returns {{valid: boolean, error?: string}}
67
+ */
68
+ function validatePath(path) {
69
+ const resolvedPath = resolve(path);
70
+
71
+ // Check if path exists
72
+ if (!existsSync(resolvedPath)) {
73
+ return {
74
+ valid: false,
75
+ error: `Error: Path does not exist: ${resolvedPath}`,
76
+ };
77
+ }
78
+
79
+ // Check if it's a directory
80
+ try {
81
+ const stats = statSync(resolvedPath);
82
+ if (!stats.isDirectory()) {
83
+ return {
84
+ valid: false,
85
+ error: `Error: Path is not a directory: ${resolvedPath}`,
86
+ };
87
+ }
88
+ } catch (err) {
89
+ return {
90
+ valid: false,
91
+ error: `Error: Cannot access path: ${resolvedPath} (${err.message})`,
92
+ };
93
+ }
94
+
95
+ // Check if directory is readable
96
+ try {
97
+ readdirSync(resolvedPath);
98
+ } catch (err) {
99
+ return {
100
+ valid: false,
101
+ error: `Error: Cannot read directory: ${resolvedPath} (${err.message})`,
102
+ };
103
+ }
104
+
105
+ return { valid: true };
106
+ }
107
+
108
+ /**
109
+ * Parse command line arguments
110
+ * @returns {{path?: string, help: boolean, version: boolean, options: object}}
111
+ */
112
+ function parseArgs() {
113
+ const args = process.argv.slice(2);
114
+ const result = {
115
+ help: false,
116
+ version: false,
117
+ options: {
118
+ noIcons: false,
119
+ noGit: false,
120
+ noWatch: false,
121
+ help: true, // show help line by default
122
+ iconSet: undefined,
123
+ theme: undefined,
124
+ },
125
+ };
126
+
127
+ for (let i = 0; i < args.length; i++) {
128
+ const arg = args[i];
129
+
130
+ if (arg === '-h' || arg === '--help') {
131
+ result.help = true;
132
+ continue;
133
+ }
134
+ if (arg === '-v' || arg === '--version') {
135
+ result.version = true;
136
+ continue;
137
+ }
138
+
139
+ if (arg === '--no-git') {
140
+ result.options.noGit = true;
141
+ continue;
142
+ }
143
+ if (arg === '--no-icons') {
144
+ result.options.noIcons = true;
145
+ continue;
146
+ }
147
+ if (arg === '--no-watch') {
148
+ result.options.noWatch = true;
149
+ continue;
150
+ }
151
+ if (arg === '--no-help') {
152
+ result.options.help = false;
153
+ continue;
154
+ }
155
+
156
+ if (arg === '--icon-set' || arg.startsWith('--icon-set=')) {
157
+ const value = arg.includes('=')
158
+ ? arg.split('=')[1]
159
+ : args[++i];
160
+ if (!value) {
161
+ console.error('Error: --icon-set requires a value (emoji|nerd|ascii)');
162
+ process.exit(1);
163
+ }
164
+ result.options.iconSet = value;
165
+ continue;
166
+ }
167
+
168
+ if (arg === '--theme' || arg.startsWith('--theme=')) {
169
+ const value = arg.includes('=')
170
+ ? arg.split('=')[1]
171
+ : args[++i];
172
+ if (!value) {
173
+ console.error('Error: --theme requires a value');
174
+ process.exit(1);
175
+ }
176
+ result.options.theme = value;
177
+ continue;
178
+ }
179
+
180
+ if (!arg.startsWith('-')) {
181
+ // Positional argument (path)
182
+ result.path = arg;
183
+ continue;
184
+ }
185
+
186
+ console.error(`Error: Unknown option: ${arg}`);
187
+ console.error('Use --help for usage information');
188
+ process.exit(1);
189
+ }
190
+
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * Main CLI entry point
196
+ */
197
+ function main() {
198
+ const { path, help, version, options } = parseArgs();
199
+
200
+ // Handle help flag
201
+ if (help) {
202
+ printHelp();
203
+ process.exit(0);
204
+ }
205
+
206
+ // Handle version flag
207
+ if (version) {
208
+ printVersion();
209
+ process.exit(0);
210
+ }
211
+
212
+ // Determine target path (default to current directory)
213
+ const targetPath = path || '.';
214
+
215
+ // Validate path
216
+ const validation = validatePath(targetPath);
217
+ if (!validation.valid) {
218
+ console.error(validation.error);
219
+ process.exit(1);
220
+ }
221
+
222
+ // Render the Ink app
223
+ const resolvedPath = resolve(targetPath);
224
+ render(React.createElement(App, { rootPath: resolvedPath, options, version: `v${version}` }));
225
+ }
226
+
227
+ // Run CLI
228
+ main();