Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 165 additions & 74 deletions frontend/src/plugins/impl/FileBrowserPlugin.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { type LucideIcon, CornerLeftUp } from "lucide-react";
import { type JSX, useEffect, useState } from "react";
import { type JSX, useEffect, useLayoutEffect, useRef, useState } from "react";
import { z } from "zod";
import {
FILE_ICON as FILE_TYPE_ICONS,
Expand Down Expand Up @@ -129,13 +129,15 @@ interface FileBrowserProps extends Data, PluginFunctions {
}

interface CheckboxOrIconProps {
name: string;
isSelected: boolean;
canSelect: boolean;
Icon: LucideIcon;
onSelect: () => void;
}

function CheckboxOrIcon({
name,
isSelected,
canSelect,
Icon,
Expand All @@ -146,17 +148,21 @@ function CheckboxOrIcon({
<>
<Checkbox
checked={isSelected}
aria-label={`Select ${name}`}
tabIndex={-1}
onClick={(e) => {
onSelect();
e.stopPropagation();
}}
className={cn({ "hidden group-hover:flex": !isSelected })}
className={cn({
"hidden group-hover:flex group-focus:flex": !isSelected,
})}
/>
<Icon
size={16}
className={cn("mr-2", {
hidden: isSelected,
"group-hover:hidden": !isSelected,
"group-hover:hidden group-focus:hidden": !isSelected,
})}
/>
</>
Expand All @@ -165,6 +171,18 @@ function CheckboxOrIcon({
return <Icon size={16} className="mr-2" />;
}

interface RowModel {
key: string;
name: string;
Icon: LucideIcon;
isSelected: boolean;
canSelect: boolean;
/** Enter and mouse-click action. */
onPrimary: () => void;
/** Space action; null when the row has nothing to toggle. */
onToggleSelect: (() => void) | null;
}

/**
* File browser component.
*
Expand All @@ -184,6 +202,13 @@ export const FileBrowser = ({
const [path, setPath] = useInternalStateWithSync(initialPath);
const [isUpdatingPath, setIsUpdatingPath] = useState(false);
const [showLoadingOverlay, setShowLoadingOverlay] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
const gridRef = useRef<HTMLTableElement | null>(null);
// Set when navigation is triggered from within the grid, so focus can follow
// to the parent row after the new listing renders instead of falling to the
// body when the previously focused row unmounts.
const refocusParentRef = useRef(false);

// HACK: use the random-id of the host element to force a re-render
// when the random-id changes, this means the cell was re-rendered
Expand All @@ -210,6 +235,22 @@ export const FileBrowser = ({
};
}, [isPending]);

// Reset the roving tabindex whenever the listing reloads (a new path or a
// same-path refresh) so activeIndex never points past the current rows.
const listingKey = `${path}::${randomId}`;
const [prevListingKey, setPrevListingKey] = useState(listingKey);
if (prevListingKey !== listingKey) {
setPrevListingKey(listingKey);
setActiveIndex(0);
}

useLayoutEffect(() => {
if (refocusParentRef.current) {
refocusParentRef.current = false;
rowRefs.current[0]?.focus();
}
}, [listingKey]);

const files = data?.files ?? [];
const selectedPaths = new Set(value.map((x) => x.path));
const canSelectDirectories =
Expand Down Expand Up @@ -269,6 +310,8 @@ export const FileBrowser = ({
return;
}

refocusParentRef.current =
gridRef.current?.contains(document.activeElement) ?? false;
setPath(newPath);
setIsUpdatingPath(false);
}
Expand Down Expand Up @@ -333,78 +376,90 @@ export const FileBrowser = ({
setValue([...value, ...filesInView]);
}

// Create rows for directories and files
const fileRows: React.ReactNode[] = [];

// Parent directory ".." row button
fileRows.push(
<TableRow
className="hover:bg-accent select-none"
key={"Parent directory"}
onClick={() => setNewPath(PARENT_DIRECTORY)}
>
<TableCell className="w-[50px] pl-4">
<CornerLeftUp size={16} />
</TableCell>
<TableCell>{PARENT_DIRECTORY}</TableCell>
</TableRow>,
);
const rowModels: RowModel[] = [
{
key: "parent",
name: PARENT_DIRECTORY,
Icon: CornerLeftUp,
isSelected: false,
canSelect: false,
onPrimary: () => setNewPath(PARENT_DIRECTORY),
onToggleSelect: null,
},
...files.map((file): RowModel => {
let filePath = file.path;
if (filePath.startsWith("//")) {
filePath = filePath.slice(1) as FilePath;
}

for (const file of files) {
let filePath = file.path;
const canSelect =
(canSelectDirectories && file.is_directory) ||
(canSelectFiles && !file.is_directory);
const isSelected = selectedPaths.has(filePath);
const fileType: FileType = file.is_directory
? "directory"
: guessFileType(file.name);

const toggle = () =>
handleSelection({
path: filePath,
name: file.name,
isDirectory: file.is_directory,
});

return {
key: file.id,
name: file.name,
Icon: FILE_TYPE_ICONS[fileType],
isSelected,
canSelect,
onPrimary: file.is_directory
? () => setNewPath(filePath)
: canSelect
? toggle
: () => {},
onToggleSelect: canSelect ? toggle : null,
};
}),
];

Comment thread
kirangadhave marked this conversation as resolved.
if (filePath.startsWith("//")) {
filePath = filePath.slice(1) as FilePath;
}
function focusRow(index: number) {
setActiveIndex(index);
rowRefs.current[index]?.focus();
}

// Click handler
const handleClick = file.is_directory
? ({ path }: { path: string }) => setNewPath(path)
: handleSelection;

// Icon
const fileType: FileType = file.is_directory
? "directory"
: guessFileType(file.name);

const Icon = FILE_TYPE_ICONS[fileType];

const isSelected = selectedPaths.has(filePath);

fileRows.push(
<TableRow
key={file.id}
className={cn("hover:bg-accent group select-none", {
"bg-primary/25 hover:bg-primary/35": isSelected,
})}
onClick={() =>
handleClick({
path: filePath,
name: file.name,
isDirectory: file.is_directory,
})
}
>
<TableCell className="w-[50px] pl-4">
<CheckboxOrIcon
isSelected={isSelected}
canSelect={
(canSelectDirectories && file.is_directory) ||
(canSelectFiles && !file.is_directory)
}
Icon={Icon}
onSelect={() =>
handleSelection({
path: filePath,
name: file.name,
isDirectory: file.is_directory,
})
}
/>
</TableCell>
<TableCell>{file.name}</TableCell>
</TableRow>,
);
function handleRowKeyDown(
e: React.KeyboardEvent<HTMLTableRowElement>,
index: number,
) {
const lastIndex = rowModels.length - 1;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusRow(Math.min(index + 1, lastIndex));
break;
case "ArrowUp":
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case "Home":
e.preventDefault();
focusRow(0);
break;
case "End":
e.preventDefault();
focusRow(lastIndex);
break;
case "Enter":
e.preventDefault();
rowModels[index].onPrimary();
break;
// Space is select-only; preventDefault stops the list from scrolling.
case " ":
e.preventDefault();
rowModels[index].onToggleSelect?.();
break;
}
}

// Get list of parent directories.
Expand Down Expand Up @@ -490,8 +545,44 @@ export const FileBrowser = ({
<span>Listing files...</span>
</div>
)}
<Table className="cursor-pointer table-fixed">
<TableBody>{fileRows}</TableBody>
<Table
ref={gridRef}
className="cursor-pointer table-fixed"
role="grid"
Comment thread
kirangadhave marked this conversation as resolved.
aria-label="File browser"
aria-multiselectable={multiple}
>
<TableBody>
{rowModels.map((row, index) => (
<TableRow
key={row.key}
role="row"
ref={(el) => {
rowRefs.current[index] = el;
}}
tabIndex={index === activeIndex ? 0 : -1}
onFocus={() => setActiveIndex(index)}
onKeyDown={(e) => handleRowKeyDown(e, index)}
className={cn(
Comment thread
kirangadhave marked this conversation as resolved.
"hover:bg-accent group select-none focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset",
{ "bg-primary/25 hover:bg-primary/35": row.isSelected },
)}
aria-selected={row.canSelect ? row.isSelected : undefined}
onClick={row.onPrimary}
>
<TableCell role="gridcell" className="w-[50px] pl-4">
<CheckboxOrIcon
name={row.name}
isSelected={row.isSelected}
canSelect={row.canSelect}
Icon={row.Icon}
onSelect={() => row.onToggleSelect?.()}
/>
</TableCell>
<TableCell role="gridcell">{row.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="mt-4">
Expand Down
Loading
Loading