feat #14: Implement design guide #15
19 changed files with 1507 additions and 723 deletions
|
|
@ -13,6 +13,8 @@ is reachable in the current application.
|
||||||
privileged mode, sudo handling, system-file writes, or backups.
|
privileged mode, sudo handling, system-file writes, or backups.
|
||||||
- Read [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) before changing
|
- Read [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) before changing
|
||||||
parsing, serialization, comment placement, or formatting preservation.
|
parsing, serialization, comment placement, or formatting preservation.
|
||||||
|
- Read the [TUI design guide](docs/design-guide.md) before changing layouts,
|
||||||
|
components, interaction states, user-visible copy, or terminal-size behavior.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ Core code that is not reachable through the TUI is not yet a supported product
|
||||||
feature. Do not advertise it in user-facing documentation until users can
|
feature. Do not advertise it in user-facing documentation until users can
|
||||||
actually invoke it.
|
actually invoke it.
|
||||||
|
|
||||||
|
Read the [TUI design guide](docs/design-guide.md) before changing layouts,
|
||||||
|
components, interaction states, user-visible copy, or terminal-size behavior.
|
||||||
|
|
||||||
## Domain language and decisions
|
## Domain language and decisions
|
||||||
|
|
||||||
Read [CONTEXT.md](CONTEXT.md) before naming user-visible concepts. Use its
|
Read [CONTEXT.md](CONTEXT.md) before naming user-visible concepts. Use its
|
||||||
|
|
@ -111,6 +114,8 @@ Documentation is part of the behavior contract:
|
||||||
persistence, recovery, configuration, or troubleshooting change.
|
persistence, recovery, configuration, or troubleshooting change.
|
||||||
- Update [CONTEXT.md](CONTEXT.md) when the canonical domain language changes;
|
- Update [CONTEXT.md](CONTEXT.md) when the canonical domain language changes;
|
||||||
keep it a glossary rather than an architecture guide or progress log.
|
keep it a glossary rather than an architecture guide or progress log.
|
||||||
|
- Update the [TUI design guide](docs/design-guide.md) when an intentional UI
|
||||||
|
pattern should become the new project-wide standard.
|
||||||
- Update ADR 0002 and the README if serialization stops being normalized or
|
- Update ADR 0002 and the README if serialization stops being normalized or
|
||||||
becomes byte-preserving.
|
becomes byte-preserving.
|
||||||
|
|
||||||
|
|
|
||||||
404
docs/design-guide.md
Normal file
404
docs/design-guide.md
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
# TUI design guide
|
||||||
|
|
||||||
|
This guide is the normative design contract for the `hosts` terminal user
|
||||||
|
interface. It evolves the application's existing dark, keyboard-first,
|
||||||
|
master-detail interface into a consistent system without replacing its visual
|
||||||
|
identity.
|
||||||
|
|
||||||
|
The words **must**, **should**, and **may** distinguish requirements,
|
||||||
|
recommendations, and optional choices. The guide describes the target state;
|
||||||
|
current deviations are implementation work, not exceptions or user-reachable
|
||||||
|
features. User-facing documentation must continue to describe only behavior
|
||||||
|
that the application currently provides.
|
||||||
|
|
||||||
|
## Design character
|
||||||
|
|
||||||
|
`hosts` is a calm, trustworthy operations console. It should feel dense and
|
||||||
|
capable without feeling busy, playful, or alarming during ordinary use.
|
||||||
|
|
||||||
|
Every screen follows these principles:
|
||||||
|
|
||||||
|
1. **Information before decoration.** Structure, alignment, and concise text do
|
||||||
|
more work than borders, color, or ornament.
|
||||||
|
2. **Safety remains visible.** Mode, protection, failures, and consequences are
|
||||||
|
explicit and persist for as long as they matter.
|
||||||
|
3. **The keyboard path is complete.** Every workflow works without a mouse;
|
||||||
|
natural mouse support remains available where Textual provides it.
|
||||||
|
4. **State has more than one signal.** Color may reinforce a state but never
|
||||||
|
carries essential meaning alone.
|
||||||
|
5. **The terminal is user-owned.** The interface does not assume a particular
|
||||||
|
font, truecolor renderer, glyph width outside predictable one-cell symbols,
|
||||||
|
or a large viewport.
|
||||||
|
|
||||||
|
## Viewports and composition
|
||||||
|
|
||||||
|
The interface has three viewport modes:
|
||||||
|
|
||||||
|
| Terminal size | Contract |
|
||||||
|
| --- | --- |
|
||||||
|
| At least 120 columns by 40 rows | Design target. Show the full 60/40 master-detail workspace. |
|
||||||
|
| At least 100 columns by 30 rows | Minimum usable size. Keep the 60/40 workspace, compact the details, and abbreviate contextual controls without hiding required actions. |
|
||||||
|
| Below 100 columns or below 30 rows | Replace the workspace with a minimum-size presentation. Do not silently clip, overlap, or squeeze controls into unusable shapes. |
|
||||||
|
|
||||||
|
The target layout is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────── hosts ─────────────────────────────┐
|
||||||
|
│ message rail: transient result or persistent problem │
|
||||||
|
├─ Search ────────────────────────────────────────────────────────┤
|
||||||
|
│ query Filters: 2 │
|
||||||
|
├─ Host Entries (60%) ───────────────┬─ Entry Details (40%) ─────┤
|
||||||
|
│ state IP address hostname DNS │ IP address value │
|
||||||
|
│ ... │ Hostnames value │
|
||||||
|
│ │ DNS details value │
|
||||||
|
│ │ Comment — │
|
||||||
|
├────────────────────────────────────┴────────────────────────────┤
|
||||||
|
│ contextual keys count · filters · READ-ONLY │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
At the minimum usable size, preserve the same regions but use compact
|
||||||
|
label-value rows in Entry Details and shorter footer descriptions. If either
|
||||||
|
dimension falls below the minimum, render a stable presentation such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────── hosts ────────────────────┐
|
||||||
|
│ Terminal too small │
|
||||||
|
│ │
|
||||||
|
│ hosts requires at least 100 columns × 30 rows │
|
||||||
|
│ Current size: 86 × 24 │
|
||||||
|
│ │
|
||||||
|
│ q Quit │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The minimum-size presentation must not permit a hidden or ambiguous mutation.
|
||||||
|
It may retain safe global actions such as Quit.
|
||||||
|
|
||||||
|
### Regions
|
||||||
|
|
||||||
|
- The header identifies the application and exposes safety-critical mode when
|
||||||
|
Privileged Mode is active.
|
||||||
|
- The message rail occupies one reserved row below the header. It never covers
|
||||||
|
the workspace.
|
||||||
|
- Search remains one row above the master-detail workspace.
|
||||||
|
- Host Entries and Entry Details use an approximate 60/40 split. Their minimum
|
||||||
|
content widths take priority over an exact percentage.
|
||||||
|
- The footer shows contextual keys on the left and durable state on the right.
|
||||||
|
It must not rely on horizontal scrolling.
|
||||||
|
|
||||||
|
## Theme and color
|
||||||
|
|
||||||
|
The application owns a semantic `hosts-dark` theme rather than inheriting
|
||||||
|
unspecified library defaults. Its initial palette preserves the current
|
||||||
|
Textual-dark appearance:
|
||||||
|
|
||||||
|
| Token | Initial dark value | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `background` | `#121212` | Screen background |
|
||||||
|
| `surface` | `#1E1E1E` | Inputs and alternating table rows |
|
||||||
|
| `panel` | `#242F38` | Header, footer, and elevated containers |
|
||||||
|
| `foreground` | `#E0E0E0` | Primary text |
|
||||||
|
| `primary` | `#0178D4` | Structure, focus, and selection |
|
||||||
|
| `secondary` | `#004578` | Secondary structure |
|
||||||
|
| `accent` | `#FFA62B` | Key hints and exceptional emphasis |
|
||||||
|
| `success` | `#4EBF71` | Successful outcomes and confirmed healthy state |
|
||||||
|
| `warning` | `#FFA62B` | Caution, inactivity, and Privileged Mode emphasis |
|
||||||
|
| `error` | `#BA3C5B` | Failures and destructive actions |
|
||||||
|
|
||||||
|
Components must consume semantic tokens such as `$primary`, `$success`, and
|
||||||
|
`$text-muted`; they must not embed named Rich colors such as `green` or
|
||||||
|
`yellow`. Accent and warning happen to share an initial value but remain
|
||||||
|
separate roles.
|
||||||
|
|
||||||
|
Dark is the default appearance. A future light theme may choose different
|
||||||
|
values, but it must preserve the same semantic roles, hierarchy, and state
|
||||||
|
redundancy. Text should approximate a contrast ratio of at least 4.5:1 where
|
||||||
|
the renderer permits controlled color. Focus and meaningful large text should
|
||||||
|
approximate at least 3:1 against adjacent colors.
|
||||||
|
|
||||||
|
Use chromatic color sparingly:
|
||||||
|
|
||||||
|
- Primary blue describes structure, focus, and selection.
|
||||||
|
- Green describes a successful result or active mapping, never a generic
|
||||||
|
decoration.
|
||||||
|
- Amber describes caution, inactivity, or elevated privilege.
|
||||||
|
- Red describes failure or destructive consequence.
|
||||||
|
- Muted foreground describes secondary information, not information required
|
||||||
|
to finish a workflow.
|
||||||
|
|
||||||
|
## Text, symbols, and density
|
||||||
|
|
||||||
|
The terminal controls the font. Assume a legible monospace font and make no
|
||||||
|
layout or branding decision that depends on a particular typeface.
|
||||||
|
|
||||||
|
- Use bold for headings, keys, and the strongest current emphasis.
|
||||||
|
- Use dim text for supporting information.
|
||||||
|
- Use italics only as reinforcement; some terminals do not distinguish them.
|
||||||
|
- Use sentence case for headings, labels, buttons, and messages.
|
||||||
|
- Use one-cell spacing increments.
|
||||||
|
- Use **compact** density for tables, details, footers, and status areas.
|
||||||
|
- Use **comfortable** density for forms and modals so fields remain easy to
|
||||||
|
navigate and errors have room to appear.
|
||||||
|
|
||||||
|
Predictable one-cell Unicode symbols such as `✓`, `×`, and `!` may reinforce a
|
||||||
|
state. Emoji are not structural UI: their width, color, and availability vary
|
||||||
|
too widely. Essential meaning must survive if a symbol is absent or rendered
|
||||||
|
poorly. Details, help, and messages therefore use words in addition to any
|
||||||
|
marker.
|
||||||
|
|
||||||
|
## Borders and hierarchy
|
||||||
|
|
||||||
|
Rounded single-line borders define major regions: Search, Host Entries, and
|
||||||
|
Entry Details. Nested borders should not make every value compete with its
|
||||||
|
parent region. Within a region, prefer alignment, whitespace, text weight, and
|
||||||
|
subtle surface changes.
|
||||||
|
|
||||||
|
Inputs receive an input treatment only while they are editable. Read-only
|
||||||
|
values are label-value text, not disabled inputs. Thick borders are reserved
|
||||||
|
for modals; the error color is reserved for destructive confirmation and
|
||||||
|
failure states.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```text
|
||||||
|
╭─ Entry Details ─────────────────────╮
|
||||||
|
│ IP address 192.0.2.10 │
|
||||||
|
│ Hostnames api.local, api │
|
||||||
|
│ Comment — │
|
||||||
|
│ State ✓ Active │
|
||||||
|
╰─────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
```text
|
||||||
|
╭─ Entry Details ─────────────────────╮
|
||||||
|
│ ╭─ IP Address ────────────────────╮ │
|
||||||
|
│ │ disabled input │ │
|
||||||
|
│ ╰────────────────────────────────╯ │
|
||||||
|
│ ╭─ Comment ───────────────────────╮ │
|
||||||
|
│ │ disabled input │ │
|
||||||
|
│ ╰────────────────────────────────╯ │
|
||||||
|
╰─────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
## State grammar
|
||||||
|
|
||||||
|
Each interaction state has a distinct visual channel:
|
||||||
|
|
||||||
|
| State | Required treatment |
|
||||||
|
| --- | --- |
|
||||||
|
| Focus | Bright primary border or cursor that remains visible independently of selection |
|
||||||
|
| Selection | Contrasting row background; moving focus away must not erase which Host Entry is selected |
|
||||||
|
| Active Entry | Stable marker plus normal or success-toned text |
|
||||||
|
| Inactive Entry | Different marker plus warning-toned or muted text; italics may reinforce it |
|
||||||
|
| Default Entry | Explicit protected marker plus muted treatment and an explanation in Entry Details |
|
||||||
|
| Disabled action | Muted but still visible when hiding it would make a restriction mysterious |
|
||||||
|
| Work in progress | Text such as `Resolving…` plus an optional one-cell activity marker |
|
||||||
|
| Failure | Error token plus an actionable textual message |
|
||||||
|
| Privileged Mode | Persistent `PRIVILEGED` wording plus restrained warning emphasis |
|
||||||
|
|
||||||
|
Selection, focus, activity, and protection are independent. A selected
|
||||||
|
Inactive Entry must still look selected; a focused Default Entry must still
|
||||||
|
look protected.
|
||||||
|
|
||||||
|
## Core components
|
||||||
|
|
||||||
|
### Search and filters
|
||||||
|
|
||||||
|
The top Search field performs immediate text filtering. `Ctrl+F` opens advanced
|
||||||
|
criteria. Both surfaces edit one coherent filter state and must show the same
|
||||||
|
search term.
|
||||||
|
|
||||||
|
When non-text criteria are active, the workspace exposes a compact indicator,
|
||||||
|
such as `Filters: 2`. A no-results state says that filters matched no Host
|
||||||
|
Entries and presents the shortcut for changing or clearing them.
|
||||||
|
|
||||||
|
Placeholders may provide examples, but never replace a persistent label when a
|
||||||
|
field's meaning would otherwise disappear after typing.
|
||||||
|
|
||||||
|
### Host Entries table
|
||||||
|
|
||||||
|
The table is a scan surface, not a compressed details view. Preserve columns
|
||||||
|
under horizontal pressure in this order:
|
||||||
|
|
||||||
|
1. state;
|
||||||
|
2. IP address;
|
||||||
|
3. Canonical Hostname;
|
||||||
|
4. DNS status.
|
||||||
|
|
||||||
|
Never truncate an IP address. Long hostnames may end in an ellipsis; Entry
|
||||||
|
Details shows their complete values. Column widths remain stable while the
|
||||||
|
selection moves. DNS status uses a short, predictable marker in the table and
|
||||||
|
complete wording in Entry Details.
|
||||||
|
|
||||||
|
### Entry Details
|
||||||
|
|
||||||
|
Display inspection data as compact aligned label-value rows. Ordinary
|
||||||
|
IP-address Host Entries do not show empty DNS Name, DNS Status, or Last Resolved
|
||||||
|
rows. DNS Entry fields appear only when they apply. Use `—` for an absent
|
||||||
|
optional value such as a comment.
|
||||||
|
|
||||||
|
When no Host Entry is selected, say so rather than showing a field-shaped empty
|
||||||
|
screen. When no entries exist or no filter results match, name the condition
|
||||||
|
and offer the relevant next action.
|
||||||
|
|
||||||
|
### Entry Editor and forms
|
||||||
|
|
||||||
|
Forms use persistent labels, consistent required-field markers, and a visual
|
||||||
|
order that matches their Tab order. Validation appears directly beneath the
|
||||||
|
responsible field, retains the user's input, and explains how to correct the
|
||||||
|
value.
|
||||||
|
|
||||||
|
The primary action is visually prominent and last in keyboard order. Cancel is
|
||||||
|
always available and non-destructive. Arrow keys operate grouped controls such
|
||||||
|
as radio buttons; Tab and Shift+Tab move between fields and action groups.
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
Modals are centered, bounded by the viewport, and share this structure:
|
||||||
|
|
||||||
|
1. concise title;
|
||||||
|
2. explanation or form content;
|
||||||
|
3. inline validation or consequence text;
|
||||||
|
4. action row.
|
||||||
|
|
||||||
|
Neutral and complex modals use the primary border. Only destructive
|
||||||
|
confirmation uses the error border. `Escape` cancels without mutation. Initial
|
||||||
|
focus lands on the safest sensible action; destructive confirmation never
|
||||||
|
defaults to the destructive button.
|
||||||
|
|
||||||
|
### Footer and help
|
||||||
|
|
||||||
|
The footer is contextual. It shows the small set of actions most relevant to
|
||||||
|
the current state rather than the complete binding catalog. Descriptions may
|
||||||
|
shorten in compact mode, but the keys and durable state remain visible. The
|
||||||
|
right side reports entry count, active filters, and either `READ-ONLY` or
|
||||||
|
`PRIVILEGED`.
|
||||||
|
|
||||||
|
`?` opens a dedicated overlay or screen instead of docking a panel that shrinks
|
||||||
|
the workspace. Group help under General, Navigation, Filtering, and Privileged
|
||||||
|
Mode. `?` and `Escape` close it and restore previous focus.
|
||||||
|
|
||||||
|
## Feedback and safety
|
||||||
|
|
||||||
|
The reserved message rail reports action results without covering content or
|
||||||
|
moving the master-detail workspace.
|
||||||
|
|
||||||
|
- Routine success and informational messages may expire.
|
||||||
|
- Errors, warnings, and unresolved safety conditions persist until the user
|
||||||
|
acknowledges them or a later result supersedes them.
|
||||||
|
- Durable state such as mode and active filters belongs in the footer.
|
||||||
|
- An error says what happened, whether state changed, and what the user can do
|
||||||
|
next.
|
||||||
|
|
||||||
|
Use the canonical terms from `CONTEXT.md`: **Read-only Mode**, **Privileged
|
||||||
|
Mode**, and **Entry Editor**. `Edit Mode` must not name Privileged Mode.
|
||||||
|
|
||||||
|
Entering Privileged Mode does not recolor the entire interface. A persistent,
|
||||||
|
warning-toned `PRIVILEGED` indicator communicates elevated capability without
|
||||||
|
normalizing alarm colors. Mutating actions become available contextually.
|
||||||
|
|
||||||
|
Default Entries have a visible protected marker and an explanation in Entry
|
||||||
|
Details. When a protected mutation is unavailable, keep the action visible if
|
||||||
|
hiding it would make the restriction unclear, and explain the restriction in
|
||||||
|
the message rail or help.
|
||||||
|
|
||||||
|
Confirm destructive operations and attempts to leave an Entry Editor with
|
||||||
|
unsaved changes. Name the affected Host Entry and initially focus the safest
|
||||||
|
sensible action. Routine navigation and inspection do not require
|
||||||
|
confirmation.
|
||||||
|
|
||||||
|
DNS resolution and other asynchronous work keep the interface responsive.
|
||||||
|
Show `Resolving…` inline for the affected DNS Entry. Completion or failure must
|
||||||
|
not move selection or steal focus unexpectedly.
|
||||||
|
|
||||||
|
Motion is optional, brief, and nonessential. It must not delay input, hide
|
||||||
|
state, or be necessary to understand an outcome.
|
||||||
|
|
||||||
|
## Input and focus
|
||||||
|
|
||||||
|
Every workflow must be keyboard-complete. Mouse support should work where
|
||||||
|
Textual provides it naturally, but no action may depend on hover or a pointer.
|
||||||
|
|
||||||
|
- Tab order follows visual reading order.
|
||||||
|
- Arrow keys operate tables and controls that conventionally use them.
|
||||||
|
- `Escape` backs out without mutation or opens the applicable unsaved-change
|
||||||
|
confirmation.
|
||||||
|
- Closing a modal or help restores focus to the control that opened it.
|
||||||
|
- Application shortcuts must not fire while a user is typing into a field
|
||||||
|
unless the shortcut is a conventional editing or escape command.
|
||||||
|
- Focus remains visible in dark, light, 256-color, and degraded-color output.
|
||||||
|
|
||||||
|
## Writing
|
||||||
|
|
||||||
|
English is the canonical interface language. Use concise, direct verbs and the
|
||||||
|
domain language in `CONTEXT.md`. Write complete strings and avoid layout
|
||||||
|
assumptions that make later localization needlessly difficult; an
|
||||||
|
internationalization architecture is outside the current contract.
|
||||||
|
|
||||||
|
Good and avoid:
|
||||||
|
|
||||||
|
| Good | Avoid | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Enter Privileged Mode` | `Toggle edit mode` | Names the resulting safety state |
|
||||||
|
| `Delete Host Entry` | `Delete Entry!` | Uses the canonical concept without decorative alarm |
|
||||||
|
| `Save failed; previous state restored. Check write access and try again.` | `❌ Save error` | States outcome, data safety, and recovery |
|
||||||
|
| `No Host Entries match the current filters.` | `Nothing here` | Names the condition |
|
||||||
|
| `—` | `N/A` | Represents an absent optional value without implying an error |
|
||||||
|
|
||||||
|
## Accessibility and terminal compatibility
|
||||||
|
|
||||||
|
The supported visual baseline is a common 256-color terminal. Truecolor may
|
||||||
|
improve fidelity. Monochrome or degraded-color output may lose polish but must
|
||||||
|
retain all essential meaning through wording, markers, position, and text
|
||||||
|
style.
|
||||||
|
|
||||||
|
Do not depend on:
|
||||||
|
|
||||||
|
- color as the only state signal;
|
||||||
|
- italics, dim text, or animation as the only distinction;
|
||||||
|
- emoji or ambiguous-width glyphs;
|
||||||
|
- a particular terminal font;
|
||||||
|
- mouse hover;
|
||||||
|
- content hidden outside the supported viewport without a visible way to reach
|
||||||
|
it.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Changes to the TUI must preserve behavior and essential visibility at 120×40
|
||||||
|
and 100×30. Tests should use Textual's test harness to cover:
|
||||||
|
|
||||||
|
- keyboard-complete workflows;
|
||||||
|
- visual-order focus traversal and focus restoration;
|
||||||
|
- selection remaining distinct from focus and entry state;
|
||||||
|
- mode, protection, validation, progress, and failure wording;
|
||||||
|
- conditional detail fields and empty states;
|
||||||
|
- absence of clipped or inaccessible essential controls at supported sizes.
|
||||||
|
|
||||||
|
Test structure and semantics rather than terminal pixels or exact glyph
|
||||||
|
rendering. Significant visual changes also receive an `agent-tui` inspection at
|
||||||
|
both supported sizes. Screenshots are illustrative evidence, not golden output.
|
||||||
|
|
||||||
|
Before review, check:
|
||||||
|
|
||||||
|
- Does the change preserve the calm operations-console character?
|
||||||
|
- Are all states legible without color?
|
||||||
|
- Is the keyboard path complete and ordered like the screen?
|
||||||
|
- Are canonical Hosts Management terms used?
|
||||||
|
- Does feedback remain visible for as long as it matters?
|
||||||
|
- Are 120×40 and 100×30 both usable without essential clipping?
|
||||||
|
- Does the full key reference remain reachable through `?`?
|
||||||
|
|
||||||
|
## Evolving the guide
|
||||||
|
|
||||||
|
This contract applies to the whole TUI, including existing screens. Track
|
||||||
|
current deviations in Forgejo rather than weakening the rules with permanent
|
||||||
|
exceptions.
|
||||||
|
|
||||||
|
An intentional departure must be explained in the relevant change. Update
|
||||||
|
this guide when the departure should become the new standard. Add an ADR only
|
||||||
|
when the decision is consequential, surprising, difficult to reverse, and the
|
||||||
|
result of a real trade-off.
|
||||||
|
|
||||||
|
|
@ -45,7 +45,17 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
||||||
)
|
)
|
||||||
yield RadioButton("DNS Name Entry", id="dns-entry-radio")
|
yield RadioButton("DNS Name Entry", id="dns-entry-radio")
|
||||||
|
|
||||||
# IP Address Section
|
# Hostnames Section
|
||||||
|
with Vertical(classes="default-section") as hostnames:
|
||||||
|
hostnames.border_title = "Hostnames"
|
||||||
|
yield Input(
|
||||||
|
placeholder="e.g., example.com, www.example.com",
|
||||||
|
id="hostnames-input",
|
||||||
|
classes="default-input",
|
||||||
|
)
|
||||||
|
yield Static("", id="hostnames-error", classes="validation-error")
|
||||||
|
|
||||||
|
# Address sections follow Hostnames in both visual and Tab order.
|
||||||
with Vertical(classes="default-section", id="ip-section") as ip_address:
|
with Vertical(classes="default-section", id="ip-section") as ip_address:
|
||||||
ip_address.border_title = "IP Address"
|
ip_address.border_title = "IP Address"
|
||||||
yield Input(
|
yield Input(
|
||||||
|
|
@ -55,7 +65,6 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
||||||
)
|
)
|
||||||
yield Static("", id="ip-error", classes="validation-error")
|
yield Static("", id="ip-error", classes="validation-error")
|
||||||
|
|
||||||
# DNS Name Section (initially hidden)
|
|
||||||
with Vertical(
|
with Vertical(
|
||||||
classes="default-section hidden", id="dns-section"
|
classes="default-section hidden", id="dns-section"
|
||||||
) as dns_name:
|
) as dns_name:
|
||||||
|
|
@ -67,16 +76,6 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
||||||
)
|
)
|
||||||
yield Static("", id="dns-error", classes="validation-error")
|
yield Static("", id="dns-error", classes="validation-error")
|
||||||
|
|
||||||
# Hostnames Section
|
|
||||||
with Vertical(classes="default-section") as hostnames:
|
|
||||||
hostnames.border_title = "Hostnames"
|
|
||||||
yield Input(
|
|
||||||
placeholder="e.g., example.com, www.example.com",
|
|
||||||
id="hostnames-input",
|
|
||||||
classes="default-input",
|
|
||||||
)
|
|
||||||
yield Static("", id="hostnames-error", classes="validation-error")
|
|
||||||
|
|
||||||
# Comment Section
|
# Comment Section
|
||||||
with Vertical(classes="default-section") as comment:
|
with Vertical(classes="default-section") as comment:
|
||||||
comment.border_title = "Comment (optional)"
|
comment.border_title = "Comment (optional)"
|
||||||
|
|
@ -99,22 +98,21 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
||||||
# Buttons
|
# Buttons
|
||||||
with Horizontal(classes="button-row"):
|
with Horizontal(classes="button-row"):
|
||||||
yield Button(
|
yield Button(
|
||||||
"Add Entry (CTRL+S)",
|
"Cancel",
|
||||||
variant="primary",
|
|
||||||
id="add-button",
|
|
||||||
classes="default-button",
|
|
||||||
)
|
|
||||||
yield Button(
|
|
||||||
"Cancel (ESC)",
|
|
||||||
variant="default",
|
variant="default",
|
||||||
id="cancel-button",
|
id="cancel-button",
|
||||||
classes="default-button",
|
classes="default-button",
|
||||||
)
|
)
|
||||||
|
yield Button(
|
||||||
|
"Add Host Entry",
|
||||||
|
variant="primary",
|
||||||
|
id="add-button",
|
||||||
|
classes="default-button",
|
||||||
|
)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Focus IP address input when modal opens."""
|
"""Focus the entry type choice when the modal opens."""
|
||||||
ip_input = self.query_one("#entry-type-radio", RadioSet)
|
self.query_one("#entry-type-radio", RadioSet).focus()
|
||||||
ip_input.focus()
|
|
||||||
|
|
||||||
def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
|
def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
|
||||||
"""Handle entry type radio button changes."""
|
"""Handle entry type radio button changes."""
|
||||||
|
|
@ -135,9 +133,7 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
||||||
if isinstance(active_section, Vertical):
|
if isinstance(active_section, Vertical):
|
||||||
active_section.border_title = "Activate Entry"
|
active_section.border_title = "Activate Entry"
|
||||||
|
|
||||||
# Focus IP input
|
self.query_one("#hostnames-input", Input).focus()
|
||||||
ip_input = self.query_one("#ip-address-input", Input)
|
|
||||||
ip_input.focus()
|
|
||||||
elif pressed_radio and pressed_radio.id == "dns-entry-radio":
|
elif pressed_radio and pressed_radio.id == "dns-entry-radio":
|
||||||
# Show DNS section, hide IP section
|
# Show DNS section, hide IP section
|
||||||
ip_section = self.query_one("#ip-section")
|
ip_section = self.query_one("#ip-section")
|
||||||
|
|
@ -155,9 +151,7 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
||||||
"Activate Entry (DNS entries activate after resolution)"
|
"Activate Entry (DNS entries activate after resolution)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Focus DNS input
|
self.query_one("#hostnames-input", Input).focus()
|
||||||
dns_input = self.query_one("#dns-name-input", Input)
|
|
||||||
dns_input.focus()
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle button presses."""
|
"""Handle button presses."""
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,9 @@ from .config_modal import ConfigModal
|
||||||
from .add_entry_modal import AddEntryModal
|
from .add_entry_modal import AddEntryModal
|
||||||
from .delete_confirmation_modal import DeleteConfirmationModal
|
from .delete_confirmation_modal import DeleteConfirmationModal
|
||||||
from .filter_modal import FilterModal
|
from .filter_modal import FilterModal
|
||||||
|
from .help_modal import HelpModal
|
||||||
from .custom_footer import CustomFooter
|
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 .keybindings import HOSTS_MANAGER_BINDINGS
|
||||||
from .table_handler import TableHandler
|
from .table_handler import TableHandler
|
||||||
from .details_handler import DetailsHandler
|
from .details_handler import DetailsHandler
|
||||||
|
|
@ -60,8 +61,6 @@ class HostsManagerApp(App):
|
||||||
CSS = HOSTS_MANAGER_CSS
|
CSS = HOSTS_MANAGER_CSS
|
||||||
BINDINGS = HOSTS_MANAGER_BINDINGS
|
BINDINGS = HOSTS_MANAGER_BINDINGS
|
||||||
|
|
||||||
help_visible = False
|
|
||||||
|
|
||||||
# Reactive attributes
|
# Reactive attributes
|
||||||
hosts_file: reactive[HostsFile] = reactive(HostsFile())
|
hosts_file: reactive[HostsFile] = reactive(HostsFile())
|
||||||
selected_entry_index: reactive[int] = reactive(0)
|
selected_entry_index: reactive[int] = reactive(0)
|
||||||
|
|
@ -74,6 +73,8 @@ class HostsManagerApp(App):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.title = "/etc/hosts Manager"
|
self.title = "/etc/hosts Manager"
|
||||||
|
self.register_theme(HOSTS_DARK_THEME)
|
||||||
|
self.theme = "hosts-dark"
|
||||||
|
|
||||||
# Initialize core components
|
# Initialize core components
|
||||||
self.parser = HostsParser()
|
self.parser = HostsParser()
|
||||||
|
|
@ -99,166 +100,212 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
# State for edit mode
|
# State for edit mode
|
||||||
self.original_entry_values = None
|
self.original_entry_values = None
|
||||||
|
self._status_timer = None
|
||||||
|
self._viewport_too_small = False
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create child widgets for the app."""
|
"""Create child widgets for the app."""
|
||||||
yield Header()
|
yield Header()
|
||||||
yield CustomFooter(id="custom-footer")
|
yield CustomFooter(id="custom-footer")
|
||||||
|
yield Static("", id="message-rail")
|
||||||
|
yield Static("", id="minimum-size", classes="hidden")
|
||||||
|
|
||||||
# Search bar above the panes
|
with Vertical(id="workspace"):
|
||||||
with Horizontal(classes="search-container") as search_container:
|
# Search remains visible above the master-detail workspace.
|
||||||
search_container.border_title = "Search"
|
with Horizontal(classes="search-container") as search_container:
|
||||||
yield Input(
|
search_container.border_title = "Search"
|
||||||
placeholder="Filter by hostname, IP address, or comment...",
|
yield Input(
|
||||||
id="search-input",
|
placeholder="Filter by hostname, IP address, or comment...",
|
||||||
classes="search-input",
|
id="search-input",
|
||||||
)
|
classes="search-input",
|
||||||
|
)
|
||||||
|
yield Static("", id="filter-summary")
|
||||||
|
|
||||||
with Horizontal(classes="hosts-container"):
|
with Horizontal(classes="hosts-container"):
|
||||||
# Left pane - entries table
|
# Left pane - entries table
|
||||||
with Vertical(classes="common-pane left-pane") as left_pane:
|
with Vertical(classes="common-pane left-pane") as left_pane:
|
||||||
left_pane.border_title = "Host Entries"
|
left_pane.border_title = "Host Entries"
|
||||||
yield DataTable(id="entries-table")
|
yield DataTable(id="entries-table")
|
||||||
|
|
||||||
# Right pane - entry details or edit form
|
# Right pane - entry details or edit form
|
||||||
with Vertical(classes="common-pane right-pane") as right_pane:
|
with Vertical(classes="common-pane right-pane") as right_pane:
|
||||||
right_pane.border_title = "Entry Details"
|
right_pane.border_title = "Entry Details"
|
||||||
|
|
||||||
# Details display form (disabled inputs)
|
# Inspection values deliberately use text, not disabled inputs.
|
||||||
with Vertical(id="entry-details-display", classes="entry-form"):
|
with Vertical(id="entry-details-display", classes="entry-form"):
|
||||||
with Vertical(
|
yield Static(
|
||||||
classes="default-section section-no-top-margin"
|
"No Host Entry selected.",
|
||||||
) as ip_address:
|
id="details-empty-state",
|
||||||
ip_address.border_title = "IP Address"
|
classes="empty-state",
|
||||||
yield Input(
|
|
||||||
placeholder="No entry selected",
|
|
||||||
id="details-ip-input",
|
|
||||||
disabled=True,
|
|
||||||
classes="default-input",
|
|
||||||
)
|
)
|
||||||
|
with Vertical(
|
||||||
with Vertical(classes="default-section") as hostnames:
|
id="details-content", classes="detail-rows hidden"
|
||||||
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"
|
|
||||||
):
|
):
|
||||||
yield RadioButton(
|
with Horizontal(classes="detail-row"):
|
||||||
"IP Address Entry", value=True, id="edit-ip-entry-radio"
|
yield Static("IP address", classes="detail-label")
|
||||||
)
|
yield Static(
|
||||||
yield RadioButton(
|
"", id="details-ip-input", classes="detail-value"
|
||||||
"DNS Name Entry", id="edit-dns-entry-radio"
|
)
|
||||||
|
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
|
# Edit form (initially hidden)
|
||||||
with Vertical(
|
with Vertical(id="entry-edit-form", classes="entry-form hidden"):
|
||||||
classes="default-section", id="edit-ip-section"
|
with Vertical(
|
||||||
) as ip_address:
|
classes="default-flex-section section-no-top-margin"
|
||||||
ip_address.border_title = "IP Address"
|
) as entry_type:
|
||||||
yield Input(
|
entry_type.border_title = "Entry Type"
|
||||||
placeholder="Enter IP address",
|
with RadioSet(
|
||||||
id="ip-input",
|
id="edit-entry-type-radio", classes="default-radio-set"
|
||||||
classes="default-input",
|
):
|
||||||
)
|
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") as hostnames:
|
||||||
with Vertical(
|
hostnames.border_title = "Hostnames (comma-separated)"
|
||||||
classes="default-section hidden", id="edit-dns-section"
|
yield Input(
|
||||||
) as dns_name:
|
placeholder="Enter hostnames",
|
||||||
dns_name.border_title = "DNS Name (to resolve)"
|
id="hostname-input",
|
||||||
yield Input(
|
classes="default-input",
|
||||||
placeholder="e.g., example.com",
|
)
|
||||||
id="dns-name-input",
|
|
||||||
classes="default-input",
|
|
||||||
)
|
|
||||||
|
|
||||||
with Vertical(classes="default-section") as hostnames:
|
with Vertical(
|
||||||
hostnames.border_title = "Hostnames (comma-separated)"
|
classes="default-section", id="edit-ip-section"
|
||||||
yield Input(
|
) as ip_address:
|
||||||
placeholder="Enter hostnames",
|
ip_address.border_title = "IP Address"
|
||||||
id="hostname-input",
|
yield Input(
|
||||||
classes="default-input",
|
placeholder="Enter IP address",
|
||||||
)
|
id="ip-input",
|
||||||
|
classes="default-input",
|
||||||
|
)
|
||||||
|
|
||||||
with Vertical(classes="default-section") as comment:
|
with Vertical(
|
||||||
comment.border_title = "Comment:"
|
classes="default-section hidden", id="edit-dns-section"
|
||||||
yield Input(
|
) as dns_name:
|
||||||
placeholder="Enter comment (optional)",
|
dns_name.border_title = "DNS Name (to resolve)"
|
||||||
id="comment-input",
|
yield Input(
|
||||||
classes="default-input",
|
placeholder="e.g., example.com",
|
||||||
)
|
id="dns-name-input",
|
||||||
|
classes="default-input",
|
||||||
|
)
|
||||||
|
|
||||||
with Vertical(classes="default-section") as active:
|
with Vertical(classes="default-section") as comment:
|
||||||
active.border_title = "Active"
|
comment.border_title = "Comment:"
|
||||||
yield Checkbox(
|
yield Input(
|
||||||
"Active", id="active-checkbox", classes="default-checkbox"
|
placeholder="Enter comment (optional)",
|
||||||
)
|
id="comment-input",
|
||||||
|
classes="default-input",
|
||||||
|
)
|
||||||
|
|
||||||
# Status bar for error/temporary messages (overlay, doesn't affect layout)
|
with Vertical(classes="default-section") as active:
|
||||||
yield Static("", id="status-bar", classes="status-bar hidden")
|
active.border_title = "Active"
|
||||||
|
yield Checkbox(
|
||||||
|
"Active",
|
||||||
|
id="active-checkbox",
|
||||||
|
classes="default-checkbox",
|
||||||
|
)
|
||||||
|
|
||||||
def on_ready(self) -> None:
|
def on_ready(self) -> None:
|
||||||
"""Called when the app is ready."""
|
"""Called when the app is ready."""
|
||||||
self.load_hosts_file()
|
self.load_hosts_file()
|
||||||
self._setup_footer()
|
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:
|
def load_hosts_file(self) -> None:
|
||||||
"""Load the hosts file and populate the table."""
|
"""Load the hosts file and populate the table."""
|
||||||
|
|
@ -276,7 +323,7 @@ class HostsManagerApp(App):
|
||||||
self.table_handler.restore_cursor_position(previous_entry)
|
self.table_handler.restore_cursor_position(previous_entry)
|
||||||
self.update_status()
|
self.update_status()
|
||||||
except Exception as e:
|
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:
|
def _setup_footer(self) -> None:
|
||||||
"""Setup the footer with initial content based on keybindings."""
|
"""Setup the footer with initial content based on keybindings."""
|
||||||
|
|
@ -287,19 +334,45 @@ class HostsManagerApp(App):
|
||||||
footer.clear_left_items()
|
footer.clear_left_items()
|
||||||
footer.clear_right_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:
|
for binding in self.BINDINGS:
|
||||||
# Skip tuple-style bindings and only process Binding objects
|
# Skip tuple-style bindings and only process Binding objects
|
||||||
if not isinstance(binding, Binding):
|
if not isinstance(binding, Binding):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Only show bindings marked with show=True
|
# Only show bindings marked with show=True
|
||||||
if binding.show:
|
if binding.show and binding.action in visible_actions:
|
||||||
# Get the display key
|
# Get the display key
|
||||||
key_display = getattr(binding, "key_display", None) or binding.key
|
key_display = getattr(binding, "key_display", None) or binding.key
|
||||||
|
|
||||||
# Get the description
|
descriptions = {
|
||||||
description = binding.description or binding.action
|
"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
|
# Determine positioning from id attribute
|
||||||
binding_id = getattr(binding, "id", None)
|
binding_id = getattr(binding, "id", None)
|
||||||
|
|
@ -320,44 +393,98 @@ class HostsManagerApp(App):
|
||||||
"""Update the footer status section."""
|
"""Update the footer status section."""
|
||||||
try:
|
try:
|
||||||
footer = self.query_one("#custom-footer", CustomFooter)
|
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)
|
entry_count = len(self.hosts_file.entries)
|
||||||
active_count = len(self.hosts_file.get_active_entries())
|
active_count = len(self.hosts_file.get_active_entries())
|
||||||
|
filter_count = self._active_filter_count()
|
||||||
status = f"{entry_count} entries ({active_count} active) | {mode}"
|
status = (
|
||||||
|
f"{entry_count} entries ({active_count} active) · "
|
||||||
|
f"Filters: {filter_count} · {mode}"
|
||||||
|
)
|
||||||
footer.set_status(status)
|
footer.set_status(status)
|
||||||
|
self.query_one("#filter-summary", Static).update(
|
||||||
|
f"Filters: {filter_count}" if filter_count else ""
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Footer not ready yet
|
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:
|
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:
|
if message:
|
||||||
# Show temporary message in the status bar
|
|
||||||
try:
|
try:
|
||||||
status_bar = self.query_one("#status-bar", Static)
|
if self._status_timer is not None:
|
||||||
status_bar.update(message)
|
self._status_timer.stop()
|
||||||
status_bar.remove_class("hidden")
|
self._status_timer = None
|
||||||
|
rail = self.query_one("#message-rail", Static)
|
||||||
if message.startswith("❌"):
|
normalized = (
|
||||||
# Auto-clear error message after 5 seconds
|
message.lstrip("✓×!· ")
|
||||||
self.set_timer(5.0, lambda: self._clear_status_message())
|
.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:
|
else:
|
||||||
# Auto-clear regular message after 3 seconds
|
self._status_timer = self.set_timer(
|
||||||
self.set_timer(3.0, lambda: self._clear_status_message())
|
3.0, lambda: self._clear_status_message()
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback if status bar not found (during initialization)
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Always update the header subtitle with current status
|
|
||||||
# Update the footer status
|
|
||||||
self._update_footer_status()
|
self._update_footer_status()
|
||||||
|
|
||||||
def _clear_status_message(self) -> None:
|
def _clear_status_message(self) -> None:
|
||||||
"""Clear the temporary status message."""
|
"""Clear the temporary status message."""
|
||||||
try:
|
try:
|
||||||
status_bar = self.query_one("#status-bar", Static)
|
rail = self.query_one("#message-rail", Static)
|
||||||
status_bar.update("")
|
rail.update("")
|
||||||
status_bar.add_class("hidden")
|
rail.remove_class("message-error")
|
||||||
|
rail.remove_class("message-warning")
|
||||||
|
self._status_timer = None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -370,6 +497,13 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool:
|
def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool:
|
||||||
"""Persist a mutation, restoring its complete pre-action state on failure."""
|
"""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(
|
save_success, save_message, hosts_file = self.manager.save_mutation(
|
||||||
self.hosts_file, snapshot.manager_state
|
self.hosts_file, snapshot.manager_state
|
||||||
)
|
)
|
||||||
|
|
@ -496,13 +630,9 @@ class HostsManagerApp(App):
|
||||||
self.update_status("Hosts file reloaded")
|
self.update_status("Hosts file reloaded")
|
||||||
|
|
||||||
def action_help(self) -> None:
|
def action_help(self) -> None:
|
||||||
"""Show help panel."""
|
"""Open the keyboard reference without reducing workspace width."""
|
||||||
if self.help_visible:
|
if not self.screen_stack or not isinstance(self.screen, HelpModal):
|
||||||
self.action_hide_help_panel()
|
self.push_screen(HelpModal())
|
||||||
self.help_visible = False
|
|
||||||
else:
|
|
||||||
self.action_show_help_panel()
|
|
||||||
self.help_visible = True
|
|
||||||
|
|
||||||
def action_config(self) -> None:
|
def action_config(self) -> None:
|
||||||
"""Show configuration modal."""
|
"""Show configuration modal."""
|
||||||
|
|
@ -517,6 +647,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_show_filters(self) -> None:
|
def action_show_filters(self) -> None:
|
||||||
"""Open the advanced filter controls and apply their returned options."""
|
"""Open the advanced filter controls and apply their returned options."""
|
||||||
|
if isinstance(self.screen, FilterModal):
|
||||||
|
return
|
||||||
|
|
||||||
def handle_filter_result(filter_options: FilterOptions | None) -> None:
|
def handle_filter_result(filter_options: FilterOptions | None) -> None:
|
||||||
if filter_options is None:
|
if filter_options is None:
|
||||||
|
|
@ -564,6 +696,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_toggle_edit_mode(self) -> None:
|
def action_toggle_edit_mode(self) -> None:
|
||||||
"""Toggle between read-only and edit mode."""
|
"""Toggle between read-only and edit mode."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
if self.edit_mode:
|
if self.edit_mode:
|
||||||
# Exit edit mode
|
# Exit edit mode
|
||||||
success, message = self.manager.exit_edit_mode()
|
success, message = self.manager.exit_edit_mode()
|
||||||
|
|
@ -626,6 +760,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_edit_entry(self) -> None:
|
def action_edit_entry(self) -> None:
|
||||||
"""Enter edit mode for the selected entry."""
|
"""Enter edit mode for the selected entry."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status(
|
self.update_status(
|
||||||
"❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
"❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
|
@ -665,6 +801,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_exit_edit_entry(self) -> None:
|
def action_exit_edit_entry(self) -> None:
|
||||||
"""Exit entry edit mode and return focus to the entries table."""
|
"""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()
|
self.edit_handler.exit_edit_entry_with_confirmation()
|
||||||
|
|
||||||
def action_next_field(self) -> None:
|
def action_next_field(self) -> None:
|
||||||
|
|
@ -677,22 +815,32 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_toggle_entry(self) -> None:
|
def action_toggle_entry(self) -> None:
|
||||||
"""Toggle the active state of the selected entry."""
|
"""Toggle the active state of the selected entry."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
self.navigation_handler.toggle_entry()
|
self.navigation_handler.toggle_entry()
|
||||||
|
|
||||||
def action_move_entry_up(self) -> None:
|
def action_move_entry_up(self) -> None:
|
||||||
"""Move the selected entry up in the list."""
|
"""Move the selected entry up in the list."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
self.navigation_handler.move_entry_up()
|
self.navigation_handler.move_entry_up()
|
||||||
|
|
||||||
def action_move_entry_down(self) -> None:
|
def action_move_entry_down(self) -> None:
|
||||||
"""Move the selected entry down in the list."""
|
"""Move the selected entry down in the list."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
self.navigation_handler.move_entry_down()
|
self.navigation_handler.move_entry_down()
|
||||||
|
|
||||||
def action_save_file(self) -> None:
|
def action_save_file(self) -> None:
|
||||||
"""Save the hosts file to disk."""
|
"""Save the hosts file to disk."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
self.navigation_handler.save_hosts_file()
|
self.navigation_handler.save_hosts_file()
|
||||||
|
|
||||||
def action_add_entry(self) -> None:
|
def action_add_entry(self) -> None:
|
||||||
"""Show the add entry modal."""
|
"""Show the add entry modal."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status(
|
self.update_status(
|
||||||
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
|
@ -736,6 +884,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_delete_entry(self) -> None:
|
def action_delete_entry(self) -> None:
|
||||||
"""Show the delete confirmation modal for the selected entry."""
|
"""Show the delete confirmation modal for the selected entry."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status(
|
self.update_status(
|
||||||
"❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
"❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
|
@ -792,6 +942,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_undo(self) -> None:
|
def action_undo(self) -> None:
|
||||||
"""Undo the last operation."""
|
"""Undo the last operation."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status("❌ Cannot undo: Application is in read-only mode")
|
self.update_status("❌ Cannot undo: Application is in read-only mode")
|
||||||
return
|
return
|
||||||
|
|
@ -819,6 +971,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_redo(self) -> None:
|
def action_redo(self) -> None:
|
||||||
"""Redo the last undone operation."""
|
"""Redo the last undone operation."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status("❌ Cannot redo: Application is in read-only mode")
|
self.update_status("❌ Cannot redo: Application is in read-only mode")
|
||||||
return
|
return
|
||||||
|
|
@ -846,6 +1000,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_refresh_dns(self) -> None:
|
def action_refresh_dns(self) -> None:
|
||||||
"""Manually refresh DNS resolution for all entries."""
|
"""Manually refresh DNS resolution for all entries."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status(
|
self.update_status(
|
||||||
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
|
@ -940,6 +1096,8 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_update_single_dns(self) -> None:
|
def action_update_single_dns(self) -> None:
|
||||||
"""Manually refresh DNS resolution for the currently selected entry."""
|
"""Manually refresh DNS resolution for the currently selected entry."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
self.update_status(
|
self.update_status(
|
||||||
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||||
|
|
@ -1134,6 +1292,10 @@ class HostsManagerApp(App):
|
||||||
"""Update the edit form with current entry values."""
|
"""Update the edit form with current entry values."""
|
||||||
self.details_handler.update_edit_form()
|
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:
|
def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None:
|
||||||
"""Update the right pane border title when entry edit mode changes."""
|
"""Update the right pane border title when entry edit mode changes."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,14 @@ class ConfigModal(ModalScreen[bool]):
|
||||||
|
|
||||||
with Horizontal(classes="button-row"):
|
with Horizontal(classes="button-row"):
|
||||||
yield Button(
|
yield Button(
|
||||||
"Save", variant="primary", id="save-button", classes="config-button"
|
"Cancel",
|
||||||
)
|
|
||||||
yield Button(
|
|
||||||
"Cancel (ESC)",
|
|
||||||
variant="default",
|
variant="default",
|
||||||
id="cancel-button",
|
id="cancel-button",
|
||||||
classes="default-button",
|
classes="default-button",
|
||||||
)
|
)
|
||||||
|
yield Button(
|
||||||
|
"Save", variant="primary", id="save-button", classes="config-button"
|
||||||
|
)
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle button presses."""
|
"""Handle button presses."""
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ class DeleteConfirmationModal(ModalScreen[bool]):
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("escape", "cancel", "Cancel"),
|
Binding("escape", "cancel", "Cancel"),
|
||||||
Binding("enter", "confirm", "Delete"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, entry: HostEntry):
|
def __init__(self, entry: HostEntry):
|
||||||
|
|
@ -35,7 +34,7 @@ class DeleteConfirmationModal(ModalScreen[bool]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create the delete confirmation modal layout."""
|
"""Create the delete confirmation modal layout."""
|
||||||
with Vertical(classes="delete-container"):
|
with Vertical(classes="delete-container"):
|
||||||
yield Static("Delete Entry", classes="delete-title")
|
yield Static("Delete Host Entry", classes="delete-title")
|
||||||
|
|
||||||
yield Static(
|
yield Static(
|
||||||
"Are you sure you want to delete this entry?", classes="delete-message"
|
"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"):
|
with Horizontal(classes="button-row"):
|
||||||
yield Button(
|
yield Button(
|
||||||
"Delete",
|
"Cancel",
|
||||||
variant="error",
|
variant="default",
|
||||||
id="delete-button",
|
id="cancel-button",
|
||||||
classes="default-button",
|
classes="default-button",
|
||||||
)
|
)
|
||||||
yield Button(
|
yield Button(
|
||||||
"Cancel (ESC)",
|
"Delete Host Entry",
|
||||||
variant="default",
|
variant="error",
|
||||||
id="cancel-button",
|
id="delete-button",
|
||||||
classes="default-button",
|
classes="default-button",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,113 @@
|
||||||
"""
|
"""Read-only Entry Details and editable Entry Editor coordination."""
|
||||||
Details pane management for the hosts TUI application.
|
|
||||||
|
|
||||||
This module handles the display and updating of entry details
|
from textual.widgets import Checkbox, Input, Static
|
||||||
and edit forms in the right pane.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from textual.widgets import Input, Checkbox
|
|
||||||
|
|
||||||
|
|
||||||
class DetailsHandler:
|
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):
|
def __init__(self, app):
|
||||||
"""Initialize the details handler with reference to the main app."""
|
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
def update_entry_details(self) -> None:
|
def update_entry_details(self) -> None:
|
||||||
"""Update the right pane with selected entry details."""
|
|
||||||
if self.app.entry_edit_mode:
|
if self.app.entry_edit_mode:
|
||||||
self.update_edit_form()
|
self.update_edit_form()
|
||||||
else:
|
else:
|
||||||
self.update_details_display()
|
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:
|
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")
|
details_display = self.app.query_one("#entry-details-display")
|
||||||
edit_form = self.app.query_one("#entry-edit-form")
|
edit_form = self.app.query_one("#entry-edit-form")
|
||||||
|
|
||||||
# Show details display, hide edit form
|
|
||||||
details_display.remove_class("hidden")
|
details_display.remove_class("hidden")
|
||||||
edit_form.add_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:
|
if not self.app.hosts_file.entries:
|
||||||
# Show empty message
|
self._show_empty_state("No Host Entries are loaded.")
|
||||||
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
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get visible entries to check if we need to adjust selection
|
|
||||||
visible_entries = self.app.table_handler.get_visible_entries()
|
visible_entries = self.app.table_handler.get_visible_entries()
|
||||||
if not visible_entries:
|
if not visible_entries:
|
||||||
ip_input.value = ""
|
self._show_empty_state(
|
||||||
ip_input.placeholder = "No visible entries"
|
"No Host Entries match the current filters. Press Ctrl+F to change them."
|
||||||
hostname_input.value = ""
|
)
|
||||||
hostname_input.placeholder = "No visible entries"
|
|
||||||
comment_input.value = ""
|
|
||||||
comment_input.placeholder = "No visible entries"
|
|
||||||
active_checkbox.value = False
|
|
||||||
return
|
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):
|
if self.app.selected_entry_index >= len(self.app.hosts_file.entries):
|
||||||
self.app.selected_entry_index = 0
|
self.app.selected_entry_index = 0
|
||||||
|
|
||||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
|
|
||||||
# Update the input widgets with entry data
|
self.app.query_one("#details-empty-state", Static).add_class("hidden")
|
||||||
ip_input.value = entry.ip_address
|
self.app.query_one("#details-content").remove_class("hidden")
|
||||||
ip_input.placeholder = ""
|
self._set_detail("details-ip-input", entry.ip_address)
|
||||||
hostname_input.value = ", ".join(entry.hostnames)
|
self._set_detail("details-hostname-input", ", ".join(entry.hostnames))
|
||||||
hostname_input.placeholder = ""
|
self._set_detail("details-comment-input", entry.comment or "—")
|
||||||
comment_input.value = entry.comment or ""
|
self._set_detail(
|
||||||
comment_input.placeholder = "No comment"
|
"details-active-checkbox", "✓ Active" if entry.is_active else "· Inactive"
|
||||||
active_checkbox.value = entry.is_active
|
)
|
||||||
|
|
||||||
# For default entries, show warning in placeholder text
|
default_notice = self.app.query_one("#details-default-notice", Static)
|
||||||
if entry.is_default_entry():
|
if entry.is_default_entry():
|
||||||
ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
entry_state = "✓ Active" if entry.is_active else "· Inactive"
|
||||||
hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
self._set_detail(
|
||||||
comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
"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
|
dns_rows = self.app.query_one("#details-dns-rows")
|
||||||
self._update_dns_information(entry)
|
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:
|
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")
|
details_display = self.app.query_one("#entry-details-display")
|
||||||
edit_form = self.app.query_one("#entry-edit-form")
|
edit_form = self.app.query_one("#entry-edit-form")
|
||||||
|
|
||||||
# Hide details display, show edit form
|
|
||||||
details_display.add_class("hidden")
|
details_display.add_class("hidden")
|
||||||
edit_form.remove_class("hidden")
|
edit_form.remove_class("hidden")
|
||||||
|
|
||||||
|
|
@ -117,80 +117,8 @@ class DetailsHandler:
|
||||||
return
|
return
|
||||||
|
|
||||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
|
self.app.query_one("#ip-input", Input).value = entry.ip_address
|
||||||
# Update form fields with current entry values
|
self.app.query_one("#hostname-input", Input).value = ", ".join(entry.hostnames)
|
||||||
ip_input = self.app.query_one("#ip-input", Input)
|
self.app.query_one("#comment-input", Input).value = entry.comment or ""
|
||||||
hostname_input = self.app.query_one("#hostname-input", Input)
|
self.app.query_one("#active-checkbox", Checkbox).value = entry.is_active
|
||||||
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.edit_handler.populate_edit_form_with_type_detection()
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -425,9 +425,9 @@ class EditHandler:
|
||||||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
# Build field list based on current entry type
|
# Build field list based on current entry type
|
||||||
fields = [radio_set]
|
fields = [radio_set, hostname_input]
|
||||||
|
|
||||||
# Add IP or DNS field based on visibility
|
# The address field follows Hostnames in the visual editor.
|
||||||
try:
|
try:
|
||||||
ip_section = self.app.query_one("#edit-ip-section")
|
ip_section = self.app.query_one("#edit-ip-section")
|
||||||
if not ip_section.has_class("hidden"):
|
if not ip_section.has_class("hidden"):
|
||||||
|
|
@ -444,8 +444,7 @@ class EditHandler:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add remaining fields
|
fields.extend([comment_input, active_checkbox])
|
||||||
fields.extend([hostname_input, comment_input, active_checkbox])
|
|
||||||
|
|
||||||
# Find currently focused field and move to next
|
# Find currently focused field and move to next
|
||||||
for i, field in enumerate(fields):
|
for i, field in enumerate(fields):
|
||||||
|
|
@ -471,9 +470,9 @@ class EditHandler:
|
||||||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
# Build field list based on current entry type
|
# Build field list based on current entry type
|
||||||
fields = [radio_set]
|
fields = [radio_set, hostname_input]
|
||||||
|
|
||||||
# Add IP or DNS field based on visibility
|
# The address field follows Hostnames in the visual editor.
|
||||||
try:
|
try:
|
||||||
ip_section = self.app.query_one("#edit-ip-section")
|
ip_section = self.app.query_one("#edit-ip-section")
|
||||||
if not ip_section.has_class("hidden"):
|
if not ip_section.has_class("hidden"):
|
||||||
|
|
@ -490,8 +489,7 @@ class EditHandler:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add remaining fields
|
fields.extend([comment_input, active_checkbox])
|
||||||
fields.extend([hostname_input, comment_input, active_checkbox])
|
|
||||||
|
|
||||||
# Find currently focused field and move to previous
|
# Find currently focused field and move to previous
|
||||||
for i, field in enumerate(fields):
|
for i, field in enumerate(fields):
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,15 @@ filtering options including status, type, resolution status, and search filterin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Grid, Horizontal, Container
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
from textual.widgets import (
|
from textual.widgets import (
|
||||||
Static,
|
Static,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Label,
|
|
||||||
RadioSet,
|
RadioSet,
|
||||||
RadioButton,
|
RadioButton,
|
||||||
Collapsible,
|
|
||||||
)
|
)
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual import on
|
from textual import on
|
||||||
|
|
@ -24,131 +22,19 @@ from textual.binding import Binding
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
|
|
||||||
from ..core.filters import FilterOptions, EntryFilter
|
from ..core.filters import FilterOptions, EntryFilter
|
||||||
|
from .styles import FILTER_MODAL_CSS
|
||||||
|
|
||||||
|
|
||||||
class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
"""Advanced filtering configuration modal."""
|
"""Advanced filtering configuration modal."""
|
||||||
|
|
||||||
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
BINDINGS = [
|
||||||
|
Binding("escape", "cancel", "Cancel"),
|
||||||
|
Binding("tab", "focus_next", show=False),
|
||||||
|
Binding("shift+tab", "focus_previous", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
CSS = FILTER_MODAL_CSS
|
||||||
FilterModal {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter-dialog {
|
|
||||||
grid-size: 1;
|
|
||||||
grid-gutter: 1 2;
|
|
||||||
grid-rows: auto 1fr auto;
|
|
||||||
padding: 0 1;
|
|
||||||
width: 80;
|
|
||||||
height: auto;
|
|
||||||
border: thick $background 80%;
|
|
||||||
background: $surface;
|
|
||||||
max-height: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter-header {
|
|
||||||
dock: top;
|
|
||||||
width: 1fr;
|
|
||||||
height: 3;
|
|
||||||
content-align: center middle;
|
|
||||||
text-style: bold;
|
|
||||||
background: $primary;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter-content {
|
|
||||||
layout: vertical;
|
|
||||||
overflow-y: auto;
|
|
||||||
height: auto;
|
|
||||||
max-height: 70vh;
|
|
||||||
padding: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter-actions {
|
|
||||||
dock: bottom;
|
|
||||||
layout: horizontal;
|
|
||||||
width: 1fr;
|
|
||||||
height: 3;
|
|
||||||
align: center middle;
|
|
||||||
padding: 0 1;
|
|
||||||
background: $panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
margin: 1 0;
|
|
||||||
padding: 1;
|
|
||||||
border: round $primary 20%;
|
|
||||||
background: $panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section-title {
|
|
||||||
text-style: bold;
|
|
||||||
color: $primary;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-checkboxes {
|
|
||||||
layout: vertical;
|
|
||||||
margin: 0 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-radios {
|
|
||||||
layout: vertical;
|
|
||||||
margin: 0 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input-row {
|
|
||||||
layout: horizontal;
|
|
||||||
margin: 0 2;
|
|
||||||
height: 3;
|
|
||||||
align: left middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input-label {
|
|
||||||
width: 20;
|
|
||||||
content-align: left middle;
|
|
||||||
margin-right: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input {
|
|
||||||
width: 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-row {
|
|
||||||
layout: horizontal;
|
|
||||||
margin: 1 2;
|
|
||||||
height: 3;
|
|
||||||
align: left middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-select {
|
|
||||||
width: 30;
|
|
||||||
margin-right: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
margin: 0 1;
|
|
||||||
min-width: 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
Checkbox {
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
RadioButton {
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-display {
|
|
||||||
text-style: italic;
|
|
||||||
color: $text-muted;
|
|
||||||
content-align: center middle;
|
|
||||||
height: 1;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -171,133 +57,140 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Compose the filter modal interface."""
|
"""Compose the filter modal interface."""
|
||||||
with Grid(id="filter-dialog"):
|
with VerticalScroll(classes="filter-container"):
|
||||||
yield Static("Advanced Filtering", id="filter-header")
|
yield Static("Advanced Filtering", classes="filter-title")
|
||||||
|
yield Static("", id="count-display", classes="filter-count")
|
||||||
|
|
||||||
with Container(id="filter-content"):
|
with Vertical(classes="default-flex-section") as presets:
|
||||||
# Filter presets section
|
presets.border_title = "Presets"
|
||||||
with Collapsible(title="Filter Presets", collapsed=False):
|
with Horizontal(classes="filter-preset-row"):
|
||||||
with Container(classes="filter-section"):
|
yield self._create_preset_select()
|
||||||
with Horizontal(classes="preset-row"):
|
yield Button(
|
||||||
yield Label("Preset:", classes="filter-input-label")
|
"Load", id="load-preset", variant="primary", compact=True
|
||||||
yield self._create_preset_select()
|
)
|
||||||
yield Button("Load", id="load-preset", variant="primary")
|
yield Button("Save", id="save-preset", compact=True)
|
||||||
yield Button("Save", id="save-preset")
|
yield Button(
|
||||||
yield Button("Delete", id="delete-preset", variant="error")
|
"Delete",
|
||||||
|
id="delete-preset",
|
||||||
|
variant="error",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Status filtering section
|
with Vertical(classes="default-flex-section") as status:
|
||||||
with Collapsible(title="Status Filtering", collapsed=False):
|
status.border_title = "Entry status"
|
||||||
with Container(classes="filter-section"):
|
with RadioSet(id="status-filter-type", classes="default-radio-set"):
|
||||||
yield Static("Status Filtering", classes="filter-section-title")
|
yield RadioButton("Show all", id="status-all")
|
||||||
with RadioSet(id="status-filter-type"):
|
yield RadioButton("Active only", id="status-active")
|
||||||
yield RadioButton("Show All", id="status-all")
|
yield RadioButton("Inactive only", id="status-inactive")
|
||||||
yield RadioButton("Active Only", id="status-active")
|
yield RadioButton("Custom", id="status-custom")
|
||||||
yield RadioButton("Inactive Only", id="status-inactive")
|
with Vertical(
|
||||||
yield RadioButton("Custom", id="status-custom")
|
classes="filter-custom-options", id="status-custom-options"
|
||||||
|
):
|
||||||
|
yield Checkbox(
|
||||||
|
"Show active entries",
|
||||||
|
value=True,
|
||||||
|
id="show-active",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show inactive entries",
|
||||||
|
value=True,
|
||||||
|
id="show-inactive",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
|
||||||
with Container(
|
with Vertical(classes="default-flex-section") as entry_type:
|
||||||
classes="filter-checkboxes", id="status-custom-options"
|
entry_type.border_title = "Entry type"
|
||||||
):
|
with RadioSet(id="type-filter-type", classes="default-radio-set"):
|
||||||
yield Checkbox(
|
yield RadioButton("Show all", id="type-all")
|
||||||
"Show Active Entries", value=True, id="show-active"
|
yield RadioButton("DNS entries only", id="type-dns")
|
||||||
)
|
yield RadioButton("IP entries only", id="type-ip")
|
||||||
yield Checkbox(
|
yield RadioButton("Custom", id="type-custom")
|
||||||
"Show Inactive Entries", value=True, id="show-inactive"
|
with Vertical(
|
||||||
)
|
classes="filter-custom-options", id="type-custom-options"
|
||||||
|
):
|
||||||
|
yield Checkbox(
|
||||||
|
"Show DNS entries", value=True, id="show-dns", compact=True
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show IP entries", value=True, id="show-ip", compact=True
|
||||||
|
)
|
||||||
|
|
||||||
# DNS type filtering section
|
with Vertical(classes="default-flex-section") as resolution:
|
||||||
with Collapsible(title="Entry Type Filtering", collapsed=False):
|
resolution.border_title = "DNS resolution"
|
||||||
with Container(classes="filter-section"):
|
with RadioSet(id="resolution-filter-type", classes="default-radio-set"):
|
||||||
yield Static(
|
yield RadioButton("Show all", id="resolution-all")
|
||||||
"Entry Type Filtering", classes="filter-section-title"
|
yield RadioButton("Resolved only", id="resolution-resolved")
|
||||||
)
|
yield RadioButton("Mismatches only", id="resolution-mismatch")
|
||||||
with RadioSet(id="type-filter-type"):
|
yield RadioButton("Custom", id="resolution-custom")
|
||||||
yield RadioButton("Show All", id="type-all")
|
with Vertical(
|
||||||
yield RadioButton("DNS Entries Only", id="type-dns")
|
classes="filter-custom-options", id="resolution-custom-options"
|
||||||
yield RadioButton("IP Entries Only", id="type-ip")
|
):
|
||||||
yield RadioButton("Custom", id="type-custom")
|
yield Checkbox(
|
||||||
|
"Show resolved", value=True, id="show-resolved", compact=True
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show unresolved",
|
||||||
|
value=True,
|
||||||
|
id="show-unresolved",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show resolving",
|
||||||
|
value=True,
|
||||||
|
id="show-resolving",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show failed", value=True, id="show-failed", compact=True
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show mismatched",
|
||||||
|
value=True,
|
||||||
|
id="show-mismatched",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
|
||||||
with Container(
|
with Vertical(classes="default-flex-section") as search:
|
||||||
classes="filter-checkboxes", id="type-custom-options"
|
search.border_title = "Search"
|
||||||
):
|
yield Input(
|
||||||
yield Checkbox(
|
placeholder="Hostname, IP address, or comment",
|
||||||
"Show DNS Entries", value=True, id="show-dns"
|
value=self.current_options.search_term or "",
|
||||||
)
|
id="search-term",
|
||||||
yield Checkbox("Show IP Entries", value=True, id="show-ip")
|
classes="filter-search-input",
|
||||||
|
)
|
||||||
|
with Vertical(classes="filter-custom-options"):
|
||||||
|
yield Checkbox(
|
||||||
|
"Search hostnames",
|
||||||
|
value=True,
|
||||||
|
id="search-hostnames",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Search comments",
|
||||||
|
value=True,
|
||||||
|
id="search-comments",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Search IP addresses",
|
||||||
|
value=True,
|
||||||
|
id="search-ips",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Case sensitive",
|
||||||
|
value=False,
|
||||||
|
id="search-case-sensitive",
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
|
||||||
# DNS resolution status filtering section
|
with Horizontal(classes="filter-actions"):
|
||||||
with Collapsible(title="Resolution Status Filtering", collapsed=False):
|
yield Button("Cancel", id="cancel", classes="default-button")
|
||||||
with Container(classes="filter-section"):
|
yield Button("Reset", id="reset", classes="default-button")
|
||||||
yield Static(
|
yield Button(
|
||||||
"Resolution Status Filtering",
|
"Apply", id="apply", variant="primary", classes="default-button"
|
||||||
classes="filter-section-title",
|
)
|
||||||
)
|
|
||||||
with RadioSet(id="resolution-filter-type"):
|
|
||||||
yield RadioButton("Show All", id="resolution-all")
|
|
||||||
yield RadioButton(
|
|
||||||
"Resolved Only",
|
|
||||||
id="resolution-resolved",
|
|
||||||
)
|
|
||||||
yield RadioButton(
|
|
||||||
"Mismatches Only",
|
|
||||||
id="resolution-mismatch",
|
|
||||||
)
|
|
||||||
yield RadioButton("Custom", id="resolution-custom")
|
|
||||||
|
|
||||||
with Container(
|
|
||||||
classes="filter-checkboxes", id="resolution-custom-options"
|
|
||||||
):
|
|
||||||
yield Checkbox(
|
|
||||||
"Show Resolved", value=True, id="show-resolved"
|
|
||||||
)
|
|
||||||
yield Checkbox(
|
|
||||||
"Show Unresolved", value=True, id="show-unresolved"
|
|
||||||
)
|
|
||||||
yield Checkbox(
|
|
||||||
"Show Resolving", value=True, id="show-resolving"
|
|
||||||
)
|
|
||||||
yield Checkbox("Show Failed", value=True, id="show-failed")
|
|
||||||
yield Checkbox(
|
|
||||||
"Show Mismatched", value=True, id="show-mismatched"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Search filtering section
|
|
||||||
with Collapsible(title="Search Filtering", collapsed=True):
|
|
||||||
with Container(classes="filter-section"):
|
|
||||||
yield Static("Search Filtering", classes="filter-section-title")
|
|
||||||
|
|
||||||
with Horizontal(classes="filter-input-row"):
|
|
||||||
yield Label("Search term:", classes="filter-input-label")
|
|
||||||
yield Input(
|
|
||||||
placeholder="Enter search term...",
|
|
||||||
value=self.current_options.search_term or "",
|
|
||||||
id="search-term",
|
|
||||||
classes="filter-input",
|
|
||||||
)
|
|
||||||
|
|
||||||
with Container(classes="filter-checkboxes"):
|
|
||||||
yield Checkbox(
|
|
||||||
"Search in hostnames", value=True, id="search-hostnames"
|
|
||||||
)
|
|
||||||
yield Checkbox(
|
|
||||||
"Search in comments", value=True, id="search-comments"
|
|
||||||
)
|
|
||||||
yield Checkbox(
|
|
||||||
"Search in IP addresses", value=True, id="search-ips"
|
|
||||||
)
|
|
||||||
yield Checkbox(
|
|
||||||
"Case sensitive",
|
|
||||||
value=False,
|
|
||||||
id="search-case-sensitive",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Entry count display
|
|
||||||
yield Static("", id="count-display", classes="count-display")
|
|
||||||
|
|
||||||
with Horizontal(id="filter-actions"):
|
|
||||||
yield Button("Apply", id="apply", variant="primary")
|
|
||||||
yield Button("Reset", id="reset")
|
|
||||||
yield Button("Cancel", id="cancel")
|
|
||||||
|
|
||||||
def _create_preset_select(self) -> Select:
|
def _create_preset_select(self) -> Select:
|
||||||
"""Create the preset picker without selecting a preset by default."""
|
"""Create the preset picker without selecting a preset by default."""
|
||||||
|
|
@ -307,18 +200,90 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
preset_options,
|
preset_options,
|
||||||
value=self.current_options.preset_name,
|
value=self.current_options.preset_name,
|
||||||
id="preset-select",
|
id="preset-select",
|
||||||
classes="preset-select",
|
classes="filter-preset-select",
|
||||||
|
compact=True,
|
||||||
)
|
)
|
||||||
return Select(
|
return Select(
|
||||||
preset_options,
|
preset_options,
|
||||||
id="preset-select",
|
id="preset-select",
|
||||||
classes="preset-select",
|
classes="filter-preset-select",
|
||||||
|
compact=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Initialize the modal with current options."""
|
"""Initialize the modal with current options."""
|
||||||
self._update_ui_from_options()
|
self._update_ui_from_options()
|
||||||
self._update_count_display()
|
self._update_count_display()
|
||||||
|
self.query_one("#preset-select", Select).focus()
|
||||||
|
|
||||||
|
def _focusable_control_ids(self) -> List[str]:
|
||||||
|
"""Return the modal's tab order, omitting hidden custom controls."""
|
||||||
|
control_ids = [
|
||||||
|
"preset-select",
|
||||||
|
"load-preset",
|
||||||
|
"save-preset",
|
||||||
|
"delete-preset",
|
||||||
|
"status-filter-type",
|
||||||
|
]
|
||||||
|
if self.query_one("#status-custom", RadioButton).value:
|
||||||
|
control_ids.extend(("show-active", "show-inactive"))
|
||||||
|
|
||||||
|
control_ids.append("type-filter-type")
|
||||||
|
if self.query_one("#type-custom", RadioButton).value:
|
||||||
|
control_ids.extend(("show-dns", "show-ip"))
|
||||||
|
|
||||||
|
control_ids.append("resolution-filter-type")
|
||||||
|
if self.query_one("#resolution-custom", RadioButton).value:
|
||||||
|
control_ids.extend(
|
||||||
|
(
|
||||||
|
"show-resolved",
|
||||||
|
"show-unresolved",
|
||||||
|
"show-resolving",
|
||||||
|
"show-failed",
|
||||||
|
"show-mismatched",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
control_ids.extend(
|
||||||
|
(
|
||||||
|
"search-term",
|
||||||
|
"search-hostnames",
|
||||||
|
"search-comments",
|
||||||
|
"search-ips",
|
||||||
|
"search-case-sensitive",
|
||||||
|
"cancel",
|
||||||
|
"reset",
|
||||||
|
"apply",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return control_ids
|
||||||
|
|
||||||
|
def action_focus_next(self) -> None:
|
||||||
|
"""Move focus in form order, including controls below the scroll position."""
|
||||||
|
self._move_focus_in_form(1)
|
||||||
|
|
||||||
|
def action_focus_previous(self) -> None:
|
||||||
|
"""Move focus backward in form order, including controls below the scroll position."""
|
||||||
|
self._move_focus_in_form(-1)
|
||||||
|
|
||||||
|
def _move_focus_in_form(self, direction: int) -> None:
|
||||||
|
"""Focus the next or previous currently-visible form control."""
|
||||||
|
control_ids = self._focusable_control_ids()
|
||||||
|
focused_id = (
|
||||||
|
str(self.focused.id)
|
||||||
|
if self.focused is not None and self.focused.id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if focused_id is None:
|
||||||
|
focused_index = -1 if direction > 0 else 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
focused_index = control_ids.index(focused_id)
|
||||||
|
except ValueError:
|
||||||
|
focused_index = -1 if direction > 0 else 0
|
||||||
|
|
||||||
|
next_id = control_ids[(focused_index + direction) % len(control_ids)]
|
||||||
|
self.query_one(f"#{next_id}").focus()
|
||||||
|
|
||||||
def _update_ui_from_options(self) -> None:
|
def _update_ui_from_options(self) -> None:
|
||||||
"""Update UI controls to reflect current options."""
|
"""Update UI controls to reflect current options."""
|
||||||
|
|
|
||||||
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(
|
Binding(
|
||||||
"ctrl+e",
|
"ctrl+e",
|
||||||
"toggle_edit_mode",
|
"toggle_edit_mode",
|
||||||
"Toggle edit mode",
|
"Privileged Mode",
|
||||||
show=True,
|
show=True,
|
||||||
id="left:toggle_edit_mode",
|
id="left:toggle_edit_mode",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -40,29 +40,27 @@ class SaveConfirmationModal(ModalScreen[str]):
|
||||||
|
|
||||||
with Horizontal(classes="button-row"):
|
with Horizontal(classes="button-row"):
|
||||||
yield Button(
|
yield Button(
|
||||||
"Save (S)",
|
"Cancel",
|
||||||
variant="primary",
|
variant="default",
|
||||||
id="save-button",
|
id="cancel-button",
|
||||||
classes="save-confirmation-button",
|
classes="save-confirmation-button",
|
||||||
)
|
)
|
||||||
yield Button(
|
yield Button(
|
||||||
"Discard (D)",
|
"Discard",
|
||||||
variant="default",
|
variant="default",
|
||||||
id="discard-button",
|
id="discard-button",
|
||||||
classes="save-confirmation-button",
|
classes="save-confirmation-button",
|
||||||
)
|
)
|
||||||
yield Button(
|
yield Button(
|
||||||
"Cancel (ESC)",
|
"Save",
|
||||||
variant="default",
|
variant="primary",
|
||||||
id="cancel-button",
|
id="save-button",
|
||||||
classes="save-confirmation-button",
|
classes="save-confirmation-button",
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Called when the modal is mounted. Set focus to the first button."""
|
"""Start at the non-destructive Cancel action."""
|
||||||
# Focus on the Save button by default
|
self.query_one("#cancel-button", Button).focus()
|
||||||
save_button = self.query_one("#save-button", Button)
|
|
||||||
save_button.focus()
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle button presses."""
|
"""Handle button presses."""
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,24 @@ This module contains all CSS definitions for consistent styling
|
||||||
across the application.
|
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 classes shared across components
|
||||||
COMMON_CSS = """
|
COMMON_CSS = """
|
||||||
.default-button {
|
.default-button {
|
||||||
|
|
@ -71,17 +89,66 @@ HOSTS_MANAGER_CSS = (
|
||||||
margin-bottom: 0;
|
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 {
|
.search-input {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#filter-summary {
|
||||||
|
width: auto;
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
content-align: right middle;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
.hosts-container {
|
.hosts-container {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-layout .entry-form {
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
.common-pane {
|
.common-pane {
|
||||||
border: round $primary;
|
border: round $primary;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -127,24 +194,35 @@ HOSTS_MANAGER_CSS = (
|
||||||
display: none;
|
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 {
|
.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;
|
height: auto;
|
||||||
padding: 1;
|
color: $warning;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
height: 1fr;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
Header {
|
Header {
|
||||||
|
|
@ -157,7 +235,7 @@ Header.-tall {
|
||||||
|
|
||||||
/* Custom Footer Styling */
|
/* Custom Footer Styling */
|
||||||
CustomFooter {
|
CustomFooter {
|
||||||
background: $surface;
|
background: $panel;
|
||||||
color: $text;
|
color: $text;
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
height: 1;
|
height: 1;
|
||||||
|
|
@ -313,6 +391,68 @@ ConfigModal {
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Advanced Filter Modal CSS
|
||||||
|
FILTER_MODAL_CSS = (
|
||||||
|
COMMON_CSS
|
||||||
|
+ """
|
||||||
|
FilterModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-container {
|
||||||
|
width: 80;
|
||||||
|
height: 28;
|
||||||
|
background: $surface;
|
||||||
|
border: thick $primary;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-title {
|
||||||
|
text-align: center;
|
||||||
|
text-style: bold;
|
||||||
|
color: $primary;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-count {
|
||||||
|
height: 1;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-muted;
|
||||||
|
text-style: italic;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-preset-row {
|
||||||
|
height: 1;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-preset-select {
|
||||||
|
width: 1fr;
|
||||||
|
margin-right: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-custom-options {
|
||||||
|
height: auto;
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-search-input {
|
||||||
|
height: 1;
|
||||||
|
width: 1fr;
|
||||||
|
margin: 0 2;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
dock: bottom;
|
||||||
|
height: 3;
|
||||||
|
background: $surface;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Save Confirmation Modal CSS
|
# Save Confirmation Modal CSS
|
||||||
SAVE_CONFIRMATION_MODAL_CSS = (
|
SAVE_CONFIRMATION_MODAL_CSS = (
|
||||||
COMMON_CSS
|
COMMON_CSS
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,8 @@ class TableHandler:
|
||||||
arrow = "↑" if self.app.sort_ascending else "↓"
|
arrow = "↑" if self.app.sort_ascending else "↓"
|
||||||
hostname_label = f"{arrow} Canonical Hostname"
|
hostname_label = f"{arrow} Canonical Hostname"
|
||||||
|
|
||||||
# Add columns with proper labels (Active, IP, Hostname, DNS)
|
# Keep the canonical hostname beside state for fast table scanning.
|
||||||
table.add_columns(active_label, ip_label, hostname_label, dns_label)
|
table.add_columns(active_label, hostname_label, ip_label, dns_label)
|
||||||
|
|
||||||
# Get visible entries (after filtering)
|
# Get visible entries (after filtering)
|
||||||
visible_entries = self.get_visible_entries()
|
visible_entries = self.get_visible_entries()
|
||||||
|
|
@ -185,25 +185,21 @@ class TableHandler:
|
||||||
# Get DNS status indicator
|
# Get DNS status indicator
|
||||||
dns_text = self._get_dns_status_indicator(entry)
|
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:
|
if is_default:
|
||||||
# Default entries are always shown in dim grey regardless of active status
|
entry_state = "✓ Active" if entry.is_active else "· Inactive"
|
||||||
active_text = Text("✓" if entry.is_active else "", style="dim white")
|
active_text = Text(f"■ Default · {entry_state}", style="dim")
|
||||||
ip_text = Text(entry.ip_address, style="dim white")
|
ip_text = Text(entry.ip_address, style="dim")
|
||||||
hostname_text = Text(canonical_hostname, style="dim white")
|
hostname_text = Text(canonical_hostname, style="dim")
|
||||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
|
||||||
elif entry.is_active:
|
elif entry.is_active:
|
||||||
# Active entries in green with checkmark
|
active_text = Text("✓ Active")
|
||||||
active_text = Text("✓", style="bold green")
|
ip_text = entry.ip_address
|
||||||
ip_text = Text(entry.ip_address, style="bold green")
|
hostname_text = canonical_hostname
|
||||||
hostname_text = Text(canonical_hostname, style="bold green")
|
|
||||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
|
||||||
else:
|
else:
|
||||||
# Inactive entries in dim yellow with italic (no checkmark)
|
active_text = Text("· Inactive", style="dim")
|
||||||
active_text = Text("", style="dim yellow italic")
|
ip_text = Text(entry.ip_address, style="dim")
|
||||||
ip_text = Text(entry.ip_address, style="dim yellow italic")
|
hostname_text = Text(canonical_hostname, style="dim")
|
||||||
hostname_text = Text(canonical_hostname, style="dim yellow italic")
|
table.add_row(active_text, hostname_text, ip_text, dns_text)
|
||||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
|
||||||
|
|
||||||
def restore_cursor_position(self, previous_entry) -> None:
|
def restore_cursor_position(self, previous_entry) -> None:
|
||||||
"""Restore cursor position after reload, maintaining selection if possible."""
|
"""Restore cursor position after reload, maintaining selection if possible."""
|
||||||
|
|
@ -265,7 +261,7 @@ class TableHandler:
|
||||||
"""Get DNS name and status indicator for an entry."""
|
"""Get DNS name and status indicator for an entry."""
|
||||||
# If entry has no DNS name configured, show empty
|
# If entry has no DNS name configured, show empty
|
||||||
if not entry.has_dns_name():
|
if not entry.has_dns_name():
|
||||||
return Text("", style="dim white")
|
return Text("")
|
||||||
|
|
||||||
# Start with the DNS name
|
# Start with the DNS name
|
||||||
dns_display = entry.dns_name
|
dns_display = entry.dns_name
|
||||||
|
|
@ -274,28 +270,21 @@ class TableHandler:
|
||||||
dns_status = entry.dns_resolution_status or "not_resolved"
|
dns_status = entry.dns_resolution_status or "not_resolved"
|
||||||
|
|
||||||
if dns_status == "not_resolved":
|
if dns_status == "not_resolved":
|
||||||
status_icon = "⏳"
|
status_icon = "· Pending"
|
||||||
style = "dim yellow"
|
|
||||||
elif dns_status == "resolving":
|
elif dns_status == "resolving":
|
||||||
status_icon = "🔄"
|
status_icon = "! Resolving"
|
||||||
style = "yellow"
|
|
||||||
elif dns_status == "resolved":
|
elif dns_status == "resolved":
|
||||||
status_icon = "✅"
|
status_icon = "✓ Resolved"
|
||||||
style = "green"
|
|
||||||
elif dns_status == "match":
|
elif dns_status == "match":
|
||||||
status_icon = "✅"
|
status_icon = "✓ Matches"
|
||||||
style = "bold green"
|
|
||||||
elif dns_status == "mismatch":
|
elif dns_status == "mismatch":
|
||||||
status_icon = "⚠️"
|
status_icon = "! Mismatch"
|
||||||
style = "red"
|
|
||||||
elif dns_status == "failed":
|
elif dns_status == "failed":
|
||||||
status_icon = "❌"
|
status_icon = "× Failed"
|
||||||
style = "red"
|
|
||||||
else:
|
else:
|
||||||
status_icon = ""
|
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:
|
def sort_entries_by_hostname(self) -> None:
|
||||||
"""Sort entries by canonical hostname."""
|
"""Sort entries by canonical hostname."""
|
||||||
|
|
|
||||||
|
|
@ -193,15 +193,15 @@ class TestAddEntryModalRadioButtonLogic:
|
||||||
# Mock the query_one method for sections and inputs
|
# Mock the query_one method for sections and inputs
|
||||||
mock_ip_section = Mock()
|
mock_ip_section = Mock()
|
||||||
mock_dns_section = Mock()
|
mock_dns_section = Mock()
|
||||||
mock_ip_input = Mock(spec=Input)
|
mock_hostname_input = Mock(spec=Input)
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type=None):
|
def mock_query_one(selector, widget_type=None):
|
||||||
if selector == "#ip-section":
|
if selector == "#ip-section":
|
||||||
return mock_ip_section
|
return mock_ip_section
|
||||||
elif selector == "#dns-section":
|
elif selector == "#dns-section":
|
||||||
return mock_dns_section
|
return mock_dns_section
|
||||||
elif selector == "#ip-address-input":
|
elif selector == "#hostnames-input":
|
||||||
return mock_ip_input
|
return mock_hostname_input
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
@ -225,22 +225,22 @@ class TestAddEntryModalRadioButtonLogic:
|
||||||
# Verify IP section is shown and DNS section is hidden
|
# Verify IP section is shown and DNS section is hidden
|
||||||
mock_ip_section.remove_class.assert_called_with("hidden")
|
mock_ip_section.remove_class.assert_called_with("hidden")
|
||||||
mock_dns_section.add_class.assert_called_with("hidden")
|
mock_dns_section.add_class.assert_called_with("hidden")
|
||||||
mock_ip_input.focus.assert_called_once()
|
mock_hostname_input.focus.assert_called_once()
|
||||||
|
|
||||||
def test_radio_button_change_to_dns_entry(self):
|
def test_radio_button_change_to_dns_entry(self):
|
||||||
"""Test radio button change to DNS entry mode."""
|
"""Test radio button change to DNS entry mode."""
|
||||||
# Mock the query_one method for sections and inputs
|
# Mock the query_one method for sections and inputs
|
||||||
mock_ip_section = Mock()
|
mock_ip_section = Mock()
|
||||||
mock_dns_section = Mock()
|
mock_dns_section = Mock()
|
||||||
mock_dns_input = Mock(spec=Input)
|
mock_hostname_input = Mock(spec=Input)
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type=None):
|
def mock_query_one(selector, widget_type=None):
|
||||||
if selector == "#ip-section":
|
if selector == "#ip-section":
|
||||||
return mock_ip_section
|
return mock_ip_section
|
||||||
elif selector == "#dns-section":
|
elif selector == "#dns-section":
|
||||||
return mock_dns_section
|
return mock_dns_section
|
||||||
elif selector == "#dns-name-input":
|
elif selector == "#hostnames-input":
|
||||||
return mock_dns_input
|
return mock_hostname_input
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
@ -264,7 +264,7 @@ class TestAddEntryModalRadioButtonLogic:
|
||||||
# Verify DNS section is shown and IP section is hidden
|
# Verify DNS section is shown and IP section is hidden
|
||||||
mock_ip_section.add_class.assert_called_with("hidden")
|
mock_ip_section.add_class.assert_called_with("hidden")
|
||||||
mock_dns_section.remove_class.assert_called_with("hidden")
|
mock_dns_section.remove_class.assert_called_with("hidden")
|
||||||
mock_dns_input.focus.assert_called_once()
|
mock_hostname_input.focus.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestAddEntryModalSaveLogic:
|
class TestAddEntryModalSaveLogic:
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from textual.app import SuspendNotSupported
|
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.filters import FilterOptions
|
||||||
from src.hosts.core.models import HostEntry, HostsFile
|
from src.hosts.core.models import HostEntry, HostsFile
|
||||||
from src.hosts.tui.app import HostsManagerApp
|
from src.hosts.tui.app import HostsManagerApp
|
||||||
from src.hosts.tui.custom_footer import CustomFooter
|
from src.hosts.tui.custom_footer import CustomFooter
|
||||||
from src.hosts.tui.filter_modal import FilterModal
|
from src.hosts.tui.filter_modal import FilterModal
|
||||||
|
from src.hosts.tui.help_modal import HelpModal
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
@ -45,16 +46,13 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
footer = app.query_one("#custom-footer", CustomFooter)
|
footer = app.query_one("#custom-footer", CustomFooter)
|
||||||
footer_text = footer.query_one("#footer-right", Static).render()
|
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()
|
app.action_help()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert isinstance(app.query_one(HelpPanel), HelpPanel)
|
assert isinstance(app.screen, HelpModal)
|
||||||
assert any(
|
assert app.screen.query_one("#help-close") is not None
|
||||||
binding.action == "show_filters" and binding.description == "Filter entries"
|
await pilot.press("escape")
|
||||||
for _, binding, _, _ in app.screen.active_bindings.values()
|
|
||||||
)
|
|
||||||
app.action_help()
|
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
await pilot.press("ctrl+f")
|
await pilot.press("ctrl+f")
|
||||||
|
|
@ -72,6 +70,71 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_shortcut_does_not_stack_filter_modals():
|
||||||
|
"""Repeated filter shortcuts keep the existing filter modal in focus."""
|
||||||
|
app = app_with_filterable_entries()
|
||||||
|
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("ctrl+f")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("ctrl+f")
|
||||||
|
await pilot.press("ctrl+f")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert isinstance(app.screen, FilterModal)
|
||||||
|
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."""
|
||||||
|
app = app_with_filterable_entries()
|
||||||
|
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("ctrl+f")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert app.focused is app.screen.query_one("#preset-select")
|
||||||
|
for expected_id in (
|
||||||
|
"load-preset",
|
||||||
|
"save-preset",
|
||||||
|
"delete-preset",
|
||||||
|
"status-filter-type",
|
||||||
|
"type-filter-type",
|
||||||
|
"resolution-filter-type",
|
||||||
|
"search-term",
|
||||||
|
"search-hostnames",
|
||||||
|
"search-comments",
|
||||||
|
"search-ips",
|
||||||
|
"search-case-sensitive",
|
||||||
|
"cancel",
|
||||||
|
"reset",
|
||||||
|
"apply",
|
||||||
|
):
|
||||||
|
await pilot.press("tab")
|
||||||
|
assert app.focused is app.screen.query_one(f"#{expected_id}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters():
|
async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters():
|
||||||
"""Reset clears the form on Apply, while Cancel leaves the applied filter alone."""
|
"""Reset clears the form on Apply, while Cancel leaves the applied filter alone."""
|
||||||
|
|
@ -103,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:
|
class TestPrivilegedModeAuthorization:
|
||||||
"""Test the user-visible privileged-mode authorization flow."""
|
"""Test the user-visible privileged-mode authorization flow."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
# Should handle error gracefully
|
# Should handle error gracefully
|
||||||
app.update_status.assert_called_with(
|
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):
|
def test_load_hosts_file_permission_error(self):
|
||||||
|
|
@ -122,7 +122,7 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
# Should handle error gracefully
|
# Should handle error gracefully
|
||||||
app.update_status.assert_called_with(
|
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):
|
def test_populate_entries_table_logic(self):
|
||||||
|
|
@ -207,13 +207,15 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
app.update_entry_details()
|
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_details_display.remove_class.assert_called_with("hidden")
|
||||||
mock_edit_form.add_class.assert_called_with("hidden")
|
mock_edit_form.add_class.assert_called_with("hidden")
|
||||||
assert mock_ip_input.value == "127.0.0.1"
|
mock_ip_input.update.assert_called_with("127.0.0.1")
|
||||||
assert mock_hostname_input.value == "localhost, local"
|
mock_hostname_input.update.assert_called_with("localhost, local")
|
||||||
assert mock_comment_input.value == "Test comment"
|
mock_comment_input.update.assert_called_with("Test comment")
|
||||||
assert mock_active_checkbox.value
|
mock_active_checkbox.update.assert_called_with(
|
||||||
|
"■ Default (protected) · ✓ Active"
|
||||||
|
)
|
||||||
|
|
||||||
def test_update_entry_details_no_entries(self):
|
def test_update_entry_details_no_entries(self):
|
||||||
"""Test updating entry details with no entries."""
|
"""Test updating entry details with no entries."""
|
||||||
|
|
@ -233,6 +235,7 @@ class TestHostsManagerApp:
|
||||||
mock_hostname_input = Mock()
|
mock_hostname_input = Mock()
|
||||||
mock_comment_input = Mock()
|
mock_comment_input = Mock()
|
||||||
mock_active_checkbox = Mock()
|
mock_active_checkbox = Mock()
|
||||||
|
mock_empty_state = Mock()
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type=None):
|
def mock_query_one(selector, widget_type=None):
|
||||||
if selector == "#entry-details-display":
|
if selector == "#entry-details-display":
|
||||||
|
|
@ -247,6 +250,8 @@ class TestHostsManagerApp:
|
||||||
return mock_comment_input
|
return mock_comment_input
|
||||||
elif selector == "#details-active-checkbox":
|
elif selector == "#details-active-checkbox":
|
||||||
return mock_active_checkbox
|
return mock_active_checkbox
|
||||||
|
elif selector == "#details-empty-state":
|
||||||
|
return mock_empty_state
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
cast(Any, app).query_one = mock_query_one
|
cast(Any, app).query_one = mock_query_one
|
||||||
|
|
@ -254,16 +259,10 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
app.update_entry_details()
|
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_details_display.remove_class.assert_called_with("hidden")
|
||||||
mock_edit_form.add_class.assert_called_with("hidden")
|
mock_edit_form.add_class.assert_called_with("hidden")
|
||||||
assert mock_ip_input.value == ""
|
mock_empty_state.update.assert_called_with("No Host Entries are loaded.")
|
||||||
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
|
|
||||||
|
|
||||||
def test_update_status_default(self):
|
def test_update_status_default(self):
|
||||||
"""Test status bar update with default information."""
|
"""Test status bar update with default information."""
|
||||||
|
|
@ -301,7 +300,7 @@ class TestHostsManagerApp:
|
||||||
# Verify footer status was updated
|
# Verify footer status was updated
|
||||||
mock_footer.set_status.assert_called_once()
|
mock_footer.set_status.assert_called_once()
|
||||||
status_call = mock_footer.set_status.call_args[0][0]
|
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 "2 entries" in status_call
|
||||||
assert "1 active" 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
|
# Mock set_timer and query_one to avoid event loop and UI issues
|
||||||
app.set_timer = Mock()
|
app.set_timer = Mock()
|
||||||
mock_status_bar = Mock()
|
mock_message_rail = Mock()
|
||||||
mock_footer = Mock()
|
mock_footer = Mock()
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type=None):
|
def mock_query_one(selector, widget_type=None):
|
||||||
if selector == "#status-bar":
|
if selector == "#message-rail":
|
||||||
return mock_status_bar
|
return mock_message_rail
|
||||||
elif selector == "#custom-footer":
|
elif selector == "#custom-footer":
|
||||||
return mock_footer
|
return mock_footer
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
@ -343,14 +342,13 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
app.update_status("Custom status message")
|
app.update_status("Custom status message")
|
||||||
|
|
||||||
# Verify status bar was updated with custom message
|
# Verify the reserved message rail was updated with the message.
|
||||||
mock_status_bar.update.assert_called_with("Custom status message")
|
mock_message_rail.update.assert_called_with("Custom status message")
|
||||||
mock_status_bar.remove_class.assert_called_with("hidden")
|
|
||||||
# Verify footer status was updated with current status (not the custom message)
|
# Verify footer status was updated with current status (not the custom message)
|
||||||
mock_footer.set_status.assert_called_once()
|
mock_footer.set_status.assert_called_once()
|
||||||
footer_status = mock_footer.set_status.call_args[0][0]
|
footer_status = mock_footer.set_status.call_args[0][0]
|
||||||
assert "2 entries" in footer_status
|
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
|
# Verify timer was set for auto-clearing
|
||||||
app.set_timer.assert_called_once()
|
app.set_timer.assert_called_once()
|
||||||
|
|
||||||
|
|
@ -382,12 +380,12 @@ class TestHostsManagerApp:
|
||||||
patch("hosts.tui.app.Config", return_value=mock_config),
|
patch("hosts.tui.app.Config", return_value=mock_config),
|
||||||
):
|
):
|
||||||
app = HostsManagerApp()
|
app = HostsManagerApp()
|
||||||
app.action_show_help_panel = Mock()
|
app.push_screen = Mock()
|
||||||
|
|
||||||
app.action_help()
|
app.action_help()
|
||||||
|
|
||||||
# Should call the built-in help action
|
# Help is a dedicated overlay, not a docked panel.
|
||||||
app.action_show_help_panel.assert_called_once()
|
app.push_screen.assert_called_once()
|
||||||
|
|
||||||
def test_action_config(self):
|
def test_action_config(self):
|
||||||
"""Test config action opens modal."""
|
"""Test config action opens modal."""
|
||||||
|
|
|
||||||
|
|
@ -57,15 +57,15 @@ class TestSaveConfirmationModal:
|
||||||
|
|
||||||
@patch.object(SaveConfirmationModal, "query_one")
|
@patch.object(SaveConfirmationModal, "query_one")
|
||||||
def test_on_mount_sets_focus(self, mock_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()
|
modal = SaveConfirmationModal()
|
||||||
mock_save_button = Mock()
|
mock_cancel_button = Mock()
|
||||||
mock_query_one.return_value = mock_save_button
|
mock_query_one.return_value = mock_cancel_button
|
||||||
|
|
||||||
modal.on_mount()
|
modal.on_mount()
|
||||||
|
|
||||||
mock_query_one.assert_called_once_with("#save-button", Button)
|
mock_query_one.assert_called_once_with("#cancel-button", Button)
|
||||||
mock_save_button.focus.assert_called_once()
|
mock_cancel_button.focus.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestSaveConfirmationIntegration:
|
class TestSaveConfirmationIntegration:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue