Feat #2: add Host Entry from selected
This commit is contained in:
parent
6ce1d7da2f
commit
f2e2d45480
9 changed files with 213 additions and 11 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -313,6 +313,11 @@ AddEntryModal {
|
|||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.entry-source {
|
||||
color: $text-muted;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
color: $error;
|
||||
margin: 0 2;
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue