diff --git a/README.md b/README.md index 0dba2f3..1a043e7 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/user-guide.md b/docs/user-guide.md index 8352228..c490c63 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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 | diff --git a/src/hosts/tui/add_entry_modal.py b/src/hosts/tui/add_entry_modal.py index 467c14f..373e0a7 100644 --- a/src/hosts/tui/add_entry_modal.py +++ b/src/hosts/tui/add_entry_modal.py @@ -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)" diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 237fb2e..1708bec 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -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.""" diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py index 13794f4..132de5c 100644 --- a/src/hosts/tui/help_modal.py +++ b/src/hosts/tui/help_modal.py @@ -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") diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 4281a68..2596b68 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -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( diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index d927a46..ed6a293 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -313,6 +313,11 @@ AddEntryModal { margin-bottom: 1; } +.entry-source { + color: $text-muted; + margin-bottom: 1; +} + .validation-error { color: $error; margin: 0 2; diff --git a/tests/test_add_entry_modal.py b/tests/test_add_entry_modal.py index 313a0e1..9b7aa67 100644 --- a/tests/test_add_entry_modal.py +++ b/tests/test_add_entry_modal.py @@ -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.""" diff --git a/tests/test_app.py b/tests/test_app.py index 3830615..4050baa 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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."""