ui: Filesystem @mentions for Chat Form (#26715)
* base : @-mention picker foundation - glob search, picker nav, highlight * feat : @-mention file/folder picker and mention badges in message bubbles * fix: Imports * feat : wire the @-mention picker into the chat form * fix: Bound the glob-search result cache key and prune stale entries
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||
buildMentionInsertion,
|
||||
decodeFileLinkPath,
|
||||
encodeFileLinkPath,
|
||||
fileMentionLinkRe,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel,
|
||||
mentionLinkEndingAt
|
||||
} from '$lib/utils';
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
|
||||
describe('encodeFileLinkPath', () => {
|
||||
it('leaves a clean path unchanged', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/bar.txt')).toBe('/Users/foo/bar.txt');
|
||||
});
|
||||
|
||||
it('encodes spaces per path segment', () => {
|
||||
expect(
|
||||
encodeFileLinkPath('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png')
|
||||
).toBe('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png');
|
||||
});
|
||||
|
||||
it('preserves the leading and trailing slash (directory marker)', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/bar/')).toBe('/Users/foo/bar/');
|
||||
});
|
||||
|
||||
it('encodes parentheses in macOS screenshot names', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/Pic (1).png')).toBe('/Users/foo/Pic%20(1).png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileMentionLinkRe', () => {
|
||||
it('matches a standard mention link', () => {
|
||||
expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match non-file links', () => {
|
||||
expect(fileMentionLinkRe().test('[foo](https://example.com)')).toBe(false);
|
||||
expect(fileMentionLinkRe().test('plain text')).toBe(false);
|
||||
});
|
||||
|
||||
it('admits a close paren in a macOS-style file name', () => {
|
||||
const match = fileMentionLinkRe().exec(
|
||||
'[Screenshot (1).png](file:///Users/foo/Screenshot (1).png)'
|
||||
);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.[1]).toBe('Screenshot (1).png');
|
||||
expect(match?.[2]).toBe('/Users/foo/Screenshot (1).png');
|
||||
});
|
||||
|
||||
it('admits a parenthesized folder segment', () => {
|
||||
expect(
|
||||
fileMentionLinkRe().exec('[main.rs](file:///Users/foo/Project (Stuff)/main.rs)')?.[2]
|
||||
).toBe('/Users/foo/Project (Stuff)/main.rs');
|
||||
});
|
||||
|
||||
it('stops at the closing paren of an adjacent badge', () => {
|
||||
expect(fileMentionLinkRe().exec('[a](file:///p)[b](file:///q)')?.[0]).toBe('[a](file:///p)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentionBadgeIconPaths', () => {
|
||||
it('returns the folder glyphs for a trailing-separator path', () => {
|
||||
expect(getMentionBadgeIconPaths('/Users/foo/bar/')).toBe(MENTION_BADGE_FOLDER_ICON_PATHS);
|
||||
});
|
||||
|
||||
it('returns the file glyphs otherwise', () => {
|
||||
expect(getMentionBadgeIconPaths('/Users/foo/bar.txt')).toBe(MENTION_BADGE_FILE_ICON_PATHS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentionBadgeLabel', () => {
|
||||
it('returns the name by default', () => {
|
||||
expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', false)).toBe('bar');
|
||||
});
|
||||
|
||||
it('renders the decoded full path without the trailing separator', () => {
|
||||
expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', true)).toBe('/Users/foo/bar');
|
||||
expect(getMentionBadgeLabel('shot', '/Users/foo/Screenshot%20(1).png', true)).toBe(
|
||||
'/Users/foo/Screenshot (1).png'
|
||||
);
|
||||
});
|
||||
|
||||
it('abbreviates a known home prefix to a tilde', () => {
|
||||
expect(getMentionBadgeLabel('main.rs', '/home/user/src/main.rs', true, '/home/user')).toBe(
|
||||
'~/src/main.rs'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the name when the decoded path is empty', () => {
|
||||
expect(getMentionBadgeLabel('root', '/', true)).toBe('root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeFileLinkPath', () => {
|
||||
it('decodes encoded segments back to the original path', () => {
|
||||
expect(
|
||||
decodeFileLinkPath('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png')
|
||||
).toBe('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png');
|
||||
});
|
||||
|
||||
it('is the inverse of encodeFileLinkPath', () => {
|
||||
for (const path of [
|
||||
'/a/b.txt',
|
||||
'/Users/foo/Desktop/Screenshot 2026-08-05 at 11.33.45.png',
|
||||
'/Users/foo/bar (1)/dir/',
|
||||
'/sp ace/pa%th.txt'
|
||||
]) {
|
||||
expect(decodeFileLinkPath(encodeFileLinkPath(path))).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the input on malformed percent sequences', () => {
|
||||
expect(decodeFileLinkPath('/a/%zz.txt')).toBe('/a/%zz.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMentionInsertion', () => {
|
||||
const file = (path: string, name: string) => ({
|
||||
path,
|
||||
name,
|
||||
type: FileMentionEntryType.FILE
|
||||
});
|
||||
const dir = (path: string, name: string) => ({
|
||||
path,
|
||||
name,
|
||||
type: FileMentionEntryType.DIRECTORY
|
||||
});
|
||||
|
||||
it('splices a root-anchored file link in place of the token', () => {
|
||||
const value = 'hello @repo';
|
||||
const result = buildMentionInsertion(file('/Users/foo/myRepo', 'myRepo'), value, {
|
||||
start: 6,
|
||||
end: 11
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
const { newValue, caretOffset } = result!;
|
||||
expect(newValue).toBe('hello [myRepo](file:///Users/foo/myRepo) ');
|
||||
expect(caretOffset).toBe(6 + '[myRepo](file:///Users/foo/myRepo) '.length);
|
||||
});
|
||||
|
||||
it('keeps the trailing slash on the directory marker', () => {
|
||||
const value = 'see @src';
|
||||
const { newValue } = buildMentionInsertion(dir('/Users/foo/myRepo/src/', 'src'), value, {
|
||||
start: 4,
|
||||
end: 8
|
||||
})!;
|
||||
expect(newValue).toBe('see [src](file:///Users/foo/myRepo/src/) ');
|
||||
});
|
||||
|
||||
it('escapes spaces and parens in the target', () => {
|
||||
const value = '@pic';
|
||||
const { newValue } = buildMentionInsertion(
|
||||
file('/Users/foo/Desktop/Pic (1).png', 'Pic (1).png'),
|
||||
value,
|
||||
{ start: 0, end: 4 }
|
||||
)!;
|
||||
expect(newValue).toBe('[Pic (1).png](file:///Users/foo/Desktop/Pic%20(1).png) ');
|
||||
});
|
||||
|
||||
it('re-adds the directory marker when the cleaned path empties', () => {
|
||||
const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { start: 0, end: 1 })!;
|
||||
expect(newValue).toBe('[root](file:///) ');
|
||||
});
|
||||
|
||||
it('returns null for an out-of-range token', () => {
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 0, end: 5 })).toBeNull();
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentionLinkEndingAt', () => {
|
||||
const LINK = '[docs](file:///a/b)';
|
||||
|
||||
it('returns the extent when the caret is exactly at the link end', () => {
|
||||
expect(mentionLinkEndingAt(`see ${LINK} here`, 4 + LINK.length)).toEqual({
|
||||
start: 4,
|
||||
end: 4 + LINK.length
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the caret is inside or past the link', () => {
|
||||
expect(mentionLinkEndingAt(LINK, LINK.length - 1)).toBeNull();
|
||||
expect(mentionLinkEndingAt(`${LINK} `, LINK.length + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-file links and plain text', () => {
|
||||
expect(mentionLinkEndingAt('[foo](https://example.com)', 26)).toBeNull();
|
||||
expect(mentionLinkEndingAt('plain', 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the link that ends at the caret when several exist', () => {
|
||||
const value = `${LINK} and ${LINK}`;
|
||||
expect(mentionLinkEndingAt(value, value.length)).toEqual({
|
||||
start: value.length - LINK.length,
|
||||
end: value.length
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user