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 | | `Ctrl+E` | Enter or leave Privileged Mode |
| `b` | Review and restore the session Pre-edit Backup | | `b` | Review and restore the session Pre-edit Backup |
| `n` | Add a Host Entry | | `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 | | `e` | Open the selected Host Entry in the Entry Editor |
| `d` | Delete the selected Host Entry | | `d` | Delete the selected Host Entry |
| `Space` | Activate or deactivate 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: In Privileged Mode:
- Press `n` to add a Host Entry. - 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. - 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 `d` and confirm to delete the selected Host Entry.
- Press `Space` to activate or deactivate the selected Host Entry. - Press `Space` to activate or deactivate the selected Host Entry.
@ -253,6 +254,7 @@ continually retrying it.
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| `n` | Add a Host Entry | | `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 | | `e` | Open the selected Host Entry in the Entry Editor |
| `d` | Delete the selected Host Entry | | `d` | Delete the selected Host Entry |
| `Space` | Activate or deactivate 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"), Binding("ctrl+s", "save", "Save"),
] ]
def __init__(self): def __init__(self, source_entry: HostEntry | None = None):
super().__init__() 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: def compose(self) -> ComposeResult:
"""Create the add entry modal layout.""" """Create the add entry modal layout."""
with VerticalScroll(classes="add-entry-container"): 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 # Entry Type Selection
with Vertical(classes="default-flex-section") as entry_type: with Vertical(classes="default-flex-section") as entry_type:
@ -112,7 +139,21 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
def on_mount(self) -> None: def on_mount(self) -> None:
"""Focus the entry type choice when the modal opens.""" """Focus the entry type choice when the modal opens."""
if not self._source_hostname:
self.query_one("#entry-type-radio", RadioSet).focus() 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: def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
"""Handle entry type radio button changes.""" """Handle entry type radio button changes."""
@ -128,8 +169,11 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
ip_section.remove_class("hidden") ip_section.remove_class("hidden")
dns_section.add_class("hidden") dns_section.add_class("hidden")
# Reset checkbox to default (active) for IP entries self._dns_draft = self.query_one("#dns-name-input", Input).value
active_checkbox.value = True 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): if isinstance(active_section, Vertical):
active_section.border_title = "Activate Entry" active_section.border_title = "Activate Entry"
@ -144,8 +188,13 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
ip_section.add_class("hidden") ip_section.add_class("hidden")
dns_section.remove_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.value = False
active_checkbox.disabled = True
if isinstance(active_section, Vertical): if isinstance(active_section, Vertical):
active_section.border_title = ( active_section.border_title = (
"Activate Entry (DNS entries activate after resolution)" "Activate Entry (DNS entries activate after resolution)"

View file

@ -905,13 +905,39 @@ class HostsManagerApp(App):
def action_add_entry(self) -> None: def action_add_entry(self) -> None:
"""Show the add entry modal.""" """Show the add entry modal."""
if not self._allow_mutation_action(): if not self._allow_add_entry_action():
return 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: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ 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: def handle_add_entry_result(new_entry) -> None:
if new_entry is None: if new_entry is None:
@ -946,7 +972,7 @@ class HostsManagerApp(App):
else: else:
self.update_status(f"{result.message}") 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: def action_delete_entry(self) -> None:
"""Show the delete confirmation modal for the selected entry.""" """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("Privileged Mode", classes="help-section")
yield Static( 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", classes="help-copy",
) )
yield Button("Close", id="help-close", variant="primary") 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 # Key bindings for the hosts manager application
HOSTS_MANAGER_BINDINGS = [ HOSTS_MANAGER_BINDINGS = [
Binding("n", "add_entry", "New entry", show=True, id="left:new_entry"), 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("d", "delete_entry", "Delete entry", show=True, id="left:delete_entry"),
Binding("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"), Binding("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"),
Binding( Binding(

View file

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

View file

@ -8,7 +8,7 @@ DNS name entries, validation, and mutual exclusion logic.
import pytest import pytest
from typing import cast from typing import cast
from unittest.mock import Mock 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.tui.add_entry_modal import AddEntryModal
from src.hosts.core.models import HostEntry from src.hosts.core.models import HostEntry
@ -267,6 +267,50 @@ class TestAddEntryModalRadioButtonLogic:
mock_hostname_input.focus.assert_called_once() 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: class TestAddEntryModalSaveLogic:
"""Test cases for save logic in AddEntryModal.""" """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.backup_restore_modal import BackupRestoreModal
from src.hosts.tui.filter_modal import FilterModal from src.hosts.tui.filter_modal import FilterModal
from src.hosts.tui.help_modal import HelpModal from src.hosts.tui.help_modal import HelpModal
from src.hosts.tui.add_entry_modal import AddEntryModal
from src.hosts.tui.privilege_prompt import ( from src.hosts.tui.privilege_prompt import (
render_sudo_authentication_notice, render_sudo_authentication_notice,
sudo_authentication_screen, 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 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 @pytest.mark.asyncio
async def test_help_overlay_closes_to_the_control_that_opened_it(): async def test_help_overlay_closes_to_the_control_that_opened_it():
"""Help is an overlay and returns keyboard focus to its opener.""" """Help is an overlay and returns keyboard focus to its opener."""