Skip to content

Commit a0c7520

Browse files
feat: add watch option to FileBrowser for automatic file change detection
The FileBrowser component now supports a `watch` parameter that, when enabled, automatically monitors the current directory for file changes and refreshes the file list. This uses the same approach as the Style component's CSS hot reload feature, using watchfiles.awatch() in an async task managed via use_effect. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 936d215 commit a0c7520

File tree

2 files changed

+198
-0
lines changed

2 files changed

+198
-0
lines changed

solara/components/file_browser.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import os
23
from os.path import isfile, join
34
from pathlib import Path
@@ -8,6 +9,11 @@
89
import ipyvuetify as vy
910
import traitlets
1011

12+
try:
13+
import watchfiles
14+
except ModuleNotFoundError:
15+
watchfiles = None # type: ignore
16+
1117
import solara
1218
from solara.components import Div
1319

@@ -84,6 +90,7 @@ def FileBrowser(
8490
on_file_name: Optional[Callable[[str], None]] = None,
8591
start_directory=None,
8692
can_select=False,
93+
watch: bool = False,
8794
):
8895
"""File/directory browser at the server side.
8996
@@ -109,6 +116,8 @@ def FileBrowser(
109116
* `directory_first`: If `True` directories are shown before files. Default: `False`.
110117
* `on_file_name`: (deprecated) Use on_file_open instead.
111118
* `start_directory`: (deprecated) Use directory instead.
119+
* `watch`: If `True`, watch the current directory for file changes and automatically refresh the file list.
120+
Requires the `watchfiles` package to be installed.
112121
"""
113122
if start_directory is not None:
114123
directory = start_directory # pragma: no cover
@@ -122,6 +131,8 @@ def FileBrowser(
122131
warning, set_warning = solara.use_state(cast(Optional[str], None))
123132
scroll_pos_stack, set_scroll_pos_stack = solara.use_state(cast(List[int], []))
124133
scroll_pos, set_scroll_pos = solara.use_state(0)
134+
# Counter to trigger re-render when files change
135+
file_change_counter, set_file_change_counter = solara.use_state(0)
125136
selected_private, set_selected_private = use_reactive_or_value(
126137
selected,
127138
on_value=on_path_select if can_select else lambda x: None,
@@ -143,6 +154,38 @@ def sync_directory_from_selected():
143154

144155
solara.use_effect(sync_directory_from_selected, [selected_private])
145156

157+
def watch_directory():
158+
if not watch:
159+
return
160+
if not watchfiles:
161+
logger.warning("watchfiles not installed, cannot watch directory")
162+
return
163+
164+
# Check if there's a running event loop before creating the coroutine
165+
try:
166+
asyncio.get_running_loop()
167+
except RuntimeError:
168+
# No running event loop (e.g., in unit tests or non-server environments)
169+
logger.warning("No running event loop, cannot watch directory for changes")
170+
return
171+
172+
dir_to_watch = current_dir.value
173+
174+
async def watch_task():
175+
try:
176+
async for _ in watchfiles.awatch(dir_to_watch):
177+
logger.debug("Directory %s changed, refreshing file list", dir_to_watch)
178+
set_file_change_counter(lambda x: x + 1)
179+
except RuntimeError:
180+
pass # swallow the RuntimeError: Already borrowed errors from watchfiles
181+
except Exception:
182+
logger.exception("Error watching directory")
183+
184+
future = asyncio.create_task(watch_task())
185+
return future.cancel
186+
187+
solara.use_effect(watch_directory, [watch, current_dir.value])
188+
146189
def change_dir(new_dir: Path):
147190
if os.access(new_dir, os.R_OK):
148191
current_dir.value = new_dir

tests/unit/file_browser_test.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import platform
23
import unittest.mock
34
from pathlib import Path
@@ -290,3 +291,157 @@ def Test3():
290291
selected.value = current_dir.value / ".." / "unit" / ".."
291292
assert current_dir.value == HERE.parent
292293
rc.close()
294+
295+
296+
def test_file_browser_watch_no_event_loop(tmpdir: Path):
297+
"""Test that watch=True gracefully handles no running event loop."""
298+
tmpdir = Path(tmpdir)
299+
(tmpdir / "test.txt").write_text("test")
300+
301+
# When watch=True but no event loop is available, it should log a warning but not crash
302+
with unittest.mock.patch.object(solara.components.file_browser.logger, "warning") as mock_warning:
303+
304+
@solara.component
305+
def Test():
306+
return solara.FileBrowser(tmpdir, watch=True)
307+
308+
div, rc = solara.render_fixed(Test(), handle_error=False)
309+
file_list: solara.components.file_browser.FileListWidget = div.children[1]
310+
311+
# Verify files are still shown correctly
312+
files = {k["name"] for k in file_list.files}
313+
assert "test.txt" in files
314+
315+
# Verify warning was logged about no event loop
316+
mock_warning.assert_called_with("No running event loop, cannot watch directory for changes")
317+
rc.close()
318+
319+
320+
def test_file_browser_watch_disabled_by_default():
321+
"""Test that watch is disabled by default (no warning when watchfiles not available)."""
322+
323+
@solara.component
324+
def Test():
325+
return solara.FileBrowser(HERE.parent)
326+
327+
# This should work without any issues even if watchfiles is not installed
328+
div, rc = solara.render_fixed(Test(), handle_error=False)
329+
file_list: solara.components.file_browser.FileListWidget = div.children[1]
330+
assert "file_browser_test.py" in file_list
331+
rc.close()
332+
333+
334+
def test_file_browser_watch_no_watchfiles(tmpdir: Path):
335+
"""Test that watch=True logs a warning when watchfiles is not installed."""
336+
tmpdir = Path(tmpdir)
337+
(tmpdir / "test.txt").write_text("test")
338+
339+
with unittest.mock.patch.object(solara.components.file_browser, "watchfiles", None):
340+
with unittest.mock.patch.object(solara.components.file_browser.logger, "warning") as mock_warning:
341+
342+
@solara.component
343+
def Test():
344+
return solara.FileBrowser(tmpdir, watch=True)
345+
346+
div, rc = solara.render_fixed(Test(), handle_error=False)
347+
348+
# Give the effect a chance to run
349+
import time
350+
351+
time.sleep(0.1)
352+
353+
mock_warning.assert_called_with("watchfiles not installed, cannot watch directory")
354+
rc.close()
355+
356+
357+
@pytest.mark.asyncio
358+
async def test_file_browser_watch_detects_new_file(tmpdir: Path):
359+
"""Test that watch=True actually detects when a new file is added."""
360+
tmpdir = Path(tmpdir)
361+
(tmpdir / "initial.txt").write_text("initial")
362+
363+
files_changed_event = asyncio.Event()
364+
365+
@solara.component
366+
def Test():
367+
return solara.FileBrowser(tmpdir, watch=True)
368+
369+
div, rc = solara.render_fixed(Test(), handle_error=False)
370+
file_list: solara.components.file_browser.FileListWidget = div.children[1]
371+
372+
# Verify initial state
373+
initial_files = {k["name"] for k in file_list.files}
374+
assert "initial.txt" in initial_files
375+
assert "new_file.txt" not in initial_files
376+
377+
# Set up observer to detect when files trait changes
378+
def on_files_change(change):
379+
files_changed_event.set()
380+
381+
file_list.observe(on_files_change, "files")
382+
383+
# Give the watcher task a moment to start
384+
await asyncio.sleep(0.1)
385+
386+
# Create a new file - this should trigger the watcher
387+
(tmpdir / "new_file.txt").write_text("new content")
388+
389+
# Wait for the watcher to detect the change
390+
try:
391+
await asyncio.wait_for(files_changed_event.wait(), timeout=5.0)
392+
except asyncio.TimeoutError:
393+
pytest.fail("File change was not detected within timeout")
394+
395+
# Verify the new file is now in the list
396+
new_files = {k["name"] for k in file_list.files}
397+
assert "new_file.txt" in new_files
398+
assert "initial.txt" in new_files
399+
400+
rc.close()
401+
402+
403+
@pytest.mark.asyncio
404+
async def test_file_browser_watch_detects_deleted_file(tmpdir: Path):
405+
"""Test that watch=True detects when a file is deleted."""
406+
tmpdir = Path(tmpdir)
407+
(tmpdir / "file1.txt").write_text("content1")
408+
(tmpdir / "file2.txt").write_text("content2")
409+
410+
files_changed_event = asyncio.Event()
411+
412+
@solara.component
413+
def Test():
414+
return solara.FileBrowser(tmpdir, watch=True)
415+
416+
div, rc = solara.render_fixed(Test(), handle_error=False)
417+
file_list: solara.components.file_browser.FileListWidget = div.children[1]
418+
419+
# Verify initial state
420+
initial_files = {k["name"] for k in file_list.files}
421+
assert "file1.txt" in initial_files
422+
assert "file2.txt" in initial_files
423+
424+
# Set up observer
425+
def on_files_change(change):
426+
files_changed_event.set()
427+
428+
file_list.observe(on_files_change, "files")
429+
430+
# Give the watcher task a moment to start
431+
await asyncio.sleep(0.1)
432+
433+
# Delete a file
434+
(tmpdir / "file2.txt").unlink()
435+
436+
# Wait for the watcher to detect the change
437+
try:
438+
await asyncio.wait_for(files_changed_event.wait(), timeout=5.0)
439+
except asyncio.TimeoutError:
440+
pytest.fail("File deletion was not detected within timeout")
441+
442+
# Verify file2 is gone
443+
new_files = {k["name"] for k in file_list.files}
444+
assert "file1.txt" in new_files
445+
assert "file2.txt" not in new_files
446+
447+
rc.close()

0 commit comments

Comments
 (0)