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:
youfch
2026-04-21 20:31:14 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 36b177dc81
commit bc6cb91954
3 changed files with 281 additions and 30 deletions
+135 -15
View File
@@ -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 }) => { const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
// Track if we restored a draft on mount (for text selection) // Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null); const initialDraftRef = React.useRef<string | null>(null);
@@ -670,8 +701,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
return draft; 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 [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false);
const [isInternalDrag, setIsInternalDrag] = React.useState(false);
const [showFileMention, setShowFileMention] = React.useState(false); const [showFileMention, setShowFileMention] = React.useState(false);
const [mentionQuery, setMentionQuery] = React.useState(''); const [mentionQuery, setMentionQuery] = React.useState('');
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); 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 [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 [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
const textareaRef = React.useRef<HTMLTextAreaElement>(null); const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const cursorPosRef = React.useRef(0);
const previousMessageLengthRef = React.useRef(message.length); const previousMessageLengthRef = React.useRef(message.length);
const dropZoneRef = React.useRef<HTMLDivElement>(null); const dropZoneRef = React.useRef<HTMLDivElement>(null);
const dragEnterCountRef = React.useRef(0);
const suppressNextFileDropTextInsertRef = React.useRef(false); const suppressNextFileDropTextInsertRef = React.useRef(false);
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]); const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
@@ -780,7 +819,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (knownAgentNames.has(mentionPath.toLowerCase())) { if (knownAgentNames.has(mentionPath.toLowerCase())) {
return true; return true;
} }
if (mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.')) { if (isConfirmedFilePath(mentionPath)) {
return true; return true;
} }
} }
@@ -809,7 +848,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const isFileMention = isBoundary const isFileMention = isBoundary
&& mention.length > 0 && mention.length > 0
&& !knownAgentNames.has(mention.toLowerCase()) && !knownAgentNames.has(mention.toLowerCase())
&& (mention.includes('/') || mention.includes('\\') || mention.includes('.')); && isConfirmedFilePath(mention);
if (start > lastIndex) { if (start > lastIndex) {
parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' }); parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' });
@@ -873,7 +912,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
continue; continue;
} }
const looksLikeFilePath = mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.'); const looksLikeFilePath = isConfirmedFilePath(mentionPath);
if (!looksLikeFilePath) { if (!looksLikeFilePath) {
continue; continue;
} }
@@ -992,6 +1031,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
saveStoredDraft(sessionId, draft); 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); lastPersistedDraftRef.current.set(key, draft);
}, []); }, []);
@@ -1044,6 +1092,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Restore draft for the session we're entering // Restore draft for the session we're entering
const newDraft = getStoredDraft(currentSessionId); const newDraft = getStoredDraft(currentSessionId);
setMessage(newDraft); setMessage(newDraft);
confirmedMentionsRef.current = loadConfirmedMentions(currentSessionId);
if (newDraft) { if (newDraft) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
textareaRef.current?.select(); textareaRef.current?.select();
@@ -1052,6 +1101,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} else { } else {
// Persist disabled: clear input without saving // Persist disabled: clear input without saving
setMessage(''); setMessage('');
confirmedMentionsRef.current = new Set();
} }
} }
}, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]); }, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]);
@@ -1263,6 +1313,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}); });
// Clear input and attachments // 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(''); setMessage('');
if (attachmentsToQueue.length > 0) { if (attachmentsToQueue.length > 0) {
clearAttachedFiles(); clearAttachedFiles();
@@ -1426,8 +1479,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
if (!queuedOnly) { if (!queuedOnly) {
setMessage(''); setMessage('');
confirmedMentionsRef.current.clear();
// Clear per-session draft on submit // Clear per-session draft on submit
saveStoredDraft(currentSessionId, ''); saveStoredDraft(currentSessionId, '');
saveConfirmedMentions(currentSessionId, confirmedMentionsRef.current);
// Reset message history navigation state // Reset message history navigation state
setHistoryIndex(-1); setHistoryIndex(-1);
setDraftMessage(''); setDraftMessage('');
@@ -1599,6 +1654,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const selectionStart = textarea?.selectionStart ?? message.length; const selectionStart = textarea?.selectionStart ?? message.length;
const selectionEnd = textarea?.selectionEnd ?? message.length; const selectionEnd = textarea?.selectionEnd ?? message.length;
const hasCollapsedSelection = selectionStart === selectionEnd; const hasCollapsedSelection = selectionStart === selectionEnd;
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
if (hasCollapsedSelection) { if (hasCollapsedSelection) {
const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart; 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 token = message.slice(tokenStart, tokenEnd);
const mentionContent = token.slice(1);
const looksLikeFileMention = FILE_MENTION_TOKEN.test(token) const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
&& (token.includes('/') || token.includes('\\') || token.includes('.')); && !knownAgentNames.has(mentionContent.toLowerCase())
&& isConfirmedFilePath(mentionContent);
if (looksLikeFileMention) { if (looksLikeFileMention) {
confirmedMentionsRef.current.delete(mentionContent);
const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd; const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd;
const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`; const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`;
e.preventDefault(); e.preventDefault();
@@ -2266,6 +2325,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
? file.relativePath.trim() ? file.relativePath.trim()
: (toProjectRelativeMentionPath(file.path) || file.name); : (toProjectRelativeMentionPath(file.path) || file.name);
confirmedMentionsRef.current.add(mentionPath);
if (lastAtSymbol !== -1) { if (lastAtSymbol !== -1) {
const newMessage = const newMessage =
message.substring(0, lastAtSymbol) + message.substring(0, lastAtSymbol) +
@@ -2443,6 +2504,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (lowerTypes.includes('files')) return true; if (lowerTypes.includes('files')) return true;
if (lowerTypes.includes('text/uri-list')) return true; if (lowerTypes.includes('text/uri-list')) return true;
if (lowerTypes.includes('codefiles')) 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; 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[]) => { const addVSCodeDroppedUrisAsMentions = React.useCallback((uris: string[]) => {
if (uris.length === 0) return; if (uris.length === 0) return;
const mentions = Array.from(new Set(uris const paths = uris
.map((entry) => normalizeDroppedPath(entry)) .map((entry) => normalizeDroppedPath(entry))
.map((entry) => toProjectRelativeMentionPath(entry)) .map((entry) => toProjectRelativeMentionPath(entry))
.map((entry) => entry.trim().replace(/^\.\//, '')) .map((entry) => entry.trim().replace(/^\.\//, ''))
.filter((entry) => entry.length > 0) .filter((entry) => entry.length > 0);
.map((entry) => `@${entry}`)));
for (const p of paths) {
confirmedMentionsRef.current.add(p);
}
const mentions = Array.from(new Set(paths.map((entry) => `@${entry}`)));
if (mentions.length === 0) { if (mentions.length === 0) {
return; return;
@@ -2563,6 +2630,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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) { if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
setIsDragging(true); setIsDragging(true);
} }
@@ -2583,13 +2655,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const handleDragLeave = (e: React.DragEvent) => { const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (e.currentTarget === e.target) { dragEnterCountRef.current--;
if (dragEnterCountRef.current <= 0) {
dragEnterCountRef.current = 0;
setIsDragging(false); setIsDragging(false);
setIsInternalDrag(false);
clearDropTextSuppression(); clearDropTextSuppression();
} }
}; };
const handleDragEnd = () => {
dragEnterCountRef.current = 0;
setIsDragging(false);
setIsInternalDrag(false);
clearDropTextSuppression();
};
const handleDrop = async (e: React.DragEvent) => { const handleDrop = async (e: React.DragEvent) => {
dragEnterCountRef.current = 0;
const draggedFiles = hasDraggedFiles(e.dataTransfer); const draggedFiles = hasDraggedFiles(e.dataTransfer);
if (!draggedFiles) { if (!draggedFiles) {
clearDropTextSuppression(); clearDropTextSuppression();
@@ -2601,6 +2684,37 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!currentSessionId && !newSessionDraftOpen) return; 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); const files = collectDroppedFiles(e.dataTransfer);
if (files.length === 0 && isVSCodeRuntime()) { if (files.length === 0 && isVSCodeRuntime()) {
@@ -2631,15 +2745,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}; };
const handleDropCapture = (e: React.DragEvent) => { const handleDropCapture = (e: React.DragEvent) => {
if (!isVSCodeRuntime()) {
return;
}
if (!hasDraggedFiles(e.dataTransfer)) { if (!hasDraggedFiles(e.dataTransfer)) {
return; return;
} }
suppressNextFileDropTextInsertRef.current = true; // Prevent native textarea drop text insertion for all runtimes
scheduleDropTextSuppressionExpiry();
e.preventDefault(); e.preventDefault();
if (isVSCodeRuntime()) {
suppressNextFileDropTextInsertRef.current = true;
scheduleDropTextSuppressionExpiry();
}
}; };
// Tauri desktop: handle native file drops via onDragDropEvent // Tauri desktop: handle native file drops via onDragDropEvent
@@ -3379,6 +3493,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onDragEnd={handleDragEnd}
> >
{isDragging && ( {isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/90 rounded-xl"> <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')} /> <RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button> </button>
</div> </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>
</div> </div>
)} )}
@@ -3507,6 +3622,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDropCapture={handleDropCapture} onDropCapture={handleDropCapture}
onDrop={handleDrop} onDrop={handleDrop}
onDragEnd={handleDragEnd}
onPointerDownCapture={handleTextareaPointerDownCapture} onPointerDownCapture={handleTextareaPointerDownCapture}
onKeyUp={updateAutocompleteOverlayPosition} onKeyUp={updateAutocompleteOverlayPosition}
onClick={updateAutocompleteOverlayPosition} onClick={updateAutocompleteOverlayPosition}
@@ -3517,7 +3633,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`; composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`;
} }
}} }}
onSelect={updateAutocompleteOverlayPosition} onSelect={(e) => {
const ta = e.currentTarget;
cursorPosRef.current = ta.selectionStart ?? 0;
updateAutocompleteOverlayPosition();
}}
placeholder={currentSessionId || newSessionDraftOpen placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell' ? inputMode === 'shell'
? "Enter shell command..." ? "Enter shell command..."
@@ -1,5 +1,5 @@
import React from 'react'; 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 { cn, truncatePathMiddle } from '@/lib/utils';
import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
@@ -70,8 +70,10 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const showHidden = useDirectoryShowHidden(); const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored(); const showGitignored = useFilesViewShowGitignored();
const [files, setFiles] = React.useState<FileInfo[]>([]); const [files, setFiles] = React.useState<FileInfo[]>([]);
const [directories, setDirectories] = React.useState<FileInfo[]>([]);
const [agents, setAgents] = React.useState<AgentInfo[]>([]); const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const pendingSearchRef = React.useRef(0);
const [selectedIndex, setSelectedIndex] = React.useState(0); const [selectedIndex, setSelectedIndex] = React.useState(0);
const [marqueeWidth, setMarqueeWidth] = React.useState(360); const [marqueeWidth, setMarqueeWidth] = React.useState(360);
const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({}); const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({});
@@ -143,7 +145,6 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
React.useEffect(() => { React.useEffect(() => {
if (!currentDirectory) { if (!currentDirectory) {
setFiles([]); setFiles([]);
setLoading(false);
return; return;
} }
@@ -155,11 +156,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
if (!normalizedQueryLower) { if (!normalizedQueryLower) {
setFiles([]); setFiles([]);
setLoading(false);
return; return;
} }
let cancelled = false; let cancelled = false;
pendingSearchRef.current++;
setLoading(true); setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, { searchFiles(currentDirectory, normalizedQueryLower, 80, {
@@ -182,15 +183,78 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}) })
.finally(() => { .finally(() => {
if (!cancelled) { if (!cancelled) {
setLoading(false); pendingSearchRef.current--;
if (pendingSearchRef.current <= 0) {
pendingSearchRef.current = 0;
setLoading(false);
}
} }
}); });
return () => { return () => {
cancelled = true; cancelled = true;
pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1);
if (pendingSearchRef.current <= 0) {
setLoading(false);
}
}; };
}, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]); }, [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(() => { React.useEffect(() => {
const visibleAgents = getVisibleAgents(); const visibleAgents = getVisibleAgents();
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase(); const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
@@ -214,7 +278,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
setSelectedIndex(0); setSelectedIndex(0);
setOverflowMap({}); setOverflowMap({});
setMarqueeDurations({}); setMarqueeDurations({});
}, [files, recentFiles.length, visibleAgents.length]); }, [files, directories, recentFiles.length, visibleAgents.length]);
React.useEffect(() => { React.useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ itemRefs.current[selectedIndex]?.scrollIntoView({
@@ -261,7 +325,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
} }
window.removeEventListener('resize', updateOverflow); window.removeEventListener('resize', updateOverflow);
}; };
}, [files]); }, [files, directories]);
React.useEffect(() => { React.useEffect(() => {
const labelNode = labelRefs.current[selectedIndex]; const labelNode = labelRefs.current[selectedIndex];
@@ -305,7 +369,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return; return;
} }
const total = visibleAgents.length + recentFiles.length + files.length; const total = visibleAgents.length + directories.length + recentFiles.length + files.length;
if (total === 0) { if (total === 0) {
return; return;
} }
@@ -329,7 +393,15 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
} }
return; 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 const selectedFile = fileIndex < recentFiles.length
? recentFiles[fileIndex] ? recentFiles[fileIndex]
: files[fileIndex - recentFiles.length]; : 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 getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase(); const ext = file.extension?.toLowerCase();
@@ -444,11 +516,38 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
Type to search more agents Type to search more agents
</div> </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" /> <div className="my-1 border-t border-border/60" />
)} )}
{recentFiles.map((file, index) => { {recentFiles.map((file, index) => {
const rowIndex = visibleAgents.length + index; const rowIndex = visibleAgents.length + directories.length + index;
const relativePath = file.relativePath || file.name; const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex; const isSelected = selectedIndex === rowIndex;
@@ -500,7 +599,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
<div className="my-1 border-t border-border/60" /> <div className="my-1 border-t border-border/60" />
)} )}
{files.map((file, index) => { {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 relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex; const isSelected = selectedIndex === rowIndex;
@@ -553,7 +652,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</React.Fragment> </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"> <div className="px-3 py-2 typography-ui-label text-muted-foreground">
No matches found No matches found
</div> </div>
@@ -85,6 +85,18 @@ const normalizePath = (value: string): string => {
return normalized; 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 => { const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
}; };
@@ -122,6 +134,7 @@ const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
interface FileRowProps { interface FileRowProps {
node: FileNode; node: FileNode;
root: string;
isExpanded: boolean; isExpanded: boolean;
isActive: boolean; isActive: boolean;
status?: FileStatus | null; status?: FileStatus | null;
@@ -144,6 +157,7 @@ interface FileRowProps {
const FileRow: React.FC<FileRowProps> = ({ const FileRow: React.FC<FileRowProps> = ({
node, node,
root,
isExpanded, isExpanded,
isActive, isActive,
status, status,
@@ -179,6 +193,13 @@ const FileRow: React.FC<FileRowProps> = ({
setContextMenuPath(node.path); setContextMenuPath(node.path);
}, [node.path, setContextMenuPath]); }, [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 ( return (
<div <div
className="group relative flex items-center" className="group relative flex items-center"
@@ -188,9 +209,12 @@ const FileRow: React.FC<FileRowProps> = ({
type="button" type="button"
onClick={handleInteraction} onClick={handleInteraction}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
draggable
onDragStart={handleDragStart}
className={cn( 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', '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 ? ( {isDir ? (
@@ -755,6 +779,7 @@ export const SidebarFilesTree: React.FC = () => {
)} )}
<FileRow <FileRow
node={node} node={node}
root={root}
isExpanded={isExpanded} isExpanded={isExpanded}
isActive={isActive} isActive={isActive}
status={!isDir ? getFileStatus(node.path) : undefined} status={!isDir ? getFileStatus(node.path) : undefined}
@@ -848,8 +873,15 @@ export const SidebarFilesTree: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => handleOpenFile(node)} 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( 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' isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
)} )}
title={node.path} title={node.path}