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,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;