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