11/* Copyright 2026 Marimo. All rights reserved. */
22
3- import { CornerLeftUp } from "lucide-react" ;
3+ import { type LucideIcon , CornerLeftUp } from "lucide-react" ;
44import { type JSX , useEffect , useState } from "react" ;
55import { z } from "zod" ;
66import {
@@ -128,6 +128,43 @@ interface FileBrowserProps extends Data, PluginFunctions {
128128 host : HTMLElement ;
129129}
130130
131+ interface CheckboxOrIconProps {
132+ isSelected : boolean ;
133+ canSelect : boolean ;
134+ Icon : LucideIcon ;
135+ onSelect : ( ) => void ;
136+ }
137+
138+ function CheckboxOrIcon ( {
139+ isSelected,
140+ canSelect,
141+ Icon,
142+ onSelect,
143+ } : CheckboxOrIconProps ) {
144+ if ( canSelect ) {
145+ return (
146+ < >
147+ < Checkbox
148+ checked = { isSelected }
149+ onClick = { ( e ) => {
150+ onSelect ( ) ;
151+ e . stopPropagation ( ) ;
152+ } }
153+ className = { cn ( { "hidden group-hover:flex" : ! isSelected } ) }
154+ />
155+ < Icon
156+ size = { 16 }
157+ className = { cn ( "mr-2" , {
158+ hidden : isSelected ,
159+ "group-hover:hidden" : ! isSelected ,
160+ } ) }
161+ />
162+ </ >
163+ ) ;
164+ }
165+ return < Icon size = { 16 } className = "mr-2" /> ;
166+ }
167+
131168/**
132169 * File browser component.
133170 *
@@ -145,7 +182,6 @@ export const FileBrowser = ({
145182 host,
146183} : FileBrowserProps ) : JSX . Element | null => {
147184 const [ path , setPath ] = useInternalStateWithSync ( initialPath ) ;
148- const [ selectAllLabel , setSelectAllLabel ] = useState ( "Select all" ) ;
149185 const [ isUpdatingPath , setIsUpdatingPath ] = useState ( false ) ;
150186 const [ showLoadingOverlay , setShowLoadingOverlay ] = useState ( false ) ;
151187
@@ -158,7 +194,6 @@ export const FileBrowser = ({
158194 const { data, error, isPending } = useAsyncData ( ( ) => {
159195 return list_directory ( { path : path } ) ;
160196 } , [ path , randomId ] ) ;
161- const spinnerLabel = "Listing files..." ;
162197
163198 useEffect ( ( ) => {
164199 if ( ! isPending ) {
@@ -175,25 +210,29 @@ export const FileBrowser = ({
175210 } ;
176211 } , [ isPending ] ) ;
177212
213+ const files = data ?. files ?? [ ] ;
214+ const selectedPaths = new Set ( value . map ( ( x ) => x . path ) ) ;
215+ const canSelectDirectories =
216+ selectionMode === "directory" || selectionMode === "all" ;
217+ const canSelectFiles = selectionMode === "file" || selectionMode === "all" ;
218+
219+ const selectable = files . filter (
220+ ( f ) =>
221+ ( canSelectDirectories && f . is_directory ) ||
222+ ( canSelectFiles && ! f . is_directory ) ,
223+ ) ;
224+ const allSelected =
225+ selectable . length > 0 && selectable . every ( ( f ) => selectedPaths . has ( f . path ) ) ;
226+
178227 if ( ! data && error ) {
179228 return < Banner kind = "danger" > { error . message } </ Banner > ;
180229 }
181230
182- let { files } = data || { } ;
183- if ( files === undefined ) {
184- files = [ ] ;
185- }
186-
187231 const pathBuilder = PathBuilder . guessDeliminator ( initialPath ) ;
188232 const delimiter = pathBuilder . deliminator ;
189233
190- const selectedPaths = new Set ( value . map ( ( x ) => x . path ) ) ;
191234 const selectedFiles = value . map ( ( x ) => < li key = { x . id } > { x . path } </ li > ) ;
192235
193- const canSelectDirectories =
194- selectionMode === "directory" || selectionMode === "all" ;
195- const canSelectFiles = selectionMode === "file" || selectionMode === "all" ;
196-
197236 function setNewPath ( newPath : string ) {
198237 // Prevent updating path while updating
199238 if ( isUpdatingPath ) {
@@ -230,9 +269,7 @@ export const FileBrowser = ({
230269 return ;
231270 }
232271
233- // Update path and reset select all label
234272 setPath ( newPath ) ;
235- setSelectAllLabel ( "Select all" ) ;
236273 setIsUpdatingPath ( false ) ;
237274 }
238275
@@ -264,28 +301,18 @@ export const FileBrowser = ({
264301 } ) {
265302 const fileInfo = createFileInfo ( { path, name, isDirectory } ) ;
266303
267- if ( multiple ) {
268- if ( selectedPaths . has ( path ) ) {
269- setValue ( value . filter ( ( x ) => x . path !== path ) ) ;
270- setSelectAllLabel ( "Select all" ) ;
271- } else {
272- setValue ( [ ...value , fileInfo ] ) ;
273- }
304+ if ( selectedPaths . has ( path ) ) {
305+ setValue ( value . filter ( ( x ) => x . path !== path ) ) ;
274306 } else {
275- setValue ( [ fileInfo ] ) ;
307+ setValue ( multiple ? [ ... value , fileInfo ] : [ fileInfo ] ) ;
276308 }
277309 }
278310
279311 function deselectAllFiles ( ) {
280312 setValue ( value . filter ( ( x ) => Paths . dirname ( x . path ) !== path ) ) ;
281- setSelectAllLabel ( "Select all" ) ;
282313 }
283314
284315 function selectAllFiles ( ) {
285- if ( ! files ) {
286- return ;
287- }
288-
289316 const filesInView : FileInfo [ ] = [ ] ;
290317
291318 for ( const file of files ) {
@@ -304,7 +331,6 @@ export const FileBrowser = ({
304331 }
305332
306333 setValue ( [ ...value , ...filesInView ] ) ;
307- setSelectAllLabel ( "Deselect all" ) ;
308334 }
309335
310336 // Create rows for directories and files
@@ -313,7 +339,7 @@ export const FileBrowser = ({
313339 // Parent directory ".." row button
314340 fileRows . push (
315341 < TableRow
316- className = "hover:bg-primary hover:bg-opacity-25 select-none"
342+ className = "hover:bg-accent select-none"
317343 key = { "Parent directory" }
318344 onClick = { ( ) => setNewPath ( PARENT_DIRECTORY ) }
319345 >
@@ -344,50 +370,13 @@ export const FileBrowser = ({
344370 const Icon = FILE_TYPE_ICONS [ fileType ] ;
345371
346372 const isSelected = selectedPaths . has ( filePath ) ;
347- const renderCheckboxOrIcon = ( ) => {
348- if (
349- ( canSelectDirectories && file . is_directory ) ||
350- ( canSelectFiles && ! file . is_directory )
351- ) {
352- return (
353- < >
354- < Checkbox
355- checked = { isSelected }
356- onClick = { ( e ) => {
357- handleSelection ( {
358- path : filePath ,
359- name : file . name ,
360- isDirectory : file . is_directory ,
361- } ) ;
362- e . stopPropagation ( ) ;
363- } }
364- className = { cn ( "" , {
365- "hidden group-hover:flex" : ! isSelected ,
366- } ) }
367- />
368- < Icon
369- size = { 16 }
370- className = { cn ( "mr-2" , {
371- hidden : isSelected ,
372- "group-hover:hidden" : ! isSelected ,
373- } ) }
374- />
375- </ >
376- ) ;
377- }
378-
379- return < Icon size = { 16 } className = "mr-2" /> ;
380- } ;
381373
382374 fileRows . push (
383375 < TableRow
384376 key = { file . id }
385- className = { cn (
386- "hover:bg-primary hover:bg-opacity-25 group select-none" ,
387- {
388- "bg-primary bg-opacity-25" : isSelected ,
389- } ,
390- ) }
377+ className = { cn ( "hover:bg-accent group select-none" , {
378+ "bg-primary/25 hover:bg-primary/35" : isSelected ,
379+ } ) }
391380 onClick = { ( ) =>
392381 handleClick ( {
393382 path : filePath ,
@@ -397,7 +386,21 @@ export const FileBrowser = ({
397386 }
398387 >
399388 < TableCell className = "w-[50px] pl-4" >
400- { renderCheckboxOrIcon ( ) }
389+ < CheckboxOrIcon
390+ isSelected = { isSelected }
391+ canSelect = {
392+ ( canSelectDirectories && file . is_directory ) ||
393+ ( canSelectFiles && ! file . is_directory )
394+ }
395+ Icon = { Icon }
396+ onSelect = { ( ) =>
397+ handleSelection ( {
398+ path : filePath ,
399+ name : file . name ,
400+ isDirectory : file . is_directory ,
401+ } )
402+ }
403+ />
401404 </ TableCell >
402405 < TableCell > { file . name } </ TableCell >
403406 </ TableRow > ,
@@ -423,8 +426,9 @@ export const FileBrowser = ({
423426 : PluralWords . of ( "file" ) ;
424427
425428 const renderHeader = ( ) => {
426- label = label ?? `Select ${ selectionKindLabel . join ( " and " , 2 ) } ...` ;
427- const labelText = < Label > { renderHTML ( { html : label } ) } </ Label > ;
429+ const displayLabel =
430+ label ?? `Select ${ selectionKindLabel . join ( " and " , 2 ) } ...` ;
431+ const labelText = < Label > { renderHTML ( { html : displayLabel } ) } </ Label > ;
428432
429433 if ( multiple ) {
430434 return (
@@ -434,13 +438,9 @@ export const FileBrowser = ({
434438 < Button
435439 size = "xs"
436440 variant = "link"
437- onClick = {
438- selectAllLabel === "Select all"
439- ? ( ) => selectAllFiles ( )
440- : ( ) => deselectAllFiles ( )
441- }
441+ onClick = { allSelected ? deselectAllFiles : selectAllFiles }
442442 >
443- { renderHTML ( { html : selectAllLabel } ) }
443+ { allSelected ? "Deselect all" : "Select all" }
444444 </ Button >
445445 </ div >
446446 </ div >
@@ -461,7 +461,7 @@ export const FileBrowser = ({
461461 onChange = { ( e ) => setNewPath ( e . target . value ) }
462462 >
463463 { parentDirectories . map ( ( dir ) => (
464- < option value = { dir } key = { dir } selected = { dir === path } >
464+ < option value = { dir } key = { dir } >
465465 { dir }
466466 </ option >
467467 ) ) }
@@ -487,7 +487,7 @@ export const FileBrowser = ({
487487 role = "status"
488488 >
489489 < Spinner size = "small" />
490- < span > { spinnerLabel } </ span >
490+ < span > Listing files... </ span >
491491 </ div >
492492 ) }
493493 < Table className = "cursor-pointer table-fixed" >
0 commit comments