@@ -3,12 +3,15 @@ import type {
33 ContextMenuOpenContext as TreeContextMenuOpenContext ,
44} from "@pierre/trees" ;
55import type { EnvironmentId , ProjectEntry } from "@t3tools/contracts" ;
6- import { FileTree , useFileTree } from "@pierre/trees/react" ;
6+ import { FileTree , useFileTree , useFileTreeSearch } from "@pierre/trees/react" ;
77import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger" ;
8- import { RefreshCw , Search } from "lucide-react" ;
8+ import { RotateCw } from "lucide-react" ;
99import { useEffect , useMemo , useRef } from "react" ;
1010
11+ import { Button } from "~/components/ui/button" ;
12+ import { InputGroup , InputGroupInput } from "~/components/ui/input-group" ;
1113import { toastManager } from "~/components/ui/toast" ;
14+ import { Tooltip , TooltipPopup , TooltipTrigger } from "~/components/ui/tooltip" ;
1215import { useComposerHandleContext } from "~/composerHandleContext" ;
1316import { writeTextToClipboard } from "~/hooks/useCopyToClipboard" ;
1417import { useTheme } from "~/hooks/useTheme" ;
@@ -42,6 +45,55 @@ function treePath(entry: ProjectEntry): string {
4245 return entry . kind === "directory" ? `${ entry . path } /` : entry . path ;
4346}
4447
48+ function RefreshFilesButton ( props : { isPending : boolean ; onRefresh : ( ) => void } ) {
49+ return (
50+ < Tooltip >
51+ < TooltipTrigger
52+ render = {
53+ < Button
54+ type = "button"
55+ variant = "ghost"
56+ size = "icon-xs"
57+ aria-label = "Refresh workspace files"
58+ onClick = { props . onRefresh }
59+ />
60+ }
61+ >
62+ < RotateCw className = { cn ( props . isPending && "animate-spin" ) } />
63+ </ TooltipTrigger >
64+ < TooltipPopup > { props . isPending ? "Refreshing…" : "Refresh files" } </ TooltipPopup >
65+ </ Tooltip >
66+ ) ;
67+ }
68+
69+ function FileSearchField ( props : {
70+ ariaLabel : string ;
71+ name : string ;
72+ onClose : ( ) => void ;
73+ onValueChange : ( value : string ) => void ;
74+ value : string ;
75+ } ) {
76+ return (
77+ < InputGroup variant = "ghost" className = "h-7 min-w-0 flex-1 rounded-md" >
78+ < InputGroupInput
79+ type = "search"
80+ name = { props . name }
81+ size = "sm"
82+ value = { props . value }
83+ aria-label = { props . ariaLabel }
84+ placeholder = "Search files"
85+ spellCheck = { false }
86+ onChange = { ( event ) => props . onValueChange ( event . target . value ) }
87+ onKeyDown = { ( event ) => {
88+ if ( event . key !== "Escape" ) return ;
89+ props . onClose ( ) ;
90+ event . currentTarget . blur ( ) ;
91+ } }
92+ />
93+ </ InputGroup >
94+ ) ;
95+ }
96+
4597export default function FileBrowserPanel ( {
4698 environmentId,
4799 cwd,
@@ -176,9 +228,17 @@ export default function FileBrowserPanel({
176228 }
177229 } ,
178230 paths : [ ] ,
179- search : true ,
231+ search : false ,
180232 unsafeCSS : TREE_UNSAFE_CSS ,
181233 } ) ;
234+ const search = useFileTreeSearch ( model ) ;
235+ const handleSearchValueChange = ( value : string ) => {
236+ if ( value . trim ( ) . length === 0 ) {
237+ search . close ( ) ;
238+ return ;
239+ }
240+ search . setValue ( value ) ;
241+ } ;
182242
183243 useEffect ( ( ) => {
184244 if ( previousTreePathsRef . current === treePaths ) return ;
@@ -187,11 +247,6 @@ export default function FileBrowserPanel({
187247 model . resetPaths ( treePaths ) ;
188248 } , [ entryKinds , model , treePaths ] ) ;
189249
190- const fileCount = useMemo (
191- ( ) => entries . reduce ( ( count , entry ) => count + ( entry . kind === "file" ? 1 : 0 ) , 0 ) ,
192- [ entries ] ,
193- ) ;
194-
195250 // Tag tree drags with the composer mention payload. The row is read from
196251 // the composed event path (the tree's shadow root is open), so this does
197252 // not depend on running after the tree's own dragstart handler; the drag
@@ -223,32 +278,15 @@ export default function FileBrowserPanel({
223278 className = "flex min-h-0 flex-1 flex-col bg-background"
224279 data-file-browser-panel = { `${ environmentId } :${ cwd } ` }
225280 >
226- < div className = "flex h-9 shrink-0 items-center gap-2 border-b border-border/60 px-3" >
227- < div className = "min-w-0 flex-1" >
228- < div className = "truncate text-xs font-medium text-foreground" > { projectName } </ div >
229- < div className = "truncate text-[10px] leading-none text-muted-foreground" >
230- { entriesQuery . isPending && entriesQuery . data === null
231- ? "Indexing…"
232- : `${ fileCount . toLocaleString ( ) } files` }
233- { entriesQuery . data ?. truncated ? " · partial" : "" }
234- </ div >
235- </ div >
236- < button
237- type = "button"
238- className = "rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
239- aria-label = "Search workspace files"
240- onClick = { ( ) => model . openSearch ( ) }
241- >
242- < Search className = "size-3.5" />
243- </ button >
244- < button
245- type = "button"
246- className = "rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
247- aria-label = "Refresh workspace files"
248- onClick = { entriesQuery . refresh }
249- >
250- < RefreshCw className = { cn ( "size-3.5" , entriesQuery . isPending && "animate-spin" ) } />
251- </ button >
281+ < div className = "surface-subheader gap-1 px-2" data-surface-subheader >
282+ < RefreshFilesButton isPending = { entriesQuery . isPending } onRefresh = { entriesQuery . refresh } />
283+ < FileSearchField
284+ name = "project-files-search"
285+ ariaLabel = { `Search ${ projectName } files` }
286+ value = { search . value }
287+ onValueChange = { handleSearchValueChange }
288+ onClose = { search . close }
289+ />
252290 </ div >
253291 { entriesQuery . error && entriesQuery . data === null ? (
254292 < div className = "p-4 text-xs leading-relaxed text-destructive" > { entriesQuery . error } </ div >
0 commit comments