t3code/implement-issue-five #20

Merged
phg merged 7 commits from t3code/implement-issue-five into main 2026-09-08 16:37:56 +00:00
2 changed files with 83 additions and 31 deletions
Showing only changes of commit 41a96b8156 - Show all commits

View file

@ -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."""

View file

@ -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."""