Compare commits

..

7 Commits

24 changed files with 2605 additions and 534 deletions

View File

@ -96,20 +96,20 @@ Running `sshkeeper` without arguments opens the TUI.
### Main Window
```
sshkeeper 0 servers
Vault unlocked | 0 OK | 0 FAIL
NAME ALIAS ROUTE AUTH GROUP STATUS
No servers yet. Press Ctrl+A to add one.
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit
Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
sshkeeper / Servers Vault unlocked · 1 profiles
────────────────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────────────────────────────────────────────┐
│1 servers │
│ NAME AUTH GROUP STATUS │
│> Production agent - ? │
└──────────────────────────────────────────────────────────────────────────────┘
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit | Ctrl+Q: quit
```
### Quick Help (?)
Press `?` on any screen for a compact hotkey reference.
Press `?` outside text editors for a compact hotkey reference. Inside forms and
search, `?` remains normal text input.
### Full Help (F1)
@ -118,13 +118,13 @@ forwarding, tunnels, and vault.
### Screenshots
| Main list | Actions | Route edit |
|-----------|---------|------------|
| ![Main list](docs/screenshots/screen_1.png) | ![Actions](docs/screenshots/screen_2.png) | ![Route edit](docs/screenshots/screen_3.png) |
| Wide dashboard | Dashboard 80x24 | Server form 60x16 |
|----------------|-----------------|-------------------|
| ![Wide dashboard](docs/screenshots/screen_1.png) | ![Dashboard 80x24](docs/screenshots/screen_2.png) | ![Server form 60x16](docs/screenshots/screen_3.png) |
| Port forwards | Tunnel manager |
|---------------|----------------|
| ![Port forwards](docs/screenshots/screen_4.png) | ![Tunnel manager](docs/screenshots/screen_5.png) |
| Port forward form | Safe confirmation |
|-------------------|-------------------|
| ![Port forward form](docs/screenshots/screen_4.png) | ![Safe confirmation](docs/screenshots/screen_5.png) |
### Key Reference

View File

@ -146,6 +146,9 @@ func runTUI() error {
v.Lock()
return nil
}
tui.VaultUnlocked = func() bool {
return getOrCreateVault().IsUnlocked()
}
tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error {
return appDB.UpdateTestResult(alias, status, testErr)
}

View File

@ -121,17 +121,32 @@ Vault created and unlocked for this command. You're ready to go!
### Главный экран
```
sshkeeper 0 servers
Vault unlocked | 0 OK | 0 FAIL
NAME ALIAS ROUTE AUTH GROUP STATUS
No servers yet. Press Ctrl+A to add one.
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit
Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
sshkeeper / Servers Vault unlocked · 1 profiles
────────────────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────────────────────────────────────────────┐
│1 servers │
│ NAME AUTH GROUP STATUS │
│> Production agent - ? │
└──────────────────────────────────────────────────────────────────────────────┘
Selected profile
Alias: prod Target: ops@prod.example:22
Auth: agent Group: - Status: ?
```
Интерфейс адаптируется к ширине терминала:
- от 100 столбцов список и подробности выбранного профиля показаны в двух панелях;
- от 70 до 99 столбцов подробности переносятся под список;
- от 60 до 69 столбцов остаётся компактная таблица без второстепенных полей;
- минимальный поддерживаемый размер — `60x16`; ниже показывается сообщение о необходимом размере.
Статус vault в заголовке отражает реальное состояние. Выбор и фокус всегда
обозначены текстовым маркером `>`, поэтому интерфейс остаётся понятным без цвета.
![Широкий главный экран](screenshots/screen_1.png)
![Главный экран 80x24](screenshots/screen_2.png)
**Столбцы:**
| Столбец | Описание |
@ -158,9 +173,13 @@ Vault unlocked | 0 OK | 0 FAIL
| `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход |
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
изменения, сначала открывается безопасное подтверждение с выбранным Cancel.
### Быстрая справка по клавишам
Нажмите `?` на любом экране:
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
остаётся обычным вводом. `F1` открывает полную справку также из форм.
```
sshkeeper — Quick Help
@ -255,10 +274,10 @@ sshkeeper — Full Help
```
Add Server
Alias: mail.kp
Alias *: mail.kp
Display Name: Production mail
Host: mail.example.org
Port: 22
Host *: mail.example.org
Port *: 22
User: root
Auth Method: key
Identity File: ~/.ssh/id_ed25519
@ -273,6 +292,13 @@ Add Server
Tab/↓: next | ↑: prev | /: pick list | Enter: select | Esc: back
```
Поля с `*` обязательны. Порты принимают только десятичные значения от `1` до
`65535`; ошибочный текст остаётся в поле рядом с понятным сообщением. На
маленьком экране форма показывает окно полей вокруг текущего фокуса, сохраняя
кнопки и подсказку видимыми.
![Форма сервера 60x16](screenshots/screen_3.png)
**Навигация по форме:**
| Клавиша | Действие |
@ -283,7 +309,7 @@ Add Server
| `/` на Group | Выбрать из существующих групп |
| `Enter` на Test | Проверить подключение |
| `Enter` на Save | Сохранить |
| `Esc` | Отмена |
| `Esc` | Назад; при изменённых данных сначала запросить подтверждение сброса |
### Редактирование сервера
@ -297,7 +323,10 @@ Add Server
1. Выберите сервер стрелками
2. Нажмите `Ctrl+X` (меню действий)
3. Выберите "Delete"
4. Подтвердите: `Enter` или `Y` — да, `Esc` или `N` — нет
4. Диалог по умолчанию выделяет **Cancel**. Нажмите `Tab`, затем `Enter`, чтобы удалить; `Esc` отменяет операцию. Быстрые клавиши `Y`/`N` также поддерживаются.
Диалог называет точный профиль и предупреждает, что вместе с ним удаляются
сохранённые forwards и секреты vault.
### Тест подключения
@ -385,18 +414,18 @@ sshkeeper route clear web
Port Forwards — web
NAME TYPE LISTEN TARGET ON
Local PostgreSQL Local 127.0.0.1:15432 127.0.0.1:5432 yes
Web Admin Local 127.0.0.1:18080 internal.web:80 yes
SOCKS Proxy SOCKS 127.0.0.1:1080 SOCKS yes
> Local PostgreSQL local 127.0.0.1:15432 127.0.0.1:5432 yes
Selected
Selected
Port 127.0.0.1:15432 on this machine will be forwarded through web to 127.0.0.1:5432.
-L 127.0.0.1:15432:127.0.0.1:5432
-o ExitOnForwardFailure=yes
ssh -L 127.0.0.1:15432:127.0.0.1:5432
Ctrl+A: add | Ctrl+E/Enter: edit | Ctrl+D: delete | Esc: back
```
Строки и пояснение выбранного forward сокращаются по экранным ячейкам, а не
по байтам, поэтому кириллица, CJK и emoji не ломают границы таблицы.
**Действия:**
| Клавиша | Действие |
@ -414,20 +443,15 @@ Port Forwards — web
```
Add Port Forward
Name: Local PostgreSQL
Name *: Local PostgreSQL
Description: optional
Type
▸ 1. Local port on my machine → service on SSH server
2. Remote port on SSH server → service on my machine
3. SOCKS local dynamic SOCKS proxy through SSH
Type ● 1 Local ○ 2 Remote ○ 3 SOCKS
Opens a local port on this machine and forwards it through SSH to the target address.
Listen Address: 127.0.0.1
Listen Port: 15432
Target Host: 127.0.0.1
Target Port: 5432
Listen Address *: 127.0.0.1
Listen Port *: 15432
Target Host *: 127.0.0.1
Target Port *: 5432
Preview
-L 127.0.0.1:15432:127.0.0.1:5432
@ -438,6 +462,11 @@ Add Port Forward
Tab/↓: next | ↑: prev | 1/2/3: select type | Enter: save | Esc: back
```
Цифры `1`/`2`/`3` переключают тип только когда фокус находится на селекторе
типа. В полях адреса и порта они вводятся как обычный текст.
![Форма port forward 80x24](screenshots/screen_4.png)
**Поля зависят от типа:**
| Тип | Поля |
@ -768,8 +797,12 @@ sshkeeper connect secure
| Клавиша | Действие |
|---------|----------|
| `Enter` / `Y` | Да |
| `Esc` / `N` | Нет |
| `Tab` / `Shift+Tab` / `←` / `→` | Переключить Cancel/действие |
| `Enter` | Выполнить выделенный вариант; по умолчанию это Cancel |
| `Y` | Подтвердить действие |
| `Esc` / `N` | Отменить |
![Безопасное подтверждение удаления](screenshots/screen_5.png)
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,131 @@
# sshkeeper TUI UX Redesign 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:** Implement the approved safe, truthful, responsive sshkeeper TUI and verify it through automated state transitions and real terminal screenshots.
**Architecture:** Keep the existing Bubble Tea v1 root model and screen enum. Add explicit overlay return/pending state and small pure layout helpers; route keys from overlays and editors outward; keep persistence and process callbacks at the existing command boundary.
**Tech Stack:** Go 1.25, Bubble Tea v1.3.10, Bubbles v1.0.0, Lip Gloss v1.1.0, tmux-based runtime capture.
## Global Constraints
- Do not migrate Bubble Tea or add dependencies.
- Preserve CLI behavior and callback boundaries.
- Supported terminal floor is 60x16.
- Rendering must not mutate model state.
- Printable runes belong to focused editors.
- Every behavior change follows red-green TDD.
- Each task is committed and pushed to `codex/tui-ux-redesign` after focused and full tests.
---
### Task 1: Safe destructive action state machine
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/forward.go`
- Modify: `internal/tui/app_test.go`
- Create: `internal/tui/confirm_test.go`
**Interfaces:**
- Produces: `confirmState` with parent screen, message, consequence, focus, pending, action, and cancel/success return states.
- Consumes: existing delete/stop callbacks and result messages.
- [ ] Add failing tests proving server deletion opens confirmation, Cancel is the default, Esc returns to the recorded parent, Enter cannot execute while Cancel is focused, deletion executes once, forward deletion returns to its list, and errors remain visible in that list.
- [ ] Run `go test ./internal/tui -run 'Test(ServerDelete|Confirm|ForwardDelete)' -count=1` and verify the new tests fail for the missing state transitions.
- [ ] Replace `confirmMsg`/`confirmAction` with explicit confirmation state and route all server/forward/tag/template/tunnel destructive actions through it.
- [ ] Run the focused tests, then `go test ./... -count=1`.
- [ ] Commit as `fix: make tui destructive actions safe` and push the feature branch.
### Task 2: Truthful status, durable notifications, and help return context
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/help_screen.go`
- Modify: `cmd/tui.go`
- Modify: `internal/tui/app_test.go`
**Interfaces:**
- Produces: `VaultUnlocked func() bool`, durable `notification` state, and explicit help parent screen.
- Consumes: current vault callback setup and existing help models.
- [ ] Add failing tests proving lock changes the dashboard label, repeated `View()` retains notifications, F1 opens from manager screens, and closing help returns to the originating screen.
- [ ] Run `go test ./internal/tui -run 'Test(Vault|Notification|Help)' -count=1` and verify expected failures.
- [ ] Wire real vault state, move notification clearing to explicit update events, and store/restore help parent context.
- [ ] Propagate resize messages to active help, action menu, forward, form, template, and tunnel children.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `fix: keep tui status and help context truthful` and push.
### Task 3: Strict validation and dirty form exits
**Files:**
- Modify: `internal/tui/form.go`
- Modify: `internal/tui/forward.go`
- Modify: `internal/tui/template_form.go`
- Modify: `internal/tui/app.go`
- Create: `internal/tui/form_validation_test.go`
**Interfaces:**
- Produces: strict `parsePort(value string) (int, error)` and `Dirty() bool` methods for all editable forms.
- Consumes: the confirmation state from Task 1.
- [ ] Add failing tests for non-numeric, zero, and 65536 server ports; preserved invalid input; clean Esc; dirty Esc cancel/discard; and return to server/forward/template parent.
- [ ] Run `go test ./internal/tui -run 'Test(ServerPort|Dirty|Discard)' -count=1` and verify failure reasons.
- [ ] Implement strict parsing, persistent validation error state, form snapshots, and discard confirmation through the common overlay.
- [ ] Add required markers without changing stored field names.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `fix: validate tui forms and protect edits` and push.
### Task 4: Responsive dashboard and form layout
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/form.go`
- Modify: `internal/tui/forward.go`
- Modify: `internal/tui/template_form.go`
- Create: `internal/tui/layout.go`
- Create: `internal/tui/layout_test.go`
**Interfaces:**
- Produces: pure display-cell truncation, size-class, bounded-row, and pane-layout helpers.
- Consumes: model width/height and existing view data.
- [ ] Add failing render tests at 120x40, 80x24, 60x16 and below-floor size, including long Cyrillic, CJK, combining, and emoji values.
- [ ] Run `go test ./internal/tui -run 'Test(Layout|Dashboard|FormRender|DisplayWidth)' -count=1` and verify layout/width failures.
- [ ] Implement the wide two-pane, medium stacked, and narrow compact dashboard shown in the approved mockup.
- [ ] Keep form header, focused field window, inline status, action row, and footer within the height budget; render a minimum-size message below 60x16.
- [ ] Honor `NO_COLOR` when constructing styles while retaining textual markers.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `feat: add responsive tui layouts` and push.
### Task 5: Runtime visual verification and documentation alignment
**Files:**
- Modify: `docs/guide.md`
- Replace as needed: `docs/screenshots/screen_1.png` through `docs/screenshots/screen_5.png`
**Interfaces:**
- Consumes: completed TUI behavior.
- Produces: current screenshots and user-facing key/confirmation/responsive documentation.
- [ ] Build with `go build -o /tmp/sshkeeper-tui-audit .` and run with fresh isolated `XDG_CONFIG_HOME` and `XDG_DATA_HOME`.
- [ ] Capture the main dashboard, server form, forward form, safe confirmation, and manager states at 120x40; capture responsive dashboard/form states at 80x24 and 60x16.
- [ ] Inspect every PNG for clipping, cursor/focus visibility, status truthfulness, target naming, and footer visibility; fix defects through a new failing render/state test before code changes.
- [ ] Update `docs/guide.md` so shortcuts, confirmation behavior, minimum size, and screenshots agree with implementation.
- [ ] Run `gofmt -w` on changed Go files, `go vet ./...`, `go test ./... -count=1`, and a clean `go build ./...`.
- [ ] Commit as `docs: refresh tui guide and screenshots` and push.
### Task 6: Review and integration
**Files:**
- No planned product file changes unless review finds a defect.
**Interfaces:**
- Consumes: all prior commits.
- Produces: reviewed commit range ready for `main`.
- [ ] Request an independent code review against this design and plan.
- [ ] Resolve every Critical or Important issue using a failing regression test first.
- [ ] Re-run `go vet ./...`, `go test ./... -count=1`, `go build ./...`, and the three-size runtime capture.
- [ ] Verify feature branch is clean and synchronized, fast-forward `main`, push `main` to `origin` and `github`, and verify all three refs resolve to the same SHA.

View File

@ -0,0 +1,112 @@
# sshkeeper TUI UX Redesign
## Goal
Make destructive actions safe, keep security and asynchronous status truthful,
make forms predictable, and preserve primary tasks from 120x40 down to 60x16.
## Evidence and approved direction
The design is based on a source audit, the existing documentation/screenshots,
and runtime checks in isolated XDG directories at 120x40, 80x24, and 60x16.
The approved mockups use:
- a two-pane server dashboard at wide sizes;
- a compact single-pane server table at narrow sizes;
- persistent breadcrumbs and security status;
- explicit non-color focus/selection markers;
- field-adjacent validation with preserved input;
- confirmation dialogs that name the target and default to Cancel.
## Interaction contract
Input ownership is:
`confirmation or help overlay -> active picker -> active text input -> focused component -> screen -> global`
- Printable runes always belong to a focused text input.
- `Enter` activates the focused row, button, or confirmation choice.
- `Esc` closes the most local state and returns to its recorded parent.
- `Tab` and `Shift+Tab` traverse controls in forms and dialogs.
- `?` opens contextual shortcut help outside text inputs.
- `F1` opens full help from every non-editor screen and every form.
- `Ctrl+Q` quits only from a clean state; dirty forms require discard confirmation.
## Destructive actions
Server, forward, tag, template, and running-tunnel deletion/stop operations use
one confirmation state containing:
- exact target and consequence;
- parent screen and return selection;
- safe Cancel choice as the initial focus;
- pending state that ignores repeated activation;
- success and error transitions back to the parent screen.
Deleting a server must identify that its saved forwards and vault secrets are
also removed. Deleting a saved forward must return to the forward list.
## Status and notifications
The root model receives the actual vault lock state instead of rendering
`unlocked` unconditionally. Notifications are durable model state. Rendering is
pure: `View` never clears errors or success messages. A later explicit user
event or replacement notification clears them.
Loading, pending, success, and error are distinct. Actions that may take time
show their target and disable duplicate execution.
## Forms
Server and forward ports use strict decimal parsing and the range 1 through
65535. Invalid text remains in the field and produces an actionable error.
Required fields are marked with `*`.
Every editable form stores an initial snapshot. `Esc` returns immediately when
unchanged; otherwise it opens a discard confirmation and returns to the correct
parent only after confirmation.
The server and forward forms use a viewport-like visible window centered on
the focused control. Header, validation/status area, action row, and footer
remain visible. Type selector shortcuts do not consume digits while an editor
owns input.
## Responsive layout
### 100 columns and wider
The server screen shows a list pane and a details pane. Columns are Name,
Target/Route, Auth, and Status. The details pane repeats the exact selected
target and exposes the two primary actions.
### 70 through 99 columns
The details pane moves below the list. Secondary fields are omitted from the
table, not clipped.
### 60 through 69 columns
The table contains Name, Auth, and Status. The focused row and footer remain
visible. Details are available through the action menu/help rather than taking
vertical space.
The declared supported floor is 60x16. Below it, the TUI renders a minimum-size
message rather than a misleading clipped form.
## Accessibility and compatibility
- Selection, focus, status, and severity never depend on color alone.
- Layout measurements use terminal display width, not byte length.
- Decorative color honors `NO_COLOR`; textual markers remain.
- Unicode content is truncated by display cells without splitting runes.
- Mouse remains optional; every primary task is keyboard accessible.
## Verification contract
Automated state-transition tests cover confirmation yes/no/error/repeat,
printable input ownership, form validation and dirty return paths, help return
context, vault status, resize propagation, and Unicode truncation.
Runtime checks use an isolated XDG profile and real Bubble Tea execution at
120x40, 80x24, and 60x16. Each size is captured and visually inspected for
clipping, missing focus, missing actions, and false status.

