Feat #2: add Host Entry from selected

This commit is contained in:
Philip Henning 2026-09-06 16:43:48 +02:00
parent 6ce1d7da2f
commit f2e2d45480
9 changed files with 213 additions and 11 deletions

View file

@ -66,6 +66,7 @@ including persistence behavior and manual recovery.
| `Ctrl+E` | Enter or leave Privileged Mode |
| `b` | Review and restore the session Pre-edit Backup |
| `n` | Add a Host Entry |
| `Shift+N` | Add a Host Entry based on the selected Host Entry |
| `e` | Open the selected Host Entry in the Entry Editor |
| `d` | Delete the selected Host Entry |
| `Space` | Activate or deactivate the selected Host Entry |

View file

@ -63,6 +63,7 @@ separate from the Entry Editor used to change one Host Entry.
In Privileged Mode:
- Press `n` to add a Host Entry.
- Press `Shift+N` to add a Host Entry based on the selected Host Entry.
- Select a non-default Host Entry and press `e` to open the Entry Editor.
- Press `d` and confirm to delete the selected Host Entry.
- Press `Space` to activate or deactivate the selected Host Entry.
@ -253,6 +254,7 @@ continually retrying it.
| Key | Action |
| --- | --- |
| `n` | Add a Host Entry |
| `Shift+N` | Add a Host Entry based on the selected Host Entry |
| `e` | Open the selected Host Entry in the Entry Editor |
| `d` | Delete the selected Host Entry |
| `Space` | Activate or deactivate the selected Host Entry |

View file

