Skip to content

Commit 73a26dd

Browse files
feat: control selected file or directory externally in FileBrowser
1 parent 4b17224 commit 73a26dd

File tree

3 files changed

+127
-27
lines changed

3 files changed

+127
-27
lines changed

solara/components/file_browser.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import os
22
from os.path import isfile, join
33
from pathlib import Path
4-
from typing import Callable, Dict, List, Optional, Union, cast
4+
from typing import Callable, Dict, List, Optional, TypeVar, Union, cast
5+
import logging
56

67
import humanize
78
import ipyvuetify as vy
@@ -10,6 +11,9 @@
1011
import solara
1112
from solara.components import Div
1213

14+
T = TypeVar("T")
15+
logger = logging.getLogger(__name__)
16+
1317

1418
def list_dir(path, filter: Callable[[Path], bool] = lambda x: True, directory_first: bool = False) -> List[dict]:
1519
def mk_item(n):
@@ -48,9 +52,30 @@ def __contains__(self, name):
4852
return name in [k["name"] for k in self.files]
4953

5054

55+
def use_reactive_or_value(
56+
value: Union[T, solara.Reactive[T]], on_value: Optional[Callable[[T], None]] = None, value_name="value", on_value_name="on_value", use_internal_value=False
57+
):
58+
def hookup_on_value():
59+
if isinstance(value, solara.Reactive) and on_value:
60+
return value.subscribe(on_value)
61+
62+
solara.use_effect(hookup_on_value, [isinstance(value, solara.Reactive), on_value])
63+
internal_value, set_internal_value = solara.use_state(value.value if isinstance(value, solara.Reactive) else value)
64+
if use_internal_value:
65+
return internal_value, set_internal_value
66+
if isinstance(value, solara.Reactive):
67+
return value.value, value.set
68+
elif on_value:
69+
return value, on_value
70+
else:
71+
logger.warning("You should provide an %s callback if you are not using a reactive value, otherwise %s input will not update", on_value_name, value_name)
72+
return value, lambda x: None
73+
74+
5175
@solara.component
5276
def FileBrowser(
5377
directory: Union[None, str, Path, solara.Reactive[Path]] = None,
78+
selected: Union[None, Path, solara.Reactive[Optional[Path]]] = None,
5479
on_directory_change: Optional[Callable[[Path], None]] = None,
5580
on_path_select: Optional[Callable[[Optional[Path]], None]] = None,
5681
on_file_open: Optional[Callable[[Path], None]] = None,
@@ -75,7 +100,8 @@ def FileBrowser(
75100
76101
## Arguments
77102
78-
* `directory`: The directory to start in. If `None` the current working directory is used.
103+
* `directory`: The directory to start in. If `None`, the current working directory is used.
104+
* `selected`: The selected file or directory. If `None`, no file or directory is selected (requires `can_select=True`).
79105
* `on_directory_change`: Depends on mode, see above.
80106
* `on_path_select`: Depends on mode, see above.
81107
* `on_file_open`: Depends on mode, see above.
@@ -90,13 +116,30 @@ def FileBrowser(
90116
directory = os.getcwd() # pragma: no cover
91117
if isinstance(directory, str):
92118
directory = Path(directory)
119+
# directory = directory.resolve()
93120
current_dir = solara.use_reactive(directory)
94-
selected, set_selected = solara.use_state(None)
95121
double_clicked, set_double_clicked = solara.use_state(None)
96122
warning, set_warning = solara.use_state(cast(Optional[str], None))
97123
scroll_pos_stack, set_scroll_pos_stack = solara.use_state(cast(List[int], []))
98124
scroll_pos, set_scroll_pos = solara.use_state(0)
99-
selected, set_selected = solara.use_state(None)
125+
selected_private, set_selected_private = use_reactive_or_value(
126+
selected,
127+
on_value=on_path_select if can_select else lambda x: None,
128+
value_name="selected",
129+
on_value_name="on_path_select",
130+
use_internal_value=not can_select,
131+
)
132+
# remove so we don't accidentally use it
133+
del selected
134+
135+
def sync_directory_from_selected():
136+
if selected_private is not None:
137+
# if we select a file, we need to make sure the directory is correct
138+
# NOTE: although we expect a Path, abuse might make it a string
139+
if isinstance(selected_private, Path):
140+
current_dir.value = selected_private.resolve().parent
141+
142+
solara.use_effect(sync_directory_from_selected, [selected_private])
100143

101144
def change_dir(new_dir: Path):
102145
if os.access(new_dir, os.R_OK):
@@ -121,7 +164,7 @@ def on_item(item, double_click):
121164
last_pos = scroll_pos_stack[-1]
122165
set_scroll_pos_stack(scroll_pos_stack[:-1])
123166
set_scroll_pos(last_pos)
124-
set_selected(None)
167+
set_selected_private(None)
125168
set_double_clicked(None)
126169
if on_path_select and can_select:
127170
on_path_select(None)
@@ -142,7 +185,7 @@ def on_item(item, double_click):
142185
if change_dir(path):
143186
set_scroll_pos_stack(scroll_pos_stack + [scroll_pos])
144187
set_scroll_pos(0)
145-
set_selected(None)
188+
set_selected_private(None)
146189
set_double_clicked(None)
147190
if on_path_select and can_select:
148191
on_path_select(None)
@@ -153,7 +196,7 @@ def on_item(item, double_click):
153196
raise RuntimeError("Combination should not happen") # pragma: no cover
154197

155198
def on_click(item):
156-
set_selected(item)
199+
set_selected_private(item["name"] if item else None)
157200
on_item(item, False)
158201

159202
def on_double_click(item):
@@ -163,12 +206,20 @@ def on_double_click(item):
163206
# otherwise we can ignore it, single click will handle it
164207

165208
files = [{"name": "..", "is_file": False}] + list_dir(current_dir.value, filter=filter, directory_first=directory_first)
209+
clicked = (
210+
{
211+
"name": selected_private.name if isinstance(selected_private, Path) else selected_private,
212+
"is_file": isinstance(selected_private, Path),
213+
"size": None,
214+
}
215+
if selected_private is not None
216+
else None
217+
)
166218
with Div(class_="solara-file-browser") as main:
167219
Div(children=[str(current_dir.value.resolve())])
168220
FileListWidget.element(
169221
files=files,
170-
selected=selected,
171-
clicked=selected,
222+
clicked=clicked,
172223
on_clicked=on_click,
173224
double_clicked=double_clicked,
174225
on_double_clicked=on_double_click,

solara/website/pages/documentation/components/input/file_browser.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,37 @@
22

33
from pathlib import Path
44
from typing import Optional, cast
5-
5+
import random
66
import solara
77
from solara.website.utils import apidoc
88

9+
opened = solara.reactive(cast(Optional[Path], None))
10+
selected = solara.reactive(cast(Optional[Path], None))
11+
directory = solara.reactive(Path("~").expanduser())
12+
can_select = solara.reactive(False)
13+
914

1015
@solara.component
1116
def Page():
12-
file, set_file = solara.use_state(cast(Optional[Path], None))
13-
path, set_path = solara.use_state(cast(Optional[Path], None))
14-
directory, set_directory = solara.use_state(Path("~").expanduser())
15-
16-
can_select = solara.ui_checkbox("Enable select")
17-
1817
def reset_path():
19-
set_path(None)
20-
set_file(None)
18+
opened.value = None
19+
selected.value = None
20+
21+
def select_random_file():
22+
files = list(directory.value.glob("*"))
23+
if files:
24+
selected.value = random.choice(files)
2125

2226
# reset path and file when can_select changes
23-
solara.use_memo(reset_path, [can_select])
24-
solara.FileBrowser(directory, on_directory_change=set_directory, on_path_select=set_path, on_file_open=set_file, can_select=can_select)
27+
solara.use_memo(reset_path, [can_select.value])
28+
solara.Checkbox(label="Enable select", value=can_select)
29+
solara.FileBrowser(directory, selected=selected, on_file_open=opened.set, can_select=can_select.value)
2530
solara.Info(f"You are in directory: {directory}")
26-
solara.Info(f"You selected path: {path}")
27-
solara.Info(f"You opened file: {file}")
31+
solara.Info(f"You selected path: {selected}")
32+
solara.Info(f"You opened file: {opened}")
33+
34+
if can_select.value:
35+
solara.Button(label="Select random file", on_click=select_random_file)
2836

2937

3038
__doc__ += apidoc(solara.FileBrowser.f) # type: ignore

tests/unit/file_browser_test.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import platform
22
import unittest.mock
33
from pathlib import Path
4+
from typing import Optional, cast
45

56
import pytest
67

@@ -22,7 +23,7 @@ def Test():
2223
HERE.parent, on_path_select=on_path_select, on_directory_change=on_directory_change, on_file_open=on_file_open, on_file_name=on_file_name
2324
)
2425

25-
div, rc = solara.render_fixed(Test())
26+
div, rc = solara.render_fixed(Test(), handle_error=False)
2627
on_directory_change.assert_not_called()
2728
on_file_open.assert_not_called()
2829
on_path_select.assert_not_called()
@@ -149,10 +150,12 @@ def test_file_browser_no_access(tmpdir: Path):
149150

150151
@solara.component
151152
def Test():
152-
return solara.FileBrowser(tmpdir, on_path_select=on_path_select, on_directory_change=on_directory_change, on_file_open=on_file_open, can_select=True)
153+
return solara.FileBrowser(
154+
Path(tmpdir), on_path_select=on_path_select, on_directory_change=on_directory_change, on_file_open=on_file_open, can_select=True
155+
)
153156

154157
try:
155-
div, rc = solara.render_fixed(Test())
158+
div, rc = solara.render_fixed(Test(), handle_error=False)
156159

157160
list: solara.components.file_browser.FileListWidget = div.children[1]
158161
# select is ok
@@ -179,7 +182,7 @@ def Test():
179182
list: solara.components.file_browser.FileListWidget = div.children[1]
180183
items = list.files
181184
names = {k["name"] for k in items}
182-
assert names == {"unit", "ui", "docs", "integration", "pyinstaller", ".."}
185+
assert names.issuperset({"unit", "ui", "docs", "integration", "pyinstaller", ".."})
183186

184187

185188
def test_file_browser_test_change_directory():
@@ -212,7 +215,7 @@ def set_directory(path: Path) -> None:
212215
file_list.observe(mock, "files")
213216
items = file_list.files
214217
names = {k["name"] for k in items}
215-
assert names == {"unit", "ui", "docs", "integration", "pyinstaller", ".."}
218+
assert names.issuperset({"unit", "ui", "docs", "integration", "pyinstaller", ".."})
216219
file_list.test_click("..")
217220
assert mock.call_count == 0
218221
file_list.test_click("integration")
@@ -233,3 +236,41 @@ def Test():
233236
list.test_click("..")
234237
files_parent = {k["name"] for k in list.files}
235238
assert files_parent != files
239+
240+
241+
def test_file_browser_programmatic_select():
242+
# using a reactive value to select a file
243+
selected = solara.reactive(cast(Optional[Path], None))
244+
245+
@solara.component
246+
def Test():
247+
return solara.FileBrowser(HERE.parent, selected=selected, can_select=True)
248+
249+
div, rc = solara.render_fixed(Test(), handle_error=False)
250+
list: solara.components.file_browser.FileListWidget = div.children[1]
251+
files = list.files.copy()
252+
assert list.clicked is None
253+
selected.value = HERE.parent / "file_browser_test.py"
254+
assert list.clicked is not None
255+
assert list.clicked["name"] == "file_browser_test.py"
256+
list.test_click("..", double_click=True)
257+
assert list.files != files
258+
assert selected.value is None
259+
260+
selected.value = None
261+
262+
# passing selected as a value (non reactive)
263+
@solara.component
264+
def Test2():
265+
return solara.FileBrowser(HERE.parent, selected=selected.value, on_path_select=selected.set, can_select=True)
266+
267+
div, rc = solara.render_fixed(Test2(), handle_error=False)
268+
list = div.children[1]
269+
files = list.files.copy()
270+
assert list.clicked is None
271+
selected.value = HERE.parent / "file_browser_test.py"
272+
assert list.clicked is not None
273+
assert list.clicked["name"] == "file_browser_test.py"
274+
list.test_click("..", double_click=True)
275+
assert list.files != files
276+
assert selected.value is None

0 commit comments

Comments
 (0)