Chat input: drag-drop files and folders from file tree with @folder autocomplete (#963)
* feat(chat): drag-drop files/folders from file tree into chat input Features: - Support drag-and-drop files and folders from file tree into chat input - Support @folder autocomplete for file/folder mentions in chat input - Add drag support to file tree nodes for dropping into chat input - Persist confirmed mentions to localStorage across sessions Fixes: - Fix drag state getting stuck with drag enter counter and onDragEnd cleanup - Fix pendingSearchRef counter leak on query cancellation - Fix confirmed mentions highlight/delete for folder paths - Fix clear confirmed mentions on send and autocomplete loading state - Guard drag-start against empty and root-relative paths - Reset loading state when pendingSearchRef reaches zero * fix(chat): address Greptile PR review findings - P1: move confirmedMentionsRef.clear() out of handleQueueMessage to prevent bare-name mentions from being lost when queued messages are sent via handleSubmit - P2: distinguish internal file-tree drag from external file drag in overlay text (Drop to insert as mention vs Drop files here to attach) - P2: add directories to marquee overflow effect dependency array * fix(chat): prune stale confirmed mentions --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
36b177dc81
commit
bc6cb91954
@@ -655,6 +655,37 @@ const saveStoredDraft = (sessionId: string | null, draft: string): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// Per-session confirmed mentions key — tracks which @mentions are confirmed (blue) vs plain text
|
||||
const getConfirmedMentionsKey = (sessionId: string | null): string =>
|
||||
`openchamber_chat_confirmed_mentions_${sessionId ?? 'new'}`;
|
||||
|
||||
const saveConfirmedMentions = (sessionId: string | null, mentions: Set<string>): void => {
|
||||
try {
|
||||
if (mentions.size > 0) {
|
||||
localStorage.setItem(getConfirmedMentionsKey(sessionId), JSON.stringify([...mentions]));
|
||||
} else {
|
||||
localStorage.removeItem(getConfirmedMentionsKey(sessionId));
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfirmedMentions = (sessionId: string | null): Set<string> => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getConfirmedMentionsKey(sessionId));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
return new Set();
|
||||
};
|
||||
|
||||
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||
// Track if we restored a draft on mount (for text selection)
|
||||
const initialDraftRef = React.useRef<string | null>(null);
|
||||
@@ -670,8 +701,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
return draft;
|
||||
});
|
||||
// Restore confirmed mentions from localStorage on mount
|
||||
const confirmedMentionsRef = React.useRef<Set<string>>(loadConfirmedMentions(initialSessionIdRef.current));
|
||||
// Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed)
|
||||
const isConfirmedFilePath = (text: string): boolean =>
|
||||
text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text);
|
||||
const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [isInternalDrag, setIsInternalDrag] = React.useState(false);
|
||||
const [showFileMention, setShowFileMention] = React.useState(false);
|
||||
const [mentionQuery, setMentionQuery] = React.useState('');
|
||||
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
|
||||
@@ -686,8 +723,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
|
||||
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const cursorPosRef = React.useRef(0);
|
||||
const previousMessageLengthRef = React.useRef(message.length);
|
||||
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||
const dragEnterCountRef = React.useRef(0);
|
||||
const suppressNextFileDropTextInsertRef = React.useRef(false);
|
||||
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
|
||||
@@ -780,7 +819,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (knownAgentNames.has(mentionPath.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.')) {
|
||||
if (isConfirmedFilePath(mentionPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -809,7 +848,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const isFileMention = isBoundary
|
||||
&& mention.length > 0
|
||||
&& !knownAgentNames.has(mention.toLowerCase())
|
||||
&& (mention.includes('/') || mention.includes('\\') || mention.includes('.'));
|
||||
&& isConfirmedFilePath(mention);
|
||||
|
||||
if (start > lastIndex) {
|
||||
parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' });
|
||||
@@ -873,7 +912,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
continue;
|
||||
}
|
||||
|
||||
const looksLikeFilePath = mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.');
|
||||
const looksLikeFilePath = isConfirmedFilePath(mentionPath);
|
||||
if (!looksLikeFilePath) {
|
||||
continue;
|
||||
}
|
||||
@@ -992,6 +1031,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
|
||||
saveStoredDraft(sessionId, draft);
|
||||
// Only persist confirmed mentions that are actually present in the draft text
|
||||
const activeMentions = new Set<string>();
|
||||
for (const mention of confirmedMentionsRef.current) {
|
||||
if (draft.includes(`@${mention}`)) {
|
||||
activeMentions.add(mention);
|
||||
}
|
||||
}
|
||||
confirmedMentionsRef.current = activeMentions;
|
||||
saveConfirmedMentions(sessionId, activeMentions);
|
||||
lastPersistedDraftRef.current.set(key, draft);
|
||||
}, []);
|
||||
|
||||
@@ -1044,6 +1092,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
// Restore draft for the session we're entering
|
||||
const newDraft = getStoredDraft(currentSessionId);
|
||||
setMessage(newDraft);
|
||||
confirmedMentionsRef.current = loadConfirmedMentions(currentSessionId);
|
||||
if (newDraft) {
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.select();
|
||||
@@ -1052,6 +1101,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
} else {
|
||||
// Persist disabled: clear input without saving
|
||||
setMessage('');
|
||||
confirmedMentionsRef.current = new Set();
|
||||
}
|
||||
}
|
||||
}, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]);
|
||||
@@ -1263,6 +1313,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
});
|
||||
|
||||
// Clear input and attachments
|
||||
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
||||
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
||||
// The ref is cleared in handleSubmit after all queued messages are sent.
|
||||
setMessage('');
|
||||
if (attachmentsToQueue.length > 0) {
|
||||
clearAttachedFiles();
|
||||
@@ -1426,8 +1479,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
if (!queuedOnly) {
|
||||
setMessage('');
|
||||
confirmedMentionsRef.current.clear();
|
||||
// Clear per-session draft on submit
|
||||
saveStoredDraft(currentSessionId, '');
|
||||
saveConfirmedMentions(currentSessionId, confirmedMentionsRef.current);
|
||||
// Reset message history navigation state
|
||||
setHistoryIndex(-1);
|
||||
setDraftMessage('');
|
||||
@@ -1599,6 +1654,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const selectionStart = textarea?.selectionStart ?? message.length;
|
||||
const selectionEnd = textarea?.selectionEnd ?? message.length;
|
||||
const hasCollapsedSelection = selectionStart === selectionEnd;
|
||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
||||
|
||||
if (hasCollapsedSelection) {
|
||||
const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart;
|
||||
@@ -1614,10 +1670,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
|
||||
const token = message.slice(tokenStart, tokenEnd);
|
||||
const mentionContent = token.slice(1);
|
||||
const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
|
||||
&& (token.includes('/') || token.includes('\\') || token.includes('.'));
|
||||
&& !knownAgentNames.has(mentionContent.toLowerCase())
|
||||
&& isConfirmedFilePath(mentionContent);
|
||||
|
||||
if (looksLikeFileMention) {
|
||||
confirmedMentionsRef.current.delete(mentionContent);
|
||||
const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd;
|
||||
const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`;
|
||||
e.preventDefault();
|
||||
@@ -2266,6 +2325,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
? file.relativePath.trim()
|
||||
: (toProjectRelativeMentionPath(file.path) || file.name);
|
||||
|
||||
confirmedMentionsRef.current.add(mentionPath);
|
||||
|
||||
if (lastAtSymbol !== -1) {
|
||||
const newMessage =
|
||||
message.substring(0, lastAtSymbol) +
|
||||
@@ -2443,6 +2504,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (lowerTypes.includes('files')) return true;
|
||||
if (lowerTypes.includes('text/uri-list')) return true;
|
||||
if (lowerTypes.includes('codefiles')) return true;
|
||||
if (lowerTypes.includes('application/x-openchamber-file-path')) return true;
|
||||
if (lowerTypes.some((type) => type.includes('vnd.code.tree'))) return true;
|
||||
}
|
||||
|
||||
@@ -2542,12 +2604,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const addVSCodeDroppedUrisAsMentions = React.useCallback((uris: string[]) => {
|
||||
if (uris.length === 0) return;
|
||||
|
||||
const mentions = Array.from(new Set(uris
|
||||
const paths = uris
|
||||
.map((entry) => normalizeDroppedPath(entry))
|
||||
.map((entry) => toProjectRelativeMentionPath(entry))
|
||||
.map((entry) => entry.trim().replace(/^\.\//, ''))
|
||||
.filter((entry) => entry.length > 0)
|
||||
.map((entry) => `@${entry}`)));
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
for (const p of paths) {
|
||||
confirmedMentionsRef.current.add(p);
|
||||
}
|
||||
|
||||
const mentions = Array.from(new Set(paths.map((entry) => `@${entry}`)));
|
||||
|
||||
if (mentions.length === 0) {
|
||||
return;
|
||||
@@ -2563,6 +2630,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragEnterCountRef.current++;
|
||||
const isInternal = e.dataTransfer.types?.includes('application/x-openchamber-file-path') ?? false;
|
||||
if (isInternal !== isInternalDrag) {
|
||||
setIsInternalDrag(isInternal);
|
||||
}
|
||||
if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
@@ -2583,13 +2655,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.currentTarget === e.target) {
|
||||
dragEnterCountRef.current--;
|
||||
if (dragEnterCountRef.current <= 0) {
|
||||
dragEnterCountRef.current = 0;
|
||||
setIsDragging(false);
|
||||
setIsInternalDrag(false);
|
||||
clearDropTextSuppression();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragEnterCountRef.current = 0;
|
||||
setIsDragging(false);
|
||||
setIsInternalDrag(false);
|
||||
clearDropTextSuppression();
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
dragEnterCountRef.current = 0;
|
||||
const draggedFiles = hasDraggedFiles(e.dataTransfer);
|
||||
if (!draggedFiles) {
|
||||
clearDropTextSuppression();
|
||||
@@ -2601,6 +2684,37 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
if (!currentSessionId && !newSessionDraftOpen) return;
|
||||
|
||||
// Internal drag: file tree → chat input (relative path as @mention)
|
||||
const internalPath = e.dataTransfer.getData('application/x-openchamber-file-path');
|
||||
if (internalPath && internalPath !== '.') {
|
||||
confirmedMentionsRef.current.add(internalPath);
|
||||
const mention = `@${internalPath}`;
|
||||
const textarea = textareaRef.current;
|
||||
const currentMessage = messageRef.current;
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart ?? cursorPosRef.current;
|
||||
const end = textarea.selectionEnd ?? pos;
|
||||
const before = currentMessage.slice(0, pos);
|
||||
const after = currentMessage.slice(end);
|
||||
const needSpaceBefore = before.length > 0 && !/\s$/.test(before);
|
||||
const needSpaceAfter = after.length > 0 && !/^\s/.test(after);
|
||||
const insert = `${needSpaceBefore ? ' ' : ''}${mention}${needSpaceAfter ? ' ' : ''}`;
|
||||
const nextMessage = `${before}${insert}${after}`;
|
||||
setMessage(nextMessage);
|
||||
requestAnimationFrame(() => {
|
||||
const cursorPos = pos + insert.length;
|
||||
textarea.selectionStart = cursorPos;
|
||||
textarea.selectionEnd = cursorPos;
|
||||
cursorPosRef.current = cursorPos;
|
||||
textarea.focus();
|
||||
});
|
||||
} else {
|
||||
setMessage((prev) => appendInlineText(prev, mention));
|
||||
}
|
||||
clearDropTextSuppression();
|
||||
return;
|
||||
}
|
||||
|
||||
const files = collectDroppedFiles(e.dataTransfer);
|
||||
|
||||
if (files.length === 0 && isVSCodeRuntime()) {
|
||||
@@ -2631,15 +2745,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
};
|
||||
|
||||
const handleDropCapture = (e: React.DragEvent) => {
|
||||
if (!isVSCodeRuntime()) {
|
||||
return;
|
||||
}
|
||||
if (!hasDraggedFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
suppressNextFileDropTextInsertRef.current = true;
|
||||
scheduleDropTextSuppressionExpiry();
|
||||
// Prevent native textarea drop text insertion for all runtimes
|
||||
e.preventDefault();
|
||||
if (isVSCodeRuntime()) {
|
||||
suppressNextFileDropTextInsertRef.current = true;
|
||||
scheduleDropTextSuppressionExpiry();
|
||||
}
|
||||
};
|
||||
|
||||
// Tauri desktop: handle native file drops via onDragDropEvent
|
||||
@@ -3379,6 +3493,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/90 rounded-xl">
|
||||
@@ -3394,7 +3509,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
|
||||
<p className="mt-2 typography-ui-label text-muted-foreground">{isInternalDrag ? 'Drop to insert as mention' : 'Drop files here to attach'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -3507,6 +3622,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
onDragOver={handleDragOver}
|
||||
onDropCapture={handleDropCapture}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
onPointerDownCapture={handleTextareaPointerDownCapture}
|
||||
onKeyUp={updateAutocompleteOverlayPosition}
|
||||
onClick={updateAutocompleteOverlayPosition}
|
||||
@@ -3517,7 +3633,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`;
|
||||
}
|
||||
}}
|
||||
onSelect={updateAutocompleteOverlayPosition}
|
||||
onSelect={(e) => {
|
||||
const ta = e.currentTarget;
|
||||
cursorPosRef.current = ta.selectionStart ?? 0;
|
||||
updateAutocompleteOverlayPosition();
|
||||
}}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? "Enter shell command..."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
|
||||
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiFolder3Fill, RiRefreshLine } from '@remixicon/react';
|
||||
import { cn, truncatePathMiddle } from '@/lib/utils';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
@@ -70,8 +70,10 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
const showHidden = useDirectoryShowHidden();
|
||||
const showGitignored = useFilesViewShowGitignored();
|
||||
const [files, setFiles] = React.useState<FileInfo[]>([]);
|
||||
const [directories, setDirectories] = React.useState<FileInfo[]>([]);
|
||||
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const pendingSearchRef = React.useRef(0);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
|
||||
const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({});
|
||||
@@ -143,7 +145,6 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,11 +156,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
|
||||
if (!normalizedQueryLower) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
pendingSearchRef.current++;
|
||||
setLoading(true);
|
||||
|
||||
searchFiles(currentDirectory, normalizedQueryLower, 80, {
|
||||
@@ -182,15 +183,78 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
pendingSearchRef.current--;
|
||||
if (pendingSearchRef.current <= 0) {
|
||||
pendingSearchRef.current = 0;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1);
|
||||
if (pendingSearchRef.current <= 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory) {
|
||||
setDirectories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = (debouncedQuery ?? '').trim();
|
||||
const normalizedQueryLower = normalizedQuery
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
|
||||
if (!normalizedQueryLower) {
|
||||
setDirectories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
pendingSearchRef.current++;
|
||||
setLoading(true);
|
||||
|
||||
searchFiles(currentDirectory, normalizedQueryLower, 20, {
|
||||
includeHidden: showHidden,
|
||||
respectGitignore: !showGitignored,
|
||||
type: 'directory',
|
||||
})
|
||||
.then((hits) => {
|
||||
if (!cancelled) {
|
||||
setDirectories(hits.slice(0, 10));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDirectories([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
pendingSearchRef.current--;
|
||||
if (pendingSearchRef.current <= 0) {
|
||||
pendingSearchRef.current = 0;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1);
|
||||
if (pendingSearchRef.current <= 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [currentDirectory, debouncedQuery, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleAgents = getVisibleAgents();
|
||||
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
|
||||
@@ -214,7 +278,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
setSelectedIndex(0);
|
||||
setOverflowMap({});
|
||||
setMarqueeDurations({});
|
||||
}, [files, recentFiles.length, visibleAgents.length]);
|
||||
}, [files, directories, recentFiles.length, visibleAgents.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
@@ -261,7 +325,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
window.removeEventListener('resize', updateOverflow);
|
||||
};
|
||||
}, [files]);
|
||||
}, [files, directories]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const labelNode = labelRefs.current[selectedIndex];
|
||||
@@ -305,7 +369,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return;
|
||||
}
|
||||
|
||||
const total = visibleAgents.length + recentFiles.length + files.length;
|
||||
const total = visibleAgents.length + directories.length + recentFiles.length + files.length;
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -329,7 +393,15 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
return;
|
||||
}
|
||||
const fileIndex = safeIndex - visibleAgents.length;
|
||||
const dirIndex = safeIndex - visibleAgents.length;
|
||||
if (dirIndex < directories.length) {
|
||||
const dir = directories[dirIndex];
|
||||
if (dir) {
|
||||
handleFileSelect(dir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const fileIndex = dirIndex - directories.length;
|
||||
const selectedFile = fileIndex < recentFiles.length
|
||||
? recentFiles[fileIndex]
|
||||
: files[fileIndex - recentFiles.length];
|
||||
@@ -338,7 +410,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
}
|
||||
}
|
||||
}), [files, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
||||
}), [files, directories, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
||||
|
||||
const getFileIcon = (file: FileInfo) => {
|
||||
const ext = file.extension?.toLowerCase();
|
||||
@@ -444,11 +516,38 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
Type to search more agents
|
||||
</div>
|
||||
)}
|
||||
{visibleAgents.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
|
||||
{visibleAgents.length > 0 && (directories.length > 0 || recentFiles.length > 0 || files.length > 0) && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{directories.map((dir, index) => {
|
||||
const rowIndex = visibleAgents.length + index;
|
||||
const relativePath = dir.relativePath || dir.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`dir-${dir.path}`}
|
||||
ref={(el) => { itemRefs.current[rowIndex] = el; }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
||||
isSelected && "bg-interactive-selection"
|
||||
)}
|
||||
onClick={() => handleFileSelect(dir)}
|
||||
onMouseEnter={() => setSelectedIndex(rowIndex)}
|
||||
>
|
||||
<RiFolder3Fill className="h-3.5 w-3.5 text-primary/60" />
|
||||
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
|
||||
{displayPath}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{directories.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{recentFiles.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + index;
|
||||
const rowIndex = visibleAgents.length + directories.length + index;
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
@@ -500,7 +599,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{files.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + recentFiles.length + index;
|
||||
const rowIndex = visibleAgents.length + directories.length + recentFiles.length + index;
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
@@ -553,7 +652,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
|
||||
{files.length === 0 && directories.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
No matches found
|
||||
</div>
|
||||
|
||||
@@ -85,6 +85,18 @@ const normalizePath = (value: string): string => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const getRelativePath = (root: string, path: string): string => {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const normalizedRoot = normalizePath(root).replace(/\/+$/, '');
|
||||
if (normalizedPath === normalizedRoot) {
|
||||
return '.';
|
||||
}
|
||||
if (!normalizedRoot || !normalizedPath.startsWith(`${normalizedRoot}/`)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
return normalizedPath.slice(normalizedRoot.length + 1);
|
||||
};
|
||||
|
||||
const isAbsolutePath = (value: string): boolean => {
|
||||
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
|
||||
};
|
||||
@@ -122,6 +134,7 @@ const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
|
||||
|
||||
interface FileRowProps {
|
||||
node: FileNode;
|
||||
root: string;
|
||||
isExpanded: boolean;
|
||||
isActive: boolean;
|
||||
status?: FileStatus | null;
|
||||
@@ -144,6 +157,7 @@ interface FileRowProps {
|
||||
|
||||
const FileRow: React.FC<FileRowProps> = ({
|
||||
node,
|
||||
root,
|
||||
isExpanded,
|
||||
isActive,
|
||||
status,
|
||||
@@ -179,6 +193,13 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
setContextMenuPath(node.path);
|
||||
}, [node.path, setContextMenuPath]);
|
||||
|
||||
const handleDragStart = React.useCallback((e: React.DragEvent) => {
|
||||
const path = getRelativePath(root, node.path);
|
||||
if (!path || path === '.') return;
|
||||
e.dataTransfer.setData('application/x-openchamber-file-path', path);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}, [node.path, root]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center"
|
||||
@@ -188,9 +209,12 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
type="button"
|
||||
onClick={handleInteraction}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
|
||||
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
|
||||
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40',
|
||||
'cursor-grab active:cursor-grabbing'
|
||||
)}
|
||||
>
|
||||
{isDir ? (
|
||||
@@ -755,6 +779,7 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
)}
|
||||
<FileRow
|
||||
node={node}
|
||||
root={root}
|
||||
isExpanded={isExpanded}
|
||||
isActive={isActive}
|
||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||
@@ -848,8 +873,15 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenFile(node)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
const path = node.relativePath || getRelativePath(root ?? '', node.path);
|
||||
if (!path || path === '.') return;
|
||||
e.dataTransfer.setData('application/x-openchamber-file-path', path);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors',
|
||||
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors cursor-grab active:cursor-grabbing',
|
||||
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
|
||||
)}
|
||||
title={node.path}
|
||||
|
||||
Reference in New Issue
Block a user