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:
Aleksander Grygier
2026-08-07 18:45:54 +02:00
committed by GitHub
parent 4cb22cd537
commit 23634783c5
40 changed files with 1839 additions and 399 deletions
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/services/tools.service', () => ({
ToolsService: { executeToolRaw: vi.fn() }
}));
import { ToolsService } from '$lib/services/tools.service';
import { GlobSearchType } from '$lib/enums';
import { runGlobSearchWithChildren } from '$lib/utils';
const mockExecute = vi.mocked(ToolsService.executeToolRaw);
// Distinct roots per test so the module-level search cache never serves a
// prior test's result under the same (type, path, glob, depth) key.
beforeEach(() => {
mockExecute.mockReset();
});
describe('runGlobSearchWithChildren', () => {
it('returns ranked outer entries as absolute paths without descending', async () => {
mockExecute.mockResolvedValueOnce({
base: '/Users/rootA',
entries: [
{ path: 'note.md', type: 'file' },
{ path: 'src', type: 'dir' }
]
});
const res = await runGlobSearchWithChildren(
'note',
'/Users/rootA',
3,
50,
new AbortController().signal
);
expect(res.error).toBeUndefined();
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootA/note.md', '/Users/rootA/src']);
expect(res.exactDir).toBeUndefined();
expect(mockExecute).toHaveBeenCalledTimes(1);
});
it('appends a matched directorys children when the query ends with a separator', async () => {
mockExecute
.mockResolvedValueOnce({ base: '/Users/rootB', entries: [{ path: 'src', type: 'dir' }] })
.mockResolvedValueOnce({
base: '/Users/rootB/src',
entries: [
{ path: 'a.txt', type: 'file' },
{ path: 'sub', type: 'dir' }
]
});
const res = await runGlobSearchWithChildren(
'/Users/rootB/src/',
'/Users/rootB',
3,
50,
new AbortController().signal,
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
);
expect(res.error).toBeUndefined();
expect(res.exactDir).toBe('/Users/rootB/src');
expect(res.entries.map((e) => e.path)).toEqual([
'/Users/rootB/src',
'/Users/rootB/src/a.txt',
'/Users/rootB/src/sub'
]);
expect(mockExecute).toHaveBeenCalledTimes(2);
});
it('does not descend without a trailing separator in mention mode', async () => {
mockExecute.mockResolvedValueOnce({
base: '/Users/rootC',
entries: [{ path: 'src', type: 'dir' }]
});
const res = await runGlobSearchWithChildren(
'/Users/rootC/src',
'/Users/rootC',
3,
50,
new AbortController().signal,
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
);
expect(res.exactDir).toBeUndefined();
expect(mockExecute).toHaveBeenCalledTimes(1);
});
it('descends on an exact directory match in WD mode', async () => {
mockExecute
.mockResolvedValueOnce({ base: '/Users/rootD', entries: [{ path: 'src', type: 'dir' }] })
.mockResolvedValueOnce({
base: '/Users/rootD/src',
entries: [{ path: 'a.txt', type: 'file' }]
});
const res = await runGlobSearchWithChildren(
'/Users/rootD/src',
'/Users/rootD',
3,
50,
new AbortController().signal,
{ type: GlobSearchType.DIR }
);
expect(res.exactDir).toBe('/Users/rootD/src');
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootD/src', '/Users/rootD/src/a.txt']);
expect(mockExecute).toHaveBeenCalledTimes(2);
});
it('surfaces a server error without attempting a child walk', async () => {
mockExecute.mockResolvedValueOnce({ error: 'boom' });
const res = await runGlobSearchWithChildren(
'src',
'/Users/rootE',
3,
50,
new AbortController().signal
);
expect(res.error).toBe('boom');
expect(res.entries).toEqual([]);
expect(mockExecute).toHaveBeenCalledTimes(1);
});
});
+202
View File
@@ -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
});
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { findMentionToken, takeMentionDismissSnapshot } from '$lib/utils';
describe('findMentionToken', () => {
it('returns null for an empty/bare cursor', () => {
expect(findMentionToken('', 0)).toBeNull();
expect(findMentionToken('text', 0)).toBeNull();
});
it('recognizes a mention at the start of the value', () => {
expect(findMentionToken('@pr', 3)).toEqual({ start: 0, end: 3, query: 'pr' });
});
it('recognizes a mention after a word boundary', () => {
expect(findMentionToken('hello @pr', 9)).toEqual({ start: 6, end: 9, query: 'pr' });
});
it('returns null when the @ is mid-identifier', () => {
expect(findMentionToken('em@', 3)).toBeNull();
expect(findMentionToken('text@pr', 7)).toBeNull();
});
it('returns null when the cursor is past the whitespace break', () => {
expect(findMentionToken('@pr hello', 9)).toBeNull();
});
it('treats boundary characters (parens, brackets, comma) as token starts', () => {
expect(findMentionToken('(@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
expect(findMentionToken('[@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
expect(findMentionToken('a,@pr', 5)).toEqual({ start: 2, end: 5, query: 'pr' });
});
it('does not treat an identifier character as a boundary', () => {
expect(findMentionToken('user@abc', 8)).toBeNull();
});
it('extracts the whole token up to the trailing boundary as the query', () => {
expect(findMentionToken('@', 1)).toEqual({ start: 0, end: 1, query: '' });
expect(findMentionToken('@hello', 6)).toEqual({ start: 0, end: 6, query: 'hello' });
});
it('keeps the whole token as the query when the caret is mid-token', () => {
expect(findMentionToken('@hello', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
expect(findMentionToken('@hello world', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
});
it('ignores a boundary @ and keeps the most recent token', () => {
expect(findMentionToken('a @foo @bar', 11)).toEqual({ start: 7, end: 11, query: 'bar' });
});
});
describe('takeMentionDismissSnapshot', () => {
it('returns null when there is no valid mention at the cursor', () => {
expect(takeMentionDismissSnapshot('plain text', 5)).toBeNull();
expect(takeMentionDismissSnapshot('user@abc', 8)).toBeNull();
});
it('captures start and query of the current mention', () => {
expect(takeMentionDismissSnapshot('hello @proj', 11)).toEqual({
start: 6,
query: 'proj'
});
});
});
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
splitPathQuery,
buildCaseInsensitiveGlob,
buildGlobSearchArgs,
rankEntries,
joinPath,
highlightMatch
} from '$lib/utils';
import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
describe('splitPathQuery', () => {
it('treats a plain query as a home-relative glob (not navigation)', () => {
@@ -124,3 +126,41 @@ describe('highlightMatch', () => {
expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
});
});
describe('buildGlobSearchArgs', () => {
const DEPTH = 6;
it('glob-matches home-relative within the scope path', () => {
const args = buildGlobSearchArgs('docs', '/home', DEPTH);
expect(args.path).toBe('/home');
expect(args.include).toBe(buildCaseInsensitiveGlob('docs'));
expect(args.maxDepth).toBe(DEPTH);
expect(args.rankQuery).toBe('docs');
expect(args.last).toBeUndefined();
});
it('navigates home for a `~` path query', () => {
const args = buildGlobSearchArgs('~/proj', '/home', DEPTH);
expect(args.path).toBe('~');
expect(args.include).toBe(buildCaseInsensitiveGlob('proj'));
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
expect(args.rankQuery).toBe('proj');
expect(args.last).toBe('proj');
});
it('lists the scope root when a path query has no last segment', () => {
const args = buildGlobSearchArgs('~/', '/home', DEPTH);
expect(args.path).toBe('~');
expect(args.include).toBe(GLOB_WILDCARD);
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
});
it('navigates an absolute path under its root', () => {
const args = buildGlobSearchArgs('/usr/local/bin', '/home', DEPTH);
expect(args.path).toBe('/usr/local');
expect(args.include).toBe(buildCaseInsensitiveGlob('bin'));
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
expect(args.rankQuery).toBe('bin');
expect(args.last).toBe('bin');
});
});