Skip to content

Commit 873cb5a

Browse files
Merge pull request #27 from ai-action/feat/suggestions
2 parents 61dbeeb + 12311cb commit 873cb5a

5 files changed

Lines changed: 650 additions & 33 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { exec } from 'node:child_process';
2+
import type { Dirent } from 'node:fs';
3+
import { readdirSync } from 'node:fs';
4+
5+
import { render } from 'ink-testing-library';
6+
7+
import { KEY } from '../../constants';
8+
import { tick } from '../../utils/test';
9+
10+
vi.mock('node:child_process', () => ({
11+
exec: vi.fn(),
12+
}));
13+
14+
vi.mock('node:fs', async () => {
15+
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
16+
return {
17+
...actual,
18+
readdirSync: vi.fn(),
19+
};
20+
});
21+
22+
import { FileSuggestions } from './FileSuggestions';
23+
24+
function createDirent(
25+
name: string,
26+
type: 'directory' | 'file' | 'other',
27+
): Dirent {
28+
return {
29+
name,
30+
isBlockDevice: () => false,
31+
isCharacterDevice: () => false,
32+
isDirectory: () => type === 'directory',
33+
isFIFO: () => false,
34+
isFile: () => type === 'file',
35+
isSocket: () => false,
36+
isSymbolicLink: () => false,
37+
} as Dirent;
38+
}
39+
40+
describe('FileSuggestions', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
});
44+
45+
it('loads file suggestions with ripgrep', async () => {
46+
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
47+
callback?.(null, 'src/app.ts\nsrc/utils/tools.ts\nREADME.md\n', '');
48+
return {} as ReturnType<typeof exec>;
49+
});
50+
51+
const { lastFrame } = render(
52+
<FileSuggestions input="@src" onSelect={vi.fn()} />,
53+
);
54+
55+
await tick(20);
56+
57+
expect(lastFrame()).toContain('src/app.ts');
58+
expect(lastFrame()).toContain('src/utils/tools.ts');
59+
expect(lastFrame()).not.toContain('README.md');
60+
});
61+
62+
it('falls back to Node.js traversal when ripgrep fails', async () => {
63+
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
64+
callback?.(new Error('rg missing'), '', '');
65+
return {} as ReturnType<typeof exec>;
66+
});
67+
68+
vi.mocked(readdirSync).mockImplementation((path) => {
69+
const currentPath = String(path);
70+
71+
if (currentPath.endsWith('/src')) {
72+
return [createDirent('feature.ts', 'file')] as unknown as ReturnType<
73+
typeof readdirSync
74+
>;
75+
}
76+
77+
if (currentPath.endsWith('/.github')) {
78+
return [
79+
createDirent('workflows', 'directory'),
80+
] as unknown as ReturnType<typeof readdirSync>;
81+
}
82+
83+
if (currentPath.endsWith('/workflows')) {
84+
return [createDirent('test.yml', 'file')] as unknown as ReturnType<
85+
typeof readdirSync
86+
>;
87+
}
88+
89+
return [
90+
createDirent('.git', 'directory'),
91+
createDirent('.github', 'directory'),
92+
createDirent('src', 'directory'),
93+
createDirent('.gitignore', 'file'),
94+
createDirent('socket', 'other'),
95+
] as unknown as ReturnType<typeof readdirSync>;
96+
});
97+
98+
const { lastFrame } = render(
99+
<FileSuggestions input="@git" onSelect={vi.fn()} />,
100+
);
101+
102+
await tick(20);
103+
104+
expect(lastFrame()).toContain('.gitignore');
105+
expect(lastFrame()).not.toContain('.git/');
106+
expect(lastFrame()).not.toContain('HEAD');
107+
});
108+
109+
it('selects the focused file on Tab', async () => {
110+
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
111+
callback?.(
112+
null,
113+
'src/components/App.tsx\nsrc/utils/tools.ts\nsrc/components/Input.tsx\n',
114+
'',
115+
);
116+
return {} as ReturnType<typeof exec>;
117+
});
118+
119+
const onSelect = vi.fn();
120+
const { stdin } = render(
121+
<FileSuggestions input="read @src" onSelect={onSelect} />,
122+
);
123+
124+
await tick(20);
125+
stdin.write(KEY.DOWN);
126+
await tick();
127+
stdin.write(KEY.TAB);
128+
await tick();
129+
130+
expect(onSelect).toHaveBeenCalledWith('read src/components/Input.tsx ');
131+
});
132+
133+
it('ignores keyboard interactions when disabled', async () => {
134+
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
135+
callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', '');
136+
return {} as ReturnType<typeof exec>;
137+
});
138+
139+
const onSelect = vi.fn();
140+
const { stdin } = render(
141+
<FileSuggestions input="@src" isDisabled onSelect={onSelect} />,
142+
);
143+
144+
await tick(20);
145+
stdin.write(KEY.TAB);
146+
await tick();
147+
148+
expect(onSelect).not.toHaveBeenCalled();
149+
});
150+
151+
it('ignores non-navigation key presses when suggestions are visible', async () => {
152+
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
153+
callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', '');
154+
return {} as ReturnType<typeof exec>;
155+
});
156+
157+
const onSelect = vi.fn();
158+
const { stdin } = render(
159+
<FileSuggestions input="@src" onSelect={onSelect} />,
160+
);
161+
162+
await tick(20);
163+
stdin.write('x');
164+
await tick();
165+
166+
expect(onSelect).not.toHaveBeenCalled();
167+
});
168+
169+
it('ignores non-mention input and keeps the first option focused on Up', async () => {
170+
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
171+
callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', '');
172+
return {} as ReturnType<typeof exec>;
173+
});
174+
175+
const onSelect = vi.fn();
176+
const { lastFrame, stdin, rerender } = render(
177+
<FileSuggestions input="hello" onSelect={onSelect} />,
178+
);
179+
180+
await tick(20);
181+
182+
expect(lastFrame()).toBe('');
183+
184+
rerender(<FileSuggestions input="@src" onSelect={onSelect} />);
185+
await tick(20);
186+
stdin.write(KEY.UP);
187+
await tick();
188+
stdin.write(KEY.TAB);
189+
await tick();
190+
191+
expect(onSelect).toHaveBeenCalledWith('src/components/App.tsx ');
192+
});
193+
194+
it('shows at most five visible options', async () => {
195+
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
196+
callback?.(
197+
null,
198+
'src/1.ts\nsrc/2.ts\nsrc/3.ts\nsrc/4.ts\nsrc/5.ts\nsrc/6.ts\n',
199+
'',
200+
);
201+
return {} as ReturnType<typeof exec>;
202+
});
203+
204+
const { lastFrame } = render(
205+
<FileSuggestions input="@src" onSelect={vi.fn()} />,
206+
);
207+
208+
await tick(20);
209+
210+
const frame = lastFrame() ?? '';
211+
expect(frame).toContain('src/1.ts');
212+
expect(frame).toContain('src/5.ts');
213+
expect(frame).not.toContain('src/6.ts');
214+
});
215+
});

0 commit comments

Comments
 (0)