1import React, { useState, useEffect, useRef, useCallback, useId } from "react";
2import { api } from "../services/api";
3
4interface DirectoryEntry {
5 name: string;
6 is_dir: boolean;
7 git_head_subject?: string;
8}
9
10interface CachedDirectory {
11 path: string;
12 parent: string;
13 entries: DirectoryEntry[];
14 git_head_subject?: string;
15 git_worktree_root?: string;
16}
17
18interface DirectoryPickerModalProps {
19 isOpen: boolean;
20 onClose: () => void;
21 onSelect: (path: string) => void;
22 initialPath?: string;
23}
24
25function DirectoryPickerModal({
26 isOpen,
27 onClose,
28 onSelect,
29 initialPath,
30}: DirectoryPickerModalProps) {
31 const [inputPath, setInputPath] = useState(() => {
32 if (!initialPath) return "";
33 return initialPath.endsWith("/") ? initialPath : initialPath + "/";
34 });
35 const [loading, setLoading] = useState(false);
36 const [error, setError] = useState<string | null>(null);
37 const inputRef = useRef<HTMLInputElement>(null);
38
39 // State for create directory mode
40 const [isCreating, setIsCreating] = useState(false);
41 const [newDirName, setNewDirName] = useState("");
42 const [createError, setCreateError] = useState<string | null>(null);
43 const [createLoading, setCreateLoading] = useState(false);
44 const newDirInputRef = useRef<HTMLInputElement>(null);
45 const createInputId = useId();
46
47 // Cache for directory listings
48 const cacheRef = useRef<Map<string, CachedDirectory>>(new Map());
49
50 // Current directory being displayed (the parent directory of what's being typed)
51 const [displayDir, setDisplayDir] = useState<CachedDirectory | null>(null);
52 // Filter prefix (the part after the last slash that we're filtering by)
53 const [filterPrefix, setFilterPrefix] = useState("");
54
55 // Parse input path into directory and filter prefix
56 const parseInputPath = useCallback((path: string): { dirPath: string; prefix: string } => {
57 if (!path) {
58 return { dirPath: "", prefix: "" };
59 }
60
61 // If path ends with /, we're looking at contents of that directory
62 if (path.endsWith("/")) {
63 return { dirPath: path.slice(0, -1) || "/", prefix: "" };
64 }
65
66 // Otherwise, split into directory and prefix
67 const lastSlash = path.lastIndexOf("/");
68 if (lastSlash === -1) {
69 // No slash, treat as prefix in current directory
70 return { dirPath: "", prefix: path };
71 }
72 if (lastSlash === 0) {
73 // Root directory with prefix
74 return { dirPath: "/", prefix: path.slice(1) };
75 }
76 return {
77 dirPath: path.slice(0, lastSlash),
78 prefix: path.slice(lastSlash + 1),
79 };
80 }, []);
81
82 // Load directory from cache or API
83 const loadDirectory = useCallback(async (path: string): Promise<CachedDirectory | null> => {
84 const normalizedPath = path || "/";
85
86 // Check cache first
87 const cached = cacheRef.current.get(normalizedPath);
88 if (cached) {
89 return cached;
90 }
91
92 // Load from API
93 setLoading(true);
94 setError(null);
95 try {
96 const result = await api.listDirectory(path || undefined);
97 if (result.error) {
98 setError(result.error);
99 return null;
100 }
101
102 const dirData: CachedDirectory = {
103 path: result.path,
104 parent: result.parent,
105 entries: result.entries || [],
106 git_head_subject: result.git_head_subject,
107 git_worktree_root: result.git_worktree_root,
108 };
109
110 // Cache it
111 cacheRef.current.set(result.path, dirData);
112
113 return dirData;
114 } catch (err) {
115 setError(err instanceof Error ? err.message : "Failed to load directory");
116 return null;
117 } finally {
118 setLoading(false);
119 }
120 }, []);
121
122 // Track the current expected path to avoid race conditions
123 const expectedPathRef = useRef<string>("");
124
125 // Update display when input changes
126 useEffect(() => {
127 if (!isOpen) return;
128
129 const { dirPath, prefix } = parseInputPath(inputPath);
130 setFilterPrefix(prefix);
131
132 // Track which path we expect to display
133 const normalizedDirPath = dirPath || "/";
134 expectedPathRef.current = normalizedDirPath;
135
136 // Load the directory
137 loadDirectory(dirPath).then((dir) => {
138 // Only update if this is still the path we want
139 if (dir && expectedPathRef.current === normalizedDirPath) {
140 setDisplayDir(dir);
141 setError(null);
142 }
143 });
144 }, [isOpen, inputPath, parseInputPath, loadDirectory]);
145
146 // Initialize when modal opens
147 useEffect(() => {
148 if (isOpen) {
149 if (!initialPath) {
150 setInputPath("");
151 } else {
152 setInputPath(initialPath.endsWith("/") ? initialPath : initialPath + "/");
153 }
154 // Clear cache on open to get fresh data
155 cacheRef.current.clear();
156 }
157 }, [isOpen, initialPath]);
158
159 // Focus input when modal opens (but not on mobile to avoid keyboard popup)
160 useEffect(() => {
161 if (isOpen && inputRef.current) {
162 // Check if mobile device (touch-based)
163 const isMobile = window.matchMedia("(max-width: 768px)").matches || "ontouchstart" in window;
164 if (!isMobile) {
165 inputRef.current.focus();
166 // Move cursor to end
167 const len = inputRef.current.value.length;
168 inputRef.current.setSelectionRange(len, len);
169 }
170 }
171 }, [isOpen]);
172
173 // Filter entries based on prefix (case-insensitive)
174 const filteredEntries =
175 displayDir?.entries.filter((entry) => {
176 if (!filterPrefix) return true;
177 return entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase());
178 }) || [];
179
180 const handleEntryClick = (entry: DirectoryEntry) => {
181 if (entry.is_dir) {
182 const basePath = displayDir?.path || "";
183 const newPath = basePath === "/" ? `/${entry.name}/` : `${basePath}/${entry.name}/`;
184 setInputPath(newPath);
185 }
186 };
187
188 const handleParentClick = () => {
189 if (displayDir?.parent) {
190 const newPath = displayDir.parent === "/" ? "/" : `${displayDir.parent}/`;
191 setInputPath(newPath);
192 }
193 };
194
195 const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
196 // Don't submit while IME is composing (e.g., converting Japanese hiragana to kanji)
197 if (e.nativeEvent.isComposing) {
198 return;
199 }
200 if (e.key === "Enter") {
201 e.preventDefault();
202 handleSelect();
203 }
204 };
205
206 const handleSelect = () => {
207 // Use the current directory path for selection
208 const { dirPath } = parseInputPath(inputPath);
209 const selectedPath = inputPath.endsWith("/") ? (dirPath === "/" ? "/" : dirPath) : dirPath;
210 onSelect(selectedPath || displayDir?.path || "");
211 onClose();
212 };
213
214 // Focus the new directory input when entering create mode
215 useEffect(() => {
216 if (isCreating && newDirInputRef.current) {
217 newDirInputRef.current.focus();
218 }
219 }, [isCreating]);
220
221 const handleStartCreate = () => {
222 setIsCreating(true);
223 setNewDirName("");
224 setCreateError(null);
225 };
226
227 const handleCancelCreate = () => {
228 setIsCreating(false);
229 setNewDirName("");
230 setCreateError(null);
231 };
232
233 const handleCreateDirectory = async () => {
234 if (!newDirName.trim()) {
235 setCreateError("Directory name is required");
236 return;
237 }
238
239 // Validate directory name (no path separators or special chars)
240 if (newDirName.includes("/") || newDirName.includes("\\")) {
241 setCreateError("Directory name cannot contain slashes");
242 return;
243 }
244
245 const basePath = displayDir?.path || "/";
246 const newPath = basePath === "/" ? `/${newDirName}` : `${basePath}/${newDirName}`;
247
248 setCreateLoading(true);
249 setCreateError(null);
250
251 try {
252 const result = await api.createDirectory(newPath);
253 if (result.error) {
254 setCreateError(result.error);
255 return;
256 }
257
258 // Clear the cache for the current directory so it reloads with the new dir
259 cacheRef.current.delete(basePath);
260
261 // Exit create mode and navigate to the new directory
262 setIsCreating(false);
263 setNewDirName("");
264 setInputPath(newPath + "/");
265 } catch (err) {
266 setCreateError(err instanceof Error ? err.message : "Failed to create directory");
267 } finally {
268 setCreateLoading(false);
269 }
270 };
271
272 const handleCreateKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
273 if (e.nativeEvent.isComposing) return;
274 if (e.key === "Enter") {
275 e.preventDefault();
276 handleCreateDirectory();
277 } else if (e.key === "Escape") {
278 e.preventDefault();
279 handleCancelCreate();
280 }
281 };
282
283 const handleBackdropClick = (e: React.MouseEvent) => {
284 if (e.target === e.currentTarget) {
285 onClose();
286 }
287 };
288
289 if (!isOpen) return null;
290
291 // Determine if we should show the parent entry
292 const showParent = displayDir?.parent && displayDir.parent !== "";
293
294 return (
295 <div className="modal-overlay" onClick={handleBackdropClick}>
296 <div className="modal directory-picker-modal">
297 {/* Header */}
298 <div className="modal-header">
299 <h2 className="modal-title">Select Directory</h2>
300 <button onClick={onClose} className="btn-icon" aria-label="Close modal">
301 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
302 <path
303 strokeLinecap="round"
304 strokeLinejoin="round"
305 strokeWidth={2}
306 d="M6 18L18 6M6 6l12 12"
307 />
308 </svg>
309 </button>
310 </div>
311
312 {/* Content */}
313 <div className="modal-body directory-picker-body">
314 {/* Path input */}
315 <div className="directory-picker-input-container">
316 <input
317 ref={inputRef}
318 type="text"
319 value={inputPath}
320 onChange={(e) => setInputPath(e.target.value)}
321 onKeyDown={handleInputKeyDown}
322 className="directory-picker-input"
323 placeholder="/path/to/directory"
324 />
325 </div>
326
327 {/* Current directory indicator */}
328 {displayDir && (
329 <div
330 className={`directory-picker-current${displayDir.git_head_subject ? " directory-picker-current-git" : ""}`}
331 >
332 <span className="directory-picker-current-path">
333 {displayDir.path}
334 {filterPrefix && <span className="directory-picker-filter">/{filterPrefix}*</span>}
335 </span>
336 {displayDir.git_head_subject && (
337 <span
338 className="directory-picker-current-subject"
339 title={displayDir.git_head_subject}
340 >
341 {displayDir.git_head_subject}
342 </span>
343 )}
344 </div>
345 )}
346
347 {/* Go to git root button for worktrees */}
348 {displayDir?.git_worktree_root && (
349 <button
350 className="directory-picker-git-root-btn"
351 onClick={() => setInputPath(displayDir.git_worktree_root + "/")}
352 >
353 <svg
354 fill="none"
355 stroke="currentColor"
356 viewBox="0 0 24 24"
357 className="directory-picker-icon"
358 >
359 <path
360 strokeLinecap="round"
361 strokeLinejoin="round"
362 strokeWidth={2}
363 d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
364 />
365 </svg>
366 <span>Go to git root</span>
367 <span className="directory-picker-git-root-path">{displayDir.git_worktree_root}</span>
368 </button>
369 )}
370
371 {/* Error message */}
372 {error && <div className="directory-picker-error">{error}</div>}
373
374 {/* Loading state */}
375 {loading && (
376 <div className="directory-picker-loading">
377 <div className="spinner spinner-small"></div>
378 <span>Loading...</span>
379 </div>
380 )}
381
382 {/* Directory listing */}
383 {!loading && !error && (
384 <div className="directory-picker-list">
385 {/* Parent directory entry */}
386 {showParent && (
387 <button
388 className="directory-picker-entry directory-picker-entry-parent"
389 onClick={handleParentClick}
390 >
391 <svg
392 fill="none"
393 stroke="currentColor"
394 viewBox="0 0 24 24"
395 className="directory-picker-icon"
396 >
397 <path
398 strokeLinecap="round"
399 strokeLinejoin="round"
400 strokeWidth={2}
401 d="M11 17l-5-5m0 0l5-5m-5 5h12"
402 />
403 </svg>
404 <span>..</span>
405 </button>
406 )}
407
408 {/* Directory entries */}
409 {filteredEntries.map((entry) => (
410 <button
411 key={entry.name}
412 className={`directory-picker-entry${entry.git_head_subject ? " directory-picker-entry-git" : ""}`}
413 onClick={() => handleEntryClick(entry)}
414 >
415 <svg
416 fill="none"
417 stroke="currentColor"
418 viewBox="0 0 24 24"
419 className="directory-picker-icon"
420 >
421 <path
422 strokeLinecap="round"
423 strokeLinejoin="round"
424 strokeWidth={2}
425 d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
426 />
427 </svg>
428 <span className="directory-picker-entry-name">
429 {filterPrefix &&
430 entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase()) ? (
431 <>
432 <strong>{entry.name.slice(0, filterPrefix.length)}</strong>
433 {entry.name.slice(filterPrefix.length)}
434 </>
435 ) : (
436 entry.name
437 )}
438 </span>
439 {entry.git_head_subject && (
440 <span className="directory-picker-git-subject" title={entry.git_head_subject}>
441 {entry.git_head_subject}
442 </span>
443 )}
444 </button>
445 ))}
446
447 {/* Create new directory inline form */}
448 {isCreating && (
449 <div className="directory-picker-create-form">
450 <svg
451 fill="none"
452 stroke="currentColor"
453 viewBox="0 0 24 24"
454 className="directory-picker-icon"
455 >
456 <path
457 strokeLinecap="round"
458 strokeLinejoin="round"
459 strokeWidth={2}
460 d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
461 />
462 </svg>
463 <label htmlFor={createInputId} className="sr-only">
464 New folder name
465 </label>
466 <input
467 id={createInputId}
468 ref={newDirInputRef}
469 type="text"
470 value={newDirName}
471 onChange={(e) => setNewDirName(e.target.value)}
472 onKeyDown={handleCreateKeyDown}
473 placeholder="New folder name"
474 className="directory-picker-create-input"
475 disabled={createLoading}
476 />
477 <button
478 className="directory-picker-create-btn"
479 onClick={handleCreateDirectory}
480 disabled={createLoading || !newDirName.trim()}
481 title="Create"
482 >
483 {createLoading ? (
484 <div className="spinner spinner-small"></div>
485 ) : (
486 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
487 <path
488 strokeLinecap="round"
489 strokeLinejoin="round"
490 strokeWidth={2}
491 d="M5 13l4 4L19 7"
492 />
493 </svg>
494 )}
495 </button>
496 <button
497 className="directory-picker-create-btn directory-picker-cancel-btn"
498 onClick={handleCancelCreate}
499 disabled={createLoading}
500 title="Cancel"
501 >
502 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
503 <path
504 strokeLinecap="round"
505 strokeLinejoin="round"
506 strokeWidth={2}
507 d="M6 18L18 6M6 6l12 12"
508 />
509 </svg>
510 </button>
511 </div>
512 )}
513
514 {/* Create error message */}
515 {createError && <div className="directory-picker-create-error">{createError}</div>}
516
517 {/* Empty state */}
518 {filteredEntries.length === 0 && !showParent && !isCreating && (
519 <div className="directory-picker-empty">
520 {filterPrefix ? "No matching directories" : "No subdirectories"}
521 </div>
522 )}
523 </div>
524 )}
525 </div>
526
527 {/* Footer */}
528 <div className="directory-picker-footer">
529 {/* New Folder button */}
530 {!isCreating && !loading && !error && (
531 <button
532 className="btn directory-picker-new-btn"
533 onClick={handleStartCreate}
534 title="Create new folder"
535 >
536 <svg
537 fill="none"
538 stroke="currentColor"
539 viewBox="0 0 24 24"
540 className="directory-picker-new-icon"
541 >
542 <path
543 strokeLinecap="round"
544 strokeLinejoin="round"
545 strokeWidth={2}
546 d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
547 />
548 </svg>
549 New Folder
550 </button>
551 )}
552 <div className="directory-picker-footer-spacer"></div>
553 <button className="btn" onClick={onClose}>
554 Cancel
555 </button>
556 <button className="btn-primary" onClick={handleSelect} disabled={loading || !!error}>
557 Select
558 </button>
559 </div>
560 </div>
561 </div>
562 );
563}
564
565export default DirectoryPickerModal;