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,347 @@
1
+ /**
2
+ * useGitStatus - Git Status Hook
3
+ *
4
+ * Provides git status information for files in a directory tree.
5
+ * Parses git status --porcelain=v1 -uall output to get file status.
6
+ *
7
+ * This hook detects non-git directories and handles errors gracefully.
8
+ */
9
+
10
+ import { execFile } from 'node:child_process';
11
+ import { useEffect, useState, useCallback, useRef } from 'react';
12
+ import { DEFAULT_CONFIG } from '../lib/constants.js';
13
+
14
+ /**
15
+ * Parse git status --porcelain=v1 -uall output into a status map
16
+ *
17
+ * Porcelain format: XY filename
18
+ * X = staged status, Y = unstaged status
19
+ *
20
+ * Status codes:
21
+ * - "M " (modified, staged) → "M" green
22
+ * - " M" (modified, unstaged) → "M" yellow
23
+ * - "MM" (modified both) → "M" yellow
24
+ * - "A " (added, staged) → "A" green
25
+ * - "?? " (untracked) → "U" green
26
+ * - "D " (deleted, staged) → "D" red
27
+ * - " D" (deleted, unstaged) → "D" red
28
+ * - "R " (renamed, staged) → "R" cyan
29
+ * - "!!" (ignored) → skip
30
+ *
31
+ * @param {string} output - Raw git status --porcelain=v1 -uall output
32
+ * @returns {Map<string, string>} Map of relative paths to status codes
33
+ */
34
+ export function parsePorcelain(output) {
35
+ const statusMap = new Map();
36
+
37
+ // Split output into lines and process each
38
+ const lines = output.split('\n').filter(Boolean);
39
+
40
+ for (const line of lines) {
41
+ // Skip ignored files
42
+ if (line.startsWith('!!')) {
43
+ continue;
44
+ }
45
+
46
+ // Extract status codes (first two characters)
47
+ const stagedStatus = line[0];
48
+ const unstagedStatus = line[1];
49
+
50
+ // Extract filename (after status codes and space)
51
+ // Handle quoted filenames with spaces (format: XY "filename")
52
+ let filePath;
53
+ if (line[2] === '"') {
54
+ // Quoted filename - find closing quote
55
+ const endQuote = line.indexOf('"', 3);
56
+ filePath = line.slice(3, endQuote);
57
+ } else {
58
+ // Simple filename - split by space
59
+ filePath = line.slice(3);
60
+ }
61
+
62
+ // Determine status based on porcelain codes
63
+ let statusCode = '';
64
+
65
+ // Check for rename (R in staged position, possibly followed by original filename)
66
+ // Format: R oldname -> newname
67
+ if (stagedStatus === 'R') {
68
+ statusCode = 'R'; // Renamed (cyan)
69
+ // Handle "R old -> new" format - extract the new filename
70
+ if (filePath.includes(' -> ')) {
71
+ filePath = filePath.split(' -> ')[1];
72
+ }
73
+ }
74
+ // Deleted (staged or unstaged)
75
+ else if (stagedStatus === 'D' || unstagedStatus === 'D') {
76
+ statusCode = 'D'; // Deleted (red)
77
+ }
78
+ // Added (staged)
79
+ else if (stagedStatus === 'A') {
80
+ statusCode = 'A'; // Added (green)
81
+ }
82
+ // Modified - check both staged and unstaged
83
+ else if (stagedStatus === 'M' || unstagedStatus === 'M') {
84
+ statusCode = 'M'; // Modified (yellow if unstaged, green if staged)
85
+ }
86
+ // Untracked
87
+ else if (stagedStatus === '?' && unstagedStatus === '?') {
88
+ statusCode = 'U'; // Untracked (green)
89
+ }
90
+ // Conflicted (both stage deleted, or merged but unresolved)
91
+ else if (
92
+ (stagedStatus === 'D' && unstagedStatus === 'D') ||
93
+ (stagedStatus === 'A' && unstagedStatus === 'A') ||
94
+ (stagedStatus === 'U' || unstagedStatus === 'U')
95
+ ) {
96
+ statusCode = 'C'; // Conflicted
97
+ }
98
+
99
+ // Only add if we have a status code
100
+ if (statusCode) {
101
+ // Git reports untracked directories with a trailing slash (e.g. "?? newdir/").
102
+ // Strip it so it matches our FileNode.path (which never includes a trailing slash).
103
+ if (filePath.endsWith('/') || filePath.endsWith('\\')) {
104
+ filePath = filePath.slice(0, -1);
105
+ }
106
+
107
+ // Normalize path separators to forward slashes (git uses forward slashes)
108
+ const normalizedPath = filePath.replace(/\\/g, '/');
109
+ statusMap.set(normalizedPath, statusCode);
110
+ }
111
+ }
112
+
113
+ return statusMap;
114
+ }
115
+
116
+ /**
117
+ * Compare two status maps for structural equality.
118
+ *
119
+ * @param {Map<string, string>} first - First status map
120
+ * @param {Map<string, string>} second - Second status map
121
+ * @returns {boolean} True when maps contain identical keys and values
122
+ */
123
+ function areStatusMapsEqual(first, second) {
124
+ if (first === second) return true;
125
+ if (!first || !second) return false;
126
+ if (first.size !== second.size) return false;
127
+
128
+ for (const [path, status] of first.entries()) {
129
+ if (second.get(path) !== status) {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * Checks if the given path is within a git repository
139
+ *
140
+ * @param {string} rootPath - Path to check
141
+ * @returns {Promise<boolean>} True if path is in a git repository
142
+ */
143
+ function isGitRepo(rootPath) {
144
+ return new Promise((resolve) => {
145
+ execFile(
146
+ 'git',
147
+ ['rev-parse', '--git-dir'],
148
+ { cwd: rootPath, timeout: 5000 },
149
+ (error) => {
150
+ // If git command succeeds (no error), we're in a git repo
151
+ resolve(!error);
152
+ }
153
+ );
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Executes git status --porcelain=v1 -uall and returns the output
159
+ *
160
+ * @param {string} rootPath - Root path of the repository
161
+ * @returns {Promise<string>} Git status output
162
+ */
163
+ function getGitStatus(rootPath) {
164
+ return new Promise((resolve, reject) => {
165
+ execFile(
166
+ 'git',
167
+ ['status', '--porcelain=v1', '-uall'],
168
+ { cwd: rootPath, timeout: 10000 },
169
+ (error, stdout) => {
170
+ if (error) {
171
+ reject(error);
172
+ return;
173
+ }
174
+ resolve(stdout);
175
+ }
176
+ );
177
+ });
178
+ }
179
+
180
+ /**
181
+ * React hook for git status integration
182
+ *
183
+ * This hook runs git status on mount and provides:
184
+ * - statusMap: Map of relative paths to status codes
185
+ * - isGitRepo: Boolean indicating if in a git repository
186
+ * - refresh: Function to manually refresh git status
187
+ * - isLoading: Boolean indicating if git status is being fetched
188
+ * - error: Error object if git status failed
189
+ *
190
+ * @param {string} rootPath - Root directory path
191
+ * @param {boolean} enabled - Whether git status is enabled
192
+ * @returns {{statusMap: Map<string, string>, isGitRepo: boolean, refresh: function, isLoading: boolean, error: Error|null}}
193
+ */
194
+ export function useGitStatus(rootPath, enabled = true) {
195
+ const minRefreshMs = Math.max(100, DEFAULT_CONFIG.debounceDelay * 2);
196
+ const [statusMap, setStatusMap] = useState(new Map());
197
+ const [isGitRepo, setIsGitRepo] = useState(false);
198
+ const [isLoading, setIsLoading] = useState(false);
199
+ const [error, setError] = useState(null);
200
+ const mountedRef = useRef(true);
201
+ const inFlightRef = useRef(false);
202
+ const pendingRefreshRef = useRef(false);
203
+ const refreshTimerRef = useRef(null);
204
+ const lastFetchAtRef = useRef(0);
205
+
206
+ /**
207
+ * Fetches and parses git status
208
+ */
209
+ const fetchGitStatus = useCallback(async () => {
210
+ if (inFlightRef.current) {
211
+ pendingRefreshRef.current = true;
212
+ return;
213
+ }
214
+
215
+ inFlightRef.current = true;
216
+ lastFetchAtRef.current = Date.now();
217
+
218
+ if (!enabled || !rootPath) {
219
+ setStatusMap((prev) => (prev.size === 0 ? prev : new Map()));
220
+ setIsGitRepo(false);
221
+ setError(null);
222
+ inFlightRef.current = false;
223
+ pendingRefreshRef.current = false;
224
+ return;
225
+ }
226
+
227
+ setIsLoading(true);
228
+ setError(null);
229
+
230
+ try {
231
+ // First check if we're in a git repository
232
+ const repoCheck = await isGitRepo(rootPath);
233
+
234
+ if (!mountedRef.current) return;
235
+
236
+ if (!repoCheck) {
237
+ // Not a git repository
238
+ setIsGitRepo(false);
239
+ setStatusMap((prev) => (prev.size === 0 ? prev : new Map()));
240
+ setIsLoading(false);
241
+ return;
242
+ }
243
+
244
+ setIsGitRepo(true);
245
+
246
+ // Fetch git status
247
+ const output = await getGitStatus(rootPath);
248
+
249
+ if (!mountedRef.current) return;
250
+
251
+ // Parse output into status map
252
+ const map = parsePorcelain(output);
253
+ setStatusMap((prev) => (areStatusMapsEqual(prev, map) ? prev : map));
254
+ } catch (err) {
255
+ if (!mountedRef.current) return;
256
+
257
+ // Handle errors gracefully
258
+ // Common errors: git not installed, not a git repo, permission denied
259
+ setError(err);
260
+ setIsGitRepo(false);
261
+ setStatusMap((prev) => (prev.size === 0 ? prev : new Map()));
262
+ } finally {
263
+ if (mountedRef.current) {
264
+ setIsLoading(false);
265
+ }
266
+
267
+ inFlightRef.current = false;
268
+
269
+ if (pendingRefreshRef.current && mountedRef.current && enabled && rootPath) {
270
+ pendingRefreshRef.current = false;
271
+ const elapsedMs = Date.now() - lastFetchAtRef.current;
272
+ const delayMs = Math.max(0, minRefreshMs - elapsedMs);
273
+
274
+ if (refreshTimerRef.current) {
275
+ clearTimeout(refreshTimerRef.current);
276
+ }
277
+
278
+ refreshTimerRef.current = setTimeout(() => {
279
+ refreshTimerRef.current = null;
280
+ fetchGitStatus();
281
+ }, delayMs);
282
+ }
283
+ }
284
+ }, [rootPath, enabled, minRefreshMs]);
285
+
286
+ /**
287
+ * Manual refresh function with coalescing/throttling.
288
+ */
289
+ const refresh = useCallback(() => {
290
+ if (!enabled || !rootPath) {
291
+ return;
292
+ }
293
+
294
+ if (inFlightRef.current) {
295
+ pendingRefreshRef.current = true;
296
+ return;
297
+ }
298
+
299
+ const elapsedMs = Date.now() - lastFetchAtRef.current;
300
+ if (elapsedMs < minRefreshMs) {
301
+ pendingRefreshRef.current = true;
302
+
303
+ if (refreshTimerRef.current) {
304
+ return;
305
+ }
306
+
307
+ const delayMs = minRefreshMs - elapsedMs;
308
+ refreshTimerRef.current = setTimeout(() => {
309
+ refreshTimerRef.current = null;
310
+ pendingRefreshRef.current = false;
311
+ fetchGitStatus();
312
+ }, delayMs);
313
+ return;
314
+ }
315
+
316
+ pendingRefreshRef.current = false;
317
+ if (refreshTimerRef.current) {
318
+ clearTimeout(refreshTimerRef.current);
319
+ refreshTimerRef.current = null;
320
+ }
321
+ fetchGitStatus();
322
+ }, [enabled, rootPath, minRefreshMs, fetchGitStatus]);
323
+
324
+ // Fetch on mount and when dependencies change
325
+ useEffect(() => {
326
+ mountedRef.current = true;
327
+ fetchGitStatus();
328
+
329
+ return () => {
330
+ mountedRef.current = false;
331
+ inFlightRef.current = false;
332
+ pendingRefreshRef.current = false;
333
+ if (refreshTimerRef.current) {
334
+ clearTimeout(refreshTimerRef.current);
335
+ refreshTimerRef.current = null;
336
+ }
337
+ };
338
+ }, [fetchGitStatus]);
339
+
340
+ return {
341
+ statusMap,
342
+ isGitRepo,
343
+ refresh,
344
+ isLoading,
345
+ error,
346
+ };
347
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * useIgnore - Gitignore Filtering Hook
3
+ *
4
+ * Provides gitignore-style filtering functionality using the 'ignore' package.
5
+ * Supports loading .gitignore files from the root and nested directories.
6
+ */
7
+
8
+ import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
9
+ import { resolve, join, relative, sep } from 'node:path';
10
+ import ignore from 'ignore';
11
+
12
+ /**
13
+ * Default patterns that are always applied (unless --no-ignore is set)
14
+ */
15
+ const DEFAULT_PATTERNS = [
16
+ '.git',
17
+ '.git/',
18
+ '.git/**',
19
+ 'node_modules',
20
+ 'node_modules/',
21
+ ];
22
+
23
+ /**
24
+ * Finds all .gitignore files in a directory tree
25
+ *
26
+ * @param {string} rootPath - Root directory path
27
+ * @returns {Map<string, string>} Map of directory paths to their .gitignore content
28
+ */
29
+ function findGitignoreFiles(rootPath) {
30
+ const gitignoreMap = new Map();
31
+
32
+ /**
33
+ * Recursively scan for .gitignore files
34
+ * @param {string} dirPath - Current directory to scan
35
+ */
36
+ function scanDirectory(dirPath) {
37
+ try {
38
+ const entries = readdirSync(dirPath, { withFileTypes: true });
39
+
40
+ // Check if this directory has a .gitignore file
41
+ const gitignorePath = join(dirPath, '.gitignore');
42
+ if (existsSync(gitignorePath)) {
43
+ // We'll read these lazily when needed to avoid excessive I/O
44
+ gitignoreMap.set(dirPath, gitignorePath);
45
+ }
46
+
47
+ // Recurse into subdirectories
48
+ for (const entry of entries) {
49
+ if (entry.isDirectory() && entry.name !== '.git') {
50
+ const subPath = join(dirPath, entry.name);
51
+ scanDirectory(subPath);
52
+ }
53
+ }
54
+ } catch (error) {
55
+ // Skip directories we can't read (permission errors, etc.)
56
+ }
57
+ }
58
+
59
+ scanDirectory(rootPath);
60
+ return gitignoreMap;
61
+ }
62
+
63
+ /**
64
+ * Creates an ignore filter function based on .gitignore files
65
+ *
66
+ * @param {string} rootPath - Root directory path
67
+ * @param {object} options - Configuration options
68
+ * @param {boolean} [options.showIgnored=false] - If true, don't filter anything (--no-ignore)
69
+ * @param {string[]} [options.extraIgnorePatterns] - Additional patterns to ignore
70
+ * @returns {object} Object with shouldIgnore method and addPattern method
71
+ */
72
+ export function createIgnoreFilter(rootPath, options = {}) {
73
+ const { showIgnored = false, extraIgnorePatterns = [] } = options;
74
+
75
+ // If showIgnored is true, return a pass-through filter
76
+ if (showIgnored) {
77
+ return {
78
+ shouldIgnore: (relativePath) => false,
79
+ addPattern: () => {},
80
+ filter: (paths) => paths,
81
+ };
82
+ }
83
+
84
+ // Initialize the ignore instance
85
+ const ig = ignore();
86
+
87
+ // Add default patterns (unless showIgnored is true)
88
+ for (const pattern of DEFAULT_PATTERNS) {
89
+ ig.add(pattern);
90
+ }
91
+
92
+ // Add extra patterns from options
93
+ if (extraIgnorePatterns.length > 0) {
94
+ ig.add(extraIgnorePatterns);
95
+ }
96
+
97
+ // Find all .gitignore files
98
+ const gitignoreMap = findGitignoreFiles(rootPath);
99
+
100
+ // Track which .gitignore files we've already loaded
101
+ const loadedIgnores = new Set();
102
+
103
+ /**
104
+ * Ensures .gitignore patterns for a given path are loaded
105
+ * This implements lazy loading - we only load patterns when we encounter a directory
106
+ *
107
+ * @param {string} dirAbsPath - Absolute path to directory
108
+ */
109
+ function loadPatternsForDirectory(dirAbsPath) {
110
+ if (loadedIgnores.has(dirAbsPath)) {
111
+ return; // Already loaded
112
+ }
113
+
114
+ const gitignorePath = gitignoreMap.get(dirAbsPath);
115
+ if (gitignorePath && existsSync(gitignorePath)) {
116
+ try {
117
+ // Read and parse the .gitignore file
118
+ const content = readFileSync(gitignorePath, 'utf-8');
119
+ ig.add(content);
120
+ loadedIgnores.add(dirAbsPath);
121
+ } catch (error) {
122
+ // Silently ignore read errors
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Checks if a relative path should be ignored
129
+ *
130
+ * @param {string} relativePath - Path relative to root (forward slashes)
131
+ * @returns {boolean} True if the path should be ignored
132
+ */
133
+ function shouldIgnore(relativePath) {
134
+ // Empty string (root directory) is never ignored
135
+ if (relativePath === '') {
136
+ return false;
137
+ }
138
+
139
+ // Normalize path to use forward slashes
140
+ const normalizedPath = relativePath.split(sep).join('/');
141
+
142
+ return ig.ignores(normalizedPath);
143
+ }
144
+
145
+ /**
146
+ * Filters an array of paths, keeping only non-ignored ones
147
+ *
148
+ * @param {string[]} paths - Array of relative paths
149
+ * @returns {string[]} Filtered array of paths
150
+ */
151
+ function filter(paths) {
152
+ return ig.filter(paths);
153
+ }
154
+
155
+ /**
156
+ * Adds a pattern to the ignore filter
157
+ *
158
+ * @param {string|string[]} pattern - Pattern or array of patterns to add
159
+ */
160
+ function addPattern(pattern) {
161
+ ig.add(pattern);
162
+ }
163
+
164
+ return {
165
+ shouldIgnore,
166
+ filter,
167
+ addPattern,
168
+ loadPatternsForDirectory,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * React hook for creating an ignore filter
174
+ * This is a convenience wrapper around createIgnoreFilter
175
+ *
176
+ * @param {string} rootPath - Root directory path
177
+ * @param {object} options - Configuration options
178
+ * @returns {object} Filter object with shouldIgnore method
179
+ */
180
+ export function useIgnore(rootPath, options = {}) {
181
+ return createIgnoreFilter(rootPath, options);
182
+ }