Compare commits
No commits in common. "44156a11af8e84f099b3cb74c951627a2d9a7c3f" and "be800de90b8609ab0bf3d85a8ad46dafc37624ac" have entirely different histories.
44156a11af
...
be800de90b
11
README.md
|
|
@ -111,16 +111,11 @@ sshkeeper / Servers Vault unlocked · 1 profile
|
||||||
Press `?` outside text editors for a compact hotkey reference. Inside forms and
|
Press `?` outside text editors for a compact hotkey reference. Inside forms and
|
||||||
search, `?` remains normal text input.
|
search, `?` remains normal text input.
|
||||||
|
|
||||||
### Full Help (Ctrl+H)
|
### Full Help (F1)
|
||||||
|
|
||||||
Press `Ctrl+H` on any screen for full documentation including routes, port
|
Press `F1` on any screen for full documentation including routes, port
|
||||||
forwarding, tunnels, and vault.
|
forwarding, tunnels, and vault.
|
||||||
|
|
||||||
`Ctrl+H` is the BS control character (0x08). xterm and most modern emulators
|
|
||||||
send DEL (0x7F) for Backspace, so help and text editing never collide. A
|
|
||||||
terminal configured to send BS for Backspace cannot distinguish the two; switch
|
|
||||||
it to DEL (in xterm, `backarrowKey: false`).
|
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
| Wide dashboard | Dashboard 80x24 | Server form 60x16 |
|
| Wide dashboard | Dashboard 80x24 | Server form 60x16 |
|
||||||
|
|
@ -143,7 +138,7 @@ it to DEL (in xterm, `backarrowKey: false`).
|
||||||
| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete, import/export, vault actions) |
|
| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete, import/export, vault actions) |
|
||||||
| Ins | Select / deselect a server |
|
| Ins | Select / deselect a server |
|
||||||
| ? | Quick help (hotkeys) |
|
| ? | Quick help (hotkeys) |
|
||||||
| Ctrl+H | Full documentation |
|
| F1 | Full documentation |
|
||||||
| Ctrl+Q / Ctrl+C | Quit |
|
| Ctrl+Q / Ctrl+C | Quit |
|
||||||
|
|
||||||
Templates are global entities and can run on any server. Foreground template
|
Templates are global entities and can run on any server. Foreground template
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ Auth: agent Group: - Status: ?
|
||||||
| `Ctrl+F` | Поиск |
|
| `Ctrl+F` | Поиск |
|
||||||
| `Ins` | Выбрать/снять выбор |
|
| `Ins` | Выбрать/снять выбор |
|
||||||
| `?` | Краткая справка по клавишам |
|
| `?` | Краткая справка по клавишам |
|
||||||
| `Ctrl+H` | Полная справка по приложению |
|
| `F1` | Полная справка по приложению |
|
||||||
| `Ctrl+Q` | Выход |
|
| `Ctrl+Q` | Выход |
|
||||||
|
|
||||||
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
|
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
|
||||||
|
|
@ -179,7 +179,7 @@ Auth: agent Group: - Status: ?
|
||||||
### Быстрая справка по клавишам
|
### Быстрая справка по клавишам
|
||||||
|
|
||||||
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
|
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
|
||||||
остаётся обычным вводом. `Ctrl+H` открывает полную справку также из форм.
|
остаётся обычным вводом. `F1` открывает полную справку также из форм.
|
||||||
|
|
||||||
```
|
```
|
||||||
sshkeeper — Quick Help
|
sshkeeper — Quick Help
|
||||||
|
|
@ -211,20 +211,14 @@ sshkeeper — Quick Help
|
||||||
|
|
||||||
Other
|
Other
|
||||||
? This quick help
|
? This quick help
|
||||||
Ctrl+H Full documentation
|
F1 Full documentation
|
||||||
|
|
||||||
Esc / Enter / ? / q — close
|
Esc / Enter / ? / q — close
|
||||||
```
|
```
|
||||||
|
|
||||||
### Полная справка по приложению
|
### Полная справка по приложению
|
||||||
|
|
||||||
Нажмите `Ctrl+H` на любом экране. Это полная документация по sshkeeper:
|
Нажмите `F1` на любом экране. Это полная документация по sshkeeper:
|
||||||
|
|
||||||
> **Про терминалы.** `Ctrl+H` — это управляющий символ BS (0x08), а Backspace в
|
|
||||||
> xterm и большинстве современных эмуляторов шлёт DEL (0x7F), поэтому справка и
|
|
||||||
> редактирование текста не конфликтуют. Если ваш терминал настроен отправлять BS
|
|
||||||
> по Backspace, различить их невозможно: Backspace начнёт открывать справку.
|
|
||||||
> В этом случае переключите терминал на DEL (в xterm — `backarrowKey: false`).
|
|
||||||
|
|
||||||
```
|
```
|
||||||
sshkeeper — Full Help
|
sshkeeper — Full Help
|
||||||
|
|
@ -244,7 +238,7 @@ sshkeeper — Full Help
|
||||||
Enter Select / Confirm / Open
|
Enter Select / Confirm / Open
|
||||||
Esc Back / Cancel / Close
|
Esc Back / Cancel / Close
|
||||||
? Quick help (hotkeys)
|
? Quick help (hotkeys)
|
||||||
Ctrl+H Full documentation
|
F1 Full documentation
|
||||||
Ctrl+Q Quit
|
Ctrl+Q Quit
|
||||||
|
|
||||||
Server list
|
Server list
|
||||||
|
|
@ -749,7 +743,7 @@ sshkeeper connect secure
|
||||||
| `Ctrl+X` | Меню действий |
|
| `Ctrl+X` | Меню действий |
|
||||||
| `Ins` | Выбрать/снять выбор |
|
| `Ins` | Выбрать/снять выбор |
|
||||||
| `?` | Краткая справка по клавишам |
|
| `?` | Краткая справка по клавишам |
|
||||||
| `Ctrl+H` | Полная справка по приложению |
|
| `F1` | Полная справка по приложению |
|
||||||
| `Ctrl+Q` | Выход |
|
| `Ctrl+Q` | Выход |
|
||||||
|
|
||||||
### Меню действий (Ctrl+X)
|
### Меню действий (Ctrl+X)
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 25 KiB |
|
|
@ -1,141 +0,0 @@
|
||||||
# Unified sshkeeper TUI Shell Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Apply the approved dashboard-style full-screen shell to every sshkeeper TUI screen and replace F1 help with Ctrl+H.
|
|
||||||
|
|
||||||
**Architecture:** Add one pure screen-shell renderer that owns header, notification, bordered content height, inner width, and bottom footer. Refactor each child screen to supply bounded body panels and contextual help rather than free-form terminal strings; retain existing Bubble Tea state and callback boundaries.
|
|
||||||
|
|
||||||
**Tech Stack:** Go 1.25, Bubble Tea v1.3.10, Bubbles v1.0.0, Lip Gloss v1.1.0, charmbracelet/x/ansi v0.11.6, tmux/xterm/Xvfb runtime capture.
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- The existing server dashboard is the visual reference.
|
|
||||||
- Supported terminal floor is exactly `60x16`.
|
|
||||||
- Breakpoints are narrow 60-69, medium 70-99, and wide 100+ columns.
|
|
||||||
- Every screen has a header, framed bounded content, and footer anchored to the bottom.
|
|
||||||
- No rendered row directly consumes the last terminal column.
|
|
||||||
- `Ctrl+H` is global full help; remove `F1` from runtime and documentation.
|
|
||||||
- Printable input ownership, destructive safety, validation, dirty state, and callback boundaries remain intact.
|
|
||||||
- No release publication or release-metadata changes.
|
|
||||||
- Commit and push after every task.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Shared shell and help binding
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/tui/shell.go`
|
|
||||||
- Create: `internal/tui/shell_test.go`
|
|
||||||
- Modify: `internal/tui/app.go`
|
|
||||||
- Modify: `internal/tui/help.go`
|
|
||||||
- Modify: `internal/tui/help_screen.go`
|
|
||||||
- Modify: `internal/tui/status_help_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: `screenShell`, `renderScreenShell(screenShell) string`, `renderBodyPanel(width, height int, lines []string) string`, and `footerAtBottom(body, footer string, height int) string`.
|
|
||||||
- Consumes: existing `renderPanel`, display-cell helpers, root vault/notification state, and child body strings.
|
|
||||||
|
|
||||||
- [ ] Add failing tests that render representative action/help/confirm states at 120x40, 80x24, and 60x16 and assert header, border, exact height, last-row footer, one-cell right safety margin, and no `F1` text.
|
|
||||||
- [ ] Add failing root-event tests proving `tea.KeyCtrlH` opens full help from list, manager, and form; `tea.KeyBackspace` still reaches a focused text input; and closing help restores its parent.
|
|
||||||
- [ ] Run `go test ./internal/tui -run 'Test(ScreenShell|CtrlH|Backspace|NoF1)' -count=1` and confirm failures identify missing shell/binding behavior.
|
|
||||||
- [ ] Implement the shell with a spec carrying `breadcrumb`, `status`, `notification`, `body`, `footer`, `width`, and `height`; calculate body height after wrapped footer rows and render content inside a bounded panel.
|
|
||||||
- [ ] Replace the root F1 branch with `tea.KeyCtrlH`, remove F1 entries from quick/full help content, and preserve active confirmation ownership.
|
|
||||||
- [ ] Run focused tests and `go test ./... -count=1`.
|
|
||||||
- [ ] Commit as `feat: add unified tui screen shell` and push.
|
|
||||||
|
|
||||||
### Task 2: Actions, help, search, and confirmations
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/app.go`
|
|
||||||
- Modify: `internal/tui/help_screen.go`
|
|
||||||
- Modify: `internal/tui/layout_test.go`
|
|
||||||
- Modify: `internal/tui/status_help_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `renderScreenShell` and `renderBodyPanel` from Task 1.
|
|
||||||
- Produces: shell-backed action menu, quick/full help, search, tag input, and confirmation views.
|
|
||||||
|
|
||||||
- [ ] Add failing render tests for action selection at every breakpoint, long help rows, search input, tag input, and long Unicode confirmation content; assert selection/focus markers and bottom footer.
|
|
||||||
- [ ] Run `go test ./internal/tui -run 'Test(ActionShell|HelpShell|InputShell|ConfirmationShell)' -count=1` and confirm current free-form views fail.
|
|
||||||
- [ ] Render actions as a bounded list with a wide description panel and stacked medium description; render help as a scrolling framed body; render search/tag input inside a form panel; render confirmation as a bounded dialog within the shell.
|
|
||||||
- [ ] Run focused tests and `go test ./... -count=1`.
|
|
||||||
- [ ] Capture action, help, and confirmation in real xterm at 120x40, 80x24, and 60x16; inspect header, border, focus, footer, and right margin.
|
|
||||||
- [ ] Commit as `feat: unify tui actions and help screens` and push.
|
|
||||||
|
|
||||||
### Task 3: Port forward manager and form
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/forward.go`
|
|
||||||
- Modify: `internal/tui/forward_test.go`
|
|
||||||
- Modify: `internal/tui/layout_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: shared shell and panel primitives.
|
|
||||||
- Produces: responsive framed forward table/details and framed forward editor.
|
|
||||||
|
|
||||||
- [ ] Add failing tests with long ASCII/Cyrillic/CJK/emoji names and endpoints at all three sizes. Assert every ANSI-stripped line is at most `width-1`, both table and footer stay within height, and footer occupies the final rows.
|
|
||||||
- [ ] Add table-driven tests for wide two-panel, medium stacked, and narrow compact column sets.
|
|
||||||
- [ ] Run `go test ./internal/tui -run 'Test(ForwardManagerShell|ForwardFormShell|ForwardColumns)' -count=1` and confirm overflow/frame/footer failures.
|
|
||||||
- [ ] Derive every table width from panel inner width with a one-cell safety margin; add framed table/details layouts per breakpoint and move form content into the shared framed shell.
|
|
||||||
- [ ] Run focused tests and `go test ./... -count=1`.
|
|
||||||
- [ ] Capture forward list and form at all three sizes in xterm, inspect for wrapping/overflow, and add a failing regression test before correcting any observed defect.
|
|
||||||
- [ ] Commit as `feat: redesign tui port forward screens` and push.
|
|
||||||
|
|
||||||
### Task 4: Tags, templates, results, and tunnels
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/app.go`
|
|
||||||
- Modify: `internal/tui/tunnel.go`
|
|
||||||
- Modify: `internal/tui/template_form.go`
|
|
||||||
- Modify: `internal/tui/layout_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: shared shell and bounded list/panel primitives.
|
|
||||||
- Produces: shell-backed tag manager, template manager/form/picker/mode/results, and tunnel manager.
|
|
||||||
|
|
||||||
- [ ] Add a screen-inventory render test covering normal, empty, error, and selected states for every manager at 120x40, 80x24, and 60x16.
|
|
||||||
- [ ] Run `go test ./internal/tui -run 'Test(ManagerScreenInventory|TunnelShell|TemplateShell|TagShell)' -count=1` and confirm missing frames/footer anchoring.
|
|
||||||
- [ ] Replace default Bubbles list rendering and free-form strings with bounded viewport rows inside framed panels. Keep exact selection, scroll index, and contextual actions.
|
|
||||||
- [ ] Run focused tests and `go test ./... -count=1`.
|
|
||||||
- [ ] Capture every manager family at 80x24 and its densest/longest state at 60x16; inspect borders, focus, footer, empty/error copy, and truncation.
|
|
||||||
- [ ] Commit as `feat: unify tui manager screens` and push.
|
|
||||||
|
|
||||||
### Task 5: Server form and complete screen matrix
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/form.go`
|
|
||||||
- Modify: `internal/tui/template_form.go`
|
|
||||||
- Modify: `internal/tui/layout_test.go`
|
|
||||||
- Modify: `internal/tui/form_validation_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: shared shell and existing focus-centered form viewport.
|
|
||||||
- Produces: framed server/template forms plus a complete enum-state layout matrix.
|
|
||||||
|
|
||||||
- [ ] Add failing tests proving server/template form breadcrumbs, panel borders, focused field visibility, validation visibility, action row, and bottom footer at all sizes.
|
|
||||||
- [ ] Add one exhaustive table listing every `screen` enum value with a representative model builder; assert all non-below-floor screens satisfy shell invariants.
|
|
||||||
- [ ] Run `go test ./internal/tui -run 'Test(FormShell|AllScreensUseShell)' -count=1` and confirm remaining non-shell states fail.
|
|
||||||
- [ ] Move form viewport content into the shared shell without changing navigation or save behavior; close all inventory gaps.
|
|
||||||
- [ ] Run focused tests and `go test ./... -count=1`.
|
|
||||||
- [ ] Commit as `feat: finish unified tui screen coverage` and push.
|
|
||||||
|
|
||||||
### Task 6: Runtime verification, documentation, and binary
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `README.md`
|
|
||||||
- Modify: `docs/guide.md`
|
|
||||||
- Replace after runtime verification: `docs/screenshots/screen_1.png` through `docs/screenshots/screen_5.png`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: completed TUI and isolated XDG audit profile.
|
|
||||||
- Produces: verified screenshots, synchronized documentation, and `bin/sshkeeper`.
|
|
||||||
|
|
||||||
- [ ] Build `/tmp/sshkeeper-unified-audit` and run it in tmux/xterm with the isolated audit profile.
|
|
||||||
- [ ] Capture dashboard, actions, forwards, forward form, tunnels, server form, tags/templates, confirmation, quick help, and full help at 120x40, 80x24, and 60x16.
|
|
||||||
- [ ] Inspect every capture for border continuity, right-column overflow, focus, notification truth, bottom footer, and correct Ctrl+H copy. For any defect, add a failing automated test before editing code.
|
|
||||||
- [ ] Verify real xterm sends Backspace as `KeyBackspace` and Ctrl+H as `KeyCtrlH`; document the terminal mapping constraint without restoring F1.
|
|
||||||
- [ ] Update README and guide only after runtime behavior is verified; refresh repository screenshots from the verified binary.
|
|
||||||
- [ ] Run `gofmt`, `git diff --check`, `go vet ./...`, `go test ./... -count=1`, and `go build ./...`.
|
|
||||||
- [ ] Commit as `docs: refresh unified tui screenshots` and push.
|
|
||||||
- [ ] Run `./build.sh`, report the absolute binary path, version, SHA-256, feature-branch HEAD, remote synchronization, and clean worktree. Do not publish a release.
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
# Unified sshkeeper TUI Shell
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Bring every sshkeeper TUI screen to the same visual and behavioral standard as
|
|
||||||
the approved server dashboard. Every screen must have a stable header, bounded
|
|
||||||
framed content, and a contextual footer anchored to the bottom of the terminal.
|
|
||||||
|
|
||||||
## User-approved direction
|
|
||||||
|
|
||||||
The existing server dashboard is the visual reference. Do not introduce a new
|
|
||||||
visual language or browser mockups. Apply its hierarchy, border treatment,
|
|
||||||
selection markers, spacing, colors, and responsive degradation to all child
|
|
||||||
screens.
|
|
||||||
|
|
||||||
`Ctrl+H` replaces `F1` as the global full-help binding. `?` remains contextual
|
|
||||||
quick help outside text editors. Remove `F1` from runtime help, footers, README,
|
|
||||||
and the user guide.
|
|
||||||
|
|
||||||
## Root cause
|
|
||||||
|
|
||||||
The dashboard owns a height-aware renderer with a header, panels, and footer.
|
|
||||||
Most child screens still render independent free-form strings or a default
|
|
||||||
Bubbles list. They therefore do not share inner-width budgeting, borders,
|
|
||||||
viewport height, or bottom-footer placement. The correction is a shared shell,
|
|
||||||
not per-screen blank-line padding.
|
|
||||||
|
|
||||||
## Shared screen shell
|
|
||||||
|
|
||||||
Every full-screen state uses this vertical contract:
|
|
||||||
|
|
||||||
1. Header: `sshkeeper / <breadcrumb>` on the left and truthful vault/context
|
|
||||||
status on the right.
|
|
||||||
2. Separator: one display-cell-bounded horizontal line.
|
|
||||||
3. Content: one or two bordered panels filling all available rows.
|
|
||||||
4. Footer: only the current screen's primary shortcuts, wrapped by display
|
|
||||||
width and anchored to the last terminal row.
|
|
||||||
|
|
||||||
The shell computes content height as terminal height minus header, separator,
|
|
||||||
notification, and wrapped-footer rows. A panel owns a one-cell border and at
|
|
||||||
least one-cell inner horizontal padding. No content row may consume the
|
|
||||||
terminal's last column directly.
|
|
||||||
|
|
||||||
Errors, success messages, pending state, and partial success appear in a
|
|
||||||
dedicated notification row below the separator. Rendering remains pure.
|
|
||||||
|
|
||||||
## Screen families
|
|
||||||
|
|
||||||
### Actions
|
|
||||||
|
|
||||||
Actions use a framed selectable list. At 100 columns and wider, a second panel
|
|
||||||
describes the selected action and its target. At 70-99 columns, the description
|
|
||||||
appears below the list when height permits. At 60-69 columns, only the framed
|
|
||||||
list remains. The footer is always at the bottom.
|
|
||||||
|
|
||||||
### Port forwards
|
|
||||||
|
|
||||||
At 100 columns and wider, forwards use a framed table and a framed selected-rule
|
|
||||||
panel. At 70-99 columns, the selected-rule panel is stacked below the table. At
|
|
||||||
60-69 columns, the table contains Name, Type, and On; details stay available
|
|
||||||
through the selected-rule panel only when vertical space permits.
|
|
||||||
|
|
||||||
All column widths are derived from the panel's inner width. The table must
|
|
||||||
leave an inner right margin, so an 80-column terminal never renders a row at 80
|
|
||||||
display cells. Long names, endpoints, explanations, and SSH arguments truncate
|
|
||||||
by display cells with an ellipsis.
|
|
||||||
|
|
||||||
The forward form uses the shared shell, a framed form panel, radio markers for
|
|
||||||
type, and an action row. Focused fields and validation remain visible.
|
|
||||||
|
|
||||||
### Managers and pickers
|
|
||||||
|
|
||||||
Tags, command templates, template picker/mode/results, and tunnel manager use a
|
|
||||||
framed list or result panel. Selection uses `>` as well as color. Empty and
|
|
||||||
error states remain inside the panel. Lists viewport around the selected item
|
|
||||||
and never rely on the default Bubbles frame or footer.
|
|
||||||
|
|
||||||
### Forms and text entry
|
|
||||||
|
|
||||||
Server, template, tag, search, and forward editors use a framed form panel.
|
|
||||||
Their title moves into the common breadcrumb. Required markers, validation,
|
|
||||||
dirty confirmation, and input ownership do not change. The focused control,
|
|
||||||
status/error, and action row remain in the panel's visible window.
|
|
||||||
|
|
||||||
### Confirmations
|
|
||||||
|
|
||||||
Confirmations use the same application header and a centered or width-bounded
|
|
||||||
framed dialog panel. Exact target, consequence, Cancel-first action row, and
|
|
||||||
footer remain visible at 60x16. Long Unicode text wraps by display cells.
|
|
||||||
|
|
||||||
### Help
|
|
||||||
|
|
||||||
`Ctrl+H` opens full help from every state except a confirmation overlay. `?`
|
|
||||||
opens contextual quick help only when a text editor does not own printable
|
|
||||||
input. `F1` has no documented or runtime binding.
|
|
||||||
|
|
||||||
Bubble Tea v1 distinguishes `KeyCtrlH` (ASCII BS) from `KeyBackspace` (DEL).
|
|
||||||
Automated and PTY checks must prove that xterm Backspace edits text while
|
|
||||||
`Ctrl+H` opens help. Terminals configured to emit BS for Backspace cannot
|
|
||||||
distinguish the two; the supported runtime check uses xterm's default DEL
|
|
||||||
Backspace mapping.
|
|
||||||
|
|
||||||
Both help screens use the shared shell and a framed, scrolling body. Their
|
|
||||||
footer stays at the bottom and states how to close/scroll.
|
|
||||||
|
|
||||||
## Responsive contract
|
|
||||||
|
|
||||||
- Supported floor: `60x16`.
|
|
||||||
- Wide: `width >= 100`, two panels where useful.
|
|
||||||
- Medium: `70 <= width < 100`, stacked panels.
|
|
||||||
- Narrow: `60 <= width < 70`, compact single panel.
|
|
||||||
- Below the floor: render only the minimum-size message.
|
|
||||||
|
|
||||||
Every screen family is tested at 120x40, 80x24, and 60x16 with long ASCII,
|
|
||||||
Cyrillic, CJK, combining, and emoji content. Assertions measure ANSI-aware
|
|
||||||
display cells and exact maximum height.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Automated coverage must inventory every `screen` enum value and prove that its
|
|
||||||
normal representative state:
|
|
||||||
|
|
||||||
- fits terminal width and height;
|
|
||||||
- contains a top header and at least one border;
|
|
||||||
- keeps the contextual footer on the final rendered row(s);
|
|
||||||
- retains a non-color selection/focus marker where applicable;
|
|
||||||
- does not expose `F1` and does expose `Ctrl+H` help where applicable.
|
|
||||||
|
|
||||||
Runtime verification uses the freshly built binary in an isolated XDG profile.
|
|
||||||
Capture and inspect dashboard, actions, forwards, forward form, tunnel manager,
|
|
||||||
server form, template/tag managers, confirmation, and help at all three sizes.
|
|
||||||
|
|
||||||
## Delivery boundary
|
|
||||||
|
|
||||||
Commit and push each implementation stage. Finish with a fresh binary under
|
|
||||||
`bin/sshkeeper` and report its absolute path and checksum. Do not publish a
|
|
||||||
release or update release metadata until the user explicitly approves the
|
|
||||||
binary.
|
|
||||||
|
|
@ -680,7 +680,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.err = nil
|
m.err = nil
|
||||||
m.success = ""
|
m.success = ""
|
||||||
}
|
}
|
||||||
if msg.Type == tea.KeyCtrlH && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
|
if msg.Type == tea.KeyF1 && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
|
||||||
m.helpParent = m.screen
|
m.helpParent = m.screen
|
||||||
m.fullHelp = newFullHelpModel(m.width, m.height)
|
m.fullHelp = newFullHelpModel(m.width, m.height)
|
||||||
m.screen = screenFullHelp
|
m.screen = screenFullHelp
|
||||||
|
|
@ -1213,7 +1213,8 @@ func (m *tuiModel) View() string {
|
||||||
b.WriteString(m.viewServerList())
|
b.WriteString(m.viewServerList())
|
||||||
|
|
||||||
case screenSearch:
|
case screenSearch:
|
||||||
b.WriteString(m.viewSearch())
|
b.WriteString("Search: " + m.searchInput.View() + "\n")
|
||||||
|
b.WriteString(renderHelp([]helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Esc", Action: "cancel"}}, m.width))
|
||||||
|
|
||||||
case screenForm:
|
case screenForm:
|
||||||
b.WriteString(m.form.View())
|
b.WriteString(m.form.View())
|
||||||
|
|
@ -1273,6 +1274,13 @@ func (m *tuiModel) View() string {
|
||||||
b.WriteString(m.viewConfirm())
|
b.WriteString(m.viewConfirm())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.screen != screenList && m.err != nil {
|
||||||
|
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||||
|
}
|
||||||
|
if m.screen != screenList && m.success != "" {
|
||||||
|
b.WriteString("\n" + successStyle.Render(m.success))
|
||||||
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1541,14 +1549,22 @@ func (m *tuiModel) viewConfirm() string {
|
||||||
if m.confirm == nil {
|
if m.confirm == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
body := func(width, height int) string {
|
width := m.width
|
||||||
innerWidth := max(1, width-4)
|
if width <= 0 {
|
||||||
innerHeight := max(1, height-2)
|
width = 80
|
||||||
message := wrapCells(m.confirm.target, innerWidth)
|
|
||||||
if m.confirm.consequence != "" {
|
|
||||||
message = append(message, "")
|
|
||||||
message = append(message, wrapCells(m.confirm.consequence, innerWidth)...)
|
|
||||||
}
|
}
|
||||||
|
innerWidth := max(1, width-2)
|
||||||
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(m.confirm.title, width)), ""}
|
||||||
|
for _, line := range wrapCells(m.confirm.target, innerWidth) {
|
||||||
|
lines = append(lines, " "+line)
|
||||||
|
}
|
||||||
|
if m.confirm.consequence != "" {
|
||||||
|
lines = append(lines, "")
|
||||||
|
for _, line := range wrapCells(m.confirm.consequence, innerWidth) {
|
||||||
|
lines = append(lines, " "+line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, "")
|
||||||
cancel := "[ Cancel ]"
|
cancel := "[ Cancel ]"
|
||||||
accept := "[ " + m.confirm.verb + " ]"
|
accept := "[ " + m.confirm.verb + " ]"
|
||||||
if m.confirm.focus == confirmCancel {
|
if m.confirm.focus == confirmCancel {
|
||||||
|
|
@ -1556,37 +1572,18 @@ func (m *tuiModel) viewConfirm() string {
|
||||||
} else {
|
} else {
|
||||||
accept = errorStyle.Render("> " + accept)
|
accept = errorStyle.Render("> " + accept)
|
||||||
}
|
}
|
||||||
action := cancel + " " + accept
|
|
||||||
if m.confirm.pending {
|
if m.confirm.pending {
|
||||||
action = m.confirm.verb + " in progress…"
|
lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "")
|
||||||
|
} else {
|
||||||
|
lines = append(lines, fitLine(" "+cancel+" "+accept, width), "")
|
||||||
}
|
}
|
||||||
messageRows := max(0, innerHeight-2)
|
footer := renderHelp([]helpItem{
|
||||||
if len(message) > messageRows {
|
|
||||||
message = message[:messageRows]
|
|
||||||
if len(message) > 0 {
|
|
||||||
message[len(message)-1] = truncateCells(strings.TrimSpace(message[len(message)-1])+" …", innerWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines := []string{dashboardSection(m.confirm.title)}
|
|
||||||
lines = append(lines, message...)
|
|
||||||
for len(lines) < innerHeight-1 {
|
|
||||||
lines = append(lines, "")
|
|
||||||
}
|
|
||||||
lines = append(lines, action)
|
|
||||||
return renderPaddedPanel(width, height, lines)
|
|
||||||
}
|
|
||||||
return renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Confirm",
|
|
||||||
status: shellStatus(m.vaultUnlocked, "Action required"),
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: body,
|
|
||||||
footer: []helpItem{
|
|
||||||
{Key: "Tab", Action: "choose"},
|
{Key: "Tab", Action: "choose"},
|
||||||
{Key: "Enter", Action: "activate"},
|
{Key: "Enter", Action: "activate"},
|
||||||
{Key: "Esc", Action: "cancel"},
|
{Key: "Esc", Action: "cancel"},
|
||||||
},
|
}, width)
|
||||||
})
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) beginConfirm(state confirmState) {
|
func (m *tuiModel) beginConfirm(state confirmState) {
|
||||||
|
|
@ -1774,36 +1771,6 @@ func (m *tuiModel) viewServerList() string {
|
||||||
return m.renderServerDashboard()
|
return m.renderServerDashboard()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) rootNotification() string {
|
|
||||||
if m.err != nil {
|
|
||||||
return errorStyle.Render("Error: " + m.err.Error())
|
|
||||||
}
|
|
||||||
if m.success != "" {
|
|
||||||
return successStyle.Render(m.success)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *tuiModel) viewSearch() string {
|
|
||||||
return renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Search",
|
|
||||||
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d profiles", len(m.servers))),
|
|
||||||
notification: m.rootNotification(),
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
return renderPaddedPanel(width, height, []string{
|
|
||||||
dashboardSection("Find server"),
|
|
||||||
"",
|
|
||||||
m.searchInput.View(),
|
|
||||||
"",
|
|
||||||
dashboardHelp("Search alias, host, display name, group, tags, notes, and route."),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
footer: []helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *tuiModel) viewInlineBackgroundResults() string {
|
func (m *tuiModel) viewInlineBackgroundResults() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(sectionStyle.Render("Last Background Run"))
|
b.WriteString(sectionStyle.Render("Last Background Run"))
|
||||||
|
|
@ -1924,34 +1891,37 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTags() string {
|
func (m *tuiModel) viewTags() string {
|
||||||
return renderScreenShell(screenShell{
|
var b strings.Builder
|
||||||
breadcrumb: "Tags",
|
b.WriteString(titleStyle.Render("Tags"))
|
||||||
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d tags", len(m.tags))),
|
b.WriteString("\n\n")
|
||||||
notification: m.rootNotification(),
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
if len(m.tags) == 0 {
|
if len(m.tags) == 0 {
|
||||||
return renderPaddedPanel(width, height, []string{dashboardHelp("No tags yet. Ctrl+A adds one to the selected servers.")})
|
b.WriteString(helpStyle.Render(" No tags yet. Press Ctrl+A to add one to the selected servers."))
|
||||||
}
|
b.WriteString("\n")
|
||||||
capacity := max(1, height-2)
|
} else {
|
||||||
start, end := visibleServerRange(len(m.tagList.Items()), m.tagList.Index(), capacity)
|
for i, item := range m.tagList.Items() {
|
||||||
lines := make([]string, 0, capacity)
|
tag, ok := item.(groupItem)
|
||||||
for index := start; index < end; index++ {
|
|
||||||
tag, ok := m.tagList.Items()[index].(groupItem)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
marker := " "
|
marker := " "
|
||||||
if index == m.tagList.Index() {
|
style := normalStyle
|
||||||
|
if i == m.tagList.Index() {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
|
style = selectedRowStyle
|
||||||
}
|
}
|
||||||
lines = append(lines, marker+tag.name)
|
b.WriteString(style.Render(marker + tag.name))
|
||||||
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
return renderPaddedPanel(width, height, lines)
|
}
|
||||||
},
|
b.WriteString("\n")
|
||||||
footer: []helpItem{{Key: "Enter", Action: "toggle"}, {Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "rename"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
b.WriteString(renderHelp([]helpItem{
|
||||||
})
|
{Key: "Enter", Action: "toggle for selected/current"},
|
||||||
|
{Key: "Ctrl+A", Action: "add"},
|
||||||
|
{Key: "Ctrl+E", Action: "rename"},
|
||||||
|
{Key: "Ctrl+D", Action: "delete"},
|
||||||
|
{Key: "Esc", Action: "back"},
|
||||||
|
}, m.width))
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTagInput() string {
|
func (m *tuiModel) viewTagInput() string {
|
||||||
|
|
@ -1959,83 +1929,74 @@ func (m *tuiModel) viewTagInput() string {
|
||||||
if m.tagMode == "rename" {
|
if m.tagMode == "rename" {
|
||||||
title = "Rename Tag"
|
title = "Rename Tag"
|
||||||
}
|
}
|
||||||
return renderScreenShell(screenShell{
|
return titleStyle.Render(title) + "\n\n" + m.tagInput.View() + "\n\n" + renderHelp([]helpItem{{Key: "Enter", Action: "save"}, {Key: "Esc", Action: "cancel"}}, m.width)
|
||||||
breadcrumb: title,
|
|
||||||
status: shellStatus(m.vaultUnlocked, "Tag editor"),
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
return renderPaddedPanel(width, height, []string{dashboardSection(title), "", m.tagInput.View()})
|
|
||||||
},
|
|
||||||
footer: []helpItem{{Key: "Enter", Action: "save"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTemplates() string {
|
func (m *tuiModel) viewTemplates() string {
|
||||||
return renderScreenShell(screenShell{
|
var b strings.Builder
|
||||||
breadcrumb: "Command Templates",
|
b.WriteString(titleStyle.Render("Command Templates"))
|
||||||
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d templates", len(m.templates))),
|
b.WriteString("\n\n")
|
||||||
notification: m.rootNotification(),
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
if len(m.templates) == 0 {
|
if len(m.templates) == 0 {
|
||||||
return renderPaddedPanel(width, height, []string{dashboardHelp("No command templates yet. Ctrl+A adds one.")})
|
b.WriteString(helpStyle.Render(" No command templates yet. Press Ctrl+A to add one."))
|
||||||
}
|
b.WriteString("\n")
|
||||||
capacity := max(1, height-2)
|
} else {
|
||||||
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
|
for i, item := range m.templateList.Items() {
|
||||||
lines := make([]string, 0, capacity)
|
tpl, ok := item.(templateItem)
|
||||||
for index := start; index < end; index++ {
|
|
||||||
tpl, ok := m.templateList.Items()[index].(templateItem)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
marker := " "
|
marker := " "
|
||||||
if index == m.templateList.Index() {
|
style := normalStyle
|
||||||
|
if i == m.templateList.Index() {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
|
style = selectedRowStyle
|
||||||
}
|
}
|
||||||
line := marker + tpl.template.Name + " " + tpl.template.Command
|
line := fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)
|
||||||
if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow {
|
b.WriteString(style.Render(line))
|
||||||
line += " — " + tpl.template.Description
|
b.WriteString("\n")
|
||||||
|
if tpl.template.Description != "" {
|
||||||
|
b.WriteString(helpStyle.Render(" " + tpl.template.Description))
|
||||||
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
}
|
||||||
return renderPaddedPanel(width, height, lines)
|
}
|
||||||
},
|
b.WriteString("\n")
|
||||||
footer: []helpItem{{Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "edit"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
b.WriteString(renderHelp([]helpItem{
|
||||||
})
|
{Key: "Ctrl+A", Action: "add"},
|
||||||
|
{Key: "Ctrl+E", Action: "edit"},
|
||||||
|
{Key: "Ctrl+D", Action: "delete"},
|
||||||
|
{Key: "Esc", Action: "back"},
|
||||||
|
}, m.width))
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTemplatePicker() string {
|
func (m *tuiModel) viewTemplatePicker() string {
|
||||||
targets := strings.Join(serverAliases(m.targetServers()), ", ")
|
var b strings.Builder
|
||||||
return renderScreenShell(screenShell{
|
b.WriteString(titleStyle.Render("Run Template"))
|
||||||
breadcrumb: "Run Template",
|
b.WriteString("\n")
|
||||||
status: "Targets: " + targets,
|
b.WriteString(helpStyle.Render(fmt.Sprintf("Targets: %s", strings.Join(serverAliases(m.targetServers()), ", "))))
|
||||||
width: m.width,
|
b.WriteString("\n\n")
|
||||||
height: m.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
lines := []string{dashboardSection("Choose template"), dashboardHelp("Targets: " + targets), ""}
|
|
||||||
if len(m.templates) == 0 {
|
if len(m.templates) == 0 {
|
||||||
lines = append(lines, dashboardHelp("No command templates. Press Esc, then Ctrl+P to add one."))
|
b.WriteString(helpStyle.Render(" No command templates. Press Esc, then Ctrl+P to add one."))
|
||||||
} else {
|
} else {
|
||||||
capacity := max(1, height-len(lines)-2)
|
for i, item := range m.templateList.Items() {
|
||||||
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
|
tpl, ok := item.(templateItem)
|
||||||
for index := start; index < end; index++ {
|
|
||||||
tpl, ok := m.templateList.Items()[index].(templateItem)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
marker := " "
|
marker := " "
|
||||||
if index == m.templateList.Index() {
|
style := normalStyle
|
||||||
|
if i == m.templateList.Index() {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
|
style = selectedRowStyle
|
||||||
}
|
}
|
||||||
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
|
b.WriteString(style.Render(fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)))
|
||||||
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return renderPaddedPanel(width, height, lines)
|
b.WriteString("\n")
|
||||||
},
|
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "choose"}, {Key: "Esc", Action: "back"}}, m.width))
|
||||||
footer: []helpItem{{Key: "Enter", Action: "choose"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
return b.String()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTemplateMode() string {
|
func (m *tuiModel) viewTemplateMode() string {
|
||||||
|
|
@ -2045,44 +2006,30 @@ func (m *tuiModel) viewTemplateMode() string {
|
||||||
name = m.pendingTemplate.Name
|
name = m.pendingTemplate.Name
|
||||||
command = m.pendingTemplate.Command
|
command = m.pendingTemplate.Command
|
||||||
}
|
}
|
||||||
targets := strings.Join(serverAliases(m.targetServers()), ", ")
|
return titleStyle.Render("Run Mode") + "\n\n" +
|
||||||
return renderScreenShell(screenShell{
|
fmt.Sprintf("Template: %s\nCommand: %s\nTargets: %s\n\n", name, command, strings.Join(serverAliases(m.targetServers()), ", ")) +
|
||||||
breadcrumb: "Run Template / Mode",
|
renderHelp([]helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Esc", Action: "back"}}, m.width)
|
||||||
status: "Targets: " + targets,
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
return renderPaddedPanel(width, height, []string{dashboardSection("Execution"), "", "Template: " + name, "Command: " + command, "Targets: " + targets, "", "Choose foreground for an interactive run or background to keep using sshkeeper."})
|
|
||||||
},
|
|
||||||
footer: []helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewBackgroundResults() string {
|
func (m *tuiModel) viewBackgroundResults() string {
|
||||||
return renderScreenShell(screenShell{
|
var b strings.Builder
|
||||||
breadcrumb: "Background Results",
|
b.WriteString(titleStyle.Render("Background Results"))
|
||||||
status: fmt.Sprintf("%d results", len(m.bgResults)),
|
b.WriteString("\n\n")
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
lines := make([]string, 0)
|
|
||||||
for _, result := range m.bgResults {
|
for _, result := range m.bgResults {
|
||||||
status := "OK"
|
status := "OK"
|
||||||
if result.Err != "" {
|
if result.Err != "" {
|
||||||
status = "FAIL: " + result.Err
|
status = "FAIL: " + result.Err
|
||||||
}
|
}
|
||||||
lines = append(lines, dashboardSection(result.Alias+" "+status))
|
b.WriteString(sectionStyle.Render(result.Alias + " " + status))
|
||||||
|
b.WriteString("\n")
|
||||||
if result.Output != "" {
|
if result.Output != "" {
|
||||||
for _, line := range strings.Split(result.Output, "\n") {
|
b.WriteString(result.Output)
|
||||||
lines = append(lines, strings.ReplaceAll(line, "\t", " "))
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines = append(lines, "")
|
b.WriteString("\n")
|
||||||
}
|
b.WriteString(renderHelp([]helpItem{{Key: "Enter/Esc", Action: "back"}}, m.width))
|
||||||
return renderPaddedPanel(width, height, lines)
|
return b.String()
|
||||||
},
|
|
||||||
footer: []helpItem{{Key: "Enter/Esc", Action: "back"}, {Key: "Ctrl+H", Action: "help"}},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) selectedServers() []*model.Server {
|
func (m *tuiModel) selectedServers() []*model.Server {
|
||||||
|
|
@ -2187,7 +2134,7 @@ func (m *tuiModel) removeTag(name string) {
|
||||||
// --- Server list footer ---
|
// --- Server list footer ---
|
||||||
|
|
||||||
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
|
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
|
||||||
width := m.width - 3
|
width := m.width - 2
|
||||||
if width <= 0 {
|
if width <= 0 {
|
||||||
width = 80
|
width = 80
|
||||||
}
|
}
|
||||||
|
|
@ -2216,7 +2163,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
|
||||||
helpItem{Key: "Ctrl+F", Action: "search"},
|
helpItem{Key: "Ctrl+F", Action: "search"},
|
||||||
helpItem{Key: "Ins", Action: insAction},
|
helpItem{Key: "Ins", Action: insAction},
|
||||||
helpItem{Key: "?", Action: "hotkeys"},
|
helpItem{Key: "?", Action: "hotkeys"},
|
||||||
helpItem{Key: "Ctrl+H", Action: "help"},
|
helpItem{Key: "F1", Action: "help"},
|
||||||
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
|
||||||
|
|
@ -342,8 +342,8 @@ func TestAuthMethodListViewShowsAllOptions(t *testing.T) {
|
||||||
if between := view[authPos:listPos]; strings.Contains(between, "Identity File") {
|
if between := view[authPos:listPos]; strings.Contains(between, "Identity File") {
|
||||||
t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view)
|
t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view)
|
||||||
}
|
}
|
||||||
if !strings.Contains(view, "│") {
|
if strings.Contains(view, "│") {
|
||||||
t.Fatalf("expected auth method dropdown inside the unified frame\nview:\n%s", view)
|
t.Fatalf("expected compact auth method dropdown without default list border\nview:\n%s", view)
|
||||||
}
|
}
|
||||||
for _, method := range []model.AuthMethod{
|
for _, method := range []model.AuthMethod{
|
||||||
model.AuthPassword,
|
model.AuthPassword,
|
||||||
|
|
@ -383,8 +383,8 @@ func TestGroupListViewRendersDirectlyUnderGroupField(t *testing.T) {
|
||||||
if between := view[groupPos:listPos]; strings.Contains(between, "Password") {
|
if between := view[groupPos:listPos]; strings.Contains(between, "Password") {
|
||||||
t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view)
|
t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view)
|
||||||
}
|
}
|
||||||
if !strings.Contains(view, "│") {
|
if strings.Contains(view, "│") {
|
||||||
t.Fatalf("expected group dropdown inside the unified frame\nview:\n%s", view)
|
t.Fatalf("expected compact group dropdown without default list border\nview:\n%s", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ func (m *tuiModel) renderServerDashboard() string {
|
||||||
if height <= 0 {
|
if height <= 0 {
|
||||||
height = 40
|
height = 40
|
||||||
}
|
}
|
||||||
sizeClass := classifyTerminal(width, height)
|
|
||||||
width = max(1, width-1)
|
|
||||||
|
|
||||||
header := m.renderDashboardHeader(width)
|
header := m.renderDashboardHeader(width)
|
||||||
notification := m.renderDashboardNotification(width)
|
notification := m.renderDashboardNotification(width)
|
||||||
|
|
@ -31,7 +29,7 @@ func (m *tuiModel) renderServerDashboard() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body string
|
var body string
|
||||||
switch sizeClass {
|
switch classifyTerminal(width, height) {
|
||||||
case sizeWide:
|
case sizeWide:
|
||||||
leftWidth := width * 62 / 100
|
leftWidth := width * 62 / 100
|
||||||
rightWidth := width - leftWidth - 1
|
rightWidth := width - leftWidth - 1
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,14 @@ func (fm *formModel) View() string {
|
||||||
if fm.edit {
|
if fm.edit {
|
||||||
title = "Edit Server: " + fm.server.Alias
|
title = "Edit Server: " + fm.server.Alias
|
||||||
}
|
}
|
||||||
|
footer := renderHelp([]helpItem{
|
||||||
|
{Key: "Tab/↓", Action: "next"},
|
||||||
|
{Key: "↑", Action: "prev"},
|
||||||
|
{Key: "/", Action: "pick list"},
|
||||||
|
{Key: "Enter", Action: "select"},
|
||||||
|
{Key: "Esc", Action: "back"},
|
||||||
|
}, fm.width)
|
||||||
|
|
||||||
if fm.showAuthList || fm.showGroupList {
|
if fm.showAuthList || fm.showGroupList {
|
||||||
var dropdown list.Model
|
var dropdown list.Model
|
||||||
fieldIndex := 8
|
fieldIndex := 8
|
||||||
|
|
@ -598,18 +606,10 @@ func (fm *formModel) View() string {
|
||||||
} else {
|
} else {
|
||||||
dropdown = fm.groupList
|
dropdown = fm.groupList
|
||||||
}
|
}
|
||||||
return renderScreenShell(screenShell{
|
return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" +
|
||||||
breadcrumb: title + " / Picker",
|
fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" +
|
||||||
status: "Choose a value",
|
fitLine(renderDropdown(dropdown), fm.width) + "\n" +
|
||||||
width: fm.width,
|
renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)
|
||||||
height: fm.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
lines := []string{fm.inputs[fieldIndex].View(), ""}
|
|
||||||
lines = append(lines, splitBlock(renderDropdown(dropdown))...)
|
|
||||||
return renderPaddedPanel(width, height, lines)
|
|
||||||
},
|
|
||||||
footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
status := fm.formStatusLine()
|
status := fm.formStatusLine()
|
||||||
|
|
@ -620,10 +620,14 @@ func (fm *formModel) View() string {
|
||||||
if fm.focusIdx == len(fm.inputs)+2 {
|
if fm.focusIdx == len(fm.inputs)+2 {
|
||||||
saveBtn = selectedStyle.Render("> [ Save ]")
|
saveBtn = selectedStyle.Render("> [ Save ]")
|
||||||
}
|
}
|
||||||
actions := testBtn + " " + saveBtn
|
actions := fitLine(testBtn+" "+saveBtn, fm.width)
|
||||||
|
|
||||||
body := func(width, height int) string {
|
reserved := 1 + displayLineCount(footer) + 1
|
||||||
richLayout := width >= 90 && height >= 20
|
if status != "" {
|
||||||
|
reserved++
|
||||||
|
}
|
||||||
|
fieldRows := max(4, fm.height-reserved)
|
||||||
|
richLayout := fm.width >= 90 && fm.height >= 24
|
||||||
allFields := make([]string, 0, len(fm.inputs)+5)
|
allFields := make([]string, 0, len(fm.inputs)+5)
|
||||||
focusRows := make([]int, len(fm.inputs)+1)
|
focusRows := make([]int, len(fm.inputs)+1)
|
||||||
for i := range fm.inputs {
|
for i := range fm.inputs {
|
||||||
|
|
@ -639,49 +643,37 @@ func (fm *formModel) View() string {
|
||||||
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
|
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
|
||||||
}
|
}
|
||||||
focusRows[i] = len(allFields)
|
focusRows[i] = len(allFields)
|
||||||
allFields = append(allFields, fm.inputs[i].View())
|
allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width))
|
||||||
}
|
}
|
||||||
focusRows[len(fm.inputs)] = len(allFields)
|
focusRows[len(fm.inputs)] = len(allFields)
|
||||||
allFields = append(allFields, fm.password.View())
|
allFields = append(allFields, fitLine(fm.password.View(), fm.width))
|
||||||
focusField := len(allFields) - 1
|
focusField := len(allFields) - 1
|
||||||
if fm.focusIdx <= len(fm.inputs) {
|
if fm.focusIdx <= len(fm.inputs) {
|
||||||
focusField = focusRows[fm.focusIdx]
|
focusField = focusRows[fm.focusIdx]
|
||||||
}
|
}
|
||||||
actionRows := 1
|
|
||||||
if richLayout {
|
|
||||||
actionRows = 2
|
|
||||||
}
|
|
||||||
fieldRows := max(1, height-2-actionRows)
|
|
||||||
start, end := visibleServerRange(len(allFields), focusField, fieldRows)
|
start, end := visibleServerRange(len(allFields), focusField, fieldRows)
|
||||||
visible := append([]string(nil), allFields[start:end]...)
|
visible := append([]string(nil), allFields[start:end]...)
|
||||||
if start > 0 && len(visible) > 0 {
|
if start > 0 && len(visible) > 0 {
|
||||||
visible[0] = "↑ more fields · " + visible[0]
|
visible[0] = fitLine("↑ more fields · "+visible[0], fm.width)
|
||||||
}
|
}
|
||||||
if end < len(allFields) && len(visible) > 0 {
|
if end < len(allFields) && len(visible) > 0 {
|
||||||
visible[len(visible)-1] += " · more ↓"
|
visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
|
||||||
|
lines = append(lines, visible...)
|
||||||
|
if status != "" {
|
||||||
|
lines = append(lines, fitLine(status, fm.width))
|
||||||
}
|
}
|
||||||
if richLayout {
|
if richLayout {
|
||||||
visible = append(visible, sectionStyle.Copy().MarginTop(0).Render("Actions"))
|
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
|
||||||
}
|
}
|
||||||
visible = append(visible, actions)
|
lines = append(lines, actions)
|
||||||
return renderPaddedPanel(width, height, visible)
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
|
if len(lines) > fm.height && fm.height > 0 {
|
||||||
|
lines = lines[:fm.height]
|
||||||
}
|
}
|
||||||
return renderScreenShell(screenShell{
|
return strings.Join(lines, "\n")
|
||||||
breadcrumb: title,
|
|
||||||
status: "Server profile",
|
|
||||||
notification: status,
|
|
||||||
width: fm.width,
|
|
||||||
height: fm.height,
|
|
||||||
body: body,
|
|
||||||
footer: []helpItem{
|
|
||||||
{Key: "Tab/↓", Action: "next"},
|
|
||||||
{Key: "↑", Action: "prev"},
|
|
||||||
{Key: "/", Action: "pick list"},
|
|
||||||
{Key: "Enter", Action: "select"},
|
|
||||||
{Key: "Ctrl+H", Action: "help"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fm *formModel) formStatusLine() string {
|
func (fm *formModel) formStatusLine() string {
|
||||||
|
|
@ -709,7 +701,7 @@ func (fm *formModel) formStatusLine() string {
|
||||||
|
|
||||||
func renderDropdown(l list.Model) string {
|
func renderDropdown(l list.Model) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(dashboardSection(l.Title))
|
b.WriteString(sectionStyle.Render(l.Title))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
for i, item := range l.Items() {
|
for i, item := range l.Items() {
|
||||||
group, ok := item.(groupItem)
|
group, ok := item.(groupItem)
|
||||||
|
|
|
||||||
|
|
@ -79,90 +79,52 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) View() string {
|
func (m *forwardScreenModel) View() string {
|
||||||
notification := ""
|
footer := renderHelp([]helpItem{
|
||||||
if m.err != nil {
|
|
||||||
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
|
||||||
}
|
|
||||||
body := func(width, height int) string {
|
|
||||||
switch classifyShellContent(width) {
|
|
||||||
case sizeWide:
|
|
||||||
leftWidth := width * 70 / 100
|
|
||||||
rightWidth := width - leftWidth - 1
|
|
||||||
return joinPanelColumns(
|
|
||||||
renderPaddedPanel(leftWidth, height, m.forwardListLines(leftWidth-4, height-2, false)), leftWidth,
|
|
||||||
renderPaddedPanel(rightWidth, height, m.forwardDetailLines(rightWidth-4, false)), rightWidth,
|
|
||||||
)
|
|
||||||
case sizeMedium:
|
|
||||||
detailHeight := min(7, max(4, height/3))
|
|
||||||
listHeight := max(3, height-detailHeight-1)
|
|
||||||
listPanel := renderPaddedPanel(width, listHeight, m.forwardListLines(width-4, listHeight-2, false))
|
|
||||||
detailPanel := renderPaddedPanel(width, detailHeight, m.forwardDetailLines(width-4, true))
|
|
||||||
return listPanel + "\n" + detailPanel
|
|
||||||
default:
|
|
||||||
return renderPaddedPanel(width, height, m.forwardListLines(width-4, height-2, true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Port Forwards / " + m.serverAlias,
|
|
||||||
status: fmt.Sprintf("%d rules", len(m.list)),
|
|
||||||
notification: notification,
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: body,
|
|
||||||
footer: []helpItem{
|
|
||||||
{Key: "Ctrl+A (a)", Action: "add"},
|
{Key: "Ctrl+A (a)", Action: "add"},
|
||||||
{Key: "Ctrl+E/Enter", Action: "edit"},
|
{Key: "Ctrl+E/Enter", Action: "edit"},
|
||||||
{Key: "Ctrl+D (d)", Action: "delete"},
|
{Key: "Ctrl+D (d)", Action: "delete"},
|
||||||
{Key: "Ctrl+H", Action: "help"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
},
|
}, m.width)
|
||||||
})
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine("Port Forwards — "+m.serverAlias, m.width))}
|
||||||
|
if m.err != nil {
|
||||||
|
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)), m.width))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) forwardListLines(width, capacity int, compact bool) []string {
|
footerRows := displayLineCount(footer)
|
||||||
|
detailRows := 0
|
||||||
|
if len(m.list) > 0 && m.height-footerRows >= 7 {
|
||||||
|
detailRows = 3
|
||||||
|
}
|
||||||
|
rowCapacity := max(1, m.height-len(lines)-footerRows-detailRows-1)
|
||||||
if len(m.list) == 0 {
|
if len(m.list) == 0 {
|
||||||
return []string{helpStyle.Copy().MarginLeft(0).Render("No port forwards configured. Ctrl+A adds one.")}
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
|
||||||
}
|
} else {
|
||||||
lines := []string{m.renderForwardRow(nil, false, width, compact)}
|
lines = append(lines, m.renderForwardRow(nil, false))
|
||||||
rowCapacity := max(1, capacity-1)
|
rowCapacity--
|
||||||
showRange := len(m.list) > rowCapacity
|
|
||||||
if showRange {
|
|
||||||
rowCapacity = max(1, rowCapacity-1)
|
|
||||||
}
|
|
||||||
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
|
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
|
||||||
for index := start; index < end; index++ {
|
for index := start; index < end; index++ {
|
||||||
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected, width, compact))
|
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
|
||||||
}
|
}
|
||||||
if showRange {
|
if end < len(m.list) || start > 0 {
|
||||||
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *forwardScreenModel) forwardDetailLines(width int, compact bool) []string {
|
|
||||||
lines := []string{dashboardSection("Selected rule")}
|
|
||||||
if m.selected < 0 || m.selected >= len(m.list) {
|
|
||||||
return append(lines, "", dashboardHelp("No rule selected."))
|
|
||||||
}
|
}
|
||||||
|
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
|
||||||
forward := m.list[m.selected]
|
forward := m.list[m.selected]
|
||||||
name := forward.Name
|
lines = append(lines,
|
||||||
if name == "" {
|
sectionStyle.Copy().MarginTop(0).Render("Selected"),
|
||||||
name = forward.ForwardListen()
|
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
|
||||||
|
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if compact {
|
|
||||||
lines = append(lines, name+" · "+string(forward.Type))
|
|
||||||
} else {
|
|
||||||
lines = append(lines, "", name, string(forward.Type))
|
|
||||||
}
|
}
|
||||||
lines = append(lines, wrapCells(forward.ForwardHumanExplanation(m.serverAlias), max(1, width))...)
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
if !compact {
|
if len(lines) > m.height && m.height > 0 {
|
||||||
lines = append(lines, "")
|
lines = lines[:m.height]
|
||||||
}
|
}
|
||||||
lines = append(lines, dashboardHelp("ssh "+strings.Join(forward.ForwardSSHArgs(), " ")))
|
return strings.Join(lines, "\n")
|
||||||
return lines
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool, width int, compact bool) string {
|
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
|
||||||
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
|
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
|
||||||
if forward != nil {
|
if forward != nil {
|
||||||
if selected {
|
if selected {
|
||||||
|
|
@ -180,30 +142,30 @@ func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected b
|
||||||
enabled = "no"
|
enabled = "no"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
wide := m.width >= 70
|
||||||
typeWidth, enabledWidth := 8, 3
|
typeWidth, enabledWidth := 8, 3
|
||||||
if !compact && width >= 58 {
|
if wide {
|
||||||
flexible := max(3, width-typeWidth-enabledWidth-7)
|
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
|
||||||
nameWidth := max(1, flexible*30/100)
|
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
|
||||||
listenWidth := max(1, flexible*32/100)
|
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
|
||||||
targetWidth := max(1, flexible-nameWidth-listenWidth)
|
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
|
||||||
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
|
|
||||||
if forward == nil {
|
if forward == nil {
|
||||||
return listHeaderStyle.Render(fitLine(line, width))
|
return listHeaderStyle.Render(fitLine(line, m.width))
|
||||||
}
|
}
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle.Render(fitLine(line, width))
|
return selectedRowStyle.Render(fitLine(line, m.width))
|
||||||
}
|
}
|
||||||
return fitLine(line, width)
|
return fitLine(line, m.width)
|
||||||
}
|
}
|
||||||
nameWidth := max(1, width-typeWidth-enabledWidth-5)
|
nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
|
||||||
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
|
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
|
||||||
if forward == nil {
|
if forward == nil {
|
||||||
return listHeaderStyle.Render(fitLine(line, width))
|
return listHeaderStyle.Render(fitLine(line, m.width))
|
||||||
}
|
}
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle.Render(fitLine(line, width))
|
return selectedRowStyle.Render(fitLine(line, m.width))
|
||||||
}
|
}
|
||||||
return fitLine(line, width)
|
return fitLine(line, m.width)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Forward form screen model ---
|
// --- Forward form screen model ---
|
||||||
|
|
@ -633,15 +595,12 @@ func (fm *forwardFormModel) View() string {
|
||||||
if fm.editMode {
|
if fm.editMode {
|
||||||
title = "Edit Port Forward"
|
title = "Edit Port Forward"
|
||||||
}
|
}
|
||||||
notification := ""
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
|
||||||
if fm.err != nil {
|
lines = append(lines,
|
||||||
notification = errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
|
fitLine(fm.nameInput.View(), fm.width),
|
||||||
} else if fm.saved {
|
fitLine(fm.descInput.View(), fm.width),
|
||||||
notification = successStyle.Render("✓ Saved.")
|
)
|
||||||
}
|
|
||||||
body := func(width, height int) string {
|
|
||||||
contentWidth := max(1, width-4)
|
|
||||||
lines := []string{fm.nameInput.View(), fm.descInput.View()}
|
|
||||||
typeParts := make([]string, len(forwardTypes))
|
typeParts := make([]string, len(forwardTypes))
|
||||||
for i, forwardType := range forwardTypes {
|
for i, forwardType := range forwardTypes {
|
||||||
selected := "○"
|
selected := "○"
|
||||||
|
|
@ -654,48 +613,58 @@ func (fm *forwardFormModel) View() string {
|
||||||
}
|
}
|
||||||
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
|
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
|
||||||
}
|
}
|
||||||
lines = append(lines, "Type "+strings.Join(typeParts, " "))
|
lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
|
||||||
if width >= 100 {
|
if fm.width >= 100 {
|
||||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(forwardTypes[fm.typeIdx].description))
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
|
||||||
}
|
}
|
||||||
|
|
||||||
visible := fm.visibleFields()
|
visible := fm.visibleFields()
|
||||||
for _, idx := range visible {
|
for _, idx := range visible {
|
||||||
lines = append(lines, fm.inputs[idx].View())
|
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(fm.inputs[0].Value()) == "0.0.0.0" {
|
|
||||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render("⚠ This port will be accessible from the network."))
|
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
|
||||||
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
|
||||||
|
fwd := &model.Forward{
|
||||||
|
Type: fm.currentType,
|
||||||
|
LocalAddr: fm.inputs[0].Value(),
|
||||||
|
LocalPort: 0,
|
||||||
|
RemoteAddr: fm.inputs[2].Value(),
|
||||||
|
RemotePort: 0,
|
||||||
}
|
}
|
||||||
if width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
|
|
||||||
fwd := &model.Forward{Type: fm.currentType, LocalAddr: fm.inputs[0].Value(), RemoteAddr: fm.inputs[2].Value()}
|
|
||||||
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
|
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
|
||||||
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
||||||
preview := "Preview ssh " + strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
||||||
lines = append(lines, wrapCells(preview, contentWidth)...)
|
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
|
||||||
}
|
}
|
||||||
|
|
||||||
total := 2 + 3 + len(visible) + 1
|
total := 2 + 3 + len(visible) + 1
|
||||||
button := " [ Save ]"
|
button := " [ Save ]"
|
||||||
if fm.focusIdx == total-1 {
|
if fm.focusIdx == total-1 {
|
||||||
button = selectedStyle.Render("> [ Save ]")
|
button = selectedStyle.Render("> [ Save ]")
|
||||||
}
|
}
|
||||||
lines = append(lines, "", button)
|
if fm.err != nil {
|
||||||
return renderPaddedPanel(width, height, lines)
|
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
|
||||||
}
|
}
|
||||||
return renderScreenShell(screenShell{
|
if fm.saved {
|
||||||
breadcrumb: "Port Forwards / " + title,
|
lines = append(lines, successStyle.Render("✓ Saved."))
|
||||||
status: string(fm.currentType),
|
}
|
||||||
notification: notification,
|
lines = append(lines, button)
|
||||||
width: fm.width,
|
footer := renderHelp([]helpItem{
|
||||||
height: fm.height,
|
|
||||||
body: body,
|
|
||||||
footer: []helpItem{
|
|
||||||
{Key: "Tab/↓", Action: "next"},
|
{Key: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "1/2/3", Action: "select type"},
|
{Key: "1/2/3", Action: "select type"},
|
||||||
{Key: "Enter", Action: "save"},
|
{Key: "Enter", Action: "save"},
|
||||||
{Key: "Ctrl+H", Action: "help"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
},
|
}, fm.width)
|
||||||
})
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
|
if len(lines) > fm.height && fm.height > 0 {
|
||||||
|
lines = lines[:fm.height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// forwardEditSignal is sent when user wants to edit a forward
|
// forwardEditSignal is sent when user wants to edit a forward
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package tui
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
"github.com/charmbracelet/bubbletea"
|
"github.com/charmbracelet/bubbletea"
|
||||||
|
|
@ -13,7 +14,6 @@ import (
|
||||||
type helpScreenModel struct {
|
type helpScreenModel struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
width int
|
width int
|
||||||
height int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHelpScreenModel(w, h int) *helpScreenModel {
|
func newHelpScreenModel(w, h int) *helpScreenModel {
|
||||||
|
|
@ -30,7 +30,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
|
||||||
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
|
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
|
||||||
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
|
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
|
||||||
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
|
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
|
||||||
helpScreenItem{key: "Ctrl+H", action: "Full documentation", section: "Other"},
|
helpScreenItem{key: "F1", action: "Full documentation", section: "Other"},
|
||||||
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
|
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
|
||||||
l.SetFilteringEnabled(false)
|
l.SetFilteringEnabled(false)
|
||||||
l.Styles.Title = titleStyle
|
l.Styles.Title = titleStyle
|
||||||
|
|
||||||
return &helpScreenModel{list: l, width: w, height: h}
|
return &helpScreenModel{list: l, width: w}
|
||||||
}
|
}
|
||||||
|
|
||||||
type helpScreenItem struct {
|
type helpScreenItem struct {
|
||||||
|
|
@ -86,7 +86,6 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
|
||||||
m.list.SetSize(msg.Width, msg.Height-4)
|
m.list.SetSize(msg.Width, msg.Height-4)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -96,39 +95,10 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *helpScreenModel) View() string {
|
func (m *helpScreenModel) View() string {
|
||||||
items := m.list.Items()
|
return m.list.View()
|
||||||
body := func(width, height int) string {
|
|
||||||
innerRows := max(1, height-2)
|
|
||||||
start, end := visibleServerRange(len(items), m.list.Index(), innerRows)
|
|
||||||
lines := make([]string, 0, innerRows)
|
|
||||||
for index := start; index < end; index++ {
|
|
||||||
item, ok := items[index].(helpScreenItem)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
marker := " "
|
|
||||||
if index == m.list.Index() {
|
|
||||||
marker = "> "
|
|
||||||
}
|
|
||||||
lines = append(lines, marker+padCells(item.key, 12)+" "+item.action)
|
|
||||||
}
|
|
||||||
return renderPaddedPanel(width, height, lines)
|
|
||||||
}
|
|
||||||
return renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Quick Help",
|
|
||||||
status: "Keyboard reference",
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: body,
|
|
||||||
footer: []helpItem{
|
|
||||||
{Key: "↑/↓", Action: "move"},
|
|
||||||
{Key: "Ctrl+H", Action: "full help"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Full help (Ctrl+H) ---
|
// --- Full help (F1) ---
|
||||||
|
|
||||||
type fullHelpModel struct {
|
type fullHelpModel struct {
|
||||||
width int
|
width int
|
||||||
|
|
@ -179,6 +149,11 @@ func (m *fullHelpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullHelpModel) View() string {
|
func (m *fullHelpModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(titleStyle.Render("sshkeeper — Full Help"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
sections := []struct {
|
sections := []struct {
|
||||||
title string
|
title string
|
||||||
rows [][2]string
|
rows [][2]string
|
||||||
|
|
@ -199,7 +174,7 @@ func (m *fullHelpModel) View() string {
|
||||||
{"Enter", "Select / Confirm / Open"},
|
{"Enter", "Select / Confirm / Open"},
|
||||||
{"Esc", "Back / Cancel / Close"},
|
{"Esc", "Back / Cancel / Close"},
|
||||||
{"?", "Quick help (hotkeys)"},
|
{"?", "Quick help (hotkeys)"},
|
||||||
{"Ctrl+H", "Full documentation"},
|
{"F1", "Full documentation"},
|
||||||
{"Ctrl+Q", "Quit"},
|
{"Ctrl+Q", "Quit"},
|
||||||
}},
|
}},
|
||||||
{"Server list", [][2]string{
|
{"Server list", [][2]string{
|
||||||
|
|
@ -249,37 +224,40 @@ func (m *fullHelpModel) View() string {
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var lines []string
|
|
||||||
for _, sec := range sections {
|
for _, sec := range sections {
|
||||||
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render(sec.title))
|
b.WriteString(sectionStyle.Render(sec.title))
|
||||||
|
b.WriteString("\n")
|
||||||
for _, row := range sec.rows {
|
for _, row := range sec.rows {
|
||||||
if row[0] == "" {
|
if row[0] == "" {
|
||||||
lines = append(lines, " "+row[1])
|
b.WriteString(fmt.Sprintf(" %s\n", row[1]))
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, fmt.Sprintf(" %-16s %s", row[0], row[1]))
|
b.WriteString(fmt.Sprintf(" %-16s %s\n", row[0], row[1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines = append(lines, "")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
body := func(width, height int) string {
|
b.WriteString(helpStyle.Render(" ↑/↓ scroll — q/Esc/Enter close"))
|
||||||
capacity := max(1, height-2)
|
|
||||||
start := min(m.offset, max(0, len(lines)-capacity))
|
// Simple scroll
|
||||||
end := min(len(lines), start+capacity)
|
lines := strings.Split(b.String(), "\n")
|
||||||
return renderPaddedPanel(width, height, lines[start:end])
|
maxLines := m.height - 1
|
||||||
|
if maxLines < 5 {
|
||||||
|
maxLines = 5
|
||||||
}
|
}
|
||||||
return renderScreenShell(screenShell{
|
start := m.offset
|
||||||
breadcrumb: "Full Help",
|
if start > len(lines)-maxLines {
|
||||||
status: fmt.Sprintf("line %d/%d", min(m.offset+1, len(lines)), len(lines)),
|
start = len(lines) - maxLines
|
||||||
width: m.width,
|
}
|
||||||
height: m.height,
|
if start < 0 {
|
||||||
body: body,
|
start = 0
|
||||||
footer: []helpItem{
|
}
|
||||||
{Key: "↑/↓", Action: "scroll"},
|
end := start + maxLines
|
||||||
{Key: "Ctrl+H", Action: "full help"},
|
if end > len(lines) {
|
||||||
{Key: "Esc/Enter", Action: "close"},
|
end = len(lines)
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
return strings.Join(lines[start:end], "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Action menu ---
|
// --- Action menu ---
|
||||||
|
|
@ -287,7 +265,6 @@ func (m *fullHelpModel) View() string {
|
||||||
type actionMenuItem struct {
|
type actionMenuItem struct {
|
||||||
label string
|
label string
|
||||||
action string
|
action string
|
||||||
description string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i actionMenuItem) Title() string { return i.label }
|
func (i actionMenuItem) Title() string { return i.label }
|
||||||
|
|
@ -302,20 +279,20 @@ type actionMenuModel struct {
|
||||||
|
|
||||||
func newActionMenuModel(w, h int) *actionMenuModel {
|
func newActionMenuModel(w, h int) *actionMenuModel {
|
||||||
items := []list.Item{
|
items := []list.Item{
|
||||||
actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."},
|
actionMenuItem{label: "Connect", action: "connect"},
|
||||||
actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."},
|
actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
|
||||||
actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."},
|
actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
|
||||||
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."},
|
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"},
|
||||||
actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."},
|
actionMenuItem{label: "Manage port forwards", action: "forwards"},
|
||||||
actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."},
|
actionMenuItem{label: "Manage tunnels", action: "tunnels"},
|
||||||
actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."},
|
actionMenuItem{label: "Manage route", action: "route"},
|
||||||
actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."},
|
actionMenuItem{label: "Test connection", action: "test"},
|
||||||
actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."},
|
actionMenuItem{label: "Edit", action: "edit"},
|
||||||
actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."},
|
actionMenuItem{label: "Delete", action: "delete"},
|
||||||
actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."},
|
actionMenuItem{label: "Import", action: "import"},
|
||||||
actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."},
|
actionMenuItem{label: "Export", action: "export"},
|
||||||
actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."},
|
actionMenuItem{label: "Vault: lock", action: "vault_lock"},
|
||||||
actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."},
|
actionMenuItem{label: "Vault: change password", action: "vault_change_pw"},
|
||||||
}
|
}
|
||||||
|
|
||||||
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
|
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
|
||||||
|
|
@ -347,44 +324,10 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *actionMenuModel) View() string {
|
func (m *actionMenuModel) View() string {
|
||||||
body := func(width, height int) string {
|
footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
|
||||||
listLines := m.actionLines(max(1, height-2))
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
|
||||||
if classifyShellContent(width) == sizeWide {
|
capacity := max(1, m.height-displayLineCount(footer)-1)
|
||||||
leftWidth := width * 48 / 100
|
|
||||||
rightWidth := width - leftWidth - 1
|
|
||||||
selected, _ := m.list.SelectedItem().(actionMenuItem)
|
|
||||||
detail := []string{dashboardSection("Selected action"), "", selected.label, ""}
|
|
||||||
detail = append(detail, wrapCells(selected.description, max(1, rightWidth-4))...)
|
|
||||||
return joinPanelColumns(
|
|
||||||
renderPaddedPanel(leftWidth, height, listLines), leftWidth,
|
|
||||||
renderPaddedPanel(rightWidth, height, detail), rightWidth,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if classifyShellContent(width) == sizeMedium {
|
|
||||||
if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 {
|
|
||||||
listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return renderPaddedPanel(width, height, listLines)
|
|
||||||
}
|
|
||||||
return renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Actions",
|
|
||||||
status: fmt.Sprintf("%d actions", len(m.list.Items())),
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: body,
|
|
||||||
footer: []helpItem{
|
|
||||||
{Key: "↑/↓", Action: "move"},
|
|
||||||
{Key: "Enter", Action: "select"},
|
|
||||||
{Key: "Ctrl+H", Action: "help"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *actionMenuModel) actionLines(capacity int) []string {
|
|
||||||
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
|
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
|
||||||
lines := make([]string, 0, capacity)
|
|
||||||
for index := start; index < end; index++ {
|
for index := start; index < end; index++ {
|
||||||
item, ok := m.list.Items()[index].(actionMenuItem)
|
item, ok := m.list.Items()[index].(actionMenuItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -394,7 +337,11 @@ func (m *actionMenuModel) actionLines(capacity int) []string {
|
||||||
if index == m.list.Index() {
|
if index == m.list.Index() {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
lines = append(lines, marker+item.label)
|
lines = append(lines, fitLine(marker+item.label, m.width))
|
||||||
}
|
}
|
||||||
return lines
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
|
if len(lines) > m.height && m.height > 0 {
|
||||||
|
lines = lines[:m.height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -47,7 +46,6 @@ func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
m := New(servers)
|
m := New(servers)
|
||||||
m.width, m.height = size.width, size.height
|
m.width, m.height = size.width, size.height
|
||||||
assertViewFits(t, m.View(), size.width, size.height)
|
assertViewFits(t, m.View(), size.width, size.height)
|
||||||
assertRightMargin(t, m.View(), size.width)
|
|
||||||
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
|
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
|
||||||
if !strings.Contains(m.View(), want) {
|
if !strings.Contains(m.View(), want) {
|
||||||
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
|
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
|
||||||
|
|
@ -92,7 +90,6 @@ func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
fm.updateFocus()
|
fm.updateFocus()
|
||||||
view := fm.View()
|
view := fm.View()
|
||||||
assertViewFits(t, view, size.width, size.height)
|
assertViewFits(t, view, size.width, size.height)
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
|
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
|
@ -110,9 +107,7 @@ func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
fm.inputs[1].SetValue("15432")
|
fm.inputs[1].SetValue("15432")
|
||||||
fm.inputs[2].SetValue("database.internal.example")
|
fm.inputs[2].SetValue("database.internal.example")
|
||||||
fm.inputs[3].SetValue("5432")
|
fm.inputs[3].SetValue("5432")
|
||||||
view := fm.View()
|
assertViewFits(t, fm.View(), size.width, size.height)
|
||||||
assertViewFits(t, view, size.width, size.height)
|
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +120,6 @@ func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
}
|
}
|
||||||
view := fm.View()
|
view := fm.View()
|
||||||
assertViewFits(t, view, size.width, size.height)
|
assertViewFits(t, view, size.width, size.height)
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
|
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
|
@ -139,7 +133,6 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
menu := newActionMenuModel(size.width, size.height)
|
menu := newActionMenuModel(size.width, size.height)
|
||||||
view := menu.View()
|
view := menu.View()
|
||||||
assertViewFits(t, view, size.width, size.height)
|
assertViewFits(t, view, size.width, size.height)
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
|
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
|
@ -161,7 +154,6 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
})
|
})
|
||||||
view := m.View()
|
view := m.View()
|
||||||
assertViewFits(t, view, size.width, size.height)
|
assertViewFits(t, view, size.width, size.height)
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
|
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
|
@ -170,143 +162,6 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfirmationKeepsActionsVisibleWithLongContent(t *testing.T) {
|
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
|
||||||
m := New(nil)
|
|
||||||
m.width, m.height = size.width, size.height
|
|
||||||
m.beginConfirm(confirmState{
|
|
||||||
title: "Delete port forward?",
|
|
||||||
target: strings.Repeat("非常に長い-очень-длинный-🔐 ", 20),
|
|
||||||
consequence: strings.Repeat("Active connections can be interrupted. ", 20),
|
|
||||||
verb: "Delete",
|
|
||||||
parent: screenForwardList,
|
|
||||||
})
|
|
||||||
view := m.View()
|
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
for _, want := range []string{"[ Cancel ]", "[ Delete ]"} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("confirmation at %dx%d clipped %q:\n%s", size.width, size.height, want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHelpScreensUseUnifiedShell(t *testing.T) {
|
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
|
||||||
for name, view := range map[string]string{
|
|
||||||
"quick": newHelpScreenModel(size.width, size.height).View(),
|
|
||||||
"full": newFullHelpModel(size.width, size.height).View(),
|
|
||||||
} {
|
|
||||||
t.Run(name+itoa(size.width), func(t *testing.T) {
|
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
if strings.Contains(view, "F1") || !strings.Contains(view, "Ctrl+H") {
|
|
||||||
t.Fatalf("help exposes the wrong global binding:\n%s", view)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManagerScreensUseUnifiedShell(t *testing.T) {
|
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
|
||||||
m := New([]*model.Server{{Alias: "prod", Host: "prod.example", Port: 22, User: "ops"}})
|
|
||||||
m.width, m.height = size.width, size.height
|
|
||||||
template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"}
|
|
||||||
m.setTemplates([]*model.CommandTemplate{template})
|
|
||||||
m.setTags([]string{"production"})
|
|
||||||
m.pendingTemplate = template
|
|
||||||
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
|
|
||||||
|
|
||||||
screens := []struct {
|
|
||||||
name string
|
|
||||||
screen screen
|
|
||||||
}{
|
|
||||||
{"search", screenSearch},
|
|
||||||
{"tags", screenTags},
|
|
||||||
{"tag-input", screenTagInput},
|
|
||||||
{"templates", screenTemplates},
|
|
||||||
{"template-picker", screenTemplatePicker},
|
|
||||||
{"template-mode", screenTemplateMode},
|
|
||||||
{"background-results", screenBackgroundResults},
|
|
||||||
}
|
|
||||||
for _, tt := range screens {
|
|
||||||
m.screen = tt.screen
|
|
||||||
t.Run(tt.name+itoa(size.width), func(t *testing.T) {
|
|
||||||
assertUnifiedScreen(t, m.View(), size.width, size.height)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tunnelScreen := newTunnelScreenModel(size.width, size.height)
|
|
||||||
assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
|
|
||||||
covered := map[screen]string{
|
|
||||||
screenList: "dashboard",
|
|
||||||
screenForm: "server form",
|
|
||||||
screenSearch: "manager matrix",
|
|
||||||
screenTags: "manager matrix",
|
|
||||||
screenTagInput: "manager matrix",
|
|
||||||
screenTemplates: "manager matrix",
|
|
||||||
screenTemplateForm: "template form",
|
|
||||||
screenTemplatePicker: "manager matrix",
|
|
||||||
screenTemplateMode: "manager matrix",
|
|
||||||
screenBackgroundResults: "manager matrix",
|
|
||||||
screenHelp: "help matrix",
|
|
||||||
screenActionMenu: "action matrix",
|
|
||||||
screenForwardList: "forward matrix",
|
|
||||||
screenForwardForm: "forward form matrix",
|
|
||||||
screenTunnelManager: "manager matrix",
|
|
||||||
screenConfirm: "confirmation matrix",
|
|
||||||
screenFullHelp: "help matrix",
|
|
||||||
}
|
|
||||||
for value := screenList; value <= screenFullHelp; value++ {
|
|
||||||
if _, ok := covered[value]; !ok {
|
|
||||||
t.Fatalf("screen %d is missing from the layout matrix", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShellBreakpointsUseTerminalWidth(t *testing.T) {
|
|
||||||
for _, tt := range []struct {
|
|
||||||
contentWidth int
|
|
||||||
want terminalSizeClass
|
|
||||||
}{{68, sizeNarrow}, {69, sizeMedium}, {98, sizeMedium}, {99, sizeWide}} {
|
|
||||||
if got := classifyShellContent(tt.contentWidth); got != tt.want {
|
|
||||||
t.Fatalf("content width %d classified as %v, want %v", tt.contentWidth, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTunnelErrorUsesUnifiedShellRows(t *testing.T) {
|
|
||||||
tunnelModel := newTunnelScreenModel(60, 16)
|
|
||||||
tunnelModel.tunnels = []*model.TunnelState{{Name: "prod tunnel", ServerAlias: "prod", LastError: "connection lost\nretry failed"}}
|
|
||||||
tunnelModel.rebuildList()
|
|
||||||
view := tunnelModel.View()
|
|
||||||
assertUnifiedScreen(t, view, 60, 16)
|
|
||||||
if !strings.Contains(view, "connection lost") || !strings.Contains(view, "retry failed") {
|
|
||||||
t.Fatalf("tunnel error was lost:\n%s", view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTemplateViewportKeepsSelectedDescribedItemVisible(t *testing.T) {
|
|
||||||
m := New(nil)
|
|
||||||
m.width, m.height = 60, 16
|
|
||||||
templates := make([]*model.CommandTemplate, 20)
|
|
||||||
for index := range templates {
|
|
||||||
templates[index] = &model.CommandTemplate{Name: fmt.Sprintf("template-%02d", index), Command: "echo ok", Description: "description"}
|
|
||||||
}
|
|
||||||
m.setTemplates(templates)
|
|
||||||
m.templateList.Select(len(templates) - 1)
|
|
||||||
m.screen = screenTemplates
|
|
||||||
view := m.View()
|
|
||||||
assertUnifiedScreen(t, view, 60, 16)
|
|
||||||
if !strings.Contains(view, "> template-19") {
|
|
||||||
t.Fatalf("selected template is outside viewport:\n%s", view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
form := newTemplateFormModel(nil, size.width, size.height)
|
form := newTemplateFormModel(nil, size.width, size.height)
|
||||||
|
|
@ -314,7 +169,6 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
form.inputs[1].SetValue("printf 'a very long command that remains editable'")
|
form.inputs[1].SetValue("printf 'a very long command that remains editable'")
|
||||||
view := form.View()
|
view := form.View()
|
||||||
assertViewFits(t, view, size.width, size.height)
|
assertViewFits(t, view, size.width, size.height)
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
|
for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
|
@ -323,21 +177,6 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerFormDropdownUsesUnifiedShell(t *testing.T) {
|
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
|
||||||
form := newFormModel(size.width, size.height)
|
|
||||||
form.focusIdx = 5
|
|
||||||
form.showAuthList = true
|
|
||||||
view := form.View()
|
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
|
||||||
for _, want := range []string{"Select auth method", "password", "agent", "Enter", "Esc"} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("dropdown at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertViewFits(t *testing.T, view string, width, height int) {
|
func assertViewFits(t *testing.T, view string, width, height int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
|
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
|
||||||
|
|
@ -351,37 +190,6 @@ func assertViewFits(t *testing.T, view string, width, height int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertUnifiedScreen(t *testing.T, view string, width, height int) {
|
|
||||||
t.Helper()
|
|
||||||
lines := strings.Split(view, "\n")
|
|
||||||
if len(lines) != height {
|
|
||||||
t.Fatalf("unified screen has %d lines, want %d:\n%s", len(lines), height, view)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(ansi.Strip(lines[0]), "sshkeeper / ") {
|
|
||||||
t.Fatalf("unified screen has no breadcrumb header: %q", ansi.Strip(lines[0]))
|
|
||||||
}
|
|
||||||
if !strings.Contains(ansi.Strip(view), "┌") || !strings.Contains(ansi.Strip(view), "┘") {
|
|
||||||
t.Fatalf("unified screen has no framed content:\n%s", view)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(ansi.Strip(lines[height-1])) == "" {
|
|
||||||
t.Fatalf("unified screen footer is not on last row:\n%s", view)
|
|
||||||
}
|
|
||||||
for index, line := range lines {
|
|
||||||
if got := ansi.StringWidth(line); got > width-1 {
|
|
||||||
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertRightMargin(t *testing.T, view string, width int) {
|
|
||||||
t.Helper()
|
|
||||||
for index, line := range strings.Split(view, "\n") {
|
|
||||||
if got := ansi.StringWidth(line); got > width-1 {
|
|
||||||
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type errText string
|
type errText string
|
||||||
|
|
||||||
func (e errText) Error() string { return string(e) }
|
func (e errText) Error() string { return string(e) }
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type screenShell struct {
|
|
||||||
breadcrumb string
|
|
||||||
status string
|
|
||||||
notification string
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
body func(width, height int) string
|
|
||||||
footer []helpItem
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderScreenShell(shell screenShell) string {
|
|
||||||
width, height := shell.width, shell.height
|
|
||||||
if width <= 0 {
|
|
||||||
width = 80
|
|
||||||
}
|
|
||||||
if height <= 0 {
|
|
||||||
height = 24
|
|
||||||
}
|
|
||||||
canvasWidth := max(1, width-1)
|
|
||||||
|
|
||||||
headerLeft := "sshkeeper"
|
|
||||||
if shell.breadcrumb != "" {
|
|
||||||
headerLeft += " / " + shell.breadcrumb
|
|
||||||
}
|
|
||||||
header := headerLeft
|
|
||||||
if shell.status != "" {
|
|
||||||
if gap := canvasWidth - lipgloss.Width(headerLeft) - lipgloss.Width(shell.status); gap > 0 {
|
|
||||||
header = headerLeft + strings.Repeat(" ", gap) + shell.status
|
|
||||||
} else {
|
|
||||||
header = headerLeft + " " + shell.status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
footer := renderHelp(shell.footer, canvasWidth)
|
|
||||||
footerLines := splitBlock(footer)
|
|
||||||
if len(footerLines) == 0 {
|
|
||||||
footerLines = []string{""}
|
|
||||||
}
|
|
||||||
fixedRows := 2 + len(footerLines)
|
|
||||||
notificationLines := []string(nil)
|
|
||||||
if shell.notification != "" {
|
|
||||||
notificationLines = []string{fitLine(shell.notification, canvasWidth)}
|
|
||||||
fixedRows++
|
|
||||||
}
|
|
||||||
bodyHeight := max(1, height-fixedRows)
|
|
||||||
body := ""
|
|
||||||
if shell.body != nil {
|
|
||||||
body = shell.body(canvasWidth, bodyHeight)
|
|
||||||
}
|
|
||||||
bodyLines := fitBlock(body, canvasWidth, bodyHeight)
|
|
||||||
|
|
||||||
lines := make([]string, 0, height)
|
|
||||||
lines = append(lines,
|
|
||||||
titleStyle.Copy().MarginLeft(0).Render(fitLine(header, canvasWidth)),
|
|
||||||
helpStyle.Copy().MarginLeft(0).Render(strings.Repeat("─", canvasWidth)),
|
|
||||||
)
|
|
||||||
lines = append(lines, notificationLines...)
|
|
||||||
lines = append(lines, bodyLines...)
|
|
||||||
for _, line := range footerLines {
|
|
||||||
lines = append(lines, fitLine(line, canvasWidth))
|
|
||||||
}
|
|
||||||
if len(lines) > height {
|
|
||||||
lines = lines[:height]
|
|
||||||
}
|
|
||||||
for len(lines) < height {
|
|
||||||
lines = append(lines, "")
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderPaddedPanel(width, height int, lines []string) string {
|
|
||||||
if width < 4 || height < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
contentWidth := width - 4
|
|
||||||
padded := make([]string, 0, len(lines))
|
|
||||||
for _, line := range lines {
|
|
||||||
padded = append(padded, " "+padCells(line, contentWidth)+" ")
|
|
||||||
}
|
|
||||||
return renderPanel(width, height, padded)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fitBlock(block string, width, height int) []string {
|
|
||||||
lines := splitBlock(block)
|
|
||||||
if len(lines) > height {
|
|
||||||
lines = lines[:height]
|
|
||||||
}
|
|
||||||
for index := range lines {
|
|
||||||
lines[index] = fitLine(lines[index], width)
|
|
||||||
}
|
|
||||||
for len(lines) < height {
|
|
||||||
lines = append(lines, strings.Repeat(" ", width))
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitBlock(block string) []string {
|
|
||||||
if block == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return strings.Split(strings.TrimRight(block, "\n"), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func classifyShellContent(contentWidth int) terminalSizeClass {
|
|
||||||
terminalWidth := contentWidth + 1
|
|
||||||
if terminalWidth >= 100 {
|
|
||||||
return sizeWide
|
|
||||||
}
|
|
||||||
if terminalWidth >= 70 {
|
|
||||||
return sizeMedium
|
|
||||||
}
|
|
||||||
return sizeNarrow
|
|
||||||
}
|
|
||||||
|
|
||||||
func shellStatus(vaultUnlocked bool, detail string) string {
|
|
||||||
vault := "Vault locked"
|
|
||||||
if vaultUnlocked {
|
|
||||||
vault = "Vault unlocked"
|
|
||||||
}
|
|
||||||
if detail == "" {
|
|
||||||
return vault
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s · %s", vault, detail)
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestScreenShellFitsAndAnchorsFooter(t *testing.T) {
|
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
|
||||||
t.Run(itoa(size.width)+"x"+itoa(size.height), func(t *testing.T) {
|
|
||||||
view := renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Actions / production-数据库",
|
|
||||||
status: "Vault unlocked",
|
|
||||||
width: size.width,
|
|
||||||
height: size.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
return renderPaddedPanel(width, height, []string{"Actions", "> Connect", " Manage port forwards"})
|
|
||||||
},
|
|
||||||
footer: []helpItem{{Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
|
||||||
})
|
|
||||||
|
|
||||||
lines := strings.Split(view, "\n")
|
|
||||||
if len(lines) != size.height {
|
|
||||||
t.Fatalf("shell has %d lines, want %d:\n%s", len(lines), size.height, view)
|
|
||||||
}
|
|
||||||
if !strings.Contains(ansi.Strip(lines[0]), "sshkeeper / Actions") {
|
|
||||||
t.Fatalf("missing breadcrumb header: %q", ansi.Strip(lines[0]))
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(ansi.Strip(lines[2]), "┌") || !strings.HasSuffix(ansi.Strip(lines[size.height-2]), "┘") {
|
|
||||||
t.Fatalf("body panel does not fill shell:\n%s", view)
|
|
||||||
}
|
|
||||||
if !strings.Contains(ansi.Strip(lines[size.height-1]), "Ctrl+H") {
|
|
||||||
t.Fatalf("footer is not anchored to last row: %q", ansi.Strip(lines[size.height-1]))
|
|
||||||
}
|
|
||||||
for index, line := range lines {
|
|
||||||
if got := ansi.StringWidth(line); got > size.width-1 {
|
|
||||||
t.Fatalf("line %d uses unsafe last terminal column: width=%d, terminal=%d", index+1, got, size.width)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScreenShellShowsNotificationWithoutMovingFooter(t *testing.T) {
|
|
||||||
view := renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Port Forwards / prod",
|
|
||||||
status: "2 rules",
|
|
||||||
notification: "Forward saved",
|
|
||||||
width: 60,
|
|
||||||
height: 16,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
return renderPaddedPanel(width, height, []string{"Local PostgreSQL"})
|
|
||||||
},
|
|
||||||
footer: []helpItem{{Key: "Esc", Action: "back"}},
|
|
||||||
})
|
|
||||||
lines := strings.Split(view, "\n")
|
|
||||||
if len(lines) != 16 || !strings.Contains(view, "Forward saved") || !strings.Contains(ansi.Strip(lines[15]), "Esc") {
|
|
||||||
t.Fatalf("notification broke shell geometry:\n%s", view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -45,15 +45,15 @@ func TestNotificationSurvivesRepeatedView(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
||||||
m := New(nil)
|
m := New(nil)
|
||||||
m.screen = screenForwardList
|
m.screen = screenForwardList
|
||||||
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH})
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
|
||||||
m = updated.(*tuiModel)
|
m = updated.(*tuiModel)
|
||||||
if m.screen != screenFullHelp || m.fullHelp == nil {
|
if m.screen != screenFullHelp || m.fullHelp == nil {
|
||||||
t.Fatalf("Ctrl+H did not open full help from forward list: screen=%v", m.screen)
|
t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen)
|
||||||
}
|
}
|
||||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
m = updated.(*tuiModel)
|
m = updated.(*tuiModel)
|
||||||
|
|
@ -62,27 +62,6 @@ func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestF1NoLongerOpensFullHelp(t *testing.T) {
|
|
||||||
m := New(nil)
|
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
|
|
||||||
m = updated.(*tuiModel)
|
|
||||||
if m.screen == screenFullHelp || m.fullHelp != nil {
|
|
||||||
t.Fatalf("F1 still opens full help: screen=%v", m.screen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackspaceStillEditsSearchAfterCtrlHBinding(t *testing.T) {
|
|
||||||
m := New(nil)
|
|
||||||
m.screen = screenSearch
|
|
||||||
m.searchInput.SetValue("prod")
|
|
||||||
m.searchInput.Focus()
|
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
|
||||||
m = updated.(*tuiModel)
|
|
||||||
if m.screen != screenSearch || m.searchInput.Value() != "pro" {
|
|
||||||
t.Fatalf("Backspace did not edit search: screen=%v value=%q", m.screen, m.searchInput.Value())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
|
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
|
||||||
m := New(nil)
|
m := New(nil)
|
||||||
m.screen = screenForwardList
|
m.screen = screenForwardList
|
||||||
|
|
|
||||||
|
|
@ -151,40 +151,31 @@ func (tf *templateFormModel) save() tea.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tf *templateFormModel) View() string {
|
func (tf *templateFormModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
title := "Add Template"
|
title := "Add Template"
|
||||||
if tf.edit {
|
if tf.edit {
|
||||||
title = "Edit Template"
|
title = "Edit Template"
|
||||||
}
|
}
|
||||||
notification := ""
|
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
|
||||||
if tf.err != nil {
|
b.WriteString("\n\n")
|
||||||
notification = errorStyle.Render(tf.err.Error())
|
|
||||||
} else if tf.saved {
|
|
||||||
notification = successStyle.Render("✓ Saved.")
|
|
||||||
}
|
|
||||||
return renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Command Templates / " + title,
|
|
||||||
status: "Template editor",
|
|
||||||
notification: notification,
|
|
||||||
width: tf.width,
|
|
||||||
height: tf.height,
|
|
||||||
body: func(width, height int) string {
|
|
||||||
lines := make([]string, 0, len(tf.inputs)+3)
|
|
||||||
for i := range tf.inputs {
|
for i := range tf.inputs {
|
||||||
lines = append(lines, tf.inputs[i].View())
|
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
|
||||||
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
button := " [ Save ]"
|
button := " [ Save ]"
|
||||||
if tf.focusIdx == len(tf.inputs) {
|
if tf.focusIdx == len(tf.inputs) {
|
||||||
button = selectedStyle.Render("> [ Save ]")
|
button = selectedStyle.Render("> [ Save ]")
|
||||||
}
|
}
|
||||||
lines = append(lines, "", button)
|
b.WriteString("\n" + button + "\n\n")
|
||||||
return renderPaddedPanel(width, height, lines)
|
if tf.err != nil {
|
||||||
},
|
b.WriteString(errorStyle.Render(tf.err.Error()))
|
||||||
footer: []helpItem{
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString(renderHelp([]helpItem{
|
||||||
{Key: "Tab/↓", Action: "next"},
|
{Key: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "Enter", Action: "select"},
|
{Key: "Enter", Action: "select"},
|
||||||
{Key: "Ctrl+H", Action: "help"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
},
|
}, tf.width))
|
||||||
})
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,44 +94,19 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tunnelScreenModel) View() string {
|
func (m *tunnelScreenModel) View() string {
|
||||||
notification := ""
|
var b strings.Builder
|
||||||
|
b.WriteString(m.list.View())
|
||||||
|
b.WriteString("\n\n")
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
body := func(width, height int) string {
|
b.WriteString(renderHelp([]helpItem{
|
||||||
if len(m.tunnels) == 0 {
|
|
||||||
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
|
|
||||||
}
|
|
||||||
capacity := max(1, height-2)
|
|
||||||
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3))
|
|
||||||
lines := make([]string, 0, capacity)
|
|
||||||
for index := start; index < end; index++ {
|
|
||||||
item := tunnelItem{state: m.tunnels[index]}
|
|
||||||
marker := " "
|
|
||||||
if index == m.list.Index() {
|
|
||||||
marker = "> "
|
|
||||||
}
|
|
||||||
lines = append(lines, marker+item.Title())
|
|
||||||
for _, description := range strings.Split(item.Description(), "\n") {
|
|
||||||
lines = append(lines, " "+strings.TrimSpace(description))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return renderPaddedPanel(width, height, lines)
|
|
||||||
}
|
|
||||||
return renderScreenShell(screenShell{
|
|
||||||
breadcrumb: "Tunnel Manager",
|
|
||||||
status: fmt.Sprintf("%d running", len(m.tunnels)),
|
|
||||||
notification: notification,
|
|
||||||
width: m.width,
|
|
||||||
height: m.height,
|
|
||||||
body: body,
|
|
||||||
footer: []helpItem{
|
|
||||||
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
|
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
|
||||||
{Key: "Ctrl+R (r)", Action: "refresh"},
|
{Key: "Ctrl+R (r)", Action: "refresh"},
|
||||||
{Key: "Ctrl+H", Action: "help"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
},
|
}, m.width))
|
||||||
})
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type tunnelsLoadedMsg struct {
|
type tunnelsLoadedMsg struct {
|
||||||
|
|
|
||||||