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