cruise 0.1.30

YAML-driven coding agent workflow orchestrator
Documentation
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { listDirectory } from "../lib/commands";
import type { DirEntry } from "../types";

interface DirectoryPickerProps {
  value: string;
  onChange: (value: string) => void;
  disabled?: boolean;
  placeholder?: string;
  id?: string;
}

/** Split a typed path into the parent dir and the incomplete last segment.
 *
 * Examples:
 *   "/Users/takumi/ap"  -> { dir: "/Users/takumi/", prefix: "ap" }
 *   "/Users/takumi/"    -> { dir: "/Users/takumi/", prefix: "" }
 *   "~/pr"              -> { dir: "~/", prefix: "pr" }
 */
function splitPath(value: string): { dir: string; prefix: string } {
  const lastSlash = value.lastIndexOf("/");
  if (lastSlash === -1) {
    return { dir: "", prefix: value };
  }
  return {
    dir: value.slice(0, lastSlash + 1),
    prefix: value.slice(lastSlash + 1),
  };
}

function filterByPrefix(all: DirEntry[], prefix: string): DirEntry[] {
  if (!prefix) return all;
  const lower = prefix.toLowerCase();
  return all.filter((e) => e.name.toLowerCase().startsWith(lower));
}

export function DirectoryPicker({
  value,
  onChange,
  disabled = false,
  placeholder,
  id,
}: DirectoryPickerProps) {
  const [entries, setEntries] = useState<DirEntry[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const [highlighted, setHighlighted] = useState<number>(-1);
  const cacheRef = useRef<{ dir: string | null; entries: DirEntry[] }>({ dir: null, entries: [] });

  const uid = useId();
  const listboxId = `${uid}-listbox`;

  const containerRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const fetchEntries = useCallback(
    (inputValue: string) => {
      const { dir, prefix } = splitPath(inputValue);
      const queryPath = dir || inputValue;

      if (queryPath === cacheRef.current.dir) {
        const filtered = filterByPrefix(cacheRef.current.entries, prefix);
        setEntries(filtered);
        setIsOpen(filtered.length > 0);
        return;
      }

      listDirectory(queryPath)
        .then((result) => {
          cacheRef.current = { dir: queryPath, entries: result };
          const filtered = filterByPrefix(result, prefix);
          setEntries(filtered);
          setHighlighted(-1);
          setIsOpen(filtered.length > 0);
        })
        .catch(() => {
          setEntries([]);
          setIsOpen(false);
        });
    },
    []
  );

  // Debounced fetch on value change
  useEffect(() => {
    if (debounceRef.current !== null) {
      clearTimeout(debounceRef.current);
    }
    debounceRef.current = setTimeout(() => {
      if (value.length > 0) {
        fetchEntries(value);
      } else {
        setIsOpen(false);
      }
    }, 150);

    return () => {
      if (debounceRef.current !== null) {
        clearTimeout(debounceRef.current);
      }
    };
  }, [value, fetchEntries]);

  // Close dropdown when clicking outside
  useEffect(() => {
    function handleMouseDown(e: MouseEvent) {
      if (
        containerRef.current &&
        !containerRef.current.contains(e.target as Node)
      ) {
        setIsOpen(false);
      }
    }
    document.addEventListener("mousedown", handleMouseDown);
    return () => document.removeEventListener("mousedown", handleMouseDown);
  }, []);

  function selectEntry(entry: DirEntry) {
    const newValue = entry.path.endsWith("/")
      ? entry.path
      : entry.path + "/";
    onChange(newValue);
    setIsOpen(false);
    setHighlighted(-1);
    cacheRef.current = { dir: null, entries: [] };
    inputRef.current?.focus();
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (!isOpen) return;

    if (e.key === "ArrowDown") {
      e.preventDefault();
      setHighlighted((h) => Math.min(h + 1, entries.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setHighlighted((h) => Math.max(h - 1, 0));
    } else if (e.key === "Enter") {
      if (highlighted >= 0 && highlighted < entries.length) {
        e.preventDefault();
        selectEntry(entries[highlighted]);
      }
    } else if (e.key === "Escape") {
      setIsOpen(false);
      setHighlighted(-1);
    }
  }

  async function handleBrowse() {
    try {
      const selected = await open({ directory: true, multiple: false });
      if (typeof selected === "string") {
        onChange(selected.endsWith("/") ? selected : selected + "/");
        cacheRef.current = { dir: null, entries: [] };
      }
    } catch {
      // user cancelled or dialog failed - ignore
    }
  }

  return (
    <div ref={containerRef} className="relative">
      <div className="flex gap-2">
        <input
          ref={inputRef}
          id={id}
          type="text"
          role="combobox"
          aria-expanded={isOpen}
          aria-autocomplete="list"
          aria-controls={listboxId}
          aria-activedescendant={highlighted >= 0 ? `${listboxId}-opt-${highlighted}` : undefined}
          value={value}
          onChange={(e) => {
            onChange(e.target.value);
            cacheRef.current = { dir: null, entries: [] };
          }}
          onFocus={() => {
            if (value.length > 0) fetchEntries(value);
          }}
          onKeyDown={handleKeyDown}
          disabled={disabled}
          placeholder={placeholder}
          className="flex-1 bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-blue-500 outline-none disabled:opacity-50"
        />
        <button
          type="button"
          onClick={() => void handleBrowse()}
          disabled={disabled}
          className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-sm text-gray-300 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
        >
          Browse
        </button>
      </div>

      {isOpen && entries.length > 0 && (
        <ul
          id={listboxId}
          role="listbox"
          className="absolute z-50 top-full left-0 right-0 mt-1 bg-gray-800 border border-gray-700 rounded shadow-lg max-h-56 overflow-auto"
        >
          {entries.map((entry, i) => (
            <li
              key={entry.path}
              id={`${listboxId}-opt-${i}`}
              role="option"
              aria-selected={i === highlighted}
              onMouseDown={(e) => {
                e.preventDefault();
                selectEntry(entry);
              }}
              onMouseEnter={() => setHighlighted(i)}
              className={`px-3 py-1.5 text-sm text-gray-200 cursor-pointer ${
                i === highlighted ? "bg-gray-700" : "hover:bg-gray-800"
              }`}
            >
              {entry.name}/
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}