2
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.6
github.com/creack/pty v1.1.24
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.52.0
@ -19,7 +20,6 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,221 @@
package tui
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
)
func TestServerDeleteRequiresExplicitConfirmation(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22, User: "root", AuthMethod: model.AuthKey}
m := New([]*model.Server{server})
deleted := 0
oldDelete, oldList := DeleteServer, ListServers
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
DeleteServer = func(alias string) error {
deleted++
return nil
}
ListServers = func() ([]*model.Server, error) { return nil, nil }
updated, cmd := m.updateList(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if cmd != nil || deleted != 0 {
t.Fatalf("delete ran before confirmation: cmd=%v deleted=%d", cmd != nil, deleted)
}
if m.screen != screenConfirm || m.confirm == nil {
t.Fatalf("expected confirmation screen, got screen=%v confirm=%v", m.screen, m.confirm)
}
if m.confirm.focus != confirmCancel {
t.Fatalf("default focus = %v, want Cancel", m.confirm.focus)
}
view := m.View()
for _, want := range []string{"prod", "saved port forwards", "vault secrets", "> [ Cancel ]", "[ Delete ]"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation missing %q:\n%s", want, view)
}
}
updated, cmd = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd != nil || deleted != 0 || m.screen != screenList {
t.Fatalf("Enter on default Cancel must cancel: screen=%v cmd=%v deleted=%d", m.screen, cmd != nil, deleted)
}
}
func TestServerDeleteRunsOnceAndReturnsToList(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22, User: "root", AuthMethod: model.AuthKey}
m := New([]*model.Server{server})
deleted := 0
oldDelete, oldList := DeleteServer, ListServers
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
DeleteServer = func(alias string) error {
deleted++
return nil
}
ListServers = func() ([]*model.Server, error) { return nil, nil }
updated, _ := m.updateList(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(*tuiModel)
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil || m.confirm == nil || !m.confirm.pending {
t.Fatal("expected pending destructive command")
}
_, duplicate := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
if duplicate != nil {
t.Fatal("repeated Enter must not start a duplicate delete")
}
msg := cmd()
updated, _ = m.Update(msg)
m = updated.(*tuiModel)
if deleted != 1 {
t.Fatalf("delete calls = %d, want 1", deleted)
}
if m.screen != screenList || m.confirm != nil || len(m.servers) != 0 {
t.Fatalf("unexpected completion state: screen=%v confirm=%v servers=%d", m.screen, m.confirm, len(m.servers))
}
}
func TestServerDeletePartialSuccessRemovesLocalRow(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22}
m := New([]*model.Server{server})
oldDelete, oldList := DeleteServer, ListServers
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
DeleteServer = func(string) error { return nil }
ListServers = func() ([]*model.Server, error) { return nil, errors.New("reload unavailable") }
m.confirmServerDelete(server)
m.confirm.focus = confirmAccept
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if len(m.servers) != 0 || len(m.list.Items()) != 0 {
t.Fatalf("deleted server remained visible: servers=%d items=%d", len(m.servers), len(m.list.Items()))
}
if m.err != nil || !strings.Contains(m.success, "refresh failed") {
t.Fatalf("partial success was misreported: err=%v success=%q", m.err, m.success)
}
}
func TestTagAndTemplateDeletePartialSuccessRemovesLocalRow(t *testing.T) {
t.Run("tag", func(t *testing.T) {
m := New(nil)
m.screen = screenTags
m.setTags([]string{"prod"})
oldDelete, oldList := DeleteTag, ListTags
t.Cleanup(func() { DeleteTag, ListTags = oldDelete, oldList })
DeleteTag = func(string) error { return nil }
ListTags = func() ([]string, error) { return nil, errors.New("reload unavailable") }
updated, _ := m.updateTags(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
m.confirm.focus = confirmAccept
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if len(m.tags) != 0 || !strings.Contains(m.success, "refresh failed") {
t.Fatalf("tag partial success: tags=%v success=%q err=%v", m.tags, m.success, m.err)
}
})
t.Run("template", func(t *testing.T) {
m := New(nil)
m.screen = screenTemplates
m.setTemplates([]*model.CommandTemplate{{Name: "uptime", Command: "uptime"}})
oldDelete, oldList := DeleteCommandTemplate, ListCommandTemplates
t.Cleanup(func() { DeleteCommandTemplate, ListCommandTemplates = oldDelete, oldList })
DeleteCommandTemplate = func(string) error { return nil }
ListCommandTemplates = func() ([]*model.CommandTemplate, error) { return nil, errors.New("reload unavailable") }
updated, _ := m.updateTemplates(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
m.confirm.focus = confirmAccept
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if len(m.templates) != 0 || !strings.Contains(m.success, "refresh failed") {
t.Fatalf("template partial success: templates=%v success=%q err=%v", m.templates, m.success, m.err)
}
})
}
func TestForwardDeleteReturnsToForwardListAndRetainsError(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod"}
fwd := &model.Forward{ID: 7, ServerID: 1, Name: "postgres", Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "db", RemotePort: 5432, Enabled: true}
m := New([]*model.Server{server})
m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, 80, 24)
m.forwardScreen.list = []*model.Forward{fwd}
m.forwardScreen.selected = 0
oldDelete := DeleteForward
t.Cleanup(func() { DeleteForward = oldDelete })
DeleteForward = func(id int64) error { return errors.New("database is read-only") }
updated, _ := m.updateForwardList(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if m.confirm == nil || m.confirm.parent != screenForwardList {
t.Fatalf("expected forward-list parent, confirm=%#v", m.confirm)
}
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(*tuiModel)
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
t.Fatal("expected delete command")
}
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if m.screen != screenForwardList || m.confirm != nil {
t.Fatalf("expected return to forwards, screen=%v confirm=%v", m.screen, m.confirm)
}
if m.forwardScreen == nil || m.forwardScreen.err == nil || !strings.Contains(m.forwardScreen.View(), "database is read-only") {
t.Fatalf("forward error not retained: model=%#v view=%q", m.forwardScreen, m.forwardScreen.View())
}
}
func TestOtherDestructiveActionsOpenConfirmation(t *testing.T) {
t.Run("tag", func(t *testing.T) {
m := New(nil)
m.screen = screenTags
m.setTags([]string{"prod"})
updated, cmd := m.updateTags(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTags {
t.Fatalf("tag deletion did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
}
})
t.Run("template", func(t *testing.T) {
m := New(nil)
m.screen = screenTemplates
m.setTemplates([]*model.CommandTemplate{{Name: "uptime", Command: "uptime"}})
updated, cmd := m.updateTemplates(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTemplates {
t.Fatalf("template deletion did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
}
})
t.Run("tunnel", func(t *testing.T) {
m := New(nil)
m.screen = screenTunnelManager
m.tunnelScreen = newTunnelScreenModel(80, 24)
m.tunnelScreen.tunnels = []*model.TunnelState{{ID: 11, Name: "prod tunnel", ServerAlias: "prod"}}
m.tunnelScreen.rebuildList()
updated, cmd := m.updateTunnelManager(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTunnelManager {
t.Fatalf("tunnel stop did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
}
})
}

307
internal/tui/dashboard.go Normal file
View File

@ -0,0 +1,307 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mirivlad/sshkeeper/internal/model"
)
func (m *tuiModel) renderServerDashboard() string {
width, height := m.width, m.height
if width <= 0 {
width = 120
}
if height <= 0 {
height = 40
}
header := m.renderDashboardHeader(width)
notification := m.renderDashboardNotification(width)
footer := m.renderListHelp(len(m.selectedServers()), len(m.bgResults) > 0)
headerHeight := displayLineCount(header)
notificationHeight := displayLineCount(notification)
footerHeight := displayLineCount(footer)
bodyHeight := height - headerHeight - notificationHeight - footerHeight
if bodyHeight < 5 {
bodyHeight = 5
}
var body string
switch classifyTerminal(width, height) {
case sizeWide:
leftWidth := width * 62 / 100
rightWidth := width - leftWidth - 1
left := m.renderServerPanel(leftWidth, bodyHeight, true)
right := m.renderSelectedPanel(rightWidth, bodyHeight)
body = joinPanelColumns(left, leftWidth, right, rightWidth)
case sizeMedium:
detailsHeight := 6
listHeight := bodyHeight - detailsHeight
if listHeight < 5 {
listHeight = 5
}
body = m.renderServerPanel(width, listHeight, false)
if selected := m.selectedServer(); selected != nil && listHeight+detailsHeight <= bodyHeight {
body += "\n" + m.renderCompactSelected(selected, width, detailsHeight-1)
}
default:
body = m.renderServerPanel(width, bodyHeight, false)
}
view := header + notification + body
padding := height - displayLineCount(view) - footerHeight
if padding > 0 {
view += strings.Repeat("\n", padding)
}
return view + "\n" + footer
}
func (m *tuiModel) renderDashboardNotification(width int) string {
if m.err != nil {
return fitLine(errorStyle.Render("Error: "+m.err.Error()), width) + "\n"
}
if m.success != "" {
return fitLine(successStyle.Render(m.success), width) + "\n"
}
return ""
}
func (m *tuiModel) renderDashboardHeader(width int) string {
left := "sshkeeper / Servers"
vault := "Vault locked"
if m.vaultUnlocked {
vault = "Vault unlocked"
}
right := fmt.Sprintf("%s · %d profiles", vault, len(m.servers))
if selected := len(m.selectedServers()); selected > 0 {
right += fmt.Sprintf(" · %d selected", selected)
}
line := left + " " + right
if lipgloss.Width(left)+lipgloss.Width(right)+1 <= width {
line = left + strings.Repeat(" ", width-lipgloss.Width(left)-lipgloss.Width(right)) + right
}
headerStyle := titleStyle.Copy().MarginLeft(0)
separatorStyle := helpStyle.Copy().MarginLeft(0)
return headerStyle.Render(fitLine(line, width)) + "\n" + separatorStyle.Render(strings.Repeat("─", width)) + "\n"
}
func (m *tuiModel) renderServerPanel(width, height int, showTarget bool) string {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)
lines := make([]string, 0, innerHeight)
lines = append(lines, listHeaderStyle.Render(fitLine(fmt.Sprintf("%d servers", len(m.servers)), innerWidth)))
lines = append(lines, m.renderServerColumns(innerWidth, showTarget, nil, true))
rowCapacity := max(0, innerHeight-len(lines))
showRange := len(m.servers) > rowCapacity
if showRange {
rowCapacity = max(1, rowCapacity-1)
}
if len(m.servers) == 0 {
lines = append(lines, helpStyle.Render(fitLine("No servers yet. Ctrl+A adds the first profile.", innerWidth)))
} else if rowCapacity > 0 {
start, end := visibleServerRange(len(m.servers), m.list.Index(), rowCapacity)
selected := m.selectedServer()
for _, server := range m.servers[start:end] {
lines = append(lines, m.renderServerColumns(innerWidth, showTarget, server, selected != nil && server.Alias == selected.Alias))
}
if showRange {
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.servers))))
}
}
for len(lines) < innerHeight {
lines = append(lines, "")
}
if len(lines) > innerHeight {
lines = lines[:innerHeight]
}
return renderPanel(width, height, lines)
}
func (m *tuiModel) renderServerColumns(width int, showTarget bool, server *model.Server, selected bool) string {
marker, name, target, auth, group, status := "", "NAME", "TARGET / ROUTE", "AUTH", "GROUP", "STATUS"
style := normalStyle
if server != nil {
marker = " "
if selected {
marker = ">"
style = selectedRowStyle
}
if m.selected[server.Alias] {
marker = "*"
if selected {
marker = ">*"
}
}
name = server.DisplayName
if name == "" {
name = server.Alias
}
target = fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
if len(server.Route.Hops) > 0 {
target = server.Route.DisplaySummary(target)
}
auth = authLabel(server.AuthMethod)
group = server.GroupName
if group == "" {
group = "-"
}
status = testStatusLabel(server)
}
markerWidth, authWidth, groupWidth, statusWidth := 2, 10, 10, 7
nameWidth := width - markerWidth - authWidth - groupWidth - statusWidth - 4
if showTarget {
nameWidth = min(18, max(10, nameWidth/3))
targetWidth := width - markerWidth - nameWidth - authWidth - groupWidth - statusWidth - 5
line := padCells(marker, markerWidth) + " " + padCells(name, nameWidth) + " " + padCells(target, targetWidth) + " " + padCells(auth, authWidth) + " " + padCells(group, groupWidth) + " " + padCells(status, statusWidth)
if server == nil {
return listHeaderStyle.Render(fitLine(line, width))
}
return style.Render(fitLine(line, width))
}
line := padCells(marker, markerWidth) + " " + padCells(name, nameWidth) + " " + padCells(auth, authWidth) + " " + padCells(group, groupWidth) + " " + padCells(status, statusWidth)
if server == nil {
return listHeaderStyle.Render(fitLine(line, width))
}
return style.Render(fitLine(line, width))
}
func (m *tuiModel) renderSelectedPanel(width, height int) string {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)
lines := make([]string, 0, innerHeight)
selected := m.selectedServer()
if selected == nil {
lines = append(lines, dashboardSection("Selected profile"), dashboardHelp("No profile selected."))
} else {
target := fmt.Sprintf("%s@%s:%d", selected.User, selected.Host, selected.Port)
route := "direct"
if len(selected.Route.Hops) > 0 {
route = selected.Route.DisplaySummary(target)
}
group := selected.GroupName
if group == "" {
group = "-"
}
lines = append(lines,
dashboardSection("Selected profile"),
fitLine("Alias: "+selected.Alias, innerWidth),
fitLine("Display Name: "+selected.DisplayName, innerWidth),
fitLine("Host: "+selected.Host, innerWidth),
fitLine(fmt.Sprintf("Port: %d User: %s", selected.Port, selected.User), innerWidth),
fitLine(target, innerWidth),
fitLine("Route "+route, innerWidth),
fitLine("Group "+group, innerWidth),
fitLine("Tags "+strings.Join(selected.Tags, ", "), innerWidth),
fitLine("Last test "+testStatusLabel(selected), innerWidth),
"",
dashboardSection("Primary actions"),
"Enter Connect",
"Ctrl+X More actions…",
)
lines = append(lines, m.backgroundPanelLines(selected.Alias, innerWidth)...)
}
for len(lines) < innerHeight {
lines = append(lines, "")
}
if len(lines) > innerHeight {
lines = lines[:innerHeight]
}
return renderPanel(width, height, lines)
}
func (m *tuiModel) backgroundPanelLines(alias string, width int) []string {
if len(m.bgResults) == 0 {
return nil
}
lines := []string{"", dashboardSection("Last Background Run")}
for _, result := range m.bgResults {
status := "OK"
if result.Err != "" {
status = "FAIL"
}
lines = append(lines, fitLine(result.Alias+" "+status, width))
}
result := m.backgroundResultForAlias(alias)
if result == nil && len(m.bgResults) == 1 {
result = &m.bgResults[0]
}
if result != nil {
output := strings.TrimSpace(result.Output)
if output == "" {
output = result.Err
}
if output != "" {
lines = append(lines, dashboardHelp("Output: "+result.Alias))
for _, line := range strings.Split(output, "\n") {
lines = append(lines, fitLine(strings.ReplaceAll(line, "\t", " "), width))
}
}
}
return lines
}
func (m *tuiModel) renderCompactSelected(server *model.Server, width, height int) string {
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
group := server.GroupName
if group == "" {
group = "-"
}
lines := []string{
dashboardSection("Selected profile"),
fitLine("Alias: "+server.Alias+" Target: "+target, width),
fitLine("Auth: "+authLabel(server.AuthMethod)+" Group: "+group+" Status: "+testStatusLabel(server), width),
fitLine("Enter: Connect Ctrl+X: More actions…", width),
}
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
func dashboardSection(value string) string {
return sectionStyle.Copy().MarginTop(0).Render(value)
}
func dashboardHelp(value string) string {
return helpStyle.Copy().MarginLeft(0).Render(value)
}
func renderPanel(width, height int, lines []string) string {
if width < 2 || height < 2 {
return ""
}
innerWidth := width - 2
var b strings.Builder
b.WriteString("┌" + strings.Repeat("─", innerWidth) + "┐\n")
for row := 0; row < height-2; row++ {
line := ""
if row < len(lines) {
line = lines[row]
}
b.WriteString("│" + padCells(line, innerWidth) + "│\n")
}
b.WriteString("└" + strings.Repeat("─", innerWidth) + "┘")
return b.String()
}
func joinPanelColumns(left string, leftWidth int, right string, rightWidth int) string {
leftLines := strings.Split(left, "\n")
rightLines := strings.Split(right, "\n")
rows := max(len(leftLines), len(rightLines))
joined := make([]string, rows)
for row := 0; row < rows; row++ {
leftLine, rightLine := "", ""
if row < len(leftLines) {
leftLine = leftLines[row]
}
if row < len(rightLines) {
rightLine = rightLines[row]
}
joined[row] = padCells(leftLine, leftWidth) + " " + padCells(rightLine, rightWidth)
}
return strings.Join(joined, "\n")
}

View File

@ -2,6 +2,7 @@ package tui
import (
"fmt"
"strconv"
"strings"
"time"
@ -62,6 +63,12 @@ type formModel struct {
showGroupList bool
authList list.Model
showAuthList bool
initial formSnapshot
}
type formSnapshot struct {
values []string
password string
}
func newFormModel(w, h int) *formModel {
@ -85,6 +92,7 @@ func newFormModel(w, h int) *formModel {
inputs[i].Placeholder = placeholderForLabel(label)
inputs[i].CharLimit = 128
}
inputs[3].SetValue("22")
pw := textinput.New()
pw.Placeholder = "optional"
@ -122,6 +130,7 @@ func newFormModel(w, h int) *formModel {
}
fm.updateFocus()
fm.initial = fm.snapshot()
return fm
}
@ -202,9 +211,31 @@ func newEditFormModel(s *model.Server, w, h int) *formModel {
}
}
fm.updateFocus()
fm.initial = fm.snapshot()
return fm
}
func (fm *formModel) snapshot() formSnapshot {
values := make([]string, len(fm.inputs))
for i := range fm.inputs {
values[i] = fm.inputs[i].Value()
}
return formSnapshot{values: values, password: fm.password.Value()}
}
func (fm *formModel) Dirty() bool {
current := fm.snapshot()
if current.password != fm.initial.password || len(current.values) != len(fm.initial.values) {
return true
}
for i := range current.values {
if current.values[i] != fm.initial.values[i] {
return true
}
}
return false
}
func (fm *formModel) Init() tea.Cmd {
return nil
}
@ -226,7 +257,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case saveDoneMsg:
fm.saving = false
if msg.err != nil {
fm.err = msg.err
fm.applySaveError(msg.err)
fm.saved = false
} else {
fm.saved = true
@ -370,6 +401,25 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil
}
func (fm *formModel) applySaveError(err error) {
fm.err = err
if err == nil {
return
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "alias is required"):
fm.focusIdx = 0
case strings.Contains(message, "host is required"):
fm.focusIdx = 2
case strings.Contains(message, "port"):
fm.focusIdx = 3
default:
return
}
fm.updateFocus()
}
func (fm *formModel) updateFocus() {
for i := range fm.inputs {
fm.inputs[i].Blur()
@ -389,6 +439,14 @@ func (fm *formModel) updateFocus() {
func (fm *formModel) labelAt(index int) string {
if index >= 0 && index < len(fm.labels) {
switch index {
case 0:
return "Alias *"
case 2:
return "Host *"
case 3:
return "Port *"
}
if index == 5 {
return "Auth Method (/ pick)"
}
@ -409,6 +467,10 @@ func (fm *formModel) runTest() tea.Cmd {
fm.err = nil
fm.saved = false
if _, err := parsePort(fm.inputs[3].Value()); err != nil {
fm.testing = false
return func() tea.Msg { return testDoneMsg{ok: false, err: err.Error()} }
}
s := fm.buildServer()
pw := fm.password.Value()
@ -434,6 +496,9 @@ func (fm *formModel) runSave() tea.Cmd {
fm.saved = false
fm.testResult = ""
if _, err := parsePort(fm.inputs[3].Value()); err != nil {
return func() tea.Msg { return saveDoneMsg{err: err} }
}
s := fm.buildServer()
pw := fm.password.Value()
@ -482,8 +547,7 @@ func parseRouteHops(input string) model.Route {
}
func (fm *formModel) buildServer() *model.Server {
port := 22
fmt.Sscanf(fm.inputs[3].Value(), "%d", &port)
port, _ := parsePort(fm.inputs[3].Value())
authMethod := model.AuthMethod(fm.inputs[5].Value())
if authMethod == "" {
authMethod = model.AuthKey
@ -508,131 +572,131 @@ func (fm *formModel) buildServer() *model.Server {
}
}
func (fm *formModel) View() string {
var b strings.Builder
func parsePort(value string) (int, error) {
value = strings.TrimSpace(value)
port, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("Port must be a number from 1 to 65535")
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("Port must be between 1 and 65535")
}
return port, nil
}
func (fm *formModel) View() string {
title := "Add Server"
if fm.edit {
title = "Edit Server: " + fm.server.Alias
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
reserved := 9
available := fm.height - reserved
if available < 4 {
available = 4
}
numInputs := len(fm.inputs)
startIdx := 0
endIdx := numInputs
if numInputs > available {
focusInput := fm.focusIdx
if focusInput >= numInputs {
focusInput = numInputs - 1
}
startIdx = focusInput - available/2
if startIdx < 0 {
startIdx = 0
}
endIdx = startIdx + available
if endIdx > numInputs {
endIdx = numInputs
startIdx = endIdx - available
if startIdx < 0 {
startIdx = 0
}
}
}
if startIdx > 0 {
b.WriteString(helpStyle.Render(" ↑ more fields above\n"))
}
for i := startIdx; i < endIdx; i++ {
if section := formSectionTitle(i); section != "" {
b.WriteString(sectionStyle.Render(section))
b.WriteString("\n")
}
if i == 5 {
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
}
if i == 8 && len(fm.groups) > 0 && !fm.showGroupList {
fm.inputs[i].Placeholder = truncate(strings.Join(fm.groups, ", "), 25)
}
b.WriteString(fm.inputs[i].View())
b.WriteString("\n")
if i == 5 && fm.showAuthList {
b.WriteString("\n" + renderDropdown(fm.authList) + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
if i == 8 && fm.showGroupList {
b.WriteString("\n" + renderDropdown(fm.groupList) + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
}
if endIdx < numInputs {
b.WriteString(helpStyle.Render(fmt.Sprintf(" ↓ more fields below (%d-%d of %d)\n", startIdx+1, endIdx, numInputs)))
}
b.WriteString(fm.password.View())
b.WriteString("\n")
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
if fm.testing {
b.WriteString("\n" + fm.spinner.View() + " Testing connection...\n")
} else if fm.saving {
b.WriteString("\n" + fm.spinner.View() + " Saving...\n")
} else if showResults {
if fm.testResult != "" {
b.WriteString("\n")
if fm.testOK {
b.WriteString(testOKStyle.Render("✓ " + fm.testResult))
} else {
b.WriteString(testFailStyle.Render("✗ " + fm.testResult))
}
b.WriteString("\n")
}
if fm.saved {
b.WriteString("\n" + successStyle.Render("✓ Saved.") + "\n")
}
if fm.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n")
}
}
testBtn := "[ Test ]"
saveBtn := "[ Save ]"
if fm.focusIdx == len(fm.inputs)+1 {
testBtn = selectedStyle.Render(testBtn)
} else {
testBtn = normalStyle.Render(testBtn)
}
if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render(saveBtn)
} else {
saveBtn = normalStyle.Render(saveBtn)
}
b.WriteString("\n" + sectionStyle.Render("Actions") + "\n")
b.WriteString(testBtn + " " + saveBtn + "\n\n")
b.WriteString(renderHelp([]helpItem{
footer := renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "/", Action: "pick list"},
{Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"},
}, fm.width))
}, fm.width)
return b.String()
if fm.showAuthList || fm.showGroupList {
var dropdown list.Model
fieldIndex := 8
if fm.showAuthList {
dropdown = fm.authList
fieldIndex = 5
} else {
dropdown = fm.groupList
}
return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" +
fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" +
fitLine(renderDropdown(dropdown), fm.width) + "\n" +
renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)
}
status := fm.formStatusLine()
testBtn, saveBtn := " [ Test ]", " [ Save ]"
if fm.focusIdx == len(fm.inputs)+1 {
testBtn = selectedStyle.Render("> [ Test ]")
}
if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render("> [ Save ]")
}
actions := fitLine(testBtn+" "+saveBtn, fm.width)
reserved := 1 + displayLineCount(footer) + 1
if status != "" {
reserved++
}
fieldRows := max(4, fm.height-reserved)
richLayout := fm.width >= 90 && fm.height >= 24
allFields := make([]string, 0, len(fm.inputs)+5)
focusRows := make([]int, len(fm.inputs)+1)
for i := range fm.inputs {
if richLayout {
if section := formSectionTitle(i); section != "" {
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
}
}
if i == 5 {
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
}
if i == 8 && len(fm.groups) > 0 {
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
}
focusRows[i] = len(allFields)
allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width))
}
focusRows[len(fm.inputs)] = len(allFields)
allFields = append(allFields, fitLine(fm.password.View(), fm.width))
focusField := len(allFields) - 1
if fm.focusIdx <= len(fm.inputs) {
focusField = focusRows[fm.focusIdx]
}
start, end := visibleServerRange(len(allFields), focusField, fieldRows)
visible := append([]string(nil), allFields[start:end]...)
if start > 0 && len(visible) > 0 {
visible[0] = fitLine("↑ more fields · "+visible[0], fm.width)
}
if end < len(allFields) && len(visible) > 0 {
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 {
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
}
lines = append(lines, actions)
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
}
return strings.Join(lines, "\n")
}
func (fm *formModel) formStatusLine() string {
if fm.err != nil {
return errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
}
if fm.testing {
return fm.spinner.View() + " Testing connection..."
}
if fm.saving {
return fm.spinner.View() + " Saving..."
}
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
if showResults && fm.testResult != "" {
if fm.testOK {
return testOKStyle.Render("✓ " + fm.testResult)
}
return testFailStyle.Render("✗ " + strings.ReplaceAll(fm.testResult, "\n", " "))
}
if showResults && fm.saved {
return successStyle.Render("✓ Saved.")
}
return ""
}
func renderDropdown(l list.Model) string {

View File

@ -0,0 +1,275 @@
package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
)
func TestParsePortRejectsInvalidValues(t *testing.T) {
for _, value := range []string{"", "abc", "0", "65536", "22x"} {
t.Run(value, func(t *testing.T) {
if _, err := parsePort(value); err == nil {
t.Fatalf("parsePort(%q) succeeded", value)
}
})
}
if port, err := parsePort("22"); err != nil || port != 22 {
t.Fatalf("parsePort(22) = %d, %v", port, err)
}
}
func TestServerFormPreservesInvalidPortAndDoesNotSave(t *testing.T) {
fm := newFormModel(80, 24)
fm.inputs[0].SetValue("prod")
fm.inputs[2].SetValue("prod.example")
fm.inputs[3].SetValue("abc")
oldSave := SaveServer
t.Cleanup(func() { SaveServer = oldSave })
saves := 0
SaveServer = func(*model.Server, string, string) error {
saves++
return nil
}
cmd := fm.runSave()
if cmd == nil {
t.Fatal("expected validation result command")
}
updated, _ := fm.Update(cmd())
fm = updated.(*formModel)
if saves != 0 || fm.inputs[3].Value() != "abc" {
t.Fatalf("invalid input was lost or saved: saves=%d value=%q", saves, fm.inputs[3].Value())
}
if fm.err == nil || !strings.Contains(fm.err.Error(), "Port") {
t.Fatalf("missing actionable port error: %v", fm.err)
}
if fm.focusIdx != 3 {
t.Fatalf("invalid port focus = %d, want 3", fm.focusIdx)
}
if view := fm.View(); !strings.Contains(view, "Port must be a number") {
t.Fatalf("validation error is not rendered:\n%s", view)
}
}
func TestServerSaveSuccessResetsDirtySnapshot(t *testing.T) {
m := New(nil)
m.screen = screenForm
m.form = newFormModel(80, 24)
m.form.inputs[0].SetValue("prod")
m.form.inputs[2].SetValue("prod.example")
m.form.password.SetValue("secret")
updated, _ := m.Update(saveDoneMsg{})
m = updated.(*tuiModel)
if m.form == nil || m.form.Dirty() {
t.Fatalf("saved form remains dirty: %#v", m.form)
}
if m.form.password.Value() != "" {
t.Fatal("saved secret remained in the form")
}
}
func TestForwardValidationMovesFocusToInvalidPort(t *testing.T) {
fm := newForwardFormModel(1, 60, 16)
fm.nameInput.SetValue("postgres")
fm.inputs[0].SetValue("127.0.0.1")
fm.inputs[1].SetValue("bad")
fm.inputs[2].SetValue("db")
fm.inputs[3].SetValue("5432")
updated, _ := fm.Update(fm.runSave()())
fm = updated.(*forwardFormModel)
if fm.focusIdx != 6 {
t.Fatalf("invalid listen port focus = %d, want 6", fm.focusIdx)
}
view := fm.View()
if !strings.Contains(view, "Listen Port") || !strings.Contains(view, "must be a number") {
t.Fatalf("invalid port and error are not visible together:\n%s", view)
}
}
func TestCtrlQQuitsCleanStateAndConfirmsDirtyForm(t *testing.T) {
t.Run("clean manager", func(t *testing.T) {
m := New(nil)
m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
if cmd == nil {
t.Fatal("Ctrl+Q did not quit clean manager")
}
if _, ok := cmd().(tea.QuitMsg); !ok {
t.Fatalf("Ctrl+Q command returned %T", cmd())
}
})
t.Run("dirty server form", func(t *testing.T) {
m := New(nil)
m.screen = screenForm
m.form = newFormModel(80, 24)
m.form.inputs[0].SetValue("prod")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.focus != confirmCancel {
t.Fatalf("dirty Ctrl+Q did not open safe discard: screen=%v confirm=%#v", m.screen, m.confirm)
}
m.confirm.focus = confirmAccept
updated, cmd = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
t.Fatal("accepted quit confirmation produced no command")
}
updated, quitCmd := m.Update(cmd())
m = updated.(*tuiModel)
if quitCmd == nil || m.confirm != nil || m.form != nil {
t.Fatalf("accepted dirty quit did not clear state: confirm=%v form=%v", m.confirm, m.form)
}
if _, ok := quitCmd().(tea.QuitMsg); !ok {
t.Fatalf("accepted dirty quit returned %T", quitCmd())
}
})
}
func TestDirtyServerFormRequiresDiscardConfirmation(t *testing.T) {
oldList := ListServers
t.Cleanup(func() { ListServers = oldList })
ListServers = func() ([]*model.Server, error) { return nil, nil }
m := New(nil)
m.screen = screenForm
m.form = newFormModel(80, 24)
m.form.inputs[0].SetValue("prod")
updated, cmd := m.updateForm(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenForm {
t.Fatalf("dirty form did not open discard confirmation: screen=%v confirm=%#v", m.screen, m.confirm)
}
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if m.screen != screenForm || m.form == nil || m.form.inputs[0].Value() != "prod" {
t.Fatalf("Cancel did not preserve form: screen=%v form=%#v", m.screen, m.form)
}
updated, _ = m.updateForm(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(*tuiModel)
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(*tuiModel)
updated, cmd = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
t.Fatal("expected discard command")
}
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if m.screen != screenList || m.form != nil || m.confirm != nil {
t.Fatalf("discard did not return to list: screen=%v form=%v confirm=%v", m.screen, m.form, m.confirm)
}
}
func TestCleanFormsExitWithoutConfirmation(t *testing.T) {
oldList := ListServers
t.Cleanup(func() { ListServers = oldList })
ListServers = func() ([]*model.Server, error) { return nil, nil }
tests := []struct {
name string
screen screen
setup func(*tuiModel)
exit func(*tuiModel) (tea.Model, tea.Cmd)
want screen
}{
{
name: "server",
screen: screenForm,
setup: func(m *tuiModel) { m.form = newFormModel(80, 24) },
exit: func(m *tuiModel) (tea.Model, tea.Cmd) { return m.updateForm(tea.KeyMsg{Type: tea.KeyEsc}) },
want: screenList,
},
{
name: "forward",
screen: screenForwardForm,
setup: func(m *tuiModel) {
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
m.forwardForm = newForwardFormModel(1, 80, 24)
},
exit: func(m *tuiModel) (tea.Model, tea.Cmd) {
return m.updateForwardForm(tea.KeyMsg{Type: tea.KeyEsc})
},
want: screenForwardList,
},
{
name: "template",
screen: screenTemplateForm,
setup: func(m *tuiModel) { m.templateForm = newTemplateFormModel(nil, 80, 24) },
exit: func(m *tuiModel) (tea.Model, tea.Cmd) {
return m.updateTemplateForm(tea.KeyMsg{Type: tea.KeyEsc})
},
want: screenTemplates,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := New(nil)
m.screen = tt.screen
tt.setup(m)
updated, _ := tt.exit(m)
m = updated.(*tuiModel)
if m.screen != tt.want || m.confirm != nil {
t.Fatalf("clean exit: screen=%v confirm=%v", m.screen, m.confirm)
}
})
}
}
func TestDirtyForwardAndTemplateFormsRequireConfirmation(t *testing.T) {
t.Run("forward", func(t *testing.T) {
m := New(nil)
m.screen = screenForwardForm
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
m.forwardForm = newForwardFormModel(1, 80, 24)
m.forwardForm.nameInput.SetValue("postgres")
updated, _ := m.updateForwardForm(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(*tuiModel)
if m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenForwardForm {
t.Fatalf("dirty forward form did not confirm: screen=%v confirm=%#v", m.screen, m.confirm)
}
})
t.Run("template", func(t *testing.T) {
m := New(nil)
m.screen = screenTemplateForm
m.templateForm = newTemplateFormModel(nil, 80, 24)
m.templateForm.inputs[0].SetValue("uptime")
updated, _ := m.updateTemplateForm(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(*tuiModel)
if m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTemplateForm {
t.Fatalf("dirty template form did not confirm: screen=%v confirm=%#v", m.screen, m.confirm)
}
})
}
func TestRequiredFieldsAreMarked(t *testing.T) {
serverView := newFormModel(100, 30).View()
for _, want := range []string{"Alias *", "Host *", "Port *"} {
if !strings.Contains(serverView, want) {
t.Fatalf("server form missing %q:\n%s", want, serverView)
}
}
forwardView := newForwardFormModel(1, 100, 30).View()
for _, want := range []string{"Name *", "Listen Port *", "Target Host *", "Target Port *"} {
if !strings.Contains(forwardView, want) {
t.Fatalf("forward form missing %q:\n%s", want, forwardView)
}
}
templateView := newTemplateFormModel(nil, 100, 30).View()
for _, want := range []string{"Name *", "Command *"} {
if !strings.Contains(templateView, want) {
t.Fatalf("template form missing %q:\n%s", want, templateView)
}
}
}

View File

@ -79,65 +79,93 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
}
func (m *forwardScreenModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Port Forwards — " + m.serverAlias))
b.WriteString("\n\n")
if len(m.list) == 0 {
b.WriteString(helpStyle.Render(" No port forwards configured. Press Ctrl+A to add one."))
b.WriteString("\n")
} else {
// Column header
b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
"NAME", "TYPE", "LISTEN", "TARGET", "ON")))
b.WriteString("\n")
for i, f := range m.list {
name := f.Name
if name == "" {
name = f.ForwardListen()
}
enabled := "yes"
if !f.Enabled {
enabled = "no"
}
line := fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
truncate(name, 22),
f.Type,
truncate(f.ForwardListen(), 20),
truncate(f.ForwardTarget(), 20),
enabled,
)
style := normalStyle
if i == m.selected {
style = selectedRowStyle
}
b.WriteString(style.Render(line))
b.WriteString("\n")
}
// Details for selected
if m.selected >= 0 && m.selected < len(m.list) {
f := m.list[m.selected]
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Selected"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", f.ForwardHumanExplanation(m.serverAlias)))
for _, arg := range f.ForwardSSHArgs() {
b.WriteString(fmt.Sprintf(" %s\n", arg))
}
}
}
b.WriteString("\n")
b.WriteString(renderHelp([]helpItem{
footer := renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
}, 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))
}
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 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
} else {
lines = append(lines, m.renderForwardRow(nil, false))
rowCapacity--
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
}
if end < len(m.list) || start > 0 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
forward := m.list[m.selected]
lines = append(lines,
sectionStyle.Copy().MarginTop(0).Render("Selected"),
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
)
}
}
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > m.height && m.height > 0 {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
}
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
if forward != nil {
if selected {
marker = ">"
}
name = forward.Name
if name == "" {
name = forward.ForwardListen()
}
kind = string(forward.Type)
listen = forward.ForwardListen()
target = forward.ForwardTarget()
enabled = "yes"
if !forward.Enabled {
enabled = "no"
}
}
wide := m.width >= 70
typeWidth, enabledWidth := 8, 3
if wide {
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil {
return listHeaderStyle.Render(fitLine(line, m.width))
}
if selected {
return selectedRowStyle.Render(fitLine(line, m.width))
}
return fitLine(line, m.width)
}
nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil {
return listHeaderStyle.Render(fitLine(line, m.width))
}
if selected {
return selectedRowStyle.Render(fitLine(line, m.width))
}
return fitLine(line, m.width)
}
// --- Forward form screen model ---
@ -157,6 +185,14 @@ type forwardFormModel struct {
typeIdx int // 0=local, 1=remote, 2=socks
width int
height int
initial forwardFormSnapshot
}
type forwardFormSnapshot struct {
name string
description string
values []string
forwardType model.ForwardType
}
var forwardTypes = []forwardTypeItem{
@ -182,7 +218,7 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
inputs[i].CharLimit = 128
}
return &forwardFormModel{
fm := &forwardFormModel{
serverID: serverID,
inputs: inputs,
focusIdx: 0,
@ -193,6 +229,9 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
width: w,
height: h,
}
fm.updateFocus()
fm.initial = fm.snapshot()
return fm
}
func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardFormModel {
@ -203,13 +242,48 @@ func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardF
fm.descInput.SetValue(fwd.Description)
fm.currentType = fwd.Type
fm.typeIdx = typeIndex(fwd.Type)
fm.inputs[0].SetValue(fwd.LocalAddr)
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
fm.inputs[2].SetValue(fwd.RemoteAddr)
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
if fwd.Type == model.ForwardRemote {
fm.inputs[0].SetValue(fwd.RemoteAddr)
fm.inputs[1].SetValue(strconv.Itoa(fwd.RemotePort))
fm.inputs[2].SetValue(fwd.LocalAddr)
fm.inputs[3].SetValue(strconv.Itoa(fwd.LocalPort))
} else {
fm.inputs[0].SetValue(fwd.LocalAddr)
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
fm.inputs[2].SetValue(fwd.RemoteAddr)
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
}
fm.updateFocus()
fm.initial = fm.snapshot()
return fm
}
func (fm *forwardFormModel) snapshot() forwardFormSnapshot {
values := make([]string, len(fm.inputs))
for i := range fm.inputs {
values[i] = fm.inputs[i].Value()
}
return forwardFormSnapshot{
name: fm.nameInput.Value(),
description: fm.descInput.Value(),
values: values,
forwardType: fm.currentType,
}
}
func (fm *forwardFormModel) Dirty() bool {
current := fm.snapshot()
if current.name != fm.initial.name || current.description != fm.initial.description || current.forwardType != fm.initial.forwardType || len(current.values) != len(fm.initial.values) {
return true
}
for i := range current.values {
if current.values[i] != fm.initial.values[i] {
return true
}
}
return false
}
func typeIndex(t model.ForwardType) int {
switch t {
case model.ForwardLocal:
@ -254,14 +328,14 @@ func (fm *forwardFormModel) labelForField(idx int) string {
if idx < 0 || idx >= len(labels) {
return ""
}
return labels[idx]
return labels[idx] + " *"
}
func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case saveDoneMsg:
fm.saved = (msg.err == nil)
fm.err = msg.err
fm.applySaveError(msg.err)
return fm, nil
}
@ -365,7 +439,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (fm *forwardFormModel) updateFocus() {
fm.nameInput.Blur()
fm.nameInput.Prompt = blurredStyle.Render("Name: ")
fm.nameInput.Prompt = blurredStyle.Render("Name *: ")
fm.descInput.Blur()
fm.descInput.Prompt = blurredStyle.Render("Description: ")
for i := range fm.inputs {
@ -377,7 +451,7 @@ func (fm *forwardFormModel) updateFocus() {
switch {
case fm.focusIdx == 0:
fm.nameInput.Focus()
fm.nameInput.Prompt = focusedStyle.Render("Name> ")
fm.nameInput.Prompt = focusedStyle.Render("Name *> ")
case fm.focusIdx == 1:
fm.descInput.Focus()
fm.descInput.Prompt = focusedStyle.Render("Description> ")
@ -395,47 +469,57 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg {
name := strings.TrimSpace(fm.nameInput.Value())
desc := strings.TrimSpace(fm.descInput.Value())
localPort := 0
fmt.Sscanf(fm.inputs[1].Value(), "%d", &localPort)
remotePort := 0
fmt.Sscanf(fm.inputs[3].Value(), "%d", &remotePort)
localAddr := strings.TrimSpace(fm.inputs[0].Value())
remoteAddr := strings.TrimSpace(fm.inputs[2].Value())
localAddr, remoteAddr := "", ""
localPort, remotePort := 0, 0
var err error
if name == "" {
return saveDoneMsg{err: fmt.Errorf("name is required")}
}
if localPort < 1 || localPort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid listen port %d: must be 1-65535", localPort)}
}
switch fm.currentType {
case model.ForwardLocal:
localAddr = strings.TrimSpace(fm.inputs[0].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
remoteAddr = strings.TrimSpace(fm.inputs[2].Value())
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")}
}
if remotePort < 1 || remotePort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid target port %d: must be 1-65535", remotePort)}
remotePort, err = parseNamedPort("Target port", fm.inputs[3].Value())
if err != nil {
return saveDoneMsg{err: err}
}
case model.ForwardRemote:
remoteAddr = strings.TrimSpace(fm.inputs[0].Value())
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")}
}
if remotePort < 1 || remotePort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid remote port %d: must be 1-65535", remotePort)}
remotePort, err = parseNamedPort("Remote listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
localAddr = strings.TrimSpace(fm.inputs[2].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Local target port", fm.inputs[3].Value())
if err != nil {
return saveDoneMsg{err: err}
}
case model.ForwardDynamic:
localAddr = strings.TrimSpace(fm.inputs[0].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
remoteAddr = ""
remotePort = 0
}
@ -463,70 +547,87 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
if SaveForward == nil {
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
}
err := SaveForward(fwd)
err = SaveForward(fwd)
return saveDoneMsg{err: err}
}
}
func (fm *forwardFormModel) applySaveError(err error) {
fm.err = err
if err == nil {
return
}
message := strings.ToLower(err.Error())
fieldIndex := -1
switch {
case strings.Contains(message, "name is required"):
fm.focusIdx = 0
fm.updateFocus()
return
case strings.Contains(message, "listen address"):
fieldIndex = 0
case strings.Contains(message, "listen port"):
fieldIndex = 1
case strings.Contains(message, "target host"):
fieldIndex = 2
case strings.Contains(message, "target port"):
fieldIndex = 3
}
if fieldIndex >= 0 {
fm.focusIdx = 2 + len(forwardTypes) + fieldIndex
fm.updateFocus()
}
}
func parseNamedPort(label, value string) (int, error) {
port, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s must be a number from 1 to 65535", label)
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("%s must be between 1 and 65535", label)
}
return port, nil
}
func (fm *forwardFormModel) View() string {
var b strings.Builder
title := "Add Port Forward"
if fm.editMode {
title = "Edit Port Forward"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
lines = append(lines,
fitLine(fm.nameInput.View(), fm.width),
fitLine(fm.descInput.View(), fm.width),
)
// Name
b.WriteString(fm.nameInput.View())
b.WriteString("\n")
// Description
b.WriteString(fm.descInput.View())
b.WriteString("\n\n")
// Type selector — visible radio items with descriptions
b.WriteString(sectionStyle.Render("Type"))
b.WriteString("\n")
for i, t := range forwardTypes {
prefix := " "
style := normalStyle
typeParts := make([]string, len(forwardTypes))
for i, forwardType := range forwardTypes {
selected := "○"
if i == fm.typeIdx {
prefix = "▸ "
style = selectedRowStyle
selected = "●"
}
line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description)
b.WriteString(style.Render(line))
b.WriteString("\n")
focus := " "
if fm.focusIdx == 2+i {
focus = ">"
}
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
}
// Show human-readable explanation for selected type
if fm.typeIdx >= 0 && fm.typeIdx < len(forwardTypes) {
explanations := map[model.ForwardType]string{
model.ForwardLocal: "Opens a local port on this machine and forwards it through SSH to the target address.",
model.ForwardRemote: "Opens a port on the remote SSH server and forwards it back to this machine.",
model.ForwardDynamic: "Creates a local SOCKS proxy that routes all traffic through the SSH server.",
}
if exp, ok := explanations[forwardTypes[fm.typeIdx].value]; ok {
b.WriteString(helpStyle.Render(fmt.Sprintf(" %s\n", exp)))
}
lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
if fm.width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
}
b.WriteString("\n")
// Dynamic fields based on type
visible := fm.visibleFields()
for _, idx := range visible {
b.WriteString(fm.inputs[idx].View())
b.WriteString("\n")
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
}
// Warning for 0.0.0.0
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
b.WriteString(helpStyle.Render(" ⚠ This port will be accessible from the network.\n"))
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
}
// Preview
if fm.currentType != "" && fm.inputs[1].Value() != "" {
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
fwd := &model.Forward{
Type: fm.currentType,
LocalAddr: fm.inputs[0].Value(),
@ -536,37 +637,34 @@ func (fm *forwardFormModel) View() string {
}
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
for _, arg := range fwd.ForwardSSHArgs() {
b.WriteString(" " + arg + "\n")
}
b.WriteString(" -o ExitOnForwardFailure=yes\n")
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
}
// Save button
total := 2 + 3 + len(visible) + 1
button := "\n[ Save ]"
button := " [ Save ]"
if fm.focusIdx == total-1 {
button = selectedStyle.Render(button)
button = selectedStyle.Render("> [ Save ]")
}
b.WriteString(button)
b.WriteString("\n\n")
if fm.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n")
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
}
if fm.saved {
b.WriteString(successStyle.Render("✓ Saved.") + "\n\n")
lines = append(lines, successStyle.Render("✓ Saved."))
}
b.WriteString(renderHelp([]helpItem{
lines = append(lines, button)
footer := renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "1/2/3", Action: "select type"},
{Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"},
}, fm.width))
return b.String()
}, 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

View File

@ -2,6 +2,7 @@ package tui
import (
"fmt"
"reflect"
"testing"
tea "github.com/charmbracelet/bubbletea"
@ -26,6 +27,51 @@ func TestForwardFormDigitsReachFocusedInput(t *testing.T) {
}
}
func TestRemoteForwardFormMapsListenAndTargetEndpoints(t *testing.T) {
oldSave := SaveForward
t.Cleanup(func() { SaveForward = oldSave })
var saved *model.Forward
SaveForward = func(forward *model.Forward) error {
copy := *forward
saved = &copy
return nil
}
fm := newForwardFormModel(7, 80, 24)
fm.currentType = model.ForwardRemote
fm.typeIdx = typeIndex(model.ForwardRemote)
fm.nameInput.SetValue("remote web")
fm.inputs[0].SetValue("0.0.0.0")
fm.inputs[1].SetValue("18080")
fm.inputs[2].SetValue("127.0.0.1")
fm.inputs[3].SetValue("8080")
msg := fm.runSave()()
if result, ok := msg.(saveDoneMsg); !ok || result.err != nil {
t.Fatalf("save result = %#v", msg)
}
if saved == nil {
t.Fatal("forward was not saved")
}
if saved.RemoteAddr != "0.0.0.0" || saved.RemotePort != 18080 || saved.LocalAddr != "127.0.0.1" || saved.LocalPort != 8080 {
t.Fatalf("remote forward endpoints were reversed: %#v", saved)
}
wantArgs := []string{"-R", "0.0.0.0:18080:127.0.0.1:8080"}
if got := saved.ForwardSSHArgs(); !reflect.DeepEqual(got, wantArgs) {
t.Fatalf("ForwardSSHArgs() = %#v, want %#v", got, wantArgs)
}
}
func TestRemoteForwardEditPopulatesSemanticFields(t *testing.T) {
forward := &model.Forward{ID: 9, Type: model.ForwardRemote, RemoteAddr: "0.0.0.0", RemotePort: 18080, LocalAddr: "127.0.0.1", LocalPort: 8080}
fm := newForwardEditModel(7, forward, 80, 24)
want := []string{"0.0.0.0", "18080", "127.0.0.1", "8080"}
for index, value := range want {
if got := fm.inputs[index].Value(); got != value {
t.Fatalf("input[%d] = %q, want %q", index, got, value)
}
}
}
func TestForwardFormDigitShortcutsWorkOnTypeSelector(t *testing.T) {
tests := []struct {
digit rune

View File

@ -272,8 +272,9 @@ func (i actionMenuItem) Description() string { return "" }
func (i actionMenuItem) FilterValue() string { return i.label }
type actionMenuModel struct {
list list.Model
width int
list list.Model
width int
height int
}
func newActionMenuModel(w, h int) *actionMenuModel {
@ -301,7 +302,7 @@ func newActionMenuModel(w, h int) *actionMenuModel {
l.SetShowHelp(false)
l.Styles.Title = titleStyle
return &actionMenuModel{list: l, width: w}
return &actionMenuModel{list: l, width: w, height: h}
}
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
@ -323,5 +324,24 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
}
func (m *actionMenuModel) View() string {
return m.list.View()
footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
capacity := max(1, m.height-displayLineCount(footer)-1)
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
for index := start; index < end; index++ {
item, ok := m.list.Items()[index].(actionMenuItem)
if !ok {
continue
}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, fitLine(marker+item.label, m.width))
}
lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > m.height && m.height > 0 {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
}

75
internal/tui/layout.go Normal file
View File

@ -0,0 +1,75 @@
package tui
import (
"strings"
"github.com/charmbracelet/x/ansi"
)
const (
minimumTUIWidth = 60
minimumTUIHeight = 16
)
type terminalSizeClass int
const (
sizeBelowFloor terminalSizeClass = iota
sizeNarrow
sizeMedium
sizeWide
)
func classifyTerminal(width, height int) terminalSizeClass {
if width > 0 && height > 0 && (width < minimumTUIWidth || height < minimumTUIHeight) {
return sizeBelowFloor
}
if width >= 100 {
return sizeWide
}
if width >= 70 {
return sizeMedium
}
return sizeNarrow
}
func truncateCells(value string, width int) string {
if width <= 0 {
return ""
}
if ansi.StringWidth(value) <= width {
return value
}
if width == 1 {
return "…"
}
return ansi.Truncate(value, width, "…")
}
func padCells(value string, width int) string {
value = truncateCells(value, width)
missing := width - ansi.StringWidth(value)
if missing > 0 {
value += strings.Repeat(" ", missing)
}
return value
}
func fitLine(value string, width int) string {
return truncateCells(value, width)
}
func wrapCells(value string, width int) []string {
if width <= 0 {
return []string{""}
}
return strings.Split(ansi.Wrap(value, width, ""), "\n")
}
func minimumSizeView(width int) string {
message := "sshkeeper needs at least 60x16"
if width <= 0 {
return message
}
return truncateCells(message, width)
}

210
internal/tui/layout_test.go Normal file
View File

@ -0,0 +1,210 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
"github.com/mirivlad/sshkeeper/internal/model"
)
func TestTruncateCellsHandlesUnicodeDisplayWidth(t *testing.T) {
tests := []string{
"production-сервер",
"数据库服务器",
"e\u0301-combining",
"🔐 gateway",
}
for _, value := range tests {
got := truncateCells(value, 8)
if width := ansi.StringWidth(got); width > 8 {
t.Fatalf("truncateCells(%q) width=%d result=%q", value, width, got)
}
if !strings.HasSuffix(got, "…") {
t.Fatalf("truncated value has no indicator: %q", got)
}
}
}
func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
servers := []*model.Server{
{
Alias: "staging-数据库-bastion",
DisplayName: "Production сервер 🔐 with a very long display name",
Host: "bastion.staging.example.net",
Port: 2222,
User: "operations",
AuthMethod: model.AuthAgent,
GroupName: "STAGING-LONG",
Tags: []string{"stage", "bastion", "кириллица"},
},
{Alias: "db", DisplayName: "Database", Host: "db.internal", Port: 22, User: "postgres", AuthMethod: model.AuthKeyPassphrase},
}
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
t.Run(strings.Join([]string{itoa(size.width), "x", itoa(size.height)}, ""), func(t *testing.T) {
m := New(servers)
m.width, m.height = size.width, size.height
assertViewFits(t, m.View(), size.width, size.height)
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
if !strings.Contains(m.View(), want) {
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
}
}
})
}
}
func TestDashboardNotificationFitsSupportedTerminalSizes(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", AuthMethod: model.AuthAgent}})
m.width, m.height = size.width, size.height
m.err = errText("vault reload failed and this message must remain visible")
view := m.View()
assertViewFits(t, view, size.width, size.height)
if !strings.Contains(view, "vault reload failed") {
t.Fatalf("dashboard at %dx%d lost notification:\n%s", size.width, size.height, view)
}
}
}
func TestScreensBelowSupportedFloorShowSizeMessage(t *testing.T) {
m := New(nil)
m.width, m.height = 59, 15
view := m.View()
if !strings.Contains(view, "60x16") {
t.Fatalf("missing supported-size message:\n%s", view)
}
assertViewFits(t, view, 59, 15)
}
func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
t.Run(itoa(size.width), func(t *testing.T) {
fm := newFormModel(size.width, size.height)
fm.inputs[0].SetValue("prod")
fm.inputs[2].SetValue("prod.example")
fm.inputs[3].SetValue("not-a-port")
fm.err = errText("Port must be a number from 1 to 65535")
fm.focusIdx = 3
fm.updateFocus()
view := fm.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
})
}
}
func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
fm := newForwardFormModel(1, size.width, size.height)
fm.nameInput.SetValue("Local PostgreSQL")
fm.inputs[0].SetValue("127.0.0.1")
fm.inputs[1].SetValue("15432")
fm.inputs[2].SetValue("database.internal.example")
fm.inputs[3].SetValue("5432")
assertViewFits(t, fm.View(), size.width, size.height)
}
}
func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
fm := newForwardScreenModel(1, "production-数据库-bastion", size.width, size.height)
fm.list = []*model.Forward{
{Name: "Local PostgreSQL with a very long name", Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "database.internal.example", RemotePort: 5432, Enabled: true},
{Name: "SOCKS proxy", Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080, Enabled: false},
}
view := fm.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
menu := newActionMenuModel(size.width, size.height)
view := menu.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestConfirmationFitsSupportedTerminalSizes(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: `"Очень длинный Local PostgreSQL 数据库 forward" · 127.0.0.1:15432 → database.internal.example:5432`,
consequence: "This removes the saved forwarding rule. Active tunnels are not stopped.",
verb: "Delete",
parent: screenForwardList,
})
view := m.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newTemplateFormModel(nil, size.width, size.height)
form.inputs[0].SetValue("проверка-数据库")
form.inputs[1].SetValue("printf 'a very long command that remains editable'")
view := form.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func assertViewFits(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
if len(lines) > height {
t.Fatalf("view has %d lines, terminal height is %d:\n%s", len(lines), height, view)
}
for index, line := range lines {
if lineWidth := ansi.StringWidth(line); lineWidth > width {
t.Fatalf("line %d has width %d, terminal width is %d: %q", index+1, lineWidth, width, ansi.Strip(line))
}
}
}
type errText string
func (e errText) Error() string { return string(e) }
func itoa(value int) string {
const digits = "0123456789"
if value == 0 {
return "0"
}
var out [20]byte
index := len(out)
for value > 0 {
index--
out[index] = digits[value%10]
value /= 10
}
return string(out[index:])
}

