t3code/implement-issue-five #20
2 changed files with 128 additions and 69 deletions
|
|
@ -7,15 +7,7 @@ from textual.app import ComposeResult
|
|||
from textual.binding import Binding
|
||||
from textual.containers import Grid, Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
DirectoryTree,
|
||||
Input,
|
||||
Label,
|
||||
RadioButton,
|
||||
RadioSet,
|
||||
Static,
|
||||
)
|
||||
from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static
|
||||
|
||||
from ..core.import_export import ExportFormat, ImportFormat, ImportResult
|
||||
|
||||
|
|
@ -49,13 +41,19 @@ class _FormatModal(ModalScreen):
|
|||
}
|
||||
.format-title { text-align: center; text-style: bold; color: $primary; }
|
||||
.format-copy { margin-top: 1; color: $text-muted; }
|
||||
.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; height: 3; margin: 0; }
|
||||
.format-section { margin-top: 1; padding: 0 1; border: round $primary; }
|
||||
.path-section {
|
||||
height: 3; margin-top: 1; padding: 0 1; border: round $primary;
|
||||
}
|
||||
#path-row { grid-size: 2; grid-columns: 1fr 14; height: 1; }
|
||||
#path-input { width: 1fr; height: 1; margin: 0; padding: 0; border: none; }
|
||||
#workflow-error { color: $error; height: auto; }
|
||||
#workflow-warning { color: $warning; height: auto; }
|
||||
.workflow-message.hidden { display: none; }
|
||||
.browse-button { width: 14; height: 1; margin: 0; border: none; }
|
||||
.format-section {
|
||||
height: 5; margin-top: 1; padding: 0 1; border: round $primary;
|
||||
}
|
||||
#format-select { height: 3; margin: 0; padding: 0; border: none; }
|
||||
.button-row { margin-top: 1; height: 3; align: center middle; }
|
||||
"""
|
||||
|
||||
|
|
@ -68,11 +66,16 @@ class _FormatModal(ModalScreen):
|
|||
def _path_or_error(self) -> Path | None:
|
||||
value = self.query_one("#path-input", Input).value.strip()
|
||||
if value:
|
||||
self.query_one("#workflow-error", Static).update("")
|
||||
self._show_message("#workflow-error", "")
|
||||
return Path(value).expanduser()
|
||||
self.query_one("#workflow-error", Static).update("Choose a file path first.")
|
||||
self._show_message("#workflow-error", "Choose a file path first.")
|
||||
return None
|
||||
|
||||
def _show_message(self, selector: str, message: str) -> None:
|
||||
widget = self.query_one(selector, Static)
|
||||
widget.update(message)
|
||||
widget.set_class(not message, "hidden")
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
|
|
@ -93,21 +96,31 @@ 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 Vertical(classes="path-section") as location:
|
||||
location.border_title = "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")
|
||||
yield Input(
|
||||
placeholder="/path/to/entries.hosts",
|
||||
id="path-input",
|
||||
compact=True,
|
||||
)
|
||||
yield Button(
|
||||
"Browse…",
|
||||
id="browse-button",
|
||||
classes="browse-button",
|
||||
compact=True,
|
||||
)
|
||||
yield Static("", id="workflow-error", classes="workflow-message hidden")
|
||||
yield Static("", id="workflow-warning", classes="workflow-message hidden")
|
||||
with Vertical(classes="format-section") as formats:
|
||||
formats.border_title = "File format"
|
||||
with RadioSet(id="format-select"):
|
||||
with RadioSet(id="format-select", compact=True):
|
||||
for index, format in enumerate(self._formats):
|
||||
yield RadioButton(
|
||||
_format_label(format),
|
||||
id=f"format-{format.value}",
|
||||
value=index == 0,
|
||||
compact=True,
|
||||
)
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Cancel", id="cancel-button")
|
||||
|
|
@ -135,13 +148,13 @@ class ExportModal(_FormatModal):
|
|||
request = ExportRequest(path, self._selected_format(ExportFormat))
|
||||
warnings = self._validate_path(path, request.format)
|
||||
self._request = request
|
||||
warning = self.query_one("#workflow-warning", Static)
|
||||
warning.update("\n".join(warnings))
|
||||
self._show_message("#workflow-warning", "\n".join(warnings))
|
||||
blocking_warnings = [item for item in warnings if "already exists" not in item]
|
||||
if blocking_warnings:
|
||||
self.query_one("#workflow-error", Static).update(
|
||||
self._show_message(
|
||||
"#workflow-error",
|
||||
"Correct the destination before exporting: "
|
||||
+ "; ".join(blocking_warnings)
|
||||
+ "; ".join(blocking_warnings),
|
||||
)
|
||||
return
|
||||
if warnings:
|
||||
|
|
@ -181,20 +194,30 @@ 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 Vertical(classes="path-section") as location:
|
||||
location.border_title = "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")
|
||||
yield Input(
|
||||
placeholder="/path/to/entries.hosts",
|
||||
id="path-input",
|
||||
compact=True,
|
||||
)
|
||||
yield Button(
|
||||
"Browse…",
|
||||
id="browse-button",
|
||||
classes="browse-button",
|
||||
compact=True,
|
||||
)
|
||||
yield Static("", id="workflow-error", classes="workflow-message hidden")
|
||||
with Vertical(classes="format-section") as formats:
|
||||
formats.border_title = "File format"
|
||||
with RadioSet(id="format-select"):
|
||||
with RadioSet(id="format-select", compact=True):
|
||||
for index, format in enumerate(self._formats):
|
||||
yield RadioButton(
|
||||
_format_label(format),
|
||||
id=f"format-{format.value}",
|
||||
value=index == 0,
|
||||
compact=True,
|
||||
)
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Cancel", id="cancel-button")
|
||||
|
|
@ -212,9 +235,7 @@ class ImportModal(_FormatModal):
|
|||
format = self._selected_format(ImportFormat)
|
||||
result = self._import_file(format, path)
|
||||
if not result.success:
|
||||
self.query_one("#workflow-error", Static).update(
|
||||
"; ".join(result.errors)
|
||||
)
|
||||
self._show_message("#workflow-error", "; ".join(result.errors))
|
||||
return
|
||||
self.dismiss(ImportRequest(path, format, result))
|
||||
elif event.button.id == "browse-button":
|
||||
|
|
@ -234,13 +255,28 @@ class ImportModal(_FormatModal):
|
|||
self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected)
|
||||
|
||||
|
||||
class ParentDirectoryTree(DirectoryTree):
|
||||
"""Directory tree with a conventional parent entry above its root."""
|
||||
|
||||
def _populate_node(self, node, content) -> None:
|
||||
super()._populate_node(node, content)
|
||||
if node is not self.root or node.data is None:
|
||||
return
|
||||
current = node.data.path.expanduser().resolve()
|
||||
parent = current.parent
|
||||
if parent != current:
|
||||
node.add(
|
||||
"..",
|
||||
data=type(node.data)(parent),
|
||||
allow_expand=False,
|
||||
before=0,
|
||||
)
|
||||
|
||||
|
||||
class FileBrowserModal(ModalScreen[Path | None]):
|
||||
"""A keyboard-accessible directory tree for file and folder selection."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("u", "go_up", "Up directory"),
|
||||
]
|
||||
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
||||
CSS = """
|
||||
FileBrowserModal { align: center middle; }
|
||||
#file-browser-container {
|
||||
|
|
@ -269,13 +305,12 @@ class FileBrowserModal(ModalScreen[Path | None]):
|
|||
classes="browser-title",
|
||||
)
|
||||
yield Static(
|
||||
"Use arrow keys to browse. Enter selects a file; expand folders with Right.",
|
||||
"Use arrow keys to browse. Select .. to go up; expand folders with Right.",
|
||||
classes="browser-copy",
|
||||
)
|
||||
yield Static(str(self._start_path), id="file-browser-status")
|
||||
yield DirectoryTree(self._start_path, id="file-browser-tree")
|
||||
yield ParentDirectoryTree(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(
|
||||
|
|
@ -300,33 +335,24 @@ class FileBrowserModal(ModalScreen[Path | None]):
|
|||
def on_directory_tree_directory_selected(
|
||||
self, event: DirectoryTree.DirectorySelected
|
||||
) -> None:
|
||||
if str(event.node.label) == "..":
|
||||
self._current_path = event.path
|
||||
self._selected_directory = None
|
||||
self.query_one("#file-browser-tree", DirectoryTree).path = event.path
|
||||
self.query_one("#file-browser-status", Static).update(str(event.path))
|
||||
return
|
||||
self._selected_directory = event.path
|
||||
self.query_one("#file-browser-status", Static).update(str(event.path))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "up-button":
|
||||
self.action_go_up()
|
||||
elif event.button.id == "cancel-button":
|
||||
if event.button.id == "cancel-button":
|
||||
self.action_cancel()
|
||||
elif event.button.id == "choose-directory-button":
|
||||
self.dismiss(self._selected_directory or self._start_path)
|
||||
self.dismiss(self._selected_directory or self._current_path)
|
||||
|
||||
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,8 @@
|
|||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from textual.widgets import Button, DirectoryTree, Input, RadioButton, Static
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static
|
||||
|
||||
from hosts.core.import_export import (
|
||||
ExportFormat,
|
||||
|
|
@ -58,6 +59,31 @@ async def test_export_modal_is_centered_in_the_terminal_viewport():
|
|||
assert container.region.y == (app.size.height - container.region.height) // 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_modal_uses_compact_consistent_form_sections():
|
||||
"""Location and format fields use matching borders without excess rows."""
|
||||
app = HostsManagerApp()
|
||||
app.load_hosts_file = Mock()
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.press("ctrl+x")
|
||||
await pilot.pause()
|
||||
|
||||
location = app.screen.query_one(".path-section", Vertical)
|
||||
formats = app.screen.query_one(".format-section", Vertical)
|
||||
path_input = app.screen.query_one("#path-input", Input)
|
||||
browse = app.screen.query_one("#browse-button", Button)
|
||||
format_select = app.screen.query_one("#format-select", RadioSet)
|
||||
container = app.screen.query_one("#format-container")
|
||||
|
||||
assert location.border_title == "Export file path"
|
||||
assert path_input.region.height == 1
|
||||
assert browse.region.height == 1
|
||||
assert format_select.region.height == 3
|
||||
assert formats.region.height == 5
|
||||
assert container.region.height <= 22
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_browser_lets_the_user_choose_a_destination_directory():
|
||||
"""Browse opens a centered directory tree rather than requiring a typed path."""
|
||||
|
|
@ -71,6 +97,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 path_input.region.height == 1
|
||||
assert browse.region.height == 1
|
||||
assert browse.region.x >= path_input.region.right
|
||||
assert browse.region.y == path_input.region.y
|
||||
assert browse.region.right <= container.region.right
|
||||
|
|
@ -85,7 +113,6 @@ 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")
|
||||
|
|
@ -98,8 +125,8 @@ 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."""
|
||||
async def test_file_browser_presents_parent_directory_as_dot_dot(tmp_path):
|
||||
"""The chooser exposes its parent as the first conventional tree entry."""
|
||||
child = tmp_path / "child"
|
||||
child.mkdir()
|
||||
app = HostsManagerApp()
|
||||
|
|
@ -110,11 +137,17 @@ async def test_file_browser_can_move_to_its_parent_directory(tmp_path):
|
|||
await pilot.pause()
|
||||
browser = app.screen
|
||||
assert isinstance(browser, FileBrowserModal)
|
||||
|
||||
browser.action_go_up()
|
||||
tree = browser.query_one("#file-browser-tree", DirectoryTree)
|
||||
await pilot.pause()
|
||||
|
||||
assert browser.query_one("#file-browser-tree", DirectoryTree).path == tmp_path
|
||||
assert str(tree.root.children[0].label) == ".."
|
||||
assert len(browser.query("#up-button")) == 0
|
||||
|
||||
tree.focus()
|
||||
tree.select_node(tree.root.children[0])
|
||||
await pilot.pause()
|
||||
|
||||
assert tree.path == tmp_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue