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
13 changes: 13 additions & 0 deletions examples/ui/file_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,18 @@ def _(file_browser):
return


@app.cell
def _(mo):
file_browser_all = mo.ui.file_browser(selection_mode="all")
file_browser_all
return (file_browser_all,)


@app.cell
def _(file_browser_all):
file_browser_all.value
return


if __name__ == "__main__":
app.run()
79 changes: 70 additions & 9 deletions marimo/_plugins/ui/_impl/file_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,57 @@
from marimo import _loggers
from marimo._output.rich_help import mddoc
from marimo._plugins.ui._core.ui_element import UIElement
from marimo._plugins.validators import validate_one_of
from marimo._runtime.functions import Function
from marimo._utils.files import natural_sort
from marimo._utils.paths import is_cloudpath, normalize_path

LOGGER = _loggers.marimo_logger()


_VALID_KINDS: Final[frozenset[str]] = frozenset({"file", "directory"})


def _normalize_selection_mode(
value: object,
) -> frozenset[str]:
"""Normalize ``selection_mode`` to a frozenset of selectable kinds.

Accepted inputs:
- ``"file"`` -> ``{"file"}``
- ``"directory"`` -> ``{"directory"}``
- ``"all"`` -> ``{"file", "directory"}``
- list/tuple containing ``"file"`` and/or ``"directory"`` (deduped)
"""
if isinstance(value, str):
if value == "all":
return frozenset(_VALID_KINDS)
if value in _VALID_KINDS:
return frozenset({value})
raise ValueError(
f"Invalid selection_mode {value!r}. "
f"Expected one of 'file', 'directory', 'all', "
f"or a list of 'file'/'directory'."
)

if isinstance(value, (list, tuple)):
if len(value) == 0:
raise ValueError("selection_mode list must not be empty.")
kinds: set[str] = set()
for kind in value:
if not isinstance(kind, str) or kind not in _VALID_KINDS:
raise ValueError(
f"Invalid selection_mode entry {kind!r}. "
f"Each entry must be 'file' or 'directory'."
)
kinds.add(kind)
return frozenset(kinds)

raise ValueError(
f"selection_mode must be a string or a list of strings, "
Comment thread
kirangadhave marked this conversation as resolved.
f"got {type(value).__name__}."
)


