summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/mesh/DirectoryInput.tsx
blob: e2e331e13571fdcaeba1205b69bfc51621b70da6 (plain) (tree)



























































































































































































































                                                                                                                                                                                 
import { useState, useCallback, useRef, useEffect } from "react";
import type { DaemonDirectory } from "../../lib/api";

interface DirectoryInputProps {
  value: string;
  onChange: (value: string) => void;
  suggestions: DaemonDirectory[];
  placeholder?: string;
  /** Repository URL to extract repo name for home directory suggestions */
  repoUrl?: string | null;
  className?: string;
  disabled?: boolean;
}

/** Extract repository name from URL */
function extractRepoName(url: string | null | undefined): string | null {
  if (!url) return null;

  // Handle various URL formats:
  // https://github.com/user/repo.git -> repo
  // https://github.com/user/repo -> repo
  // git@github.com:user/repo.git -> repo
  // /path/to/local/repo -> repo

  let name = url;

  // Remove trailing .git
  if (name.endsWith(".git")) {
    name = name.slice(0, -4);
  }

  // Remove trailing slash
  if (name.endsWith("/")) {
    name = name.slice(0, -1);
  }

  // Get the last path segment
  const lastSlash = name.lastIndexOf("/");
  if (lastSlash !== -1) {
    name = name.slice(lastSlash + 1);
  }

  // Handle git@host:user/repo format
  const colonIndex = name.lastIndexOf(":");
  if (colonIndex !== -1) {
    const afterColon = name.slice(colonIndex + 1);
    const slashIndex = afterColon.lastIndexOf("/");
    if (slashIndex !== -1) {
      name = afterColon.slice(slashIndex + 1);
    } else {
      name = afterColon;
    }
  }

  return name || null;
}

export function DirectoryInput({
  value,
  onChange,
  suggestions,
  placeholder = "/path/to/directory",
  repoUrl,
  className = "",
  disabled = false,
}: DirectoryInputProps) {
  const [showSuggestions, setShowSuggestions] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
  const inputRef = useRef<HTMLInputElement>(null);
  const dropdownRef = useRef<HTMLDivElement>(null);

  // Extract repo name for home directory suggestions
  const repoName = extractRepoName(repoUrl);

  // Process suggestions to add repo name to home directory
  const processedSuggestions = suggestions.map((dir) => {
    if (dir.directoryType === "home" && repoName) {
      return {
        ...dir,
        path: `${dir.path}/${repoName}`,
        label: `${dir.label} (${repoName})`,
      };
    }
    return dir;
  });

  // Filter suggestions based on current input
  const filteredSuggestions = processedSuggestions.filter((dir) => {
    if (!value) return true;
    const lowerValue = value.toLowerCase();
    return (
      dir.path.toLowerCase().includes(lowerValue) ||
      dir.label.toLowerCase().includes(lowerValue)
    );
  });

  // Handle clicking outside to close dropdown
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (
        dropdownRef.current &&
        !dropdownRef.current.contains(e.target as Node) &&
        inputRef.current &&
        !inputRef.current.contains(e.target as Node)
      ) {
        setShowSuggestions(false);
      }
    };

    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  const handleFocus = useCallback(() => {
    setShowSuggestions(true);
    setHighlightedIndex(-1);
  }, []);

  const handleBlur = useCallback(() => {
    // Delay hiding to allow click on suggestion
    setTimeout(() => {
      setShowSuggestions(false);
    }, 150);
  }, []);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (!showSuggestions || filteredSuggestions.length === 0) return;

      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          setHighlightedIndex((prev) =>
            prev < filteredSuggestions.length - 1 ? prev + 1 : 0
          );
          break;
        case "ArrowUp":
          e.preventDefault();
          setHighlightedIndex((prev) =>
            prev > 0 ? prev - 1 : filteredSuggestions.length - 1
          );
          break;
        case "Enter":
          e.preventDefault();
          if (highlightedIndex >= 0 && highlightedIndex < filteredSuggestions.length) {
            onChange(filteredSuggestions[highlightedIndex].path);
            setShowSuggestions(false);
          }
          break;
        case "Escape":
          setShowSuggestions(false);
          break;
      }
    },
    [showSuggestions, filteredSuggestions, highlightedIndex, onChange]
  );

  const handleSelectSuggestion = useCallback(
    (path: string) => {
      onChange(path);
      setShowSuggestions(false);
      inputRef.current?.focus();
    },
    [onChange]
  );

  return (
    <div className={`relative ${className}`}>
      <input
        ref={inputRef}
        type="text"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onFocus={handleFocus}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        placeholder={placeholder}
        disabled={disabled}
        className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] disabled:opacity-50"
      />

      {/* Suggestions dropdown */}
      {showSuggestions && filteredSuggestions.length > 0 && (
        <div
          ref={dropdownRef}
          className="absolute z-50 left-0 right-0 top-full mt-1 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg max-h-48 overflow-auto"
        >
          {filteredSuggestions.map((dir, index) => (
            <button
              key={`${dir.directoryType}-${index}`}
              type="button"
              onClick={() => handleSelectSuggestion(dir.path)}
              onMouseEnter={() => setHighlightedIndex(index)}
              className={`w-full text-left px-3 py-2 font-mono text-xs transition-colors ${
                index === highlightedIndex
                  ? "bg-[rgba(117,170,252,0.2)] text-[#dbe7ff]"
                  : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]"
              }`}
            >
              <div className="flex items-center justify-between gap-2">
                <div className="flex items-center gap-2">
                  <span className="text-[#75aafc]">{dir.label}</span>
                  {dir.exists === true && (
                    <span className="text-[#f0ad4e] text-[10px]" title="Directory already exists">
                      (exists)
                    </span>
                  )}
                </div>
                {dir.hostname && (
                  <span className="text-[#555] text-[10px]">({dir.hostname})</span>
                )}
              </div>
              <div className="text-[10px] text-[#555] truncate">{dir.path}</div>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}