Fix #5: make file chooser navigable
This commit is contained in:
parent
1b19d5eca2
commit
41a96b8156
2 changed files with 83 additions and 31 deletions
|
|
@ -5,7 +5,7 @@ from pathlib import Path
|
|||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Grid, Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
|
|
@ -49,10 +49,13 @@ class _FormatModal(ModalScreen):
|
|||
}
|
||||
.format-title { text-align: center; text-style: bold; color: $primary; }
|
||||
.format-copy { margin-top: 1; color: $text-muted; }
|
||||
#path-input { margin-top: 1; }
|
||||
.form-section { margin-top: 1; }
|
||||
#path-row { grid-size: 2; grid-columns: 1fr 16; height: 3; }
|
||||
#path-input { width: 1fr; height: 3; margin: 0; }
|
||||
#workflow-error { color: $error; height: auto; margin-top: 1; }
|
||||
#workflow-warning { color: $warning; height: auto; margin-top: 1; }
|
||||
.browse-button { width: 16; margin-top: 1; }
|
||||
.browse-button { width: 16; height: 3; margin: 0; }
|
||||
.format-section { margin-top: 1; padding: 0 1; border: round $primary; }
|
||||
.button-row { margin-top: 1; height: 3; align: center middle; }
|
||||
"""
|
||||
|
||||
|
|
@ -90,9 +93,15 @@ class ExportModal(_FormatModal):
|
|||
"Choose a destination and format. Existing files require confirmation.",
|
||||
classes="format-copy",
|
||||
)
|
||||
with Vertical(classes="form-section"):
|
||||
yield Label("Export file path")
|
||||
with Grid(id="path-row"):
|
||||
yield Input(placeholder="/path/to/entries.hosts", id="path-input")
|
||||
yield Button("Browse…", id="browse-button", classes="browse-button")
|
||||
yield Static("", id="workflow-error")
|
||||
yield Static("", id="workflow-warning")
|
||||
with Vertical(classes="format-section") as formats:
|
||||
formats.border_title = "File format"
|
||||
with RadioSet(id="format-select"):
|
||||
for index, format in enumerate(self._formats):
|
||||
yield RadioButton(
|
||||
|
|
@ -100,8 +109,6 @@ class ExportModal(_FormatModal):
|
|||
id=f"format-{format.value}",
|
||||
value=index == 0,
|
||||
)
|
||||
yield Static("", id="workflow-error")
|
||||
yield Static("", id="workflow-warning")
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Cancel", id="cancel-button")
|
||||
yield Button("Continue", id="continue-button", variant="primary")
|
||||
|
|
@ -174,9 +181,14 @@ class ImportModal(_FormatModal):
|
|||
"Import replaces the current Host Entries and saves the Hosts File.",
|
||||
classes="format-copy",
|
||||
)
|
||||
with Vertical(classes="form-section"):
|
||||
yield Label("Import file path")
|
||||
with Grid(id="path-row"):
|
||||
yield Input(placeholder="/path/to/entries.hosts", id="path-input")
|
||||
yield Button("Browse…", id="browse-button", classes="browse-button")
|
||||
yield Static("", id="workflow-error")
|
||||
with Vertical(classes="format-section") as formats:
|
||||
formats.border_title = "File format"
|
||||
with RadioSet(id="format-select"):
|
||||
for index, format in enumerate(self._formats):
|
||||
yield RadioButton(
|
||||
|
|
@ -184,7 +196,6 @@ class ImportModal(_FormatModal):
|
|||
id=f"format-{format.value}",
|
||||
value=index == 0,
|
||||
)
|
||||
yield Static("", id="workflow-error")
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Cancel", id="cancel-button")
|
||||
yield Button("Import and save", id="import-button", variant="primary")
|
||||
|
|
@ -226,7 +237,10 @@ class ImportModal(_FormatModal):
|
|||
class FileBrowserModal(ModalScreen[Path | None]):
|
||||
"""A keyboard-accessible directory tree for file and folder selection."""
|
||||
|
||||
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("u", "go_up", "Up directory"),
|
||||
]
|
||||
CSS = """
|
||||
FileBrowserModal { align: center middle; }
|
||||
#file-browser-container {
|
||||
|
|
@ -242,6 +256,7 @@ class FileBrowserModal(ModalScreen[Path | None]):
|
|||
def __init__(self, start_path: Path, *, choose_directory: bool):
|
||||
super().__init__()
|
||||
self._start_path = start_path if start_path.is_dir() else Path.cwd()
|
||||
self._current_path = self._start_path
|
||||
self._choose_directory = choose_directory
|
||||
self._selected_directory: Path | None = None
|
||||
|
||||
|
|
@ -260,6 +275,7 @@ class FileBrowserModal(ModalScreen[Path | None]):
|
|||
yield Static(str(self._start_path), id="file-browser-status")
|
||||
yield DirectoryTree(self._start_path, id="file-browser-tree")
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Up", id="up-button")
|
||||
yield Button("Cancel", id="cancel-button")
|
||||
if self._choose_directory:
|
||||
yield Button(
|
||||
|
|
@ -288,7 +304,9 @@ class FileBrowserModal(ModalScreen[Path | None]):
|
|||
self.query_one("#file-browser-status", Static).update(str(event.path))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "cancel-button":
|
||||
if event.button.id == "up-button":
|
||||
self.action_go_up()
|
||||
elif event.button.id == "cancel-button":
|
||||
self.action_cancel()
|
||||
elif event.button.id == "choose-directory-button":
|
||||
self.dismiss(self._selected_directory or self._start_path)
|
||||
|
|
@ -296,6 +314,19 @@ class FileBrowserModal(ModalScreen[Path | None]):
|
|||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
def action_go_up(self) -> None:
|
||||
"""Re-root the tree at the parent of the current directory."""
|
||||
parent = self._current_path.parent
|
||||
if parent == self._current_path:
|
||||
self.query_one("#file-browser-status", Static).update(
|
||||
"Already at the filesystem root."
|
||||
)
|
||||
return
|
||||
self._current_path = parent
|
||||
self._selected_directory = None
|
||||
self.query_one("#file-browser-tree", DirectoryTree).path = parent
|
||||
self.query_one("#file-browser-status", Static).update(str(parent))
|
||||
|
||||
|
||||
def _format_label(format: ExportFormat | ImportFormat) -> str:
|
||||
"""Give the core-supported formats concise, file-oriented labels."""
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from textual.widgets import Button, Input, RadioButton, Static
|
||||
from textual.widgets import Button, DirectoryTree, Input, RadioButton, Static
|
||||
|
||||
from hosts.core.import_export import (
|
||||
ExportFormat,
|
||||
|
|
@ -71,8 +71,8 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory():
|
|||
path_input = app.screen.query_one("#path-input", Input)
|
||||
container = app.screen.query_one("#format-container")
|
||||
assert browse.region.width > 0
|
||||
assert browse.region.x >= path_input.region.x
|
||||
assert browse.region.y >= path_input.region.bottom
|
||||
assert browse.region.x >= path_input.region.right
|
||||
assert browse.region.y == path_input.region.y
|
||||
assert browse.region.right <= container.region.right
|
||||
assert browse.region.bottom <= container.region.bottom
|
||||
await pilot.press("tab")
|
||||
|
|
@ -85,6 +85,7 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory():
|
|||
container = app.screen.query_one("#file-browser-container")
|
||||
assert container.region.x == (app.size.width - container.region.width) // 2
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.press("tab")
|
||||
await pilot.press("tab")
|
||||
await pilot.press("enter")
|
||||
|
|
@ -96,6 +97,26 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory():
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_browser_can_move_to_its_parent_directory(tmp_path):
|
||||
"""The chooser exposes an explicit route back up the directory tree."""
|
||||
child = tmp_path / "child"
|
||||
child.mkdir()
|
||||
app = HostsManagerApp()
|
||||
app.load_hosts_file = Mock()
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
app.push_screen(FileBrowserModal(child, choose_directory=True))
|
||||
await pilot.pause()
|
||||
browser = app.screen
|
||||
assert isinstance(browser, FileBrowserModal)
|
||||
|
||||
browser.action_go_up()
|
||||
await pilot.pause()
|
||||
|
||||
assert browser.query_one("#file-browser-tree", DirectoryTree).path == tmp_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_requires_overwrite_confirmation_before_calling_service(tmp_path):
|
||||
"""An existing output file is not replaced until the user confirms it."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue