summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh/DirectoryInput.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/mesh/DirectoryInput.tsx')
-rw-r--r--makima/frontend/src/components/mesh/DirectoryInput.tsx220
1 files changed, 220 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/DirectoryInput.tsx b/makima/frontend/src/components/mesh/DirectoryInput.tsx
new file mode 100644
index 0000000..e2e331e
--- /dev/null
+++ b/makima/frontend/src/components/mesh/DirectoryInput.tsx
@@ -0,0 +1,220 @@
+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>
+ );
+}