View File

@ -0,0 +1,114 @@
package tui
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestVaultStatusTracksSuccessfulLock(t *testing.T) {
oldUnlocked, oldLock := VaultUnlocked, LockVault
t.Cleanup(func() { VaultUnlocked, LockVault = oldUnlocked, oldLock })
VaultUnlocked = func() bool { return true }
LockVault = func() error { return nil }
m := New(nil)
if !strings.Contains(m.View(), "Vault unlocked") {
t.Fatalf("initial status is not unlocked:\n%s", m.View())
}
m.actionMenu = newActionMenuModel(80, 24)
m.screen = screenActionMenu
for i := range m.actionMenu.list.Items() {
m.actionMenu.list.Select(i)
item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem)
if ok && item.action == "vault_lock" {
break
}
}
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
view := m.View()
if !strings.Contains(view, "Vault locked") || strings.Contains(view, "Vault unlocked") {
t.Fatalf("status after lock is false:\n%s", view)
}
}
func TestNotificationSurvivesRepeatedView(t *testing.T) {
m := New(nil)
m.err = errors.New("reload failed")
first := m.View()
second := m.View()
if !strings.Contains(first, "reload failed") || !strings.Contains(second, "reload failed") {
t.Fatalf("notification was consumed by View: first=%q second=%q", first, second)
}
}
func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
m := New(nil)
m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
m = updated.(*tuiModel)
if m.screen != screenFullHelp || m.fullHelp == nil {
t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(*tuiModel)
if m.screen != screenForwardList || m.fullHelp != nil {
t.Fatalf("help did not return to forward list: screen=%v help=%v", m.screen, m.fullHelp)
}
}
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
m := New(nil)
m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
m = updated.(*tuiModel)
if m.screen != screenHelp || m.helpScreen == nil {
t.Fatalf("? did not open help from manager: screen=%v", m.screen)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
m = updated.(*tuiModel)
if m.screen != screenForwardList || m.helpScreen != nil {
t.Fatalf("q did not return to manager: screen=%v help=%v", m.screen, m.helpScreen)
}
}
func TestHelpShortcutDoesNotStealQuestionMarkFromSearch(t *testing.T) {
m := New(nil)
m.screen = screenSearch
m.searchInput.Focus()
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
m = updated.(*tuiModel)
if m.screen != screenSearch || m.searchInput.Value() != "?" {
t.Fatalf("search input lost printable rune: screen=%v value=%q", m.screen, m.searchInput.Value())
}
}
func TestResizePropagatesToActiveChildren(t *testing.T) {
m := New(nil)
m.form = newFormModel(80, 24)
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
m.forwardForm = newForwardFormModel(1, 80, 24)
m.templateForm = newTemplateFormModel(nil, 80, 24)
m.tunnelScreen = newTunnelScreenModel(80, 24)
m.helpScreen = newHelpScreenModel(80, 24)
m.fullHelp = newFullHelpModel(80, 24)
m.actionMenu = newActionMenuModel(80, 24)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 16})
m = updated.(*tuiModel)
if m.form.width != 60 || m.form.height != 16 ||
m.forwardScreen.width != 60 || m.forwardScreen.height != 16 ||
m.forwardForm.width != 60 || m.forwardForm.height != 16 ||
m.templateForm.width != 60 || m.templateForm.height != 16 ||
m.tunnelScreen.width != 60 || m.tunnelScreen.height != 16 ||
m.helpScreen.width != 60 || m.fullHelp.width != 60 || m.fullHelp.height != 16 {
t.Fatalf("resize did not reach every child: %#v", m)
}
}

