feat #14: Implement design guide #15
14 changed files with 741 additions and 442 deletions
|
|
@ -99,15 +99,15 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
|||
# Buttons
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Add Entry (CTRL+S)",
|
||||
variant="primary",
|
||||
id="add-button",
|
||||
"Cancel",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Cancel (ESC)",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
"Add Host Entry",
|
||||
variant="primary",
|
||||
id="add-button",
|
||||
classes="default-button",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ from .config_modal import ConfigModal
|
|||
from .add_entry_modal import AddEntryModal
|
||||
from .delete_confirmation_modal import DeleteConfirmationModal
|
||||
from .filter_modal import FilterModal
|
||||
from .help_modal import HelpModal
|
||||
from .custom_footer import CustomFooter
|
||||
from .styles import HOSTS_MANAGER_CSS
|
||||
from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS
|
||||
from .keybindings import HOSTS_MANAGER_BINDINGS
|
||||
from .table_handler import TableHandler
|
||||
from .details_handler import DetailsHandler
|
||||
|
|
@ -60,8 +61,6 @@ class HostsManagerApp(App):
|
|||
CSS = HOSTS_MANAGER_CSS
|
||||
BINDINGS = HOSTS_MANAGER_BINDINGS
|
||||
|
||||
help_visible = False
|
||||
|
||||
# Reactive attributes
|
||||
hosts_file: reactive[HostsFile] = reactive(HostsFile())
|
||||
selected_entry_index: reactive[int] = reactive(0)
|
||||
|
|
@ -74,6 +73,8 @@ class HostsManagerApp(App):
|
|||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title = "/etc/hosts Manager"
|
||||
self.register_theme(HOSTS_DARK_THEME)
|
||||
self.theme = "hosts-dark"
|
||||
|
||||
# Initialize core components
|
||||
self.parser = HostsParser()
|
||||
|
|
@ -99,166 +100,212 @@ class HostsManagerApp(App):
|
|||
|
||||
# State for edit mode
|
||||
self.original_entry_values = None
|
||||
self._status_timer = None
|
||||
self._viewport_too_small = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield CustomFooter(id="custom-footer")
|
||||
yield Static("", id="message-rail")
|
||||
yield Static("", id="minimum-size", classes="hidden")
|
||||
|
||||
# Search bar above the panes
|
||||
with Horizontal(classes="search-container") as search_container:
|
||||
search_container.border_title = "Search"
|
||||
yield Input(
|
||||
placeholder="Filter by hostname, IP address, or comment...",
|
||||
id="search-input",
|
||||
classes="search-input",
|
||||
)
|
||||
with Vertical(id="workspace"):
|
||||
# Search remains visible above the master-detail workspace.
|
||||
with Horizontal(classes="search-container") as search_container:
|
||||
search_container.border_title = "Search"
|
||||
yield Input(
|
||||
placeholder="Filter by hostname, IP address, or comment...",
|
||||
id="search-input",
|
||||
classes="search-input",
|
||||
)
|
||||
yield Static("", id="filter-summary")
|
||||
|
||||
with Horizontal(classes="hosts-container"):
|
||||
# Left pane - entries table
|
||||
with Vertical(classes="common-pane left-pane") as left_pane:
|
||||
left_pane.border_title = "Host Entries"
|
||||
yield DataTable(id="entries-table")
|
||||
with Horizontal(classes="hosts-container"):
|
||||
# Left pane - entries table
|
||||
with Vertical(classes="common-pane left-pane") as left_pane:
|
||||
left_pane.border_title = "Host Entries"
|
||||
yield DataTable(id="entries-table")
|
||||
|
||||
# Right pane - entry details or edit form
|
||||
with Vertical(classes="common-pane right-pane") as right_pane:
|
||||
right_pane.border_title = "Entry Details"
|
||||
# Right pane - entry details or edit form
|
||||
with Vertical(classes="common-pane right-pane") as right_pane:
|
||||
right_pane.border_title = "Entry Details"
|
||||
|
||||
# Details display form (disabled inputs)
|
||||
with Vertical(id="entry-details-display", classes="entry-form"):
|
||||
with Vertical(
|
||||
classes="default-section section-no-top-margin"
|
||||
) as ip_address:
|
||||
ip_address.border_title = "IP Address"
|
||||
yield Input(
|
||||
placeholder="No entry selected",
|
||||
id="details-ip-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
# Inspection values deliberately use text, not disabled inputs.
|
||||
with Vertical(id="entry-details-display", classes="entry-form"):
|
||||
yield Static(
|
||||
"No Host Entry selected.",
|
||||
id="details-empty-state",
|
||||
classes="empty-state",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as hostnames:
|
||||
hostnames.border_title = "Hostnames (comma-separated)"
|
||||
yield Input(
|
||||
placeholder="No entry selected",
|
||||
id="details-hostname-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as dns_name:
|
||||
dns_name.border_title = "DNS Name"
|
||||
yield Input(
|
||||
placeholder="No DNS name",
|
||||
id="details-dns-name-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as dns_status:
|
||||
dns_status.border_title = "DNS Status"
|
||||
yield Input(
|
||||
placeholder="No DNS status",
|
||||
id="details-dns-status-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as dns_resolved:
|
||||
dns_resolved.border_title = "Last Resolved"
|
||||
yield Input(
|
||||
placeholder="Not resolved yet",
|
||||
id="details-dns-resolved-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as comment:
|
||||
comment.border_title = "Comment:"
|
||||
yield Input(
|
||||
placeholder="No entry selected",
|
||||
id="details-comment-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as active:
|
||||
active.border_title = "Active"
|
||||
yield Checkbox(
|
||||
"Active",
|
||||
id="details-active-checkbox",
|
||||
disabled=True,
|
||||
classes="default-checkbox",
|
||||
)
|
||||
|
||||
# Edit form (initially hidden)
|
||||
with Vertical(id="entry-edit-form", classes="entry-form hidden"):
|
||||
# Entry Type Selection
|
||||
with Vertical(
|
||||
classes="default-flex-section section-no-top-margin"
|
||||
) as entry_type:
|
||||
entry_type.border_title = "Entry Type"
|
||||
with RadioSet(
|
||||
id="edit-entry-type-radio", classes="default-radio-set"
|
||||
with Vertical(
|
||||
id="details-content", classes="detail-rows hidden"
|
||||
):
|
||||
yield RadioButton(
|
||||
"IP Address Entry", value=True, id="edit-ip-entry-radio"
|
||||
)
|
||||
yield RadioButton(
|
||||
"DNS Name Entry", id="edit-dns-entry-radio"
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("IP address", classes="detail-label")
|
||||
yield Static(
|
||||
"", id="details-ip-input", classes="detail-value"
|
||||
)
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("Hostnames", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-hostname-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("State", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-active-checkbox",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Vertical(id="details-dns-rows", classes="hidden"):
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("DNS name", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-dns-name-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("DNS status", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-dns-status-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static(
|
||||
"Last resolved", classes="detail-label"
|
||||
)
|
||||
yield Static(
|
||||
"",
|
||||
id="details-dns-resolved-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("Comment", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-comment-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
yield Static(
|
||||
"",
|
||||
id="details-default-notice",
|
||||
classes="detail-note hidden",
|
||||
)
|
||||
|
||||
# IP Address Section
|
||||
with Vertical(
|
||||
classes="default-section", id="edit-ip-section"
|
||||
) as ip_address:
|
||||
ip_address.border_title = "IP Address"
|
||||
yield Input(
|
||||
placeholder="Enter IP address",
|
||||
id="ip-input",
|
||||
classes="default-input",
|
||||
)
|
||||
# Edit form (initially hidden)
|
||||
with Vertical(id="entry-edit-form", classes="entry-form hidden"):
|
||||
with Vertical(
|
||||
classes="default-flex-section section-no-top-margin"
|
||||
) as entry_type:
|
||||
entry_type.border_title = "Entry Type"
|
||||
with RadioSet(
|
||||
id="edit-entry-type-radio", classes="default-radio-set"
|
||||
):
|
||||
yield RadioButton(
|
||||
"IP Address Entry",
|
||||
value=True,
|
||||
id="edit-ip-entry-radio",
|
||||
)
|
||||
yield RadioButton(
|
||||
"DNS Name Entry", id="edit-dns-entry-radio"
|
||||
)
|
||||
|
||||
# DNS Name Section (initially hidden)
|
||||
with Vertical(
|
||||
classes="default-section hidden", id="edit-dns-section"
|
||||
) as dns_name:
|
||||
dns_name.border_title = "DNS Name (to resolve)"
|
||||
yield Input(
|
||||
placeholder="e.g., example.com",
|
||||
id="dns-name-input",
|
||||
classes="default-input",
|
||||
)
|
||||
with Vertical(
|
||||
classes="default-section", id="edit-ip-section"
|
||||
) as ip_address:
|
||||
ip_address.border_title = "IP Address"
|
||||
yield Input(
|
||||
placeholder="Enter IP address",
|
||||
id="ip-input",
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as hostnames:
|
||||
hostnames.border_title = "Hostnames (comma-separated)"
|
||||
yield Input(
|
||||
placeholder="Enter hostnames",
|
||||
id="hostname-input",
|
||||
classes="default-input",
|
||||
)
|
||||
with Vertical(
|
||||
classes="default-section hidden", id="edit-dns-section"
|
||||
) as dns_name:
|
||||
dns_name.border_title = "DNS Name (to resolve)"
|
||||
yield Input(
|
||||
placeholder="e.g., example.com",
|
||||
id="dns-name-input",
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as comment:
|
||||
comment.border_title = "Comment:"
|
||||
yield Input(
|
||||
placeholder="Enter comment (optional)",
|
||||
id="comment-input",
|
||||
classes="default-input",
|
||||
)
|
||||
with Vertical(classes="default-section") as hostnames:
|
||||
hostnames.border_title = "Hostnames (comma-separated)"
|
||||
yield Input(
|
||||
placeholder="Enter hostnames",
|
||||
id="hostname-input",
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as active:
|
||||
active.border_title = "Active"
|
||||
yield Checkbox(
|
||||
"Active", id="active-checkbox", classes="default-checkbox"
|
||||
)
|
||||
with Vertical(classes="default-section") as comment:
|
||||
comment.border_title = "Comment:"
|
||||
yield Input(
|
||||
placeholder="Enter comment (optional)",
|
||||
id="comment-input",
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
# Status bar for error/temporary messages (overlay, doesn't affect layout)
|
||||
yield Static("", id="status-bar", classes="status-bar hidden")
|
||||
with Vertical(classes="default-section") as active:
|
||||
active.border_title = "Active"
|
||||
yield Checkbox(
|
||||
"Active",
|
||||
id="active-checkbox",
|
||||
classes="default-checkbox",
|
||||
)
|
||||
|
||||
def on_ready(self) -> None:
|
||||
"""Called when the app is ready."""
|
||||
self.load_hosts_file()
|
||||
self._setup_footer()
|
||||
self._update_viewport()
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Switch to a safe, explicit presentation below the supported viewport."""
|
||||
self._update_viewport()
|
||||
|
||||
def _update_viewport(self) -> None:
|
||||
try:
|
||||
too_small = self.size.width < 100 or self.size.height < 30
|
||||
self._viewport_too_small = too_small
|
||||
workspace = self.query_one("#workspace")
|
||||
minimum_size = self.query_one("#minimum-size", Static)
|
||||
footer = self.query_one("#custom-footer", CustomFooter)
|
||||
if too_small:
|
||||
workspace.add_class("hidden")
|
||||
minimum_size.update(
|
||||
"Terminal too small\n\nhosts requires at least 100 columns × 30 rows\n"
|
||||
f"Current size: {self.size.width} × {self.size.height}\n\nq Quit"
|
||||
)
|
||||
minimum_size.remove_class("hidden")
|
||||
minimum_size.add_class("visible")
|
||||
footer.add_class("hidden")
|
||||
else:
|
||||
workspace.remove_class("hidden")
|
||||
minimum_size.add_class("hidden")
|
||||
minimum_size.remove_class("visible")
|
||||
footer.remove_class("hidden")
|
||||
self.set_class(
|
||||
self.size.width < 120 or self.size.height < 40, "compact-layout"
|
||||
)
|
||||
self._setup_footer()
|
||||
except Exception:
|
||||
# The widgets are not mounted during early application startup.
|
||||
pass
|
||||
|
||||
def _allow_mutation_action(self) -> bool:
|
||||
"""Keep every mutation unreachable while the workspace is replaced."""
|
||||
if not self._viewport_too_small:
|
||||
return True
|
||||
self.update_status(
|
||||
"Cannot mutate while the terminal is too small. Resize to at least 100 columns × 30 rows."
|
||||
)
|
||||
return False
|
||||
|
||||
def load_hosts_file(self) -> None:
|
||||
"""Load the hosts file and populate the table."""
|
||||
|
|
@ -276,7 +323,7 @@ class HostsManagerApp(App):
|
|||
self.table_handler.restore_cursor_position(previous_entry)
|
||||
self.update_status()
|
||||
except Exception as e:
|
||||
self.update_status(f"❌ Error loading hosts file: {e}")
|
||||
self.update_status(f"Error loading hosts file: {e}")
|
||||
|
||||
def _setup_footer(self) -> None:
|
||||
"""Setup the footer with initial content based on keybindings."""
|
||||
|
|
@ -287,19 +334,45 @@ class HostsManagerApp(App):
|
|||
footer.clear_left_items()
|
||||
footer.clear_right_items()
|
||||
|
||||
# Process keybindings and add to appropriate sections
|
||||
# The footer is a contextual reminder, not a complete binding catalogue.
|
||||
# The dedicated help overlay remains the reachable source of all keys.
|
||||
visible_actions = {
|
||||
"new_entry",
|
||||
"edit_entry",
|
||||
"toggle_edit_mode",
|
||||
"show_filters",
|
||||
"help",
|
||||
"quit",
|
||||
}
|
||||
if self.size.width < 120 or self.size.height < 40:
|
||||
visible_actions = {
|
||||
"toggle_edit_mode",
|
||||
"show_filters",
|
||||
"help",
|
||||
"quit",
|
||||
}
|
||||
|
||||
for binding in self.BINDINGS:
|
||||
# Skip tuple-style bindings and only process Binding objects
|
||||
if not isinstance(binding, Binding):
|
||||
continue
|
||||
|
||||
# Only show bindings marked with show=True
|
||||
if binding.show:
|
||||
if binding.show and binding.action in visible_actions:
|
||||
# Get the display key
|
||||
key_display = getattr(binding, "key_display", None) or binding.key
|
||||
|
||||
# Get the description
|
||||
description = binding.description or binding.action
|
||||
descriptions = {
|
||||
"new_entry": "New",
|
||||
"edit_entry": "Edit",
|
||||
"toggle_edit_mode": "Mode",
|
||||
"show_filters": "Filters",
|
||||
"help": "Help",
|
||||
"quit": "Quit",
|
||||
}
|
||||
description = descriptions.get(
|
||||
binding.action, binding.description or binding.action
|
||||
)
|
||||
|
||||
# Determine positioning from id attribute
|
||||
binding_id = getattr(binding, "id", None)
|
||||
|
|
@ -320,44 +393,98 @@ class HostsManagerApp(App):
|
|||
"""Update the footer status section."""
|
||||
try:
|
||||
footer = self.query_one("#custom-footer", CustomFooter)
|
||||
mode = "Edit" if self.edit_mode else "Read-only"
|
||||
mode = "PRIVILEGED" if self.edit_mode else "READ-ONLY"
|
||||
entry_count = len(self.hosts_file.entries)
|
||||
active_count = len(self.hosts_file.get_active_entries())
|
||||
|
||||
status = f"{entry_count} entries ({active_count} active) | {mode}"
|
||||
filter_count = self._active_filter_count()
|
||||
status = (
|
||||
f"{entry_count} entries ({active_count} active) · "
|
||||
f"Filters: {filter_count} · {mode}"
|
||||
)
|
||||
footer.set_status(status)
|
||||
self.query_one("#filter-summary", Static).update(
|
||||
f"Filters: {filter_count}" if filter_count else ""
|
||||
)
|
||||
except Exception:
|
||||
pass # Footer not ready yet
|
||||
|
||||
def _active_filter_count(self) -> int:
|
||||
"""Count the active filter groups for the durable workspace summary."""
|
||||
options = self.current_filter_options
|
||||
return sum(
|
||||
(
|
||||
bool(options.search_term),
|
||||
options.active_only
|
||||
or options.inactive_only
|
||||
or not (options.show_active and options.show_inactive),
|
||||
options.dns_only
|
||||
or options.ip_only
|
||||
or not (options.show_dns_entries and options.show_ip_entries),
|
||||
options.mismatch_only
|
||||
or options.resolved_only
|
||||
or not all(
|
||||
(
|
||||
options.show_resolved,
|
||||
options.show_unresolved,
|
||||
options.show_resolving,
|
||||
options.show_failed,
|
||||
options.show_mismatched,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def update_status(self, message: str = "") -> None:
|
||||
"""Update the header subtitle and status bar with status information."""
|
||||
"""Update the reserved message rail and durable footer state."""
|
||||
if message:
|
||||
# Show temporary message in the status bar
|
||||
try:
|
||||
status_bar = self.query_one("#status-bar", Static)
|
||||
status_bar.update(message)
|
||||
status_bar.remove_class("hidden")
|
||||
|
||||
if message.startswith("❌"):
|
||||
# Auto-clear error message after 5 seconds
|
||||
self.set_timer(5.0, lambda: self._clear_status_message())
|
||||
if self._status_timer is not None:
|
||||
self._status_timer.stop()
|
||||
self._status_timer = None
|
||||
rail = self.query_one("#message-rail", Static)
|
||||
normalized = (
|
||||
message.lstrip("✓×!· ")
|
||||
.replace("❌", "×")
|
||||
.replace("✅", "✓")
|
||||
.replace("🔄", "!")
|
||||
.replace("⚠️", "!")
|
||||
)
|
||||
normalized = normalized.replace("Edit mode", "Privileged Mode").replace(
|
||||
"edit mode", "Privileged Mode"
|
||||
)
|
||||
rail.update(normalized)
|
||||
rail.remove_class("message-error")
|
||||
rail.remove_class("message-warning")
|
||||
persistent = any(
|
||||
word in normalized.lower()
|
||||
for word in (
|
||||
"error",
|
||||
"failed",
|
||||
"cannot",
|
||||
"not granted",
|
||||
"read-only",
|
||||
)
|
||||
)
|
||||
if persistent:
|
||||
rail.add_class("message-error")
|
||||
elif normalized.startswith("!"):
|
||||
rail.add_class("message-warning")
|
||||
else:
|
||||
# Auto-clear regular message after 3 seconds
|
||||
self.set_timer(3.0, lambda: self._clear_status_message())
|
||||
self._status_timer = self.set_timer(
|
||||
3.0, lambda: self._clear_status_message()
|
||||
)
|
||||
except Exception:
|
||||
# Fallback if status bar not found (during initialization)
|
||||
pass
|
||||
|
||||
# Always update the header subtitle with current status
|
||||
# Update the footer status
|
||||
self._update_footer_status()
|
||||
|
||||
def _clear_status_message(self) -> None:
|
||||
"""Clear the temporary status message."""
|
||||
try:
|
||||
status_bar = self.query_one("#status-bar", Static)
|
||||
status_bar.update("")
|
||||
status_bar.add_class("hidden")
|
||||
rail = self.query_one("#message-rail", Static)
|
||||
rail.update("")
|
||||
rail.remove_class("message-error")
|
||||
rail.remove_class("message-warning")
|
||||
self._status_timer = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -370,6 +497,13 @@ class HostsManagerApp(App):
|
|||
|
||||
def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool:
|
||||
"""Persist a mutation, restoring its complete pre-action state on failure."""
|
||||
if not self._allow_mutation_action():
|
||||
self.hosts_file = snapshot.manager_state.hosts_file
|
||||
self.manager.undo_redo_history = snapshot.manager_state.undo_redo_history
|
||||
self.selected_entry_index = snapshot.selected_entry_index
|
||||
self.table_handler.populate_entries_table()
|
||||
self.details_handler.update_entry_details()
|
||||
return False
|
||||
save_success, save_message, hosts_file = self.manager.save_mutation(
|
||||
self.hosts_file, snapshot.manager_state
|
||||
)
|
||||
|
|
@ -496,13 +630,9 @@ class HostsManagerApp(App):
|
|||
self.update_status("Hosts file reloaded")
|
||||
|
||||
def action_help(self) -> None:
|
||||
"""Show help panel."""
|
||||
if self.help_visible:
|
||||
self.action_hide_help_panel()
|
||||
self.help_visible = False
|
||||
else:
|
||||
self.action_show_help_panel()
|
||||
self.help_visible = True
|
||||
"""Open the keyboard reference without reducing workspace width."""
|
||||
if not self.screen_stack or not isinstance(self.screen, HelpModal):
|
||||
self.push_screen(HelpModal())
|
||||
|
||||
def action_config(self) -> None:
|
||||
"""Show configuration modal."""
|
||||
|
|
@ -566,6 +696,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_toggle_edit_mode(self) -> None:
|
||||
"""Toggle between read-only and edit mode."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if self.edit_mode:
|
||||
# Exit edit mode
|
||||
success, message = self.manager.exit_edit_mode()
|
||||
|
|
@ -628,6 +760,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_edit_entry(self) -> None:
|
||||
"""Enter edit mode for the selected entry."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -667,6 +801,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_exit_edit_entry(self) -> None:
|
||||
"""Exit entry edit mode and return focus to the entries table."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.edit_handler.exit_edit_entry_with_confirmation()
|
||||
|
||||
def action_next_field(self) -> None:
|
||||
|
|
@ -679,22 +815,32 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_toggle_entry(self) -> None:
|
||||
"""Toggle the active state of the selected entry."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.navigation_handler.toggle_entry()
|
||||
|
||||
def action_move_entry_up(self) -> None:
|
||||
"""Move the selected entry up in the list."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.navigation_handler.move_entry_up()
|
||||
|
||||
def action_move_entry_down(self) -> None:
|
||||
"""Move the selected entry down in the list."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.navigation_handler.move_entry_down()
|
||||
|
||||
def action_save_file(self) -> None:
|
||||
"""Save the hosts file to disk."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.navigation_handler.save_hosts_file()
|
||||
|
||||
def action_add_entry(self) -> None:
|
||||
"""Show the add entry modal."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -738,6 +884,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_delete_entry(self) -> None:
|
||||
"""Show the delete confirmation modal for the selected entry."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -794,6 +942,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_undo(self) -> None:
|
||||
"""Undo the last operation."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status("❌ Cannot undo: Application is in read-only mode")
|
||||
return
|
||||
|
|
@ -821,6 +971,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_redo(self) -> None:
|
||||
"""Redo the last undone operation."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status("❌ Cannot redo: Application is in read-only mode")
|
||||
return
|
||||
|
|
@ -848,6 +1000,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_refresh_dns(self) -> None:
|
||||
"""Manually refresh DNS resolution for all entries."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -942,6 +1096,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_update_single_dns(self) -> None:
|
||||
"""Manually refresh DNS resolution for the currently selected entry."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -1136,6 +1292,10 @@ class HostsManagerApp(App):
|
|||
"""Update the edit form with current entry values."""
|
||||
self.details_handler.update_edit_form()
|
||||
|
||||
def watch_edit_mode(self, edit_mode: bool) -> None:
|
||||
"""Keep elevated capability explicit without changing the full palette."""
|
||||
self.sub_title = "PRIVILEGED" if edit_mode else ""
|
||||
|
||||
def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None:
|
||||
"""Update the right pane border title when entry edit mode changes."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -48,14 +48,14 @@ class ConfigModal(ModalScreen[bool]):
|
|||
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Save", variant="primary", id="save-button", classes="config-button"
|
||||
)
|
||||
yield Button(
|
||||
"Cancel (ESC)",
|
||||
"Cancel",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Save", variant="primary", id="save-button", classes="config-button"
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ class DeleteConfirmationModal(ModalScreen[bool]):
|
|||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("enter", "confirm", "Delete"),
|
||||
]
|
||||
|
||||
def __init__(self, entry: HostEntry):
|
||||
|
|
@ -35,7 +34,7 @@ class DeleteConfirmationModal(ModalScreen[bool]):
|
|||
def compose(self) -> ComposeResult:
|
||||
"""Create the delete confirmation modal layout."""
|
||||
with Vertical(classes="delete-container"):
|
||||
yield Static("Delete Entry", classes="delete-title")
|
||||
yield Static("Delete Host Entry", classes="delete-title")
|
||||
|
||||
yield Static(
|
||||
"Are you sure you want to delete this entry?", classes="delete-message"
|
||||
|
|
@ -52,15 +51,15 @@ class DeleteConfirmationModal(ModalScreen[bool]):
|
|||
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Delete",
|
||||
variant="error",
|
||||
id="delete-button",
|
||||
"Cancel",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Cancel (ESC)",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
"Delete Host Entry",
|
||||
variant="error",
|
||||
id="delete-button",
|
||||
classes="default-button",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,113 +1,113 @@
|
|||
"""
|
||||
Details pane management for the hosts TUI application.
|
||||
"""Read-only Entry Details and editable Entry Editor coordination."""
|
||||
|
||||
This module handles the display and updating of entry details
|
||||
and edit forms in the right pane.
|
||||
"""
|
||||
|
||||
from textual.widgets import Input, Checkbox
|
||||
from textual.widgets import Checkbox, Input, Static
|
||||
|
||||
|
||||
class DetailsHandler:
|
||||
"""Handles all details pane operations for the hosts manager."""
|
||||
"""Render selected Host Entry data without making inspection look editable."""
|
||||
|
||||
def __init__(self, app):
|
||||
"""Initialize the details handler with reference to the main app."""
|
||||
self.app = app
|
||||
|
||||
def update_entry_details(self) -> None:
|
||||
"""Update the right pane with selected entry details."""
|
||||
if self.app.entry_edit_mode:
|
||||
self.update_edit_form()
|
||||
else:
|
||||
self.update_details_display()
|
||||
|
||||
def _set_detail(self, detail_id: str, value: str) -> None:
|
||||
self.app.query_one(f"#{detail_id}", Static).update(value)
|
||||
|
||||
def _show_empty_state(self, message: str) -> None:
|
||||
self.app.query_one("#details-empty-state", Static).update(message)
|
||||
self.app.query_one("#details-empty-state", Static).remove_class("hidden")
|
||||
self.app.query_one("#details-content").add_class("hidden")
|
||||
self.app.query_one("#details-dns-rows").add_class("hidden")
|
||||
self.app.query_one("#details-default-notice").add_class("hidden")
|
||||
|
||||
def update_details_display(self) -> None:
|
||||
"""Update the details display using disabled Input widgets."""
|
||||
"""Show compact label-value rows for the currently selected Host Entry."""
|
||||
details_display = self.app.query_one("#entry-details-display")
|
||||
edit_form = self.app.query_one("#entry-edit-form")
|
||||
|
||||
# Show details display, hide edit form
|
||||
details_display.remove_class("hidden")
|
||||
edit_form.add_class("hidden")
|
||||
|
||||
# Get the input widgets
|
||||
ip_input = self.app.query_one("#details-ip-input", Input)
|
||||
hostname_input = self.app.query_one("#details-hostname-input", Input)
|
||||
comment_input = self.app.query_one("#details-comment-input", Input)
|
||||
active_checkbox = self.app.query_one("#details-active-checkbox", Checkbox)
|
||||
|
||||
if not self.app.hosts_file.entries:
|
||||
# Show empty message
|
||||
ip_input.value = ""
|
||||
ip_input.placeholder = "No entries loaded"
|
||||
hostname_input.value = ""
|
||||
hostname_input.placeholder = "No entries loaded"
|
||||
comment_input.value = ""
|
||||
comment_input.placeholder = "No entries loaded"
|
||||
active_checkbox.value = False
|
||||
self._show_empty_state("No Host Entries are loaded.")
|
||||
return
|
||||
|
||||
# Get visible entries to check if we need to adjust selection
|
||||
visible_entries = self.app.table_handler.get_visible_entries()
|
||||
if not visible_entries:
|
||||
ip_input.value = ""
|
||||
ip_input.placeholder = "No visible entries"
|
||||
hostname_input.value = ""
|
||||
hostname_input.placeholder = "No visible entries"
|
||||
comment_input.value = ""
|
||||
comment_input.placeholder = "No visible entries"
|
||||
active_checkbox.value = False
|
||||
self._show_empty_state(
|
||||
"No Host Entries match the current filters. Press Ctrl+F to change them."
|
||||
)
|
||||
return
|
||||
|
||||
# If default entries are hidden and selected_entry_index points to a hidden entry,
|
||||
# we need to find the corresponding visible entry
|
||||
show_defaults = self.app.config.should_show_default_entries()
|
||||
if not show_defaults:
|
||||
# Check if the currently selected entry is a default entry (hidden)
|
||||
if (
|
||||
self.app.selected_entry_index < len(self.app.hosts_file.entries)
|
||||
and self.app.hosts_file.entries[
|
||||
self.app.selected_entry_index
|
||||
].is_default_entry()
|
||||
):
|
||||
# The selected entry is hidden, so we should show the first visible entry instead
|
||||
if visible_entries:
|
||||
# Find the first visible entry in the hosts file
|
||||
for i, entry in enumerate(self.app.hosts_file.entries):
|
||||
if not entry.is_default_entry():
|
||||
self.app.selected_entry_index = i
|
||||
break
|
||||
|
||||
if self.app.selected_entry_index >= len(self.app.hosts_file.entries):
|
||||
self.app.selected_entry_index = 0
|
||||
|
||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||
|
||||
# Update the input widgets with entry data
|
||||
ip_input.value = entry.ip_address
|
||||
ip_input.placeholder = ""
|
||||
hostname_input.value = ", ".join(entry.hostnames)
|
||||
hostname_input.placeholder = ""
|
||||
comment_input.value = entry.comment or ""
|
||||
comment_input.placeholder = "No comment"
|
||||
active_checkbox.value = entry.is_active
|
||||
self.app.query_one("#details-empty-state", Static).add_class("hidden")
|
||||
self.app.query_one("#details-content").remove_class("hidden")
|
||||
self._set_detail("details-ip-input", entry.ip_address)
|
||||
self._set_detail("details-hostname-input", ", ".join(entry.hostnames))
|
||||
self._set_detail("details-comment-input", entry.comment or "—")
|
||||
self._set_detail(
|
||||
"details-active-checkbox", "✓ Active" if entry.is_active else "· Inactive"
|
||||
)
|
||||
|
||||
# For default entries, show warning in placeholder text
|
||||
default_notice = self.app.query_one("#details-default-notice", Static)
|
||||
if entry.is_default_entry():
|
||||
ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
||||
hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
||||
comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
||||
entry_state = "✓ Active" if entry.is_active else "· Inactive"
|
||||
self._set_detail(
|
||||
"details-active-checkbox", f"■ Default (protected) · {entry_state}"
|
||||
)
|
||||
default_notice.update(
|
||||
"Protected Default Entry. This operating-system mapping cannot be changed."
|
||||
)
|
||||
default_notice.remove_class("hidden")
|
||||
else:
|
||||
default_notice.add_class("hidden")
|
||||
|
||||
# Update DNS information if present
|
||||
self._update_dns_information(entry)
|
||||
dns_rows = self.app.query_one("#details-dns-rows")
|
||||
if entry.has_dns_name():
|
||||
dns_rows.remove_class("hidden")
|
||||
self._set_detail("details-dns-name-input", entry.dns_name or "—")
|
||||
self._set_detail("details-dns-status-input", self._dns_status_text(entry))
|
||||
self._set_detail(
|
||||
"details-dns-resolved-input",
|
||||
entry.last_resolved.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if entry.last_resolved
|
||||
else "Not resolved yet",
|
||||
)
|
||||
else:
|
||||
dns_rows.add_class("hidden")
|
||||
|
||||
@staticmethod
|
||||
def _dns_status_text(entry) -> str:
|
||||
labels = {
|
||||
"not_resolved": "· Not resolved",
|
||||
"resolving": "! Resolving…",
|
||||
"resolved": "✓ Resolved",
|
||||
"failed": "× Resolution failed",
|
||||
"match": "✓ IP matches DNS",
|
||||
"mismatch": "! IP differs from DNS",
|
||||
}
|
||||
status = labels.get(entry.dns_resolution_status, entry.dns_resolution_status)
|
||||
if not status:
|
||||
return "· Not resolved"
|
||||
if entry.resolved_ip and entry.dns_resolution_status in {
|
||||
"resolved",
|
||||
"match",
|
||||
"mismatch",
|
||||
}:
|
||||
return f"{status} ({entry.resolved_ip})"
|
||||
return status
|
||||
|
||||
def update_edit_form(self) -> None:
|
||||
"""Update the edit form with current entry values."""
|
||||
"""Populate the separate editable Entry Editor form."""
|
||||
details_display = self.app.query_one("#entry-details-display")
|
||||
edit_form = self.app.query_one("#entry-edit-form")
|
||||
|
||||
# Hide details display, show edit form
|
||||
details_display.add_class("hidden")
|
||||
edit_form.remove_class("hidden")
|
||||
|
||||
|
|
@ -117,80 +117,8 @@ class DetailsHandler:
|
|||
return
|
||||
|
||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||
|
||||
# Update form fields with current entry values
|
||||
ip_input = self.app.query_one("#ip-input", Input)
|
||||
hostname_input = self.app.query_one("#hostname-input", Input)
|
||||
comment_input = self.app.query_one("#comment-input", Input)
|
||||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||
|
||||
ip_input.value = entry.ip_address
|
||||
hostname_input.value = ", ".join(entry.hostnames)
|
||||
comment_input.value = entry.comment or ""
|
||||
active_checkbox.value = entry.is_active
|
||||
|
||||
# Initialize radio button state and field visibility
|
||||
self.app.query_one("#ip-input", Input).value = entry.ip_address
|
||||
self.app.query_one("#hostname-input", Input).value = ", ".join(entry.hostnames)
|
||||
self.app.query_one("#comment-input", Input).value = entry.comment or ""
|
||||
self.app.query_one("#active-checkbox", Checkbox).value = entry.is_active
|
||||
self.app.edit_handler.populate_edit_form_with_type_detection()
|
||||
|
||||
def _update_dns_information(self, entry) -> None:
|
||||
"""Update DNS information display for the selected entry."""
|
||||
try:
|
||||
# Get the three separate DNS input fields
|
||||
dns_name_input = self.app.query_one("#details-dns-name-input", Input)
|
||||
dns_status_input = self.app.query_one("#details-dns-status-input", Input)
|
||||
dns_resolved_input = self.app.query_one(
|
||||
"#details-dns-resolved-input", Input
|
||||
)
|
||||
|
||||
if not entry.has_dns_name():
|
||||
# Clear all DNS fields if no DNS information
|
||||
dns_name_input.value = ""
|
||||
dns_name_input.placeholder = "No DNS name"
|
||||
dns_status_input.value = ""
|
||||
dns_status_input.placeholder = "No DNS status"
|
||||
dns_resolved_input.value = ""
|
||||
dns_resolved_input.placeholder = "Not resolved yet"
|
||||
return
|
||||
|
||||
# Update DNS Name field
|
||||
dns_name_input.value = entry.dns_name or ""
|
||||
dns_name_input.placeholder = "" if entry.dns_name else "No DNS name"
|
||||
|
||||
# Update DNS Status field
|
||||
if entry.dns_resolution_status:
|
||||
status_text = {
|
||||
"not_resolved": "Not resolved",
|
||||
"resolving": "Resolving...",
|
||||
"resolved": "Resolved",
|
||||
"failed": "Resolution failed",
|
||||
"match": "IP matches DNS",
|
||||
"mismatch": "IP differs from DNS",
|
||||
}.get(entry.dns_resolution_status, entry.dns_resolution_status)
|
||||
|
||||
# Add resolved IP to status if available
|
||||
if entry.resolved_ip and entry.dns_resolution_status in [
|
||||
"resolved",
|
||||
"match",
|
||||
"mismatch",
|
||||
]:
|
||||
status_text += f" ({entry.resolved_ip})"
|
||||
|
||||
dns_status_input.value = status_text
|
||||
dns_status_input.placeholder = ""
|
||||
else:
|
||||
dns_status_input.value = ""
|
||||
dns_status_input.placeholder = "No DNS status"
|
||||
|
||||
# Update Last Resolved field
|
||||
if entry.last_resolved:
|
||||
time_str = entry.last_resolved.strftime("%H:%M:%S")
|
||||
date_str = entry.last_resolved.strftime("%Y-%m-%d")
|
||||
dns_resolved_input.value = f"{date_str} {time_str}"
|
||||
dns_resolved_input.placeholder = ""
|
||||
else:
|
||||
dns_resolved_input.value = ""
|
||||
dns_resolved_input.placeholder = "Not resolved yet"
|
||||
|
||||
except Exception:
|
||||
# DNS widgets not present yet, silently ignore
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -186,11 +186,11 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
|||
)
|
||||
|
||||
with Horizontal(classes="filter-actions"):
|
||||
yield Button("Cancel", id="cancel", classes="default-button")
|
||||
yield Button("Reset", id="reset", classes="default-button")
|
||||
yield Button(
|
||||
"Apply", id="apply", variant="primary", classes="default-button"
|
||||
)
|
||||
yield Button("Reset", id="reset", classes="default-button")
|
||||
yield Button("Cancel (ESC)", id="cancel", classes="default-button")
|
||||
|
||||
def _create_preset_select(self) -> Select:
|
||||
"""Create the preset picker without selecting a preset by default."""
|
||||
|
|
@ -251,9 +251,9 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
|||
"search-comments",
|
||||
"search-ips",
|
||||
"search-case-sensitive",
|
||||
"apply",
|
||||
"reset",
|
||||
"cancel",
|
||||
"reset",
|
||||
"apply",
|
||||
)
|
||||
)
|
||||
return control_ids
|
||||
|
|
|
|||
66
src/hosts/tui/help_modal.py
Normal file
66
src/hosts/tui/help_modal.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Dedicated keyboard reference overlay."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
|
||||
class HelpModal(ModalScreen[None]):
|
||||
"""Show the complete binding reference without shrinking the workspace."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "close", "Close"),
|
||||
Binding("question_mark", "close", "Close"),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
HelpModal { align: center middle; }
|
||||
#help-container {
|
||||
width: 72;
|
||||
height: auto;
|
||||
max-height: 90%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
.help-title { text-style: bold; color: $primary; text-align: center; }
|
||||
.help-section { margin-top: 1; text-style: bold; }
|
||||
.help-copy { color: $text-muted; }
|
||||
#help-close { margin-top: 1; width: 12; }
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="help-container"):
|
||||
yield Static("Keyboard help", classes="help-title")
|
||||
yield Static("General", classes="help-section")
|
||||
yield Static(
|
||||
"q Quit ? Close help c Configuration", classes="help-copy"
|
||||
)
|
||||
yield Static("Navigation", classes="help-section")
|
||||
yield Static(
|
||||
"↑/↓ Select Host Entry i Sort IP h Sort hostname",
|
||||
classes="help-copy",
|
||||
)
|
||||
yield Static("Filtering", classes="help-section")
|
||||
yield Static(
|
||||
"Type in Search for immediate filtering Ctrl+F Advanced filters",
|
||||
classes="help-copy",
|
||||
)
|
||||
yield Static("Privileged Mode", classes="help-section")
|
||||
yield Static(
|
||||
"Ctrl+E Enter or leave Privileged Mode n New e Edit d Delete",
|
||||
classes="help-copy",
|
||||
)
|
||||
yield Button("Close", id="help-close", variant="primary")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#help-close", Button).focus()
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "help-close":
|
||||
self.action_close()
|
||||
|
|
@ -22,7 +22,7 @@ HOSTS_MANAGER_BINDINGS = [
|
|||
Binding(
|
||||
"ctrl+e",
|
||||
"toggle_edit_mode",
|
||||
"Toggle edit mode",
|
||||
"Privileged Mode",
|
||||
show=True,
|
||||
id="left:toggle_edit_mode",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -40,29 +40,27 @@ class SaveConfirmationModal(ModalScreen[str]):
|
|||
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Save (S)",
|
||||
variant="primary",
|
||||
id="save-button",
|
||||
"Cancel",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="save-confirmation-button",
|
||||
)
|
||||
yield Button(
|
||||
"Discard (D)",
|
||||
"Discard",
|
||||
variant="default",
|
||||
id="discard-button",
|
||||
classes="save-confirmation-button",
|
||||
)
|
||||
yield Button(
|
||||
"Cancel (ESC)",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
"Save",
|
||||
variant="primary",
|
||||
id="save-button",
|
||||
classes="save-confirmation-button",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Called when the modal is mounted. Set focus to the first button."""
|
||||
# Focus on the Save button by default
|
||||
save_button = self.query_one("#save-button", Button)
|
||||
save_button.focus()
|
||||
"""Start at the non-destructive Cancel action."""
|
||||
self.query_one("#cancel-button", Button).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,24 @@ This module contains all CSS definitions for consistent styling
|
|||
across the application.
|
||||
"""
|
||||
|
||||
from textual.theme import Theme
|
||||
|
||||
|
||||
HOSTS_DARK_THEME = Theme(
|
||||
name="hosts-dark",
|
||||
primary="#0178D4",
|
||||
secondary="#004578",
|
||||
warning="#FFA62B",
|
||||
error="#BA3C5B",
|
||||
success="#4EBF71",
|
||||
accent="#FFA62B",
|
||||
foreground="#E0E0E0",
|
||||
background="#121212",
|
||||
surface="#1E1E1E",
|
||||
panel="#242F38",
|
||||
dark=True,
|
||||
)
|
||||
|
||||
# Common CSS classes shared across components
|
||||
COMMON_CSS = """
|
||||
.default-button {
|
||||
|
|
@ -71,6 +89,43 @@ HOSTS_MANAGER_CSS = (
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#message-rail {
|
||||
height: 1;
|
||||
background: $panel;
|
||||
color: $text;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#message-rail.message-error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
#message-rail.message-warning {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
#minimum-size {
|
||||
display: none;
|
||||
height: 1fr;
|
||||
content-align: center middle;
|
||||
text-align: center;
|
||||
padding: 1 2;
|
||||
border: round $warning;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
#workspace.hidden,
|
||||
#minimum-size.hidden,
|
||||
.detail-rows.hidden,
|
||||
#details-dns-rows.hidden,
|
||||
#details-default-notice.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#minimum-size.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
height: 1fr;
|
||||
width: 1fr;
|
||||
|
|
@ -82,6 +137,10 @@ HOSTS_MANAGER_CSS = (
|
|||
margin-top: 0;
|
||||
}
|
||||
|
||||
.compact-layout .entry-form {
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
.common-pane {
|
||||
border: round $primary;
|
||||
margin: 0;
|
||||
|
|
@ -127,24 +186,35 @@ HOSTS_MANAGER_CSS = (
|
|||
display: none;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
height: 1;
|
||||
width: 100%;
|
||||
background: $error;
|
||||
color: $text;
|
||||
content-align: center middle;
|
||||
layer: overlay;
|
||||
dock: top;
|
||||
offset-y: 1;
|
||||
}
|
||||
|
||||
.status-bar.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
height: 1fr;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
width: 14;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
width: 1fr;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.detail-note {
|
||||
height: auto;
|
||||
padding: 1;
|
||||
color: $warning;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 1fr;
|
||||
content-align: center middle;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
Header {
|
||||
|
|
@ -157,7 +227,7 @@ Header.-tall {
|
|||
|
||||
/* Custom Footer Styling */
|
||||
CustomFooter {
|
||||
background: $surface;
|
||||
background: $panel;
|
||||
color: $text;
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
|
|
|
|||
|
|
@ -185,25 +185,21 @@ class TableHandler:
|
|||
# Get DNS status indicator
|
||||
dns_text = self._get_dns_status_indicator(entry)
|
||||
|
||||
# Add row with styling based on active status and default entry status
|
||||
# Markers and wording keep each state understandable without colour.
|
||||
if is_default:
|
||||
# Default entries are always shown in dim grey regardless of active status
|
||||
active_text = Text("✓" if entry.is_active else "", style="dim white")
|
||||
ip_text = Text(entry.ip_address, style="dim white")
|
||||
hostname_text = Text(canonical_hostname, style="dim white")
|
||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
||||
entry_state = "✓ Active" if entry.is_active else "· Inactive"
|
||||
active_text = Text(f"■ Default · {entry_state}", style="dim")
|
||||
ip_text = Text(entry.ip_address, style="dim")
|
||||
hostname_text = Text(canonical_hostname, style="dim")
|
||||
elif entry.is_active:
|
||||
# Active entries in green with checkmark
|
||||
active_text = Text("✓", style="bold green")
|
||||
ip_text = Text(entry.ip_address, style="bold green")
|
||||
hostname_text = Text(canonical_hostname, style="bold green")
|
||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
||||
active_text = Text("✓ Active")
|
||||
ip_text = entry.ip_address
|
||||
hostname_text = canonical_hostname
|
||||
else:
|
||||
# Inactive entries in dim yellow with italic (no checkmark)
|
||||
active_text = Text("", style="dim yellow italic")
|
||||
ip_text = Text(entry.ip_address, style="dim yellow italic")
|
||||
hostname_text = Text(canonical_hostname, style="dim yellow italic")
|
||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
||||
active_text = Text("· Inactive", style="dim")
|
||||
ip_text = Text(entry.ip_address, style="dim")
|
||||
hostname_text = Text(canonical_hostname, style="dim")
|
||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
||||
|
||||
def restore_cursor_position(self, previous_entry) -> None:
|
||||
"""Restore cursor position after reload, maintaining selection if possible."""
|
||||
|
|
@ -265,7 +261,7 @@ class TableHandler:
|
|||
"""Get DNS name and status indicator for an entry."""
|
||||
# If entry has no DNS name configured, show empty
|
||||
if not entry.has_dns_name():
|
||||
return Text("", style="dim white")
|
||||
return Text("")
|
||||
|
||||
# Start with the DNS name
|
||||
dns_display = entry.dns_name
|
||||
|
|
@ -274,28 +270,21 @@ class TableHandler:
|
|||
dns_status = entry.dns_resolution_status or "not_resolved"
|
||||
|
||||
if dns_status == "not_resolved":
|
||||
status_icon = "⏳"
|
||||
style = "dim yellow"
|
||||
status_icon = "· Pending"
|
||||
elif dns_status == "resolving":
|
||||
status_icon = "🔄"
|
||||
style = "yellow"
|
||||
status_icon = "! Resolving"
|
||||
elif dns_status == "resolved":
|
||||
status_icon = "✅"
|
||||
style = "green"
|
||||
status_icon = "✓ Resolved"
|
||||
elif dns_status == "match":
|
||||
status_icon = "✅"
|
||||
style = "bold green"
|
||||
status_icon = "✓ Matches"
|
||||
elif dns_status == "mismatch":
|
||||
status_icon = "⚠️"
|
||||
style = "red"
|
||||
status_icon = "! Mismatch"
|
||||
elif dns_status == "failed":
|
||||
status_icon = "❌"
|
||||
style = "red"
|
||||
status_icon = "× Failed"
|
||||
else:
|
||||
status_icon = ""
|
||||
style = "dim white"
|
||||
|
||||
return Text(f"{status_icon} {dns_display}", style=style)
|
||||
return Text(f"{status_icon} {dns_display}".strip())
|
||||
|
||||
def sort_entries_by_hostname(self) -> None:
|
||||
"""Sort entries by canonical hostname."""
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ from unittest.mock import Mock, patch
|
|||
|
||||
import pytest
|
||||
from textual.app import SuspendNotSupported
|
||||
from textual.widgets import HelpPanel, RadioButton, Static
|
||||
from textual.widgets import Input, RadioButton, Static
|
||||
|
||||
from src.hosts.core.filters import FilterOptions
|
||||
from src.hosts.core.models import HostEntry, HostsFile
|
||||
from src.hosts.tui.app import HostsManagerApp
|
||||
from src.hosts.tui.custom_footer import CustomFooter
|
||||
from src.hosts.tui.filter_modal import FilterModal
|
||||
from src.hosts.tui.help_modal import HelpModal
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -45,16 +46,13 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
|
|||
async with app.run_test() as pilot:
|
||||
footer = app.query_one("#custom-footer", CustomFooter)
|
||||
footer_text = footer.query_one("#footer-right", Static).render()
|
||||
assert "ctrl+f Filter entries" in str(footer_text)
|
||||
assert "ctrl+f Filters" in str(footer_text)
|
||||
|
||||
app.action_help()
|
||||
await pilot.pause()
|
||||
assert isinstance(app.query_one(HelpPanel), HelpPanel)
|
||||
assert any(
|
||||
binding.action == "show_filters" and binding.description == "Filter entries"
|
||||
for _, binding, _, _ in app.screen.active_bindings.values()
|
||||
)
|
||||
app.action_help()
|
||||
assert isinstance(app.screen, HelpModal)
|
||||
assert app.screen.query_one("#help-close") is not None
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("ctrl+f")
|
||||
|
|
@ -89,6 +87,24 @@ 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_help_overlay_closes_to_the_control_that_opened_it():
|
||||
"""Help is an overlay and returns keyboard focus to its opener."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
search = app.query_one("#search-input", Input)
|
||||
search.focus()
|
||||
app.action_help()
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, HelpModal)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.focused is search
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_modal_tabs_through_controls_in_visual_order():
|
||||
"""The modal starts at Presets and tabs through the visible controls in order."""
|
||||
|
|
@ -111,6 +127,8 @@ async def test_filter_modal_tabs_through_controls_in_visual_order():
|
|||
"search-comments",
|
||||
"search-ips",
|
||||
"search-case-sensitive",
|
||||
"cancel",
|
||||
"reset",
|
||||
"apply",
|
||||
):
|
||||
await pilot.press("tab")
|
||||
|
|
@ -148,6 +166,79 @@ async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters(
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_uses_read_only_detail_rows_and_hides_irrelevant_dns_fields():
|
||||
"""An IP Host Entry has compact text details instead of disabled controls."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
app.table_handler.populate_entries_table()
|
||||
app.details_handler.update_entry_details()
|
||||
await pilot.pause()
|
||||
|
||||
details = app.query_one("#entry-details-display")
|
||||
assert not list(details.query(Input))
|
||||
assert "192.0.2.1" in str(app.query_one("#details-ip-input", Static).render())
|
||||
assert "Active" in str(
|
||||
app.query_one("#details-active-checkbox", Static).render()
|
||||
)
|
||||
assert app.query_one("#details-dns-rows").has_class("hidden")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_exposes_dns_and_protection_details_and_durable_state():
|
||||
"""DNS and Default Entry states remain explicit independently of colour."""
|
||||
app = app_with_filterable_entries()
|
||||
app.hosts_file.entries[0].dns_name = "active.example"
|
||||
app.hosts_file.entries[0].dns_resolution_status = "resolved"
|
||||
app.hosts_file.entries[0].resolved_ip = "192.0.2.1"
|
||||
app.current_filter_options.active_only = True
|
||||
app.current_filter_options.show_inactive = False
|
||||
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
app.table_handler.populate_entries_table()
|
||||
app.details_handler.update_entry_details()
|
||||
app._update_footer_status()
|
||||
await pilot.pause()
|
||||
|
||||
assert not app.query_one("#details-dns-rows").has_class("hidden")
|
||||
assert "Resolved" in str(
|
||||
app.query_one("#details-dns-status-input", Static).render()
|
||||
)
|
||||
footer = app.query_one("#custom-footer", CustomFooter)
|
||||
assert "Filters: 1" in footer._status_text
|
||||
assert "READ-ONLY" in footer._status_text
|
||||
assert app.has_class("compact-layout")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_viewport_replaces_workspace_with_an_explicit_safe_message():
|
||||
"""A viewport below the contract cannot expose clipped mutating controls."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
assert not app.query_one("#workspace").display
|
||||
minimum_size = app.query_one("#minimum-size", Static)
|
||||
assert "requires at least 100 columns × 30 rows" in str(minimum_size.render())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_viewport_blocks_hidden_mutation_shortcuts():
|
||||
"""A minimum-size presentation cannot open a hidden editor or privilege flow."""
|
||||
app = app_with_filterable_entries()
|
||||
app.push_screen = Mock()
|
||||
app.manager = Mock()
|
||||
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("n", "ctrl+e")
|
||||
await pilot.pause()
|
||||
|
||||
app.push_screen.assert_not_called()
|
||||
app.manager.enter_edit_mode.assert_not_called()
|
||||
|
||||
|
||||
class TestPrivilegedModeAuthorization:
|
||||
"""Test the user-visible privileged-mode authorization flow."""
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ class TestHostsManagerApp:
|
|||
|
||||
# Should handle error gracefully
|
||||
app.update_status.assert_called_with(
|
||||
"❌ Error loading hosts file: Hosts file not found"
|
||||
"Error loading hosts file: Hosts file not found"
|
||||
)
|
||||
|
||||
def test_load_hosts_file_permission_error(self):
|
||||
|
|
@ -122,7 +122,7 @@ class TestHostsManagerApp:
|
|||
|
||||
# Should handle error gracefully
|
||||
app.update_status.assert_called_with(
|
||||
"❌ Error loading hosts file: Permission denied"
|
||||
"Error loading hosts file: Permission denied"
|
||||
)
|
||||
|
||||
def test_populate_entries_table_logic(self):
|
||||
|
|
@ -207,13 +207,15 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_entry_details()
|
||||
|
||||
# Verify input widgets were updated with entry data
|
||||
# Verify read-only detail rows were updated with entry data.
|
||||
mock_details_display.remove_class.assert_called_with("hidden")
|
||||
mock_edit_form.add_class.assert_called_with("hidden")
|
||||
assert mock_ip_input.value == "127.0.0.1"
|
||||
assert mock_hostname_input.value == "localhost, local"
|
||||
assert mock_comment_input.value == "Test comment"
|
||||
assert mock_active_checkbox.value
|
||||
mock_ip_input.update.assert_called_with("127.0.0.1")
|
||||
mock_hostname_input.update.assert_called_with("localhost, local")
|
||||
mock_comment_input.update.assert_called_with("Test comment")
|
||||
mock_active_checkbox.update.assert_called_with(
|
||||
"■ Default (protected) · ✓ Active"
|
||||
)
|
||||
|
||||
def test_update_entry_details_no_entries(self):
|
||||
"""Test updating entry details with no entries."""
|
||||
|
|
@ -233,6 +235,7 @@ class TestHostsManagerApp:
|
|||
mock_hostname_input = Mock()
|
||||
mock_comment_input = Mock()
|
||||
mock_active_checkbox = Mock()
|
||||
mock_empty_state = Mock()
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#entry-details-display":
|
||||
|
|
@ -247,6 +250,8 @@ class TestHostsManagerApp:
|
|||
return mock_comment_input
|
||||
elif selector == "#details-active-checkbox":
|
||||
return mock_active_checkbox
|
||||
elif selector == "#details-empty-state":
|
||||
return mock_empty_state
|
||||
return Mock()
|
||||
|
||||
cast(Any, app).query_one = mock_query_one
|
||||
|
|
@ -254,16 +259,10 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_entry_details()
|
||||
|
||||
# Verify widgets show empty state placeholders
|
||||
# Verify the detail pane names the empty condition.
|
||||
mock_details_display.remove_class.assert_called_with("hidden")
|
||||
mock_edit_form.add_class.assert_called_with("hidden")
|
||||
assert mock_ip_input.value == ""
|
||||
assert mock_ip_input.placeholder == "No entries loaded"
|
||||
assert mock_hostname_input.value == ""
|
||||
assert mock_hostname_input.placeholder == "No entries loaded"
|
||||
assert mock_comment_input.value == ""
|
||||
assert mock_comment_input.placeholder == "No entries loaded"
|
||||
assert not mock_active_checkbox.value
|
||||
mock_empty_state.update.assert_called_with("No Host Entries are loaded.")
|
||||
|
||||
def test_update_status_default(self):
|
||||
"""Test status bar update with default information."""
|
||||
|
|
@ -301,7 +300,7 @@ class TestHostsManagerApp:
|
|||
# Verify footer status was updated
|
||||
mock_footer.set_status.assert_called_once()
|
||||
status_call = mock_footer.set_status.call_args[0][0]
|
||||
assert "Read-only" in status_call
|
||||
assert "READ-ONLY" in status_call
|
||||
assert "2 entries" in status_call
|
||||
assert "1 active" in status_call
|
||||
|
||||
|
|
@ -318,12 +317,12 @@ class TestHostsManagerApp:
|
|||
|
||||
# Mock set_timer and query_one to avoid event loop and UI issues
|
||||
app.set_timer = Mock()
|
||||
mock_status_bar = Mock()
|
||||
mock_message_rail = Mock()
|
||||
mock_footer = Mock()
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#status-bar":
|
||||
return mock_status_bar
|
||||
if selector == "#message-rail":
|
||||
return mock_message_rail
|
||||
elif selector == "#custom-footer":
|
||||
return mock_footer
|
||||
return Mock()
|
||||
|
|
@ -343,14 +342,13 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_status("Custom status message")
|
||||
|
||||
# Verify status bar was updated with custom message
|
||||
mock_status_bar.update.assert_called_with("Custom status message")
|
||||
mock_status_bar.remove_class.assert_called_with("hidden")
|
||||
# Verify the reserved message rail was updated with the message.
|
||||
mock_message_rail.update.assert_called_with("Custom status message")
|
||||
# Verify footer status was updated with current status (not the custom message)
|
||||
mock_footer.set_status.assert_called_once()
|
||||
footer_status = mock_footer.set_status.call_args[0][0]
|
||||
assert "2 entries" in footer_status
|
||||
assert "Read-only" in footer_status
|
||||
assert "READ-ONLY" in footer_status
|
||||
# Verify timer was set for auto-clearing
|
||||
app.set_timer.assert_called_once()
|
||||
|
||||
|
|
@ -382,12 +380,12 @@ class TestHostsManagerApp:
|
|||
patch("hosts.tui.app.Config", return_value=mock_config),
|
||||
):
|
||||
app = HostsManagerApp()
|
||||
app.action_show_help_panel = Mock()
|
||||
app.push_screen = Mock()
|
||||
|
||||
app.action_help()
|
||||
|
||||
# Should call the built-in help action
|
||||
app.action_show_help_panel.assert_called_once()
|
||||
# Help is a dedicated overlay, not a docked panel.
|
||||
app.push_screen.assert_called_once()
|
||||
|
||||
def test_action_config(self):
|
||||
"""Test config action opens modal."""
|
||||
|
|
|
|||
|
|
@ -57,15 +57,15 @@ class TestSaveConfirmationModal:
|
|||
|
||||
@patch.object(SaveConfirmationModal, "query_one")
|
||||
def test_on_mount_sets_focus(self, mock_query_one):
|
||||
"""Test that on_mount sets focus to the save button."""
|
||||
"""Test that on_mount sets focus to the safe cancel button."""
|
||||
modal = SaveConfirmationModal()
|
||||
mock_save_button = Mock()
|
||||
mock_query_one.return_value = mock_save_button
|
||||
mock_cancel_button = Mock()
|
||||
mock_query_one.return_value = mock_cancel_button
|
||||
|
||||
modal.on_mount()
|
||||
|
||||
mock_query_one.assert_called_once_with("#save-button", Button)
|
||||
mock_save_button.focus.assert_called_once()
|
||||
mock_query_one.assert_called_once_with("#cancel-button", Button)
|
||||
mock_cancel_button.focus.assert_called_once()
|
||||
|
||||
|
||||
class TestSaveConfirmationIntegration:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue