326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { makeRequest } from '@/lib/api';
|
|
|
|
interface FileSearchResult {
|
|
path: string;
|
|
name: string;
|
|
}
|
|
|
|
interface ApiResponse<T> {
|
|
success: boolean;
|
|
data: T | null;
|
|
message: string | null;
|
|
}
|
|
|
|
interface FileSearchTextareaProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
rows?: number;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
projectId?: string;
|
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
|
}
|
|
|
|
export function FileSearchTextarea({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
rows = 3,
|
|
disabled = false,
|
|
className,
|
|
projectId,
|
|
onKeyDown,
|
|
}: FileSearchTextareaProps) {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([]);
|
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
|
|
const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Search for files when query changes
|
|
useEffect(() => {
|
|
if (!searchQuery || !projectId || searchQuery.length < 1) {
|
|
setSearchResults([]);
|
|
setShowDropdown(false);
|
|
return;
|
|
}
|
|
|
|
const searchFiles = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await makeRequest(
|
|
`/api/projects/${projectId}/search?q=${encodeURIComponent(searchQuery)}`
|
|
);
|
|
|
|
if (response.ok) {
|
|
const result: ApiResponse<FileSearchResult[]> = await response.json();
|
|
if (result.success && result.data) {
|
|
setSearchResults(result.data);
|
|
setShowDropdown(true);
|
|
setSelectedIndex(-1);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to search files:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const debounceTimer = setTimeout(searchFiles, 300);
|
|
return () => clearTimeout(debounceTimer);
|
|
}, [searchQuery, projectId]);
|
|
|
|
// Handle text changes and detect @ symbol
|
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value;
|
|
const newCursorPosition = e.target.selectionStart || 0;
|
|
|
|
onChange(newValue);
|
|
|
|
// Check if @ was just typed
|
|
const textBeforeCursor = newValue.slice(0, newCursorPosition);
|
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
|
|
|
if (lastAtIndex !== -1) {
|
|
// Check if there's no space after the @ (still typing the search query)
|
|
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
|
const hasSpace = textAfterAt.includes(' ') || textAfterAt.includes('\n');
|
|
|
|
if (!hasSpace) {
|
|
setAtSymbolPosition(lastAtIndex);
|
|
setSearchQuery(textAfterAt);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If no valid @ context, hide dropdown
|
|
setShowDropdown(false);
|
|
setSearchQuery('');
|
|
setAtSymbolPosition(-1);
|
|
};
|
|
|
|
// Handle keyboard navigation
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Handle dropdown navigation first
|
|
if (showDropdown && searchResults.length > 0) {
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) =>
|
|
prev < searchResults.length - 1 ? prev + 1 : 0
|
|
);
|
|
return;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) =>
|
|
prev > 0 ? prev - 1 : searchResults.length - 1
|
|
);
|
|
return;
|
|
case 'Enter':
|
|
if (selectedIndex >= 0) {
|
|
e.preventDefault();
|
|
selectFile(searchResults[selectedIndex]);
|
|
return;
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
setShowDropdown(false);
|
|
setSearchQuery('');
|
|
setAtSymbolPosition(-1);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Call the passed onKeyDown handler
|
|
onKeyDown?.(e);
|
|
};
|
|
|
|
// Select a file and insert it into the text
|
|
const selectFile = (file: FileSearchResult) => {
|
|
if (atSymbolPosition === -1) return;
|
|
|
|
const beforeAt = value.slice(0, atSymbolPosition);
|
|
const afterQuery = value.slice(atSymbolPosition + 1 + searchQuery.length);
|
|
const newValue = beforeAt + file.path + afterQuery;
|
|
|
|
onChange(newValue);
|
|
setShowDropdown(false);
|
|
setSearchQuery('');
|
|
setAtSymbolPosition(-1);
|
|
|
|
// Focus back to textarea
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
const newCursorPos = atSymbolPosition + file.path.length;
|
|
textareaRef.current.focus();
|
|
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
// Calculate dropdown position relative to viewport
|
|
const getDropdownPosition = () => {
|
|
if (!textareaRef.current || atSymbolPosition === -1)
|
|
return { top: 0, left: 0, maxHeight: 240 };
|
|
|
|
const textareaRect = textareaRef.current.getBoundingClientRect();
|
|
const textBeforeAt = value.slice(0, atSymbolPosition);
|
|
const lines = textBeforeAt.split('\n');
|
|
const currentLine = lines.length - 1;
|
|
const charInLine = lines[lines.length - 1].length;
|
|
|
|
// More accurate calculation using computed styles
|
|
const computedStyle = window.getComputedStyle(textareaRef.current);
|
|
const lineHeight = parseInt(computedStyle.lineHeight) || 20;
|
|
const fontSize = parseInt(computedStyle.fontSize) || 14;
|
|
const charWidth = fontSize * 0.6; // Approximate character width
|
|
const paddingLeft = parseInt(computedStyle.paddingLeft) || 12;
|
|
const paddingTop = parseInt(computedStyle.paddingTop) || 8;
|
|
|
|
// Position relative to textarea
|
|
const relativeTop = paddingTop + currentLine * lineHeight + lineHeight;
|
|
const relativeLeft = paddingLeft + charWidth * charInLine;
|
|
|
|
// Convert to viewport coordinates
|
|
const viewportTop = textareaRect.top + relativeTop;
|
|
const viewportLeft = textareaRect.left + relativeLeft;
|
|
|
|
// Dropdown dimensions
|
|
const dropdownWidth = 256; // min-w-64 = 256px
|
|
const minDropdownHeight = 120;
|
|
const maxDropdownHeight = 400; // Increased to show more results without scrolling
|
|
|
|
let finalTop = viewportTop;
|
|
let finalLeft = viewportLeft;
|
|
let maxHeight = maxDropdownHeight;
|
|
|
|
// Prevent going off the right edge
|
|
if (viewportLeft + dropdownWidth > window.innerWidth - 16) {
|
|
finalLeft = window.innerWidth - dropdownWidth - 16;
|
|
}
|
|
|
|
// Prevent going off the left edge
|
|
if (finalLeft < 16) {
|
|
finalLeft = 16;
|
|
}
|
|
|
|
// Smart positioning: avoid clipping by positioning above when needed
|
|
const availableSpaceBelow = window.innerHeight - viewportTop - 32;
|
|
const availableSpaceAbove =
|
|
textareaRect.top + currentLine * lineHeight - 32;
|
|
|
|
// Check if dropdown would be clipped at bottom - if so, try positioning above
|
|
const wouldBeClippedBelow = availableSpaceBelow < maxDropdownHeight;
|
|
const hasEnoughSpaceAbove = availableSpaceAbove >= maxDropdownHeight;
|
|
|
|
if (wouldBeClippedBelow && hasEnoughSpaceAbove) {
|
|
// Position above the cursor line with full height
|
|
finalTop =
|
|
textareaRect.top +
|
|
paddingTop +
|
|
currentLine * lineHeight -
|
|
maxDropdownHeight;
|
|
maxHeight = maxDropdownHeight;
|
|
} else if (
|
|
wouldBeClippedBelow &&
|
|
availableSpaceAbove > availableSpaceBelow
|
|
) {
|
|
// Position above but with reduced height if not enough space
|
|
finalTop =
|
|
textareaRect.top +
|
|
paddingTop +
|
|
currentLine * lineHeight -
|
|
availableSpaceAbove;
|
|
maxHeight = Math.max(availableSpaceAbove, minDropdownHeight);
|
|
} else {
|
|
// Position below the cursor line
|
|
maxHeight = Math.min(
|
|
maxDropdownHeight,
|
|
Math.max(availableSpaceBelow, minDropdownHeight)
|
|
);
|
|
}
|
|
|
|
// Ensure minimum height
|
|
if (maxHeight < minDropdownHeight) {
|
|
maxHeight = minDropdownHeight;
|
|
finalTop = Math.max(16, window.innerHeight - minDropdownHeight - 16);
|
|
}
|
|
|
|
return { top: finalTop, left: finalLeft, maxHeight };
|
|
};
|
|
|
|
const dropdownPosition = getDropdownPosition();
|
|
|
|
return (
|
|
<div
|
|
className={`relative ${className?.includes('flex-1') ? 'flex-1' : ''}`}
|
|
>
|
|
<Textarea
|
|
ref={textareaRef}
|
|
value={value}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
disabled={disabled}
|
|
className={className}
|
|
/>
|
|
|
|
{showDropdown &&
|
|
createPortal(
|
|
<div
|
|
ref={dropdownRef}
|
|
className="fixed bg-background border border-border rounded-md shadow-lg overflow-y-auto min-w-64"
|
|
style={{
|
|
top: dropdownPosition.top,
|
|
left: dropdownPosition.left,
|
|
maxHeight: dropdownPosition.maxHeight,
|
|
zIndex: 10000, // Higher than dialog z-[9999]
|
|
}}
|
|
>
|
|
{isLoading ? (
|
|
<div className="p-2 text-sm text-muted-foreground">
|
|
Searching...
|
|
</div>
|
|
) : searchResults.length === 0 ? (
|
|
<div className="p-2 text-sm text-muted-foreground">
|
|
No files found
|
|
</div>
|
|
) : (
|
|
<div className="py-1">
|
|
{searchResults.map((file, index) => (
|
|
<div
|
|
key={file.path}
|
|
className={`px-3 py-2 cursor-pointer text-sm ${
|
|
index === selectedIndex
|
|
? 'bg-blue-50 text-blue-900'
|
|
: 'hover:bg-muted'
|
|
}`}
|
|
onClick={() => selectFile(file)}
|
|
>
|
|
<div className="font-medium truncate">{file.name}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{file.path}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
}
|