View File

@ -21,6 +21,7 @@ type templateFormModel struct {
saved bool
width int
height int
initial []string
}
func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel {
@ -44,9 +45,31 @@ func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel
inputs[2].SetValue(t.Description)
}
tf.updateFocus()
tf.initial = tf.snapshot()
return tf
}
func (tf *templateFormModel) snapshot() []string {
values := make([]string, len(tf.inputs))
for i := range tf.inputs {
values[i] = tf.inputs[i].Value()
}
return values
}
func (tf *templateFormModel) Dirty() bool {
current := tf.snapshot()
if len(current) != len(tf.initial) {
return true
}
for i := range current {
if current[i] != tf.initial[i] {
return true
}
}
return false
}
func (tf *templateFormModel) Init() tea.Cmd {
return nil
}
@ -89,14 +112,21 @@ func (tf *templateFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (tf *templateFormModel) updateFocus() {
for i := range tf.inputs {
tf.inputs[i].Blur()
tf.inputs[i].Prompt = blurredStyle.Render(tf.labels[i] + ": ")
tf.inputs[i].Prompt = blurredStyle.Render(tf.labelAt(i) + ": ")
}
if tf.focusIdx < len(tf.inputs) {
tf.inputs[tf.focusIdx].Focus()
tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labels[tf.focusIdx] + "> ")
tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labelAt(tf.focusIdx) + "> ")
}
}
func (tf *templateFormModel) labelAt(index int) string {
if index == 0 || index == 1 {
return tf.labels[index] + " *"
}
return tf.labels[index]
}
func (tf *templateFormModel) save() tea.Cmd {
return func() tea.Msg {
if SaveCommandTemplate == nil {
@ -126,15 +156,15 @@ func (tf *templateFormModel) View() string {
if tf.edit {
title = "Edit Template"
}
b.WriteString(titleStyle.Render(title))
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
b.WriteString("\n\n")
for i := range tf.inputs {
b.WriteString(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) {
button = selectedStyle.Render(button)
button = selectedStyle.Render("> [ Save ]")
}
b.WriteString("\n" + button + "\n\n")
if tf.err != nil {

View File

@ -97,6 +97,10 @@ func (m *tunnelScreenModel) View() string {
var b strings.Builder
b.WriteString(m.list.View())
b.WriteString("\n\n")
if m.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
b.WriteString("\n\n")
}
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"},