fix: ignore pasted @ for file mentions (#1649)
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
18744bf723
commit
b87de3c5b2
@@ -90,6 +90,7 @@ import {
|
||||
buildAttachmentCitationText,
|
||||
findAttachmentCitationRanges,
|
||||
} from './attachmentCitations';
|
||||
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
|
||||
import type { Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
@@ -128,6 +129,38 @@ const buildImagePasteInsertion = (pastedText: string, citationText: string): str
|
||||
return `${text}${/\s$/.test(text) ? '' : ' '}${citationText}`;
|
||||
};
|
||||
|
||||
const getInsertedTextFromChange = (previousValue: string, nextValue: string): string => {
|
||||
if (previousValue === nextValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let prefixLength = 0;
|
||||
while (
|
||||
prefixLength < previousValue.length
|
||||
&& prefixLength < nextValue.length
|
||||
&& previousValue[prefixLength] === nextValue[prefixLength]
|
||||
) {
|
||||
prefixLength += 1;
|
||||
}
|
||||
|
||||
let previousSuffix = previousValue.length;
|
||||
let nextSuffix = nextValue.length;
|
||||
while (
|
||||
previousSuffix > prefixLength
|
||||
&& nextSuffix > prefixLength
|
||||
&& previousValue[previousSuffix - 1] === nextValue[nextSuffix - 1]
|
||||
) {
|
||||
previousSuffix -= 1;
|
||||
nextSuffix -= 1;
|
||||
}
|
||||
|
||||
return nextValue.slice(prefixLength, nextSuffix);
|
||||
};
|
||||
|
||||
const getFileMentionInputSourceForInsertedText = (insertedText: string): FileMentionAutocompleteInputSource => (
|
||||
insertedText.includes('@') ? 'paste' : 'manual'
|
||||
);
|
||||
|
||||
const withInlineInsertionBoundaries = (content: string, before: string, after: string): string => {
|
||||
if (!content) {
|
||||
return content;
|
||||
@@ -963,6 +996,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const dragEnterCountRef = React.useRef(0);
|
||||
const suppressNextFileDropTextInsertRef = React.useRef(false);
|
||||
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const suppressNextFileMentionPasteRef = React.useRef(false);
|
||||
const suppressNextFileMentionPasteTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
|
||||
const canAcceptDropRef = React.useRef(false);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
@@ -2700,7 +2735,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
adjustTextareaHeight({ allowShrink });
|
||||
}, [adjustTextareaHeight, message, isMobile]);
|
||||
|
||||
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
||||
const updateAutocompleteState = React.useCallback((
|
||||
value: string,
|
||||
cursorPosition: number,
|
||||
inputSource: FileMentionAutocompleteInputSource = 'manual',
|
||||
insertedText?: string,
|
||||
) => {
|
||||
if (inputMode === 'shell') {
|
||||
setShowCommandAutocomplete(false);
|
||||
setShowFileMention(false);
|
||||
@@ -2765,19 +2805,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
setShowSnippetAutocomplete(false);
|
||||
|
||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||
if (lastAtSymbol !== -1) {
|
||||
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
||||
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
||||
if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
||||
setMentionQuery(textAfterAt);
|
||||
setShowFileMention(true);
|
||||
} else {
|
||||
setShowFileMention(false);
|
||||
}
|
||||
} else {
|
||||
const nextMentionQuery = getFileMentionAutocompleteQuery({ value, cursorPosition, inputSource, insertedText });
|
||||
if (nextMentionQuery === null) {
|
||||
setShowFileMention(false);
|
||||
} else {
|
||||
setMentionQuery(nextMentionQuery);
|
||||
setShowFileMention(true);
|
||||
}
|
||||
}, [
|
||||
inputMode,
|
||||
@@ -2791,7 +2824,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
setSnippetQuery,
|
||||
]);
|
||||
|
||||
const insertTextAtSelection = React.useCallback((text: string) => {
|
||||
const insertTextAtSelection = React.useCallback((
|
||||
text: string,
|
||||
inputSource: FileMentionAutocompleteInputSource = 'manual',
|
||||
) => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
@@ -2800,7 +2836,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!textarea) {
|
||||
const nextValue = message + text;
|
||||
setMessage(nextValue);
|
||||
updateAutocompleteState(nextValue, nextValue.length);
|
||||
updateAutocompleteState(nextValue, nextValue.length, inputSource, text);
|
||||
requestAnimationFrame(() => adjustTextareaHeight());
|
||||
return;
|
||||
}
|
||||
@@ -2820,7 +2856,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
adjustTextareaHeight();
|
||||
});
|
||||
|
||||
updateAutocompleteState(nextValue, cursorPosition);
|
||||
updateAutocompleteState(nextValue, cursorPosition, inputSource, text);
|
||||
}, [adjustTextareaHeight, message, updateAutocompleteState]);
|
||||
|
||||
const clearDropTextSuppression = React.useCallback(() => {
|
||||
@@ -2841,6 +2877,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}, 700);
|
||||
}, [clearDropTextSuppression]);
|
||||
|
||||
const clearFileMentionPasteSuppression = React.useCallback(() => {
|
||||
suppressNextFileMentionPasteRef.current = false;
|
||||
if (suppressNextFileMentionPasteTimeoutRef.current) {
|
||||
clearTimeout(suppressNextFileMentionPasteTimeoutRef.current);
|
||||
suppressNextFileMentionPasteTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markFileMentionPasteSuppression = React.useCallback(() => {
|
||||
suppressNextFileMentionPasteRef.current = true;
|
||||
if (suppressNextFileMentionPasteTimeoutRef.current) {
|
||||
clearTimeout(suppressNextFileMentionPasteTimeoutRef.current);
|
||||
}
|
||||
suppressNextFileMentionPasteTimeoutRef.current = setTimeout(() => {
|
||||
suppressNextFileMentionPasteRef.current = false;
|
||||
suppressNextFileMentionPasteTimeoutRef.current = null;
|
||||
}, 700);
|
||||
}, []);
|
||||
|
||||
const handleBeforeInput = React.useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) {
|
||||
return;
|
||||
@@ -2868,6 +2923,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
const value = e.target.value;
|
||||
const cursorPosition = e.target.selectionStart ?? value.length;
|
||||
const pastedInsertedText = nativeInputEvent?.inputType?.startsWith('insertFromPaste')
|
||||
? getInsertedTextFromChange(messageRef.current, value)
|
||||
: '';
|
||||
const isPasteInput = pastedInsertedText.includes('@') || suppressNextFileMentionPasteRef.current;
|
||||
if (suppressNextFileMentionPasteRef.current) {
|
||||
clearFileMentionPasteSuppression();
|
||||
}
|
||||
const inputSource: FileMentionAutocompleteInputSource = isPasteInput
|
||||
? 'paste'
|
||||
: 'manual';
|
||||
|
||||
if (inputMode === 'normal' && value.startsWith('!')) {
|
||||
const shellCommand = value.slice(1);
|
||||
@@ -2889,14 +2954,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
setMessage(value);
|
||||
adjustTextareaHeight();
|
||||
updateAutocompleteState(value, cursorPosition);
|
||||
updateAutocompleteState(value, cursorPosition, inputSource, pastedInsertedText);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearDropTextSuppression();
|
||||
clearFileMentionPasteSuppression();
|
||||
};
|
||||
}, [clearDropTextSuppression]);
|
||||
}, [clearDropTextSuppression, clearFileMentionPasteSuppression]);
|
||||
|
||||
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// Pasting a URL over a selection wraps it as a markdown link:
|
||||
@@ -2927,7 +2993,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
adjustTextareaHeight();
|
||||
});
|
||||
updateAutocompleteState(next, caret);
|
||||
updateAutocompleteState(next, caret, getFileMentionInputSourceForInsertedText(url), url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2951,17 +3017,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
});
|
||||
|
||||
const imageFiles = Array.from(fileMap.values());
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
if (imageFiles.length === 0) {
|
||||
if (pastedText.includes('@')) {
|
||||
markFileMentionPasteSuppression();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSessionId && !newSessionDraftOpen) {
|
||||
if (pastedText.includes('@')) {
|
||||
markFileMentionPasteSuppression();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
const assignedFilenames = assignImageAttachmentFilenames(
|
||||
imageFiles,
|
||||
[
|
||||
@@ -2979,7 +3051,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
message.slice(selectionEnd),
|
||||
);
|
||||
|
||||
insertTextAtSelection(insertionText);
|
||||
insertTextAtSelection(insertionText, getFileMentionInputSourceForInsertedText(insertionText));
|
||||
|
||||
for (let index = 0; index < imageFiles.length; index += 1) {
|
||||
const filename = assignedFilenames[index];
|
||||
@@ -2994,7 +3066,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
pendingPastedAttachmentFilenamesRef.current.delete(filename);
|
||||
}
|
||||
}
|
||||
}, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
|
||||
}, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
|
||||
|
||||
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getFileMentionAutocompleteQuery } from '../fileMentionAutocompleteState';
|
||||
|
||||
describe('getFileMentionAutocompleteQuery', () => {
|
||||
test('opens file mention autocomplete for manually typed boundary @ text', () => {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: '@config',
|
||||
cursorPosition: '@config'.length,
|
||||
inputSource: 'manual',
|
||||
})).toBe('config');
|
||||
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: 'check @main.ts',
|
||||
cursorPosition: 'check @main.ts'.length,
|
||||
inputSource: 'manual',
|
||||
})).toBe('main.ts');
|
||||
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: 'check @docs',
|
||||
cursorPosition: 'check @docs'.length,
|
||||
})).toBe('docs');
|
||||
});
|
||||
|
||||
test('does not open file mention autocomplete when pasted text contains @', () => {
|
||||
const pastedValues = [
|
||||
'@config',
|
||||
'@/path/to/file',
|
||||
'Use @main.ts',
|
||||
];
|
||||
|
||||
for (const value of pastedValues) {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value,
|
||||
cursorPosition: value.length,
|
||||
inputSource: 'paste',
|
||||
insertedText: value,
|
||||
})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not open file mention autocomplete for pasted package and email text', () => {
|
||||
const pastedValues = [
|
||||
'user@email.com',
|
||||
'npx @scope/pkg@latest',
|
||||
];
|
||||
|
||||
for (const value of pastedValues) {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value,
|
||||
cursorPosition: value.length,
|
||||
inputSource: 'paste',
|
||||
insertedText: value,
|
||||
})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps autocomplete open when pasting a query fragment after a manually typed @', () => {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: '@config',
|
||||
cursorPosition: '@config'.length,
|
||||
inputSource: 'paste',
|
||||
insertedText: 'config',
|
||||
})).toBe('config');
|
||||
});
|
||||
|
||||
test('uses current value when paste source lacks inserted text context', () => {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: '@config',
|
||||
cursorPosition: '@config'.length,
|
||||
inputSource: 'paste',
|
||||
})).toBe('config');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export type FileMentionAutocompleteInputSource = 'manual' | 'paste';
|
||||
|
||||
export const getFileMentionAutocompleteQuery = ({
|
||||
value,
|
||||
cursorPosition,
|
||||
inputSource = 'manual',
|
||||
insertedText,
|
||||
}: {
|
||||
value: string;
|
||||
cursorPosition: number;
|
||||
inputSource?: FileMentionAutocompleteInputSource;
|
||||
insertedText?: string;
|
||||
}): string | null => {
|
||||
if (inputSource === 'paste' && insertedText?.includes('@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textBeforeCursor = value.substring(0, cursorPosition);
|
||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||
if (lastAtSymbol === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
||||
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
||||
if (!isWordBoundary || textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return textAfterAt;
|
||||
};
|
||||
Reference in New Issue
Block a user