Skip to content

Commit c76fbea

Browse files
adamleithpclaude
andauthored
feat(code): file picker on quill autocomplete (#2101)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee6e14f commit c76fbea

2 files changed

Lines changed: 106 additions & 175 deletions

File tree

apps/code/src/renderer/features/command/components/FilePicker.css

Lines changed: 0 additions & 112 deletions
This file was deleted.
Lines changed: 106 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import { FileIcon } from "@components/ui/FileIcon";
2+
import { CommandKeyHints } from "@features/command/components/CommandKeyHints";
23
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
3-
import { pathToFileItem, searchFiles, useRepoFiles } from "@hooks/useRepoFiles";
4-
import { Popover, Text } from "@radix-ui/themes";
4+
import {
5+
type FileItem,
6+
pathToFileItem,
7+
searchFiles,
8+
useRepoFiles,
9+
} from "@hooks/useRepoFiles";
10+
import {
11+
Autocomplete,
12+
AutocompleteCollection,
13+
AutocompleteGroup,
14+
AutocompleteInput,
15+
AutocompleteItem,
16+
AutocompleteLabel,
17+
AutocompleteList,
18+
AutocompleteStatus,
19+
Dialog,
20+
DialogContent,
21+
} from "@posthog/quill";
522
import { useCallback, useMemo, useState } from "react";
6-
import { Command } from "./Command";
7-
import "./FilePicker.css";
823

924
interface FilePickerProps {
1025
open: boolean;
@@ -13,6 +28,12 @@ interface FilePickerProps {
1328
repoPath: string | undefined;
1429
}
1530

31+
type FileSection = { label?: string; items: FileItem[] };
32+
33+
// Cap the empty-query list to keep render cost bounded without virtualization.
34+
// Typed queries are already capped upstream by fzf (MENTION_DISPLAY_LIMIT = 20).
35+
const EMPTY_QUERY_LIMIT = 200;
36+
1637
export function FilePicker({
1738
open,
1839
onOpenChange,
@@ -28,84 +49,106 @@ export function FilePicker({
2849
const handleOpenChange = useCallback(
2950
(isOpen: boolean) => {
3051
onOpenChange(isOpen);
31-
if (!isOpen) {
32-
setSearchQuery("");
33-
}
52+
if (!isOpen) setSearchQuery("");
3453
},
3554
[onOpenChange],
3655
);
3756

3857
const { files: fileItems, fzf } = useRepoFiles(repoPath, open);
3958

40-
const displayedFiles = useMemo(() => {
41-
if (!searchQuery.trim() && recentFiles.length > 0) {
42-
return recentFiles.map(pathToFileItem);
59+
const sections = useMemo<FileSection[]>(() => {
60+
if (searchQuery.trim()) {
61+
return [{ items: searchFiles(fzf, fileItems, searchQuery) }];
62+
}
63+
if (recentFiles.length === 0) {
64+
return [{ items: fileItems.slice(0, EMPTY_QUERY_LIMIT) }];
4365
}
44-
return searchFiles(fzf, fileItems, searchQuery);
66+
// recentFiles is string[] of paths from panelLayoutStore, ordered most-recent-first.
67+
const recentPathSet = new Set(recentFiles);
68+
const recentItems = recentFiles.map(pathToFileItem);
69+
const rest = fileItems
70+
.filter((f) => !recentPathSet.has(f.path))
71+
.slice(0, Math.max(0, EMPTY_QUERY_LIMIT - recentItems.length));
72+
return [
73+
{ label: "Recent", items: recentItems },
74+
{ label: "Other files", items: rest },
75+
];
4576
}, [fzf, fileItems, searchQuery, recentFiles]);
4677

47-
const resultsKey = useMemo(
48-
() => displayedFiles.map((f) => f.path).join(","),
49-
[displayedFiles],
50-
);
51-
5278
const handleSelect = useCallback(
53-
(filePath: string) => {
54-
openFileInSplit(taskId, filePath, false);
79+
(path: string) => {
80+
openFileInSplit(taskId, path, false);
5581
handleOpenChange(false);
5682
},
5783
[openFileInSplit, taskId, handleOpenChange],
5884
);
5985

6086
return (
61-
<Popover.Root open={open} onOpenChange={handleOpenChange}>
62-
<Popover.Trigger>
63-
<div
64-
style={{
65-
left: "50%",
66-
}}
67-
className="pointer-events-none fixed top-[60px] h-[1px] w-[1px] opacity-0"
68-
/>
69-
</Popover.Trigger>
70-
<Popover.Content
71-
className="file-picker-popover p-0"
72-
maxWidth="640px"
73-
side="bottom"
74-
align="center"
75-
sideOffset={0}
76-
onInteractOutside={() => handleOpenChange(false)}
87+
<Dialog open={open} onOpenChange={handleOpenChange}>
88+
<DialogContent
89+
className="w-[720px] max-w-[90vw] gap-0 p-0"
90+
showCloseButton={false}
7791
>
78-
<Command.Root shouldFilter={false} label="File picker" key={resultsKey}>
79-
<Command.Input
80-
placeholder="Search files by name"
81-
autoFocus={true}
82-
value={searchQuery}
83-
onValueChange={setSearchQuery}
92+
{/*
93+
* `items` accepts `Value[] | { items: Value[] }[]` — we always use the
94+
* grouped shape so the same render path covers both the labeled
95+
* (Recent / Other files) and unlabeled (search results) cases.
96+
*/}
97+
<Autocomplete<FileItem>
98+
inline
99+
defaultOpen
100+
items={sections}
101+
filter={null}
102+
value={searchQuery}
103+
autoHighlight="always"
104+
onValueChange={(val, eventDetails) => {
105+
if (eventDetails.reason !== "input-change") return;
106+
if (typeof val === "string") setSearchQuery(val);
107+
}}
108+
>
109+
<AutocompleteInput placeholder="Search files…" autoFocus showClear />
110+
<AutocompleteStatus
111+
emptyContent={
112+
<span>
113+
No files match <strong>"{searchQuery}"</strong>
114+
</span>
115+
}
84116
/>
85-
86-
<Command.List>
87-
<Command.Empty>No files found.</Command.Empty>
88-
89-
{displayedFiles.map((file) => (
90-
<Command.Item
91-
key={file.path}
92-
value={file.path}
93-
onSelect={() => handleSelect(file.path)}
117+
<AutocompleteList
118+
className={`max-h-[60vh] ${sections[0]?.label ? "" : "pt-1"}`}
119+
>
120+
{(section: FileSection, index: number) => (
121+
<AutocompleteGroup
122+
key={section.label ?? `group-${index}`}
123+
items={section.items}
94124
>
95-
<FileIcon filename={file.name} size={14} />
96-
<Text ml="2" className="text-[13px]">
97-
{file.name}
98-
</Text>
99-
{file.dir && (
100-
<Text color="gray" ml="2" className="text-[13px]">
101-
{file.dir}
102-
</Text>
125+
{section.label && (
126+
<AutocompleteLabel>{section.label}</AutocompleteLabel>
103127
)}
104-
</Command.Item>
105-
))}
106-
</Command.List>
107-
</Command.Root>
108-
</Popover.Content>
109-
</Popover.Root>
128+
<AutocompleteCollection>
129+
{(file: FileItem) => (
130+
<AutocompleteItem
131+
key={file.path}
132+
value={file.path}
133+
onClick={() => handleSelect(file.path)}
134+
className="block"
135+
>
136+
<FileIcon filename={file.name} size={14} />
137+
{file.name}
138+
{file.dir && (
139+
<span className="text-muted-foreground text-xs">
140+
{file.dir}
141+
</span>
142+
)}
143+
</AutocompleteItem>
144+
)}
145+
</AutocompleteCollection>
146+
</AutocompleteGroup>
147+
)}
148+
</AutocompleteList>
149+
</Autocomplete>
150+
<CommandKeyHints />
151+
</DialogContent>
152+
</Dialog>
110153
);
111154
}

0 commit comments

Comments
 (0)