@ -28,13 +28,40 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
Binding("ctrl+s", "save", "Save"),
]
def __init__(self):
def __init__(self, source_entry: HostEntry | None = None):
super().__init__()
self._source_hostname = (
source_entry.hostnames[0]
if source_entry is not None and source_entry.hostnames
else None
)
self._hostnames_draft = (
", ".join(source_entry.hostnames) if source_entry is not None else ""
)
self._comment_draft = source_entry.comment or "" if source_entry else ""
self._is_dns_draft = bool(source_entry and source_entry.has_dns_name())
self._ip_draft = (
""
if self._is_dns_draft
else (source_entry.ip_address if source_entry else "")
)
self._dns_draft = (
source_entry.dns_name or "" if self._is_dns_draft and source_entry else ""
)
self._active_draft = (
source_entry.is_active if source_entry and not self._is_dns_draft else True
)
def compose(self) -> ComposeResult:
"""Create the add entry modal layout."""
with VerticalScroll(classes="add-entry-container"):
yield Static("Add New Host Entry", classes="add-entry-title")
yield Static("Add Host Entry", classes="add-entry-title")
if self._source_hostname:
yield Static(
f"Based on: {self._source_hostname}",
id="entry-source",
classes="entry-source",
)
# Entry Type Selection
with Vertical(classes="default-flex-section") as entry_type:
@ -112,7 +139,21 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
def on_mount(self) -> None:
"""Focus the entry type choice when the modal opens."""
self.query_one("#entry-type-radio", RadioSet).focus()
if not self._source_hostname:
self.query_one("#entry-type-radio", RadioSet).focus()
return
self.query_one("#hostnames-input", Input).value = self._hostnames_draft
self.query_one("#ip-address-input", Input).value = self._ip_draft
self.query_one("#dns-name-input", Input).value = self._dns_draft
self.query_one("#comment-input", Input).value = self._comment_draft
self.query_one("#active-checkbox", Checkbox).value = self._active_draft
if self._is_dns_draft:
self.query_one("#dns-entry-radio", RadioButton).value = True
hostnames_input = self.query_one("#hostnames-input", Input)
hostnames_input.focus()
hostnames_input.cursor_position = len(hostnames_input.value)
def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
"""Handle entry type radio button changes."""
@ -128,8 +169,11 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
ip_section.remove_class("hidden")
dns_section.add_class("hidden")
# Reset checkbox to default (active) for IP entries
active_checkbox.value = True
self._dns_draft = self.query_one("#dns-name-input", Input).value
ip_input = self.query_one("#ip-address-input", Input)
ip_input.value = self._ip_draft
active_checkbox.value = self._active_draft
active_checkbox.disabled = False
if isinstance(active_section, Vertical):
active_section.border_title = "Activate Entry"
@ -144,8 +188,13 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
ip_section.add_class("hidden")
dns_section.remove_class("hidden")
# Set checkbox to inactive for DNS entries (will be activated after resolution)
self._ip_draft = self.query_one("#ip-address-input", Input).value
self._active_draft = active_checkbox.value
self.query_one("#dns-name-input", Input).value = self._dns_draft
# DNS entries stay inactive until resolution.
active_checkbox.value = False
active_checkbox.disabled = True
if isinstance(active_section, Vertical):
active_section.border_title = (
"Activate Entry (DNS entries activate after resolution)"

View file

@ -905,13 +905,39 @@ class HostsManagerApp(App):
def action_add_entry(self) -> None:
"""Show the add entry modal."""
if not self._allow_mutation_action():
if not self._allow_add_entry_action():
return
self._open_add_entry_modal()
def action_new_from_selected(self) -> None:
"""Show the add form prefilled from the highlighted visible Host Entry."""
if not self._allow_add_entry_action():
return
table = self.query_one("#entries-table", DataTable)
visible_entries = self.get_visible_entries()
if not visible_entries or not 0 <= table.cursor_row < len(visible_entries):
self.update_status(
"No Host Entry selected; use n to add a blank Host Entry."
)
return
self._open_add_entry_modal(visible_entries[table.cursor_row])
def _allow_add_entry_action(self) -> bool:
"""Apply the shared mutation gates for both Add Host Entry actions."""
if not self._allow_mutation_action():
return False
if not self.edit_mode:
self.update_status(
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
return
return False
return True
def _open_add_entry_modal(self, source_entry: HostEntry | None = None) -> None:
"""Open the ordinary Add pipeline, optionally seeded from a Host Entry."""
def handle_add_entry_result(new_entry) -> None:
if new_entry is None:
@ -946,7 +972,7 @@ class HostsManagerApp(App):
else:
self.update_status(f"{result.message}")
self.push_screen(AddEntryModal(), handle_add_entry_result)
self.push_screen(AddEntryModal(source_entry), handle_add_entry_result)
def action_delete_entry(self) -> None:
"""Show the delete confirmation modal for the selected entry."""

View file

@ -50,7 +50,8 @@ class HelpModal(ModalScreen[None]):
)
yield Static("Privileged Mode", classes="help-section")
yield Static(
"Ctrl+E Enter or leave Privileged Mode b Review Pre-edit Backup",
"Ctrl+E Enter or leave Privileged Mode n Add Host Entry\n"
"Shift+N New from selected b Review Pre-edit Backup",
classes="help-copy",
)
yield Button("Close", id="help-close", variant="primary")

View file

@ -10,6 +10,7 @@ from textual.binding import Binding
# Key bindings for the hosts manager application
HOSTS_MANAGER_BINDINGS = [
Binding("n", "add_entry", "New entry", show=True, id="left:new_entry"),
Binding("shift+n", "new_from_selected", "New from selected", show=False),
Binding("d", "delete_entry", "Delete entry", show=True, id="left:delete_entry"),
Binding("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"),
Binding(

View file

@ -313,6 +313,11 @@ AddEntryModal {
margin-bottom: 1;
}
.entry-source {
color: $text-muted;
margin-bottom: 1;
}
.validation-error {
color: $error;
margin: 0 2;

View file

@ -8,7 +8,7 @@ DNS name entries, validation, and mutual exclusion logic.
import pytest
from typing import cast
from unittest.mock import Mock
from textual.widgets import Input, Checkbox, RadioSet, Static
from textual.widgets import Input, Checkbox, RadioSet, RadioButton, Static
from src.hosts.tui.add_entry_modal import AddEntryModal
from src.hosts.core.models import HostEntry
@ -267,6 +267,50 @@ class TestAddEntryModalRadioButtonLogic:
mock_hostname_input.focus.assert_called_once()
@pytest.mark.asyncio
async def test_prefilled_dns_modal_keeps_separate_address_drafts_when_switching_type():
"""A copied DNS Entry retains both address drafts while its type is changed."""
from src.hosts.tui.app import HostsManagerApp
source = HostEntry(
ip_address="192.0.2.45",
hostnames=["source.test", "alias.test"],
comment="Copied",
is_active=True,
dns_name="origin.example",
resolved_ip="192.0.2.45",
dns_resolution_status="resolved",
)
app = HostsManagerApp()
app.hosts_file.entries = [source]
app.load_hosts_file = Mock()
async with app.run_test() as pilot:
app.push_screen(AddEntryModal(source))
await pilot.pause()
modal = app.screen
assert modal.query_one("#dns-entry-radio", RadioButton).value
assert modal.query_one("#dns-name-input", Input).value == "origin.example"
assert modal.query_one("#ip-address-input", Input).value == ""
assert not modal.query_one("#active-checkbox", Checkbox).value
assert modal.query_one("#active-checkbox", Checkbox).disabled
await pilot.click("#ip-entry-radio")
await pilot.pause()
assert modal.query_one("#ip-address-input", Input).value == ""
assert modal.query_one("#active-checkbox", Checkbox).value
assert not modal.query_one("#active-checkbox", Checkbox).disabled
modal.query_one("#ip-address-input", Input).value = "198.51.100.8"
await pilot.click("#dns-entry-radio")
await pilot.pause()
assert modal.query_one("#dns-name-input", Input).value == "origin.example"
await pilot.click("#ip-entry-radio")
await pilot.pause()
assert modal.query_one("#ip-address-input", Input).value == "198.51.100.8"
class TestAddEntryModalSaveLogic:
"""Test cases for save logic in AddEntryModal."""

View file

@ -19,6 +19,7 @@ from src.hosts.tui.custom_footer import CustomFooter
from src.hosts.tui.backup_restore_modal import BackupRestoreModal
from src.hosts.tui.filter_modal import FilterModal
from src.hosts.tui.help_modal import HelpModal
from src.hosts.tui.add_entry_modal import AddEntryModal
from src.hosts.tui.privilege_prompt import (
render_sudo_authentication_notice,
sudo_authentication_screen,
@ -97,6 +98,78 @@ async def test_filter_shortcut_does_not_stack_filter_modals():
assert sum(isinstance(screen, FilterModal) for screen in app.screen_stack) == 1
@pytest.mark.asyncio
async def test_new_from_selected_prefills_the_highlighted_visible_entry():
"""Shift+N snapshots the highlighted visible Host Entry into the Add form."""
app = app_with_filterable_entries()
source = app.hosts_file.entries[1]
source.hostnames = ["inactive.test", "alias.test"]
source.comment = "Copied comment"
source.is_active = False
app.edit_mode = True
app.sort_column = "hostname"
async with app.run_test(size=(120, 40)) as pilot:
app.table_handler.populate_entries_table()
app.table_handler.move_cursor_to_entry_index(1)
await pilot.pause()
await pilot.press("shift+n")
await pilot.pause()
assert isinstance(app.screen, AddEntryModal)
assert app.screen.query_one("#hostnames-input", Input).value == (
"inactive.test, alias.test"
)
assert app.screen.query_one("#ip-address-input", Input).value == "192.0.2.2"
assert app.screen.query_one("#comment-input", Input).value == "Copied comment"
assert not app.screen.query_one("#active-checkbox").value
assert "Based on: inactive.test" in str(
app.screen.query_one("#entry-source", Static).render()
)
assert app.focused is app.screen.query_one("#hostnames-input", Input)
assert source.hostnames == ["inactive.test", "alias.test"]
assert source.comment == "Copied comment"
assert not source.is_active
@pytest.mark.asyncio
async def test_new_from_selected_requires_a_visible_host_entry():
"""Shift+N does not reuse a stale selection when filters hide every entry."""
app = app_with_filterable_entries()
app.edit_mode = True
app.current_filter_options.search_term = "not-a-match"
async with app.run_test(size=(120, 40)) as pilot:
app.table_handler.populate_entries_table()
await pilot.press("shift+n")
await pilot.pause()
assert not isinstance(app.screen, AddEntryModal)
assert "No Host Entry selected; use n to add a blank Host Entry." in str(
app.query_one("#message-rail", Static).render()
)
@pytest.mark.asyncio
async def test_new_from_selected_is_available_at_the_minimum_viewport():
"""Shift+N keeps the selected-entry add workflow reachable at 100×30."""
app = app_with_filterable_entries()
app.edit_mode = True
async with app.run_test(size=(100, 30)) as pilot:
app.table_handler.populate_entries_table()
await pilot.press("shift+n")
await pilot.pause()
assert isinstance(app.screen, AddEntryModal)
assert app.focused is app.screen.query_one("#hostnames-input", Input)
await pilot.press("escape")
await pilot.pause()
assert not isinstance(app.screen, AddEntryModal)
@pytest.mark.asyncio
async def test_help_overlay_closes_to_the_control_that_opened_it():
"""Help is an overlay and returns keyboard focus to its opener."""