11import { FileIcon } from "@components/ui/FileIcon" ;
2+ import { CommandKeyHints } from "@features/command/components/CommandKeyHints" ;
23import { 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" ;
522import { useCallback , useMemo , useState } from "react" ;
6- import { Command } from "./Command" ;
7- import "./FilePicker.css" ;
823
924interface 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+
1637export 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