DirectoryPickerModal.tsx

  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;