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,508 @@
1
+ /**
2
+ * useTree - FileNode Tree Builder Hook
3
+ *
4
+ * Provides utilities for building and managing a hierarchical file tree structure.
5
+ * This hook creates FileNode objects from the file system and handles tree traversal.
6
+ */
7
+
8
+ import { readdirSync, statSync, lstatSync, readlinkSync } from 'node:fs';
9
+ import { resolve, basename, relative, sep } from 'node:path';
10
+ import { useState, useCallback, useRef } from 'react';
11
+ import { DEFAULT_CONFIG } from '../lib/constants.js';
12
+ import {
13
+ mapGitStatusToChangeStatus,
14
+ mergeChangeStatus,
15
+ normalizeChangeStatus,
16
+ } from '../lib/changeStatus.js';
17
+ import { createIgnoreFilter } from './useIgnore.js';
18
+
19
+ /**
20
+ * Creates a FileNode object from file system entry information
21
+ * Handles permission errors and symlink detection
22
+ *
23
+ * @param {string} absPath - Absolute path to the file/directory
24
+ * @param {string} rootPath - Root directory path for relative path calculation
25
+ * @param {number} depth - Nesting level in the tree
26
+ * @param {boolean} isDir - Whether this is a directory
27
+ * @param {boolean} isSymlink - Whether this is a symbolic link
28
+ * @returns {FileNode} A FileNode object
29
+ */
30
+ function createFileNode(absPath, rootPath, depth, isDir, isSymlink = false) {
31
+ let stats;
32
+ let targetPath = null;
33
+ let hasError = false;
34
+ let error = null;
35
+
36
+ // Handle permission errors
37
+ try {
38
+ // Use lstatSync for symlinks (doesn't follow them)
39
+ stats = isSymlink ? lstatSync(absPath) : statSync(absPath);
40
+
41
+ // For symlinks, try to read the target
42
+ if (isSymlink) {
43
+ try {
44
+ targetPath = readlinkSync(absPath);
45
+ } catch (e) {
46
+ // Can't read symlink target - mark as error but keep node
47
+ hasError = true;
48
+ error = 'Cannot read symlink';
49
+ }
50
+ }
51
+ } catch (e) {
52
+ if (e.code === 'EACCES' || e.code === 'EPERM') {
53
+ // Permission denied - create error node
54
+ return {
55
+ name: basename(absPath),
56
+ path: relative(rootPath, absPath).split(sep).join('/'),
57
+ absPath,
58
+ isDir,
59
+ children: null,
60
+ expanded: false,
61
+ depth,
62
+ size: 0,
63
+ mode: 0,
64
+ gitStatus: '',
65
+ changeStatus: '',
66
+ hasError: true,
67
+ error: 'Permission denied',
68
+ };
69
+ }
70
+ // Re-throw other errors
71
+ throw e;
72
+ }
73
+
74
+ return {
75
+ name: basename(absPath),
76
+ path: relative(rootPath, absPath).split(sep).join('/'),
77
+ absPath,
78
+ isDir,
79
+ children: isDir ? [] : null,
80
+ expanded: false,
81
+ depth,
82
+ size: stats.size,
83
+ mode: stats.mode,
84
+ gitStatus: '', // Will be populated by git status hook (T10)
85
+ changeStatus: '', // Combined A/M indicator from git + watcher states
86
+ isSymlink,
87
+ targetPath,
88
+ hasError,
89
+ error,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Compare function for sorting FileNodes
95
+ * Directories come first, then files, both sorted case-insensitively
96
+ *
97
+ * @param {FileNode} a - First node to compare
98
+ * @param {FileNode} b - Second node to compare
99
+ * @returns {number} Comparison result for sort
100
+ */
101
+ function compareNodes(a, b) {
102
+ // Directories always come before files
103
+ if (a.isDir && !b.isDir) return -1;
104
+ if (!a.isDir && b.isDir) return 1;
105
+
106
+ // Within the same type, sort case-insensitively
107
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
108
+ }
109
+
110
+ /**
111
+ * Recursively builds a FileNode tree from the file system
112
+ *
113
+ * @param {string} rootPath - Absolute path to the directory to scan (root on initial call)
114
+ * @param {object} options - Configuration options
115
+ * @param {number} [options.depth] - Maximum depth to traverse (default: from DEFAULT_CONFIG)
116
+ * @param {number} [currentDepth=0] - Current traversal depth (internal)
117
+ * @param {object} [ignoreFilter] - Ignore filter instance (internal)
118
+ * @param {string|null} [baseRootPath] - Absolute root path used for node.path (internal)
119
+ * @returns {FileNode} The root FileNode of the tree
120
+ */
121
+ export function buildTree(rootPath, options = {}, currentDepth = 0, ignoreFilter = null, baseRootPath = null) {
122
+ const { depth = DEFAULT_CONFIG.maxDepth, showHidden = false } = options;
123
+ const finalBaseRootPath = baseRootPath || rootPath;
124
+
125
+ // Create ignore filter on initial call
126
+ if (ignoreFilter === null) {
127
+ ignoreFilter = createIgnoreFilter(finalBaseRootPath, options);
128
+ }
129
+
130
+ // Create root node
131
+ const rootNode = createFileNode(rootPath, finalBaseRootPath, currentDepth, true, false);
132
+ rootNode.expanded = true; // Root always starts expanded
133
+
134
+ // Check if we've reached the depth limit
135
+ if (currentDepth >= depth) {
136
+ return rootNode;
137
+ }
138
+
139
+ try {
140
+ // Load .gitignore patterns for this directory
141
+ if (ignoreFilter.loadPatternsForDirectory) {
142
+ ignoreFilter.loadPatternsForDirectory(rootPath);
143
+ }
144
+
145
+ // Read directory entries with file type information
146
+ const entries = readdirSync(rootPath, { withFileTypes: true });
147
+
148
+ // Process each entry
149
+ for (const entry of entries) {
150
+ const entryName = entry.name;
151
+ const entryAbsPath = resolve(rootPath, entryName);
152
+
153
+ // Always hide .git directory (even if ignore filtering is disabled)
154
+ if (entryName === '.git') {
155
+ continue;
156
+ }
157
+
158
+ // Skip hidden files if not showing them
159
+ if (!showHidden && entryName.startsWith('.')) {
160
+ continue;
161
+ }
162
+
163
+ const isDir = entry.isDirectory();
164
+ const isSymlink = entry.isSymbolicLink();
165
+
166
+ // Calculate relative path for ignore filtering
167
+ const entryRelativePath = relative(finalBaseRootPath, entryAbsPath);
168
+
169
+ // Check if this entry should be ignored
170
+ if (ignoreFilter.shouldIgnore(entryRelativePath)) {
171
+ continue;
172
+ }
173
+
174
+ // Create child node with symlink detection
175
+ let childNode;
176
+ try {
177
+ childNode = createFileNode(
178
+ entryAbsPath,
179
+ finalBaseRootPath,
180
+ currentDepth + 1,
181
+ isDir,
182
+ isSymlink,
183
+ );
184
+ } catch (e) {
185
+ // Handle permission errors for individual entries
186
+ if (e.code === 'EACCES' || e.code === 'EPERM') {
187
+ childNode = {
188
+ name: entryName,
189
+ path: relative(finalBaseRootPath, entryAbsPath).split(sep).join('/'),
190
+ absPath: entryAbsPath,
191
+ isDir,
192
+ children: null,
193
+ expanded: false,
194
+ depth: currentDepth + 1,
195
+ size: 0,
196
+ mode: 0,
197
+ gitStatus: '',
198
+ changeStatus: '',
199
+ hasError: true,
200
+ error: 'Permission denied',
201
+ isSymlink,
202
+ targetPath: null,
203
+ };
204
+ } else {
205
+ // For other errors, skip this entry
206
+ continue;
207
+ }
208
+ }
209
+
210
+ // Recursively build subtree for directories (but not for symlinks to directories)
211
+ if (isDir && !isSymlink) {
212
+ const childOptions = { ...options, showHidden };
213
+ const subtree = buildTree(
214
+ entryAbsPath,
215
+ childOptions,
216
+ currentDepth + 1,
217
+ ignoreFilter,
218
+ finalBaseRootPath,
219
+ );
220
+ childNode.children = subtree.children;
221
+ }
222
+
223
+ rootNode.children.push(childNode);
224
+ }
225
+
226
+ // Sort children: directories first, then case-insensitive alphabetical
227
+ rootNode.children.sort(compareNodes);
228
+ } catch (error) {
229
+ // If we can't read a directory, handle permission errors
230
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
231
+ rootNode.hasError = true;
232
+ rootNode.error = 'Permission denied';
233
+ rootNode.children = [];
234
+ } else {
235
+ // For other errors, also leave children empty
236
+ rootNode.children = [];
237
+ }
238
+ }
239
+
240
+ return rootNode;
241
+ }
242
+
243
+ /**
244
+ * Flattens a FileNode tree into an array of visible nodes
245
+ * Only includes children of expanded directories for rendering
246
+ *
247
+ * @param {FileNode} root - The root FileNode of the tree
248
+ * @returns {FileNode[]} Array of visible FileNodes in traversal order
249
+ */
250
+ export function flattenTree(root) {
251
+ const result = [];
252
+
253
+ /**
254
+ * Recursive helper to traverse and collect visible nodes
255
+ * @param {FileNode} node - Current node being traversed
256
+ */
257
+ function traverse(node) {
258
+ // Add current node to result
259
+ result.push(node);
260
+
261
+ // If node is expanded and has children, traverse them
262
+ if (node.expanded && node.children && node.children.length > 0) {
263
+ for (const child of node.children) {
264
+ traverse(child);
265
+ }
266
+ }
267
+ }
268
+
269
+ traverse(root);
270
+ return result;
271
+ }
272
+
273
+ /**
274
+ * Applies git status from statusMap to tree nodes
275
+ * Propagates git status through the tree with aggregation for directories
276
+ *
277
+ * Priority order for aggregation (highest to lowest):
278
+ * D (deleted) > M (modified) > A (added) > U (untracked) > R (renamed) > clean
279
+ *
280
+ * @param {FileNode} root - The root FileNode of the tree
281
+ * @param {Map<string, string>} statusMap - Map of file paths to git status codes
282
+ * @returns {FileNode} The modified root node with gitStatus set
283
+ */
284
+ export function applyGitStatus(root, statusMap) {
285
+ /**
286
+ * Walk the tree recursively and set gitStatus on nodes
287
+ * @param {FileNode} node - Current node being processed
288
+ */
289
+ function walk(node) {
290
+ if (!node.isDir) {
291
+ // File: lookup in statusMap, default to empty string (clean)
292
+ node.gitStatus = statusMap.get(node.path) || '';
293
+ } else {
294
+ // Directory: aggregate from children
295
+ let aggregatedStatus = '';
296
+
297
+ // First walk all children to populate their gitStatus
298
+ for (const child of node.children) {
299
+ walk(child);
300
+
301
+ // Aggregate child status with priority ordering
302
+ const childStatus = child.gitStatus;
303
+
304
+ if (childStatus === 'D') {
305
+ // Deleted has highest priority
306
+ aggregatedStatus = 'D';
307
+ } else if (childStatus === 'M' && aggregatedStatus !== 'D') {
308
+ // Modified has second priority
309
+ aggregatedStatus = 'M';
310
+ } else if (childStatus === 'A' && aggregatedStatus !== 'D' && aggregatedStatus !== 'M') {
311
+ // Added has third priority
312
+ aggregatedStatus = 'A';
313
+ } else if (childStatus === 'U' && !aggregatedStatus) {
314
+ // Untracked (no status yet)
315
+ aggregatedStatus = 'U';
316
+ } else if (childStatus === 'R' && !aggregatedStatus) {
317
+ // Renamed (no status yet)
318
+ aggregatedStatus = 'R';
319
+ }
320
+ }
321
+
322
+ node.gitStatus = aggregatedStatus;
323
+ }
324
+ }
325
+
326
+ walk(root);
327
+ return root;
328
+ }
329
+
330
+ /**
331
+ * Applies combined added/modified indicators to tree nodes.
332
+ * Uses git + watcher status sources and propagates to directories.
333
+ *
334
+ * Priority order: M > A > clean
335
+ *
336
+ * @param {FileNode} root - The root FileNode of the tree
337
+ * @param {Map<string, string>} gitStatusMap - Map of git statuses by path
338
+ * @param {Map<string, string>} watcherStatusMap - Map of watcher statuses by path
339
+ * @returns {FileNode} The modified root node with changeStatus set
340
+ */
341
+ export function applyChangeIndicators(root, gitStatusMap = new Map(), watcherStatusMap = new Map()) {
342
+ /**
343
+ * Walk the tree recursively and set changeStatus on nodes.
344
+ *
345
+ * @param {FileNode} node - Current node being processed
346
+ * @returns {''|'A'|'M'} Node indicator status
347
+ */
348
+ function walk(node) {
349
+ if (!node.isDir) {
350
+ const gitChangeStatus = mapGitStatusToChangeStatus(
351
+ gitStatusMap.get(node.path) || node.gitStatus || ''
352
+ );
353
+ const watcherChangeStatus = normalizeChangeStatus(
354
+ watcherStatusMap.get(node.path) || ''
355
+ );
356
+ const fileChangeStatus = mergeChangeStatus(gitChangeStatus, watcherChangeStatus);
357
+ node.changeStatus = fileChangeStatus;
358
+ return fileChangeStatus;
359
+ }
360
+
361
+ // Allow direct watcher events on directories (e.g., addDir)
362
+ let aggregatedStatus = normalizeChangeStatus(watcherStatusMap.get(node.path) || '');
363
+
364
+ if (node.children && node.children.length > 0) {
365
+ for (const child of node.children) {
366
+ const childStatus = walk(child);
367
+ aggregatedStatus = mergeChangeStatus(aggregatedStatus, childStatus);
368
+ }
369
+ }
370
+
371
+ // Never show the indicator on the root "parent" folder (depth 0).
372
+ const finalStatus = node.depth === 0 ? '' : aggregatedStatus;
373
+ node.changeStatus = finalStatus;
374
+ return finalStatus;
375
+ }
376
+
377
+ walk(root);
378
+ return root;
379
+ }
380
+
381
+ /**
382
+ * Merge expanded state from old tree into new tree
383
+ * This preserves the expand/collapse state across rebuilds
384
+ *
385
+ * @param {FileNode} oldTree - The old tree structure before rebuild
386
+ * @param {FileNode} newTree - The newly built tree structure
387
+ * @returns {FileNode} The new tree with expanded states applied
388
+ */
389
+ export function mergeExpandState(oldTree, newTree) {
390
+ if (!oldTree || !newTree) return newTree;
391
+
392
+ // Build a map of expanded paths from old tree
393
+ const expandedPaths = new Set();
394
+
395
+ function collectExpanded(node) {
396
+ if (node.isDir && node.expanded) {
397
+ expandedPaths.add(node.path);
398
+ }
399
+ if (node.children) {
400
+ for (const child of node.children) {
401
+ collectExpanded(child);
402
+ }
403
+ }
404
+ }
405
+
406
+ collectExpanded(oldTree);
407
+
408
+ // Apply expanded state to new tree
409
+ function applyExpanded(node) {
410
+ if (node.isDir && expandedPaths.has(node.path)) {
411
+ node.expanded = true;
412
+ }
413
+ if (node.children) {
414
+ for (const child of node.children) {
415
+ applyExpanded(child);
416
+ }
417
+ }
418
+ }
419
+
420
+ applyExpanded(newTree);
421
+ return newTree;
422
+ }
423
+
424
+ /**
425
+ * React hook for building and managing a file tree
426
+ * Provides state management and rebuild functionality for the file tree
427
+ *
428
+ * @param {string} rootPath - Root directory path to explore
429
+ * @param {object} options - Configuration options
430
+ * @returns {{tree: FileNode, flatList: FileNode[], refreshFlatList: Function, rebuildTree: Function, getTree: Function}} The tree structure and tree management functions
431
+ */
432
+ export function useTree(rootPath, options = {}) {
433
+ const absPath = resolve(rootPath);
434
+
435
+ // Initial tree build
436
+ const [tree, setTree] = useState(() => buildTree(absPath, options));
437
+
438
+ // Keep ref to current tree to avoid closure staleness
439
+ const treeRef = useRef(tree);
440
+ treeRef.current = tree;
441
+
442
+ // Flattened list for rendering
443
+ const [flatList, setFlatList] = useState(() => flattenTree(tree));
444
+
445
+ /**
446
+ * Refresh only the flattened visible list from the current tree state.
447
+ * Useful for UI-only changes (expand/collapse) without filesystem rescans.
448
+ */
449
+ const refreshFlatList = useCallback(() => {
450
+ const currentTree = treeRef.current;
451
+ if (!currentTree) {
452
+ return;
453
+ }
454
+ setFlatList(flattenTree(currentTree));
455
+ }, []);
456
+
457
+ /**
458
+ * Rebuild the tree, optionally preserving expanded states
459
+ * This is called when:
460
+ * - Filesystem state changes (watcher events)
461
+ * - User refreshes with 'r' key (rebuilds fresh from filesystem)
462
+ * - Any operation that needs a full rescan from disk
463
+ *
464
+ * @param {boolean} preserveState - Whether to preserve expanded states
465
+ */
466
+ const rebuildTree = useCallback((preserveState = true) => {
467
+ // Get current tree from ref (always fresh)
468
+ const oldTree = treeRef.current;
469
+
470
+ // Rebuild tree
471
+ const newTree = buildTree(absPath, options);
472
+
473
+ // Merge expanded state if requested and old tree exists
474
+ const mergedTree = preserveState && oldTree
475
+ ? mergeExpandState(oldTree, newTree)
476
+ : newTree;
477
+
478
+ // DEBUG: Log what we're doing
479
+ if (process.env.FTREE_DEBUG) {
480
+ const flat = flattenTree(mergedTree);
481
+ console.log(`[DEBUG] rebuildTree: BEFORE setTree, flatList has ${flat.length} items`);
482
+ console.log(`[DEBUG] rebuildTree: root has ${mergedTree.children?.length || 0} direct children`);
483
+ console.log(`[DEBUG] rebuildTree: newTree root has ${newTree.children?.length || 0} direct children`);
484
+ }
485
+
486
+ setTree(mergedTree);
487
+ setFlatList(flattenTree(mergedTree));
488
+
489
+ // DEBUG: Log after setTree/setFlatList
490
+ if (process.env.FTREE_DEBUG) {
491
+ console.log(`[DEBUG] rebuildTree: AFTER setTree/setFlatList, flatList state updated`);
492
+ }
493
+ }, [absPath, options]);
494
+
495
+ /**
496
+ * Get the current tree state
497
+ * Useful for external code to read the tree before rebuild
498
+ */
499
+ const getTree = useCallback(() => treeRef.current, []);
500
+
501
+ return {
502
+ tree,
503
+ flatList,
504
+ refreshFlatList,
505
+ rebuildTree,
506
+ getTree,
507
+ };
508
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * useWatcher - File System Watcher Hook
3
+ *
4
+ * Provides file system watching capabilities using chokidar.
5
+ * Detects file changes, additions, and deletions with debouncing.
6
+ */
7
+
8
+ import { useEffect, useState, useRef } from 'react';
9
+ import chokidar from 'chokidar';
10
+ import { DEFAULT_CONFIG } from '../lib/constants.js';
11
+
12
+ function shouldUsePolling(rootPath) {
13
+ // Allow override via env var for tricky filesystems (WSL /mnt, network drives, etc.)
14
+ const envOverride = process.env.FTREE_USE_POLLING;
15
+ if (envOverride === '1' || envOverride === 'true') {
16
+ return true;
17
+ }
18
+ if (envOverride === '0' || envOverride === 'false') {
19
+ return false;
20
+ }
21
+
22
+ // WSL often needs polling for /mnt/* paths.
23
+ const isWsl = process.platform === 'linux' && (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME);
24
+ if (isWsl && typeof rootPath === 'string' && rootPath.startsWith('/mnt/')) {
25
+ return true;
26
+ }
27
+
28
+ return false;
29
+ }
30
+
31
+ export function useWatcher(rootPath, onTreeChange, options = {}) {
32
+ const debounceMs = options.debounceMs ?? DEFAULT_CONFIG.debounceDelay;
33
+ const [isWatching, setIsWatching] = useState(false);
34
+ const watcherRef = useRef(null);
35
+ const onTreeChangeRef = useRef(onTreeChange);
36
+ const mountedRef = useRef(true);
37
+ const debounceTimerRef = useRef(null);
38
+ const pendingChangesRef = useRef(new Map()); // path -> event
39
+ const sawAnyEventRef = useRef(false);
40
+
41
+ // Keep callback ref in sync
42
+ useEffect(() => {
43
+ onTreeChangeRef.current = onTreeChange;
44
+ }, [onTreeChange]);
45
+
46
+ // Setup and teardown watcher
47
+ useEffect(() => {
48
+ mountedRef.current = true;
49
+
50
+ if (options.noWatch || !rootPath) {
51
+ setIsWatching(false);
52
+ return () => {
53
+ mountedRef.current = false;
54
+ };
55
+ }
56
+
57
+ const usePolling = shouldUsePolling(rootPath);
58
+ const watcher = chokidar.watch(rootPath, {
59
+ ignored: /(^|[\/\\])\.\./,
60
+ persistent: true,
61
+ ignoreInitial: true,
62
+ followSymlinks: false,
63
+ ...(usePolling ? { usePolling: true, interval: 300 } : {}),
64
+ });
65
+ watcherRef.current = watcher;
66
+
67
+ const flushPendingChange = () => {
68
+ debounceTimerRef.current = null;
69
+ const hadAnyEvent = sawAnyEventRef.current;
70
+ const pendingMap = pendingChangesRef.current;
71
+ sawAnyEventRef.current = false;
72
+ pendingChangesRef.current = new Map();
73
+
74
+ if (hadAnyEvent && mountedRef.current && onTreeChangeRef.current) {
75
+ const batch = Array.from(pendingMap.entries()).map(([path, event]) => ({ event, path }));
76
+ onTreeChangeRef.current(batch);
77
+ }
78
+ };
79
+
80
+ watcher.on('all', (event, path) => {
81
+ sawAnyEventRef.current = true;
82
+
83
+ // Keep a best-effort batch of "change indicator relevant" events.
84
+ // Prefer add/addDir over change for the same path within the debounce window.
85
+ if (event === 'add' || event === 'addDir') {
86
+ pendingChangesRef.current.set(path, event);
87
+ } else if (event === 'change') {
88
+ if (!pendingChangesRef.current.has(path)) {
89
+ pendingChangesRef.current.set(path, event);
90
+ }
91
+ }
92
+
93
+ if (debounceTimerRef.current) {
94
+ clearTimeout(debounceTimerRef.current);
95
+ }
96
+
97
+ debounceTimerRef.current = setTimeout(flushPendingChange, debounceMs);
98
+ });
99
+
100
+ watcher.on('error', () => {
101
+ setIsWatching(false);
102
+ });
103
+
104
+ watcher.on('ready', () => {
105
+ setIsWatching(true);
106
+ });
107
+
108
+ return () => {
109
+ mountedRef.current = false;
110
+
111
+ if (debounceTimerRef.current) {
112
+ clearTimeout(debounceTimerRef.current);
113
+ debounceTimerRef.current = null;
114
+ }
115
+ pendingChangesRef.current = new Map();
116
+ sawAnyEventRef.current = false;
117
+
118
+ if (watcherRef.current) {
119
+ watcherRef.current.close();
120
+ watcherRef.current = null;
121
+ }
122
+ setIsWatching(false);
123
+ };
124
+ }, [rootPath, options.noWatch, debounceMs]);
125
+
126
+ return { isWatching };
127
+ }
128
+
129
+ // Only default export to avoid conflicts with bundling
package/src/index.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ftree - Main entry point re-export
3
+ *
4
+ * This module re-exports the public API for the ftree library.
5
+ * When imported programmatically, it provides access to the core components.
6
+ */
7
+
8
+ // Re-export CLI for programmatic usage
9
+ export { default as CLI } from './cli.js';
10
+
11
+ // Re-export constants for use in components
12
+ export * from './lib/constants.js';
13
+
14
+ // TODO: Export TreeView component when implemented in T2
15
+ // export { TreeView } from './components/TreeView.jsx';
16
+
17
+ // Export icon and color utilities
18
+ export * from './lib/icons.js';
19
+
20
+ // TODO: Export core utilities when implemented
21
+ // export * from './lib/scanner.js';
22
+ // export * from './lib/git.js';