erplibre/script/todo/todo_file_browser.py
Mathieu Benoit fd2e2ca7ef [FIX] todo: test menu, file browser and KeePass refusals
Four papercuts met while using the CLI: the test menu returned a verdict
nobody could find at the end of the output, the file browser never closed
once a file was picked, a wrong KeePass password killed the whole CLI, and
a configuration key had drifted from the code that reads it.

--- FR ---

Quatre irritants rencontrés à l'usage : le menu Test rendait un verdict
introuvable en fin de sortie, le navigateur de fichiers ne se fermait
jamais une fois le fichier choisi, un mauvais mot de passe KeePass tuait
tout le CLI, et une clé de configuration avait dérivé du code qui la lit.

Assisted-by: Claude Opus 5
2026-08-16 03:56:34 -04:00

120 lines
4.1 KiB
Python

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import os
import urwid
class FileBrowser(urwid.WidgetWrap):
def __init__(self, initial_path, callback, open_dir=False):
self.callback = callback
self.current_path = os.path.abspath(initial_path)
self.list_walker = urwid.SimpleFocusListWalker([])
self.listbox = urwid.ListBox(self.list_walker)
super().__init__(self.listbox)
self.open_dir = open_dir
self.refresh_list()
def refresh_list(self):
"""Updates the list of files and directories."""
self.list_walker.clear()
self.list_walker.append(
urwid.Button("..", on_press=self.go_up_directory)
)
if self.open_dir:
self.list_walker.append(
urwid.Button(".", on_press=self.select_directory)
)
try:
entries = os.listdir(self.current_path)
entries.sort(key=lambda r: r.lower())
for entry in entries:
full_path = os.path.join(self.current_path, entry)
if os.path.isdir(full_path):
self.list_walker.append(
urwid.Button(f"{entry}/", on_press=self.open_directory)
)
elif not self.open_dir:
self.list_walker.append(
urwid.Button(entry, on_press=self.select_file)
)
except OSError as e:
# Handle directory access errors
self.list_walker.append(urwid.Text(f"Access Error: {e}"))
def go_up_directory(self, button):
"""Moves up one level in the directory hierarchy."""
parent_path = os.path.dirname(self.current_path)
if parent_path != self.current_path:
self.current_path = parent_path
self.refresh_list()
def open_directory(self, button):
"""Moves into a subdirectory."""
dirname = button.label[:-1]
new_path = os.path.join(self.current_path, dirname)
if os.path.isdir(new_path):
self.current_path = new_path
self.refresh_list()
def select_directory(self, button):
"""Selects a directory, and closes the browser."""
self.callback(self.current_path)
exit_program()
def select_file(self, button):
"""Selects a file, and closes the browser.
The callback records the choice; leaving the loop is what ENDS the
browser. Without it the selection worked and nothing seemed to happen:
the screen stayed up, no key closed it, and only Ctrl+C got out — so
the browser looked frozen at the exact moment it had done its job.
"""
filename = button.label
selected_file_path = os.path.join(self.current_path, filename)
self.callback(selected_file_path)
exit_program()
def unhandled_input(self, key):
"""Leaving without choosing has to be possible.
Every other way out of this browser selects something. A caller that
offers an alternative — typing a path — can only be reached by
cancelling, so cancelling has to exist.
"""
if key in ("q", "Q", "esc"):
exit_program()
def run_main_frame(self):
main_frame = urwid.Frame(
body=self,
header=urwid.Text(("header", f"Navigate: {self.current_path}")),
footer=urwid.Text(
(
"footer",
"Arrow keys to navigate, Enter to select, q to cancel.",
)
),
)
palette = [
("header", "dark cyan", "black"),
("footer", "dark cyan", "black"),
("body", "white", "black"),
("button", "black", "dark cyan", "standout"),
("focus", "white", "dark green", "bold"),
("bold", "bold", "black"),
]
loop = urwid.MainLoop(
main_frame, palette, unhandled_input=self.unhandled_input
)
loop.run()
def exit_program():
"""Exits the program."""
raise urwid.ExitMainLoop()