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 }) => {
// 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}