diff options
| author | soryu <soryu@soryu.co> | 2026-01-06 04:08:11 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-11 03:01:13 +0000 |
| commit | 8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch) | |
| tree | 7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/components/mesh/DirectoryInput.tsx | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/mesh/DirectoryInput.tsx')
| -rw-r--r-- | makima/frontend/src/components/mesh/DirectoryInput.tsx | 220 |
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> + ); +} |
