feat(app): add @ workspace file autocomplete in agent chat

This commit is contained in:
Mohamed Boudra
2026-02-18 13:25:55 +07:00
parent b1955cf74d
commit d30bb84741
16 changed files with 7993 additions and 7680 deletions
@@ -1,165 +1,237 @@
import {
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { searchHomeDirectories } from "./directory-suggestions.js";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js'
describe("searchHomeDirectories", () => {
let tempRoot: string;
let homeDir: string;
let outsideDir: string;
describe('searchHomeDirectories', () => {
let tempRoot: string
let homeDir: string
let outsideDir: string
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
homeDir = path.join(tempRoot, "home");
outsideDir = path.join(tempRoot, "outside");
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-')))
homeDir = path.join(tempRoot, 'home')
outsideDir = path.join(tempRoot, 'outside')
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
mkdirSync(homeDir, { recursive: true })
mkdirSync(outsideDir, { recursive: true })
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true })
mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true })
mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true })
mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true })
writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n')
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
});
mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true })
symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link'))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
rmSync(tempRoot, { recursive: true, force: true })
})
it("returns an empty list for blank queries", async () => {
it('returns an empty list for blank queries', async () => {
await expect(
searchHomeDirectories({
homeDir,
query: " ",
query: ' ',
limit: 10,
})
).resolves.toEqual([]);
});
).resolves.toEqual([])
})
it("returns only existing directories", async () => {
it('returns only existing directories', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "proj",
query: 'proj',
limit: 10,
});
})
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
});
expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, 'projects', 'paseo'))
expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md'))
})
it("supports home-relative path query syntax", async () => {
it('supports home-relative path query syntax', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~/projects/pa",
query: '~/projects/pa',
limit: 10,
});
})
expect(results).toEqual([
path.join(homeDir, "projects", "paseo"),
]);
});
expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')])
})
it("prioritizes exact segment matches before segment-prefix matches", async () => {
const exactSegmentPath = path.join(
homeDir,
"something",
"faro",
"something-else"
);
const prefixSegmentPath = path.join(
homeDir,
"something",
"somethingelse",
"faro-bla"
);
mkdirSync(exactSegmentPath, { recursive: true });
mkdirSync(prefixSegmentPath, { recursive: true });
it('prioritizes exact segment matches before segment-prefix matches', async () => {
const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else')
const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla')
mkdirSync(exactSegmentPath, { recursive: true })
mkdirSync(prefixSegmentPath, { recursive: true })
const results = await searchHomeDirectories({
homeDir,
query: "faro",
query: 'faro',
limit: 30,
});
})
const exactIndex = results.indexOf(exactSegmentPath);
const prefixIndex = results.indexOf(prefixSegmentPath);
expect(exactIndex).toBeGreaterThanOrEqual(0);
expect(prefixIndex).toBeGreaterThanOrEqual(0);
expect(exactIndex).toBeLessThan(prefixIndex);
});
const exactIndex = results.indexOf(exactSegmentPath)
const prefixIndex = results.indexOf(prefixSegmentPath)
expect(exactIndex).toBeGreaterThanOrEqual(0)
expect(prefixIndex).toBeGreaterThanOrEqual(0)
expect(exactIndex).toBeLessThan(prefixIndex)
})
it("prioritizes partial matches that appear earlier in the path", async () => {
const earlierPath = path.join(homeDir, "farofoo");
const laterPath = path.join(homeDir, "x", "y", "farofoo");
mkdirSync(earlierPath, { recursive: true });
mkdirSync(laterPath, { recursive: true });
it('prioritizes partial matches that appear earlier in the path', async () => {
const earlierPath = path.join(homeDir, 'farofoo')
const laterPath = path.join(homeDir, 'x', 'y', 'farofoo')
mkdirSync(earlierPath, { recursive: true })
mkdirSync(laterPath, { recursive: true })
const results = await searchHomeDirectories({
homeDir,
query: "arofo",
query: 'arofo',
limit: 30,
});
})
const earlierIndex = results.indexOf(earlierPath);
const laterIndex = results.indexOf(laterPath);
expect(earlierIndex).toBeGreaterThanOrEqual(0);
expect(laterIndex).toBeGreaterThanOrEqual(0);
expect(earlierIndex).toBeLessThan(laterIndex);
});
const earlierIndex = results.indexOf(earlierPath)
const laterIndex = results.indexOf(laterPath)
expect(earlierIndex).toBeGreaterThanOrEqual(0)
expect(laterIndex).toBeGreaterThanOrEqual(0)
expect(earlierIndex).toBeLessThan(laterIndex)
})
it("returns home-root suggestions when query is '~'", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~",
query: '~',
limit: 20,
});
})
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "documents"));
expect(results).not.toContain(path.join(homeDir, ".hidden"));
});
expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, 'documents'))
expect(results).not.toContain(path.join(homeDir, '.hidden'))
})
it("does not return hidden directories during tree search", async () => {
it('does not return hidden directories during tree search', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "cache",
query: 'cache',
limit: 20,
});
})
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
});
expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache'))
})
it("does not return paths that escape home through symlinks", async () => {
it('does not return paths that escape home through symlinks', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "outside",
query: 'outside',
limit: 20,
});
})
expect(results).not.toContain(path.join(homeDir, "outside-link"));
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
});
expect(results).not.toContain(path.join(homeDir, 'outside-link'))
expect(results).not.toContain(path.join(outsideDir, 'outside-match'))
})
it("respects the result limit", async () => {
it('respects the result limit', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "p",
query: 'p',
limit: 1,
});
})
expect(results).toHaveLength(1);
});
});
expect(results).toHaveLength(1)
})
})
describe('searchWorkspaceEntries', () => {
let tempRoot: string
let workspaceDir: string
let outsideDir: string
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-')))
workspaceDir = path.join(tempRoot, 'workspace')
outsideDir = path.join(tempRoot, 'outside')
mkdirSync(path.join(workspaceDir, 'src', 'components'), {
recursive: true,
})
mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true })
mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true })
writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n')
writeFileSync(
path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'),
'export const ChatInput = null;\n'
)
writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n')
symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link'))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
it('returns relative file and directory suggestions for workspace queries', async () => {
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'chat',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(results).toContainEqual({
path: 'src/components/chat-input.tsx',
kind: 'file',
})
expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false)
})
it('filters entries by kind', async () => {
const dirsOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src',
limit: 20,
includeFiles: false,
includeDirectories: true,
})
expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false)
expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true)
const filesOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'readme',
limit: 20,
includeFiles: true,
includeDirectories: false,
})
expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }])
})
it('supports path-style queries and does not escape cwd through symlinks', async () => {
const pathResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src/co',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(pathResults).toContainEqual({
path: 'src/components',
kind: 'directory',
})
const escapedResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'escaped',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
})
})
File diff suppressed because it is too large Load Diff