@dataclass
class ListDirectoryArgs:
path: str
Expand Down Expand Up @@ -70,6 +113,16 @@ class file_browser(
file_browser.name(index=0)
```

Selecting both files and directories (useful for formats like
deltalake that are stored as directories):
```python
file_browser = mo.ui.file_browser(
initial_path=Path("path/to/dir"),
selection_mode="all",
# Equivalent: selection_mode=["file", "directory"]
)
```

Connecting to an S3 (or GCS, Azure) bucket:
```python
from cloudpathlib import S3Path
Expand Down Expand Up @@ -114,8 +167,10 @@ class file_browser(
filetypes (Sequence[str], optional): The file types to display in each
directory; for example, filetypes=[".txt", ".csv"]. If None, all
files are displayed. Defaults to None.
selection_mode (Literal["file", "directory"], optional): Either "file" or "directory". Defaults to
"file".
selection_mode (str | Sequence[str], optional): Which kinds of entries
the user can select. Accepts one of "file" (default), "directory",
"all", or a list/tuple containing "file" and/or "directory".
"all" is equivalent to ["file", "directory"]. Defaults to "file".
Comment on lines +170 to +173
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't this mean ["file", "directory"] is the same as "all". What's the reason to have both list & str support?

Copy link
Copy Markdown
Member Author

@kirangadhave kirangadhave May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it's same as all. If we add something like symlinks or some other type in future, we can pick and choose with this pattern. all will always be all supported types. just a bit of over-engineering for future proofing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, im usually not keen on over-eng, but leaving it to you.

multiple (bool, optional): If True, allow the user to select multiple
files. Defaults to True.
restrict_navigation (bool, optional): If True, prevent the user from
Expand All @@ -140,7 +195,8 @@ def __init__(
self,
initial_path: str | Path = "",
filetypes: Sequence[str] | None = None,
selection_mode: Literal["file", "directory"] = "file",
selection_mode: Literal["file", "directory", "all"]
| Sequence[Literal["file", "directory"]] = "file",
multiple: bool = True,
Comment thread
kirangadhave marked this conversation as resolved.
restrict_navigation: bool = False,
*,
Expand All @@ -150,7 +206,7 @@ def __init__(
| None = None,
ignore_empty_dirs: bool = False,
) -> None:
validate_one_of(selection_mode, ["file", "directory"])
self._selection_mode = _normalize_selection_mode(selection_mode)

# Save the Path class of the initial path
self._path_cls: type[Path]
Expand All @@ -176,7 +232,6 @@ def __init__(
f"Initial path {initial_path} is not a directory."
)

self._selection_mode = selection_mode
# Normalize filetypes: ensure lowercase and dot prefix for case-insensitive matching
if filetypes:
normalized_filetypes = set()
Expand All @@ -201,13 +256,18 @@ def __init__(

self._limit = limit

if self._selection_mode == _VALID_KINDS:
wire_selection_mode = "all"
else:
(wire_selection_mode,) = self._selection_mode

super().__init__(
component_name=file_browser._name,
initial_value=[],
label=label,
args={
"initial-path": str(self._initial_path),
"selection-mode": selection_mode,
"selection-mode": wire_selection_mode,
"filetypes": filetypes if filetypes is not None else [],
"multiple": multiple,
"restrict-navigation": restrict_navigation,
Expand Down Expand Up @@ -316,8 +376,9 @@ def _list_directory(
extension = file.suffix
is_directory = file.is_dir() # Expensive call for cloud paths

# Skip non-directories if selection mode is directory
if self._selection_mode == "directory" and not is_directory:
# Directories are always shown so the user can navigate into
# them. Files are hidden when files aren't selectable.
if not is_directory and "file" not in self._selection_mode:
continue

# Skip non-matching file types (case-insensitive)
Expand Down
150 changes: 146 additions & 4 deletions tests/_plugins/ui/_impl/test_file_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
FileBrowserFileInfo,
ListDirectoryArgs,
ListDirectoryResponse,
_normalize_selection_mode,
file_browser,
)
from marimo._utils.paths import normalize_path
Expand All @@ -21,7 +22,7 @@ def test_file_browser_init(tmp_path: Path) -> None:
fb = file_browser(initial_path=tmp_path)
assert isinstance(fb._initial_path, Path)
assert str(fb._initial_path) == str(normalize_path(tmp_path))
assert fb._selection_mode == "file"
assert fb._selection_mode == frozenset({"file"})
assert fb._filetypes == set()
assert fb._restrict_navigation is False

Expand All @@ -35,7 +36,7 @@ def test_file_browser_init(tmp_path: Path) -> None:
)
assert fb._initial_path == normalize_path(tmp_path)
assert fb._filetypes == set(custom_filetypes)
assert fb._selection_mode == "directory"
assert fb._selection_mode == frozenset({"directory"})
assert fb._restrict_navigation is True


Expand Down Expand Up @@ -352,8 +353,8 @@ def resolve(self) -> CustomPathWithClient:

def test_validation() -> None:
with pytest.raises(ValueError) as e:
file_browser(initial_path="invalid", selection_mode="invalid")
assert "Value must be one of" in str(e.value)
file_browser(initial_path="invalid", selection_mode="invalid") # type: ignore[arg-type]
assert "Invalid selection_mode" in str(e.value)


def test_limit_arg(tmp_path: Path) -> None:
Expand Down Expand Up @@ -1090,3 +1091,144 @@ def test_file_browser_relative_path_sent_to_frontend_as_absolute(

finally:
os.chdir(original_cwd)


class TestNormalizeSelectionMode:
def test_string_file(self) -> None:
assert _normalize_selection_mode("file") == frozenset({"file"})

def test_string_directory(self) -> None:
assert _normalize_selection_mode("directory") == frozenset(
{"directory"}
)

def test_string_all(self) -> None:
assert _normalize_selection_mode("all") == frozenset(
{"file", "directory"}
)

def test_list_single_file(self) -> None:
assert _normalize_selection_mode(["file"]) == frozenset({"file"})

def test_list_single_directory(self) -> None:
assert _normalize_selection_mode(["directory"]) == frozenset(
{"directory"}
)

def test_list_both(self) -> None:
assert _normalize_selection_mode(["file", "directory"]) == frozenset(
{"file", "directory"}
)

def test_list_order_independent(self) -> None:
assert _normalize_selection_mode(["directory", "file"]) == frozenset(
{"file", "directory"}
)

def test_list_dedup(self) -> None:
assert _normalize_selection_mode(["file", "file"]) == frozenset(
{"file"}
)

def test_tuple_accepted(self) -> None:
assert _normalize_selection_mode(("file", "directory")) == frozenset(
{"file", "directory"}
)

@pytest.mark.parametrize(
"value",
[
"both",
"folder",
"",
"FILE",
[],
["both"],
["file", "nope"],
["file", 1],
None,
123,
],
)
def test_rejects_invalid(self, value: Any) -> None:
with pytest.raises(ValueError):
_normalize_selection_mode(value)


class TestSelectionModeAll:
def test_init_all_string(self, tmp_path: Path) -> None:
fb = file_browser(initial_path=tmp_path, selection_mode="all")
assert fb._selection_mode == frozenset({"file", "directory"})

def test_init_list_form(self, tmp_path: Path) -> None:
fb = file_browser(
initial_path=tmp_path, selection_mode=["file", "directory"]
)
assert fb._selection_mode == frozenset({"file", "directory"})

def test_init_list_single(self, tmp_path: Path) -> None:
fb = file_browser(initial_path=tmp_path, selection_mode=["directory"])
assert fb._selection_mode == frozenset({"directory"})

def test_init_rejects_both(self, tmp_path: Path) -> None:
with pytest.raises(ValueError):
file_browser(initial_path=tmp_path, selection_mode="both") # type: ignore[arg-type]

def test_init_rejects_empty_list(self, tmp_path: Path) -> None:
with pytest.raises(ValueError):
file_browser(initial_path=tmp_path, selection_mode=[])

def test_wire_format_all(self, tmp_path: Path) -> None:
fb = file_browser(initial_path=tmp_path, selection_mode="all")
assert fb._component_args["selection-mode"] == "all"

def test_wire_format_file(self, tmp_path: Path) -> None:
fb = file_browser(initial_path=tmp_path, selection_mode="file")
assert fb._component_args["selection-mode"] == "file"

def test_wire_format_directory(self, tmp_path: Path) -> None:
fb = file_browser(initial_path=tmp_path, selection_mode="directory")
assert fb._component_args["selection-mode"] == "directory"

def test_wire_format_list_normalized_to_all(self, tmp_path: Path) -> None:
fb = file_browser(
initial_path=tmp_path,
selection_mode=["directory", "file"],
)
assert fb._component_args["selection-mode"] == "all"

def test_list_directory_all_returns_files_and_dirs(
self, tmp_path: Path
) -> None:
(tmp_path / "sub").mkdir()
(tmp_path / "a.txt").touch()
(tmp_path / "b.parquet").touch()
fb = file_browser(initial_path=tmp_path, selection_mode="all")
response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path)))
names = {f["name"] for f in response.files}
assert names == {"sub", "a.txt", "b.parquet"}

def test_list_directory_all_respects_filetypes_for_files(
self, tmp_path: Path
) -> None:
(tmp_path / "sub").mkdir()
(tmp_path / "a.txt").touch()
(tmp_path / "b.parquet").touch()
fb = file_browser(
initial_path=tmp_path,
selection_mode="all",
filetypes=[".parquet"],
)
response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path)))
names = {f["name"] for f in response.files}
assert names == {"sub", "b.parquet"}

def test_list_directory_directory_only_unchanged(
self, tmp_path: Path
) -> None:
(tmp_path / "sub").mkdir()
(tmp_path / "a.txt").touch()
fb = file_browser(initial_path=tmp_path, selection_mode="directory")
response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path)))
names = {f["name"] for f in response.files}
assert names == {"sub"}
Loading