PathAutocomplete.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. "use client";
  2. import { useQuery } from "@tanstack/react-query";
  3. import { useEffect, useRef, useState } from "react";
  4. import { get } from "../../lib/api";
  5. interface Directory {
  6. name: string;
  7. path: string;
  8. }
  9. interface PathAutocompleteProps {
  10. value: string;
  11. onChange: (value: string) => void;
  12. placeholder?: string;
  13. className?: string;
  14. defaultPath?: string;
  15. }
  16. export default function PathAutocomplete({
  17. value,
  18. onChange,
  19. placeholder = "/path/to/destination",
  20. className = "",
  21. defaultPath = "",
  22. }: PathAutocompleteProps) {
  23. const [showSuggestions, setShowSuggestions] = useState(false);
  24. const [selectedIndex, setSelectedIndex] = useState(-1);
  25. const inputRef = useRef<HTMLInputElement>(null);
  26. const suggestionsRef = useRef<HTMLDivElement>(null);
  27. // Get the parent directory to search for subdirectories
  28. const searchPath = value || defaultPath;
  29. const parentPath = searchPath.endsWith("/")
  30. ? searchPath.slice(0, -1)
  31. : searchPath;
  32. // Fetch directories for autocomplete
  33. const { data: dirData } = useQuery<{ directories: Directory[] }>({
  34. queryKey: ["directories", parentPath],
  35. queryFn: async () => {
  36. if (!parentPath) return { directories: [] };
  37. return get("/directories", { path: parentPath });
  38. },
  39. enabled: !!parentPath && showSuggestions,
  40. staleTime: 30000, // Cache for 30 seconds
  41. });
  42. const directories = dirData?.directories || [];
  43. // Close suggestions when clicking outside
  44. useEffect(() => {
  45. const handleClickOutside = (event: MouseEvent) => {
  46. if (
  47. suggestionsRef.current &&
  48. !suggestionsRef.current.contains(event.target as Node) &&
  49. !inputRef.current?.contains(event.target as Node)
  50. ) {
  51. setShowSuggestions(false);
  52. }
  53. };
  54. document.addEventListener("mousedown", handleClickOutside);
  55. return () => document.removeEventListener("mousedown", handleClickOutside);
  56. }, []);
  57. const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  58. const newValue = e.target.value;
  59. onChange(newValue);
  60. setShowSuggestions(true);
  61. setSelectedIndex(-1);
  62. };
  63. const handleSuggestionClick = (dirPath: string) => {
  64. onChange(dirPath);
  65. setShowSuggestions(false);
  66. setSelectedIndex(-1);
  67. };
  68. const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  69. if (!showSuggestions || directories.length === 0) {
  70. if (e.key === "ArrowDown") {
  71. e.preventDefault();
  72. setShowSuggestions(true);
  73. }
  74. return;
  75. }
  76. switch (e.key) {
  77. case "ArrowDown":
  78. e.preventDefault();
  79. setSelectedIndex((prev) =>
  80. prev < directories.length - 1 ? prev + 1 : prev
  81. );
  82. break;
  83. case "ArrowUp":
  84. e.preventDefault();
  85. setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
  86. break;
  87. case "Enter":
  88. e.preventDefault();
  89. if (selectedIndex >= 0 && selectedIndex < directories.length) {
  90. handleSuggestionClick(directories[selectedIndex].path);
  91. }
  92. break;
  93. case "Escape":
  94. setShowSuggestions(false);
  95. setSelectedIndex(-1);
  96. break;
  97. }
  98. };
  99. const handleFocus = () => {
  100. if (directories.length > 0) {
  101. setShowSuggestions(true);
  102. }
  103. };
  104. return (
  105. <div className="relative">
  106. <input
  107. ref={inputRef}
  108. type="text"
  109. value={value}
  110. onChange={handleInputChange}
  111. onKeyDown={handleKeyDown}
  112. onFocus={handleFocus}
  113. placeholder={placeholder}
  114. className={className}
  115. />
  116. {showSuggestions && directories.length > 0 && (
  117. <div
  118. ref={suggestionsRef}
  119. className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-y-auto"
  120. >
  121. {directories.map((dir, index) => (
  122. <button
  123. key={dir.path}
  124. type="button"
  125. onClick={() => handleSuggestionClick(dir.path)}
  126. className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700 ${
  127. index === selectedIndex ? "bg-blue-50 dark:bg-blue-900/20" : ""
  128. }`}
  129. >
  130. <div className="flex items-center">
  131. <svg
  132. className="h-4 w-4 mr-2 text-gray-400"
  133. fill="none"
  134. viewBox="0 0 24 24"
  135. stroke="currentColor"
  136. >
  137. <path
  138. strokeLinecap="round"
  139. strokeLinejoin="round"
  140. strokeWidth={2}
  141. d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
  142. />
  143. </svg>
  144. <span className="text-gray-900 dark:text-gray-100">
  145. {dir.name}
  146. </span>
  147. </div>
  148. <div className="text-xs text-gray-500 dark:text-gray-400 ml-6 truncate">
  149. {dir.path}
  150. </div>
  151. </button>
  152. ))}
  153. </div>
  154. )}
  155. {defaultPath && value !== defaultPath && (
  156. <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
  157. Default: {defaultPath}
  158. </p>
  159. )}
  160. </div>
  161. );
  162. }