refactor: stop blocking UI when collecting files

This commit is contained in:
2026-08-10 17:44:25 +03:00
parent da13b7b38b
commit da7ae925f2
+82 -27
View File
@@ -9,12 +9,14 @@ Here's an example usage:
import pathlib import pathlib
import random import random
from typing import override from typing import cast, override
import typed_argparse as tap import typed_argparse as tap
from textual import work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import BindingType from textual.binding import BindingType
from textual.widgets import Footer, Header, Label from textual.widgets import Footer, Header, Label
from textual.worker import Worker, WorkerState
from random_file_picker.core import IMG_EXTENSIONS, collect_files, move_file_to_move_folder, trash_file, upload_file from random_file_picker.core import IMG_EXTENSIONS, collect_files, move_file_to_move_folder, trash_file, upload_file
from random_file_picker.openers import ( from random_file_picker.openers import (
@@ -69,6 +71,7 @@ class MenuApp(App[None]):
self.usable_files: list[pathlib.Path] = [] self.usable_files: list[pathlib.Path] = []
self.current_index: int = 0 self.current_index: int = 0
self.current_file: pathlib.Path | None = None self.current_file: pathlib.Path | None = None
self.is_loading: bool = False
@override @override
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
@@ -78,11 +81,78 @@ class MenuApp(App[None]):
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
self.gather_files() self.start_gathering_files()
if self.usable_files:
@override
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
"""Disable every action except quit while files are being gathered."""
if self.is_loading and action != "quit":
return None
return True
def start_gathering_files(self) -> None:
"""Start file collection in a background worker so the UI stays responsive."""
if not self.directory.exists() or not self.directory.is_dir():
self.query_one("#status", Label).update(f"❌ Directory '{self.directory}' not found")
return
self.is_loading = True
self.refresh_bindings()
self.query_one("#current-file", Label).update(f"🔍 Scanning '{self.directory}' for files, please wait...")
status = self.query_one("#status", Label)
status.update("")
status.loading = True
_ = self.gather_files_worker(
self.directory, self.file_limit, self.blacklist_patterns, self.whitelist_patterns,
recurse=self.recurse,
)
@work(thread=True, exclusive=True)
def gather_files_worker(
self,
directory: pathlib.Path,
file_limit: int,
blacklist_patterns: list[str],
whitelist_patterns: list[str],
*,
recurse: bool,
) -> list[pathlib.Path]:
"""Run file collection in a worker thread so a large/recursive scan doesn't block the UI."""
return collect_files(
search_path=directory,
search_subdirectories=recurse,
file_size_limit=file_limit,
blacklist_patterns=blacklist_patterns,
whitelist_patterns=whitelist_patterns,
)
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Handle the completion of the background file scan, updating the UI with results or errors."""
worker = cast("Worker[list[pathlib.Path]]", event.worker)
if worker.name != "gather_files_worker":
return
status = self.query_one("#status", Label)
self.is_loading = False
status.loading = False
self.refresh_bindings()
if event.state == WorkerState.SUCCESS:
files = worker.result or []
self.usable_files = list(files)
random.shuffle(self.usable_files)
if not self.usable_files:
status.update("❌ No usable files found")
else:
status.update(f"✅ Found {len(self.usable_files)} files")
self.current_index = 0 self.current_index = 0
self.display_current_file() self.display_current_file()
elif event.state == WorkerState.ERROR:
status.update(f"❌ Failed to scan directory: {worker.error}")
def assert_current_file_exists(self) -> bool: def assert_current_file_exists(self) -> bool:
"""Check if the current file exists and update the status label accordingly.""" """Check if the current file exists and update the status label accordingly."""
if not self.current_file or not self.current_file.exists(): if not self.current_file or not self.current_file.exists():
@@ -102,28 +172,6 @@ class MenuApp(App[None]):
self.query_one("#current-file", Label).update(f"📄 {self.current_file} {file_count_str}") self.query_one("#current-file", Label).update(f"📄 {self.current_file} {file_count_str}")
self.open_file() self.open_file()
def gather_files(self) -> None:
"""Collect files from the directory based on filters."""
self.usable_files = []
if not self.directory.exists() or not self.directory.is_dir():
self.query_one("#status", Label).update(f"❌ Directory '{self.directory}' not found")
return
self.usable_files = collect_files(
search_path=self.directory,
search_subdirectories=self.recurse,
file_size_limit=self.file_limit,
blacklist_patterns=self.blacklist_patterns,
whitelist_patterns=self.whitelist_patterns,
)
if not self.usable_files:
self.query_one("#status", Label).update("❌ No usable files found")
else:
self.query_one("#status", Label).update(f"✅ Found {len(self.usable_files)} files")
random.shuffle(self.usable_files)
def open_file(self) -> None: def open_file(self) -> None:
"""Open the current file - display images in terminal, play others externally.""" """Open the current file - display images in terminal, play others externally."""
if not self.current_file: if not self.current_file:
@@ -159,7 +207,7 @@ class MenuApp(App[None]):
self.query_one("#status", Label).update(f"❌ Upload failed: {str(e)}") self.query_one("#status", Label).update(f"❌ Upload failed: {str(e)}")
def delete_file(self) -> None: def delete_file(self) -> None:
"""Delete the current file.""" """Delete the current file (move to trash if enabled)."""
if not self.current_file or self.current_index >= len(self.usable_files): if not self.current_file or self.current_index >= len(self.usable_files):
return return
@@ -210,7 +258,7 @@ class MenuApp(App[None]):
self.query_one("#status", Label).update(f"❌ Move failed: {str(e)}") self.query_one("#status", Label).update(f"❌ Move failed: {str(e)}")
def open_in_folder(self) -> None: def open_in_folder(self) -> None:
"""Open the file's folder in file manager.""" """Open the current file's folder in the file manager."""
if not self.current_file: if not self.current_file:
return return
@@ -221,30 +269,37 @@ class MenuApp(App[None]):
self.query_one("#status", Label).update("📂 Opened file in folder") self.query_one("#status", Label).update("📂 Opened file in folder")
def action_upload(self) -> None: def action_upload(self) -> None:
"""Upload the current file to Discord webhook."""
self.send_file() self.send_file()
def action_delete(self) -> None: def action_delete(self) -> None:
"""Delete the current file (move to trash if enabled)."""
self.delete_file() self.delete_file()
def action_move_to_folder(self) -> None: def action_move_to_folder(self) -> None:
"""Move the current file to the move folder."""
self.move_to_move_folder() self.move_to_move_folder()
def action_open_folder(self) -> None: def action_open_folder(self) -> None:
"""Open the current file's folder in the file manager."""
self.open_in_folder() self.open_in_folder()
def action_next_file(self) -> None: def action_next_file(self) -> None:
"""Display the next file."""
if not self.usable_files: if not self.usable_files:
return return
self.current_index = (self.current_index + 1) % len(self.usable_files) self.current_index = (self.current_index + 1) % len(self.usable_files)
self.display_current_file() self.display_current_file()
def action_prev_file(self) -> None: def action_prev_file(self) -> None:
"""Display the previous file."""
if not self.usable_files: if not self.usable_files:
return return
self.current_index = (self.current_index - 1) % len(self.usable_files) self.current_index = (self.current_index - 1) % len(self.usable_files)
self.display_current_file() self.display_current_file()
def action_reopen_file(self) -> None: def action_reopen_file(self) -> None:
"""Reopen the current file."""
if not self.current_file: if not self.current_file:
return return
self.open_file() self.open_file()