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 }) => {
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user