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