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