Compare commits
No commits in common. "be800de90b8609ab0bf3d85a8ad46dafc37624ac" and "10bcc07601b2ea97a345cf9ff234ed10975b5ada" have entirely different histories.
be800de90b
...
10bcc07601
32
README.md
|
|
@ -96,20 +96,20 @@ Running `sshkeeper` without arguments opens the TUI.
|
|||
### Main Window
|
||||
|
||||
```
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
### Quick Help (?)
|
||||
|
||||
Press `?` outside text editors for a compact hotkey reference. Inside forms and
|
||||
search, `?` remains normal text input.
|
||||
Press `?` on any screen for a compact hotkey reference.
|
||||
|
||||
### Full Help (F1)
|
||||
|
||||
|
|
@ -118,13 +118,13 @@ forwarding, tunnels, and vault.
|
|||
|
||||
### Screenshots
|
||||
|
||||
| Wide dashboard | Dashboard 80x24 | Server form 60x16 |
|
||||
|----------------|-----------------|-------------------|
|
||||
|  |  |  |
|
||||
| Main list | Actions | Route edit |
|
||||
|-----------|---------|------------|
|
||||
|  |  |  |
|
||||
|
||||
| Port forward form | Safe confirmation |
|
||||
|-------------------|-------------------|
|
||||
|  |  |
|
||||
| Port forwards | Tunnel manager |
|
||||
|---------------|----------------|
|
||||
|  |  |
|
||||
|
||||
### Key Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -146,9 +146,6 @@ 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)
|
||||
}
|
||||
|
|
|
|||
101
docs/guide.md
|
|
@ -121,32 +121,17 @@ Vault created and unlocked for this command. You're ready to go!
|
|||
### Главный экран
|
||||
|
||||
```
|
||||
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: ?
|
||||
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
|
||||
```
|
||||
|
||||
Интерфейс адаптируется к ширине терминала:
|
||||
|
||||
- от 100 столбцов список и подробности выбранного профиля показаны в двух панелях;
|
||||
- от 70 до 99 столбцов подробности переносятся под список;
|
||||
- от 60 до 69 столбцов остаётся компактная таблица без второстепенных полей;
|
||||
- минимальный поддерживаемый размер — `60x16`; ниже показывается сообщение о необходимом размере.
|
||||
|
||||
Статус vault в заголовке отражает реальное состояние. Выбор и фокус всегда
|
||||
обозначены текстовым маркером `>`, поэтому интерфейс остаётся понятным без цвета.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**Столбцы:**
|
||||
|
||||
| Столбец | Описание |
|
||||
|
|
@ -173,13 +158,9 @@ Auth: agent Group: - Status: ?
|
|||
| `F1` | Полная справка по приложению |
|
||||
| `Ctrl+Q` | Выход |
|
||||
|
||||
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
|
||||
изменения, сначала открывается безопасное подтверждение с выбранным Cancel.
|
||||
|
||||
### Быстрая справка по клавишам
|
||||
|
||||
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
|
||||
остаётся обычным вводом. `F1` открывает полную справку также из форм.
|
||||
Нажмите `?` на любом экране:
|
||||
|
||||
```
|
||||
sshkeeper — Quick Help
|
||||
|
|
@ -274,10 +255,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
|
||||
|
|
@ -292,13 +273,6 @@ Add Server
|
|||
Tab/↓: next | ↑: prev | /: pick list | Enter: select | Esc: back
|
||||
```
|
||||
|
||||
Поля с `*` обязательны. Порты принимают только десятичные значения от `1` до
|
||||
`65535`; ошибочный текст остаётся в поле рядом с понятным сообщением. На
|
||||
маленьком экране форма показывает окно полей вокруг текущего фокуса, сохраняя
|
||||
кнопки и подсказку видимыми.
|
||||
|
||||

|
||||
|
||||
**Навигация по форме:**
|
||||
|
||||
| Клавиша | Действие |
|
||||
|
|
@ -309,7 +283,7 @@ Add Server
|
|||
| `/` на Group | Выбрать из существующих групп |
|
||||
| `Enter` на Test | Проверить подключение |
|
||||
| `Enter` на Save | Сохранить |
|
||||
| `Esc` | Назад; при изменённых данных сначала запросить подтверждение сброса |
|
||||
| `Esc` | Отмена |
|
||||
|
||||
### Редактирование сервера
|
||||
|
||||
|
|
@ -323,10 +297,7 @@ Add Server
|
|||
1. Выберите сервер стрелками
|
||||
2. Нажмите `Ctrl+X` (меню действий)
|
||||
3. Выберите "Delete"
|
||||
4. Диалог по умолчанию выделяет **Cancel**. Нажмите `Tab`, затем `Enter`, чтобы удалить; `Esc` отменяет операцию. Быстрые клавиши `Y`/`N` также поддерживаются.
|
||||
|
||||
Диалог называет точный профиль и предупреждает, что вместе с ним удаляются
|
||||
сохранённые forwards и секреты vault.
|
||||
4. Подтвердите: `Enter` или `Y` — да, `Esc` или `N` — нет
|
||||
|
||||
### Тест подключения
|
||||
|
||||
|
|
@ -414,18 +385,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
|
||||
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
|
||||
|
||||
Selected
|
||||
Selected
|
||||
Port 127.0.0.1:15432 on this machine will be forwarded through web to 127.0.0.1:5432.
|
||||
ssh -L 127.0.0.1:15432:127.0.0.1:5432
|
||||
-L 127.0.0.1:15432:127.0.0.1:5432
|
||||
-o ExitOnForwardFailure=yes
|
||||
|
||||
Ctrl+A: add | Ctrl+E/Enter: edit | Ctrl+D: delete | Esc: back
|
||||
```
|
||||
|
||||
Строки и пояснение выбранного forward сокращаются по экранным ячейкам, а не
|
||||
по байтам, поэтому кириллица, CJK и emoji не ломают границы таблицы.
|
||||
|
||||
**Действия:**
|
||||
|
||||
| Клавиша | Действие |
|
||||
|
|
@ -443,15 +414,20 @@ Selected
|
|||
```
|
||||
Add Port Forward
|
||||
|
||||
Name *: Local PostgreSQL
|
||||
Name: Local PostgreSQL
|
||||
Description: optional
|
||||
|
||||
Type ● 1 Local ○ 2 Remote ○ 3 SOCKS
|
||||
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
|
||||
|
||||
Listen Address *: 127.0.0.1
|
||||
Listen Port *: 15432
|
||||
Target Host *: 127.0.0.1
|
||||
Target Port *: 5432
|
||||
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
|
||||
|
||||
Preview
|
||||
-L 127.0.0.1:15432:127.0.0.1:5432
|
||||
|
|
@ -462,11 +438,6 @@ Add Port Forward
|
|||
Tab/↓: next | ↑: prev | 1/2/3: select type | Enter: save | Esc: back
|
||||
```
|
||||
|
||||
Цифры `1`/`2`/`3` переключают тип только когда фокус находится на селекторе
|
||||
типа. В полях адреса и порта они вводятся как обычный текст.
|
||||
|
||||

|
||||
|
||||
**Поля зависят от типа:**
|
||||
|
||||
| Тип | Поля |
|
||||
|
|
@ -797,12 +768,8 @@ sshkeeper connect secure
|
|||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `Tab` / `Shift+Tab` / `←` / `→` | Переключить Cancel/действие |
|
||||
| `Enter` | Выполнить выделенный вариант; по умолчанию это Cancel |
|
||||
| `Y` | Подтвердить действие |
|
||||
| `Esc` / `N` | Отменить |
|
||||
|
||||

|
||||
| `Enter` / `Y` | Да |
|
||||
| `Esc` / `N` | Нет |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.8 KiB |
|
|
@ -1,131 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
# 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
|
|
@ -7,7 +7,6 @@ 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
|
||||
|
|
@ -20,6 +19,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -62,15 +62,11 @@ type saveDoneMsg struct {
|
|||
|
||||
type templatesLoadedMsg struct {
|
||||
templates []*model.CommandTemplate
|
||||
deleted bool
|
||||
deletedName string
|
||||
err error
|
||||
}
|
||||
|
||||
type tagsLoadedMsg struct {
|
||||
tags []string
|
||||
deleted bool
|
||||
deletedName string
|
||||
err error
|
||||
}
|
||||
|
||||
|
|
@ -98,19 +94,6 @@ type forwardDeletedMsg struct {
|
|||
err error
|
||||
}
|
||||
|
||||
type serverDeletedMsg struct {
|
||||
alias string
|
||||
servers []*model.Server
|
||||
deleted bool
|
||||
err error
|
||||
}
|
||||
|
||||
type quitAfterDiscardMsg struct{}
|
||||
|
||||
type discardFormMsg struct {
|
||||
origin screen
|
||||
}
|
||||
|
||||
type importDoneMsg struct {
|
||||
servers []*model.Server
|
||||
count int
|
||||
|
|
@ -196,7 +179,6 @@ var (
|
|||
DeleteForward func(forwardID int64) error
|
||||
ImportServers func() (int, error)
|
||||
LockVault func() error
|
||||
VaultUnlocked func() bool
|
||||
)
|
||||
|
||||
// --- Screen type ---
|
||||
|
|
@ -223,26 +205,6 @@ const (
|
|||
screenFullHelp
|
||||
)
|
||||
|
||||
type confirmChoice int
|
||||
|
||||
const (
|
||||
confirmCancel confirmChoice = iota
|
||||
confirmAccept
|
||||
)
|
||||
|
||||
type confirmState struct {
|
||||
title string
|
||||
target string
|
||||
consequence string
|
||||
verb string
|
||||
parent screen
|
||||
complete screen
|
||||
completeSet bool
|
||||
focus confirmChoice
|
||||
pending bool
|
||||
action func() tea.Cmd
|
||||
}
|
||||
|
||||
// --- Result type — returned from TUI to caller ---
|
||||
|
||||
type TUIResult struct {
|
||||
|
|
@ -282,10 +244,9 @@ type tuiModel struct {
|
|||
actionMenu *actionMenuModel
|
||||
forwardScreen *forwardScreenModel
|
||||
forwardForm *forwardFormModel
|
||||
confirm *confirmState
|
||||
confirmMsg string
|
||||
confirmAction func() tea.Cmd
|
||||
fullHelp *fullHelpModel
|
||||
helpParent screen
|
||||
vaultUnlocked bool
|
||||
}
|
||||
|
||||
func New(servers []*model.Server) *tuiModel {
|
||||
|
|
@ -313,11 +274,6 @@ func New(servers []*model.Server) *tuiModel {
|
|||
templateList.SetShowHelp(false)
|
||||
tagList := newStringList(nil, "Tags", 0, 0)
|
||||
|
||||
vaultIsUnlocked := true
|
||||
if VaultUnlocked != nil {
|
||||
vaultIsUnlocked = VaultUnlocked()
|
||||
}
|
||||
|
||||
return &tuiModel{
|
||||
screen: screenList,
|
||||
list: l,
|
||||
|
|
@ -327,7 +283,6 @@ func New(servers []*model.Server) *tuiModel {
|
|||
tagInput: tagInput,
|
||||
templateList: templateList,
|
||||
tagList: tagList,
|
||||
vaultUnlocked: vaultIsUnlocked,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -350,38 +305,6 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.form.width = msg.Width
|
||||
m.form.height = msg.Height
|
||||
}
|
||||
if m.templateForm != nil {
|
||||
m.templateForm.width = msg.Width
|
||||
m.templateForm.height = msg.Height
|
||||
}
|
||||
if m.forwardScreen != nil {
|
||||
m.forwardScreen.width = msg.Width
|
||||
m.forwardScreen.height = msg.Height
|
||||
}
|
||||
if m.forwardForm != nil {
|
||||
m.forwardForm.width = msg.Width
|
||||
m.forwardForm.height = msg.Height
|
||||
}
|
||||
if m.tunnelScreen != nil {
|
||||
m.tunnelScreen.width = msg.Width
|
||||
m.tunnelScreen.height = msg.Height
|
||||
m.tunnelScreen.list.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||
}
|
||||
if m.helpScreen != nil {
|
||||
updated, _ := m.helpScreen.Update(msg)
|
||||
if help, ok := updated.(*helpScreenModel); ok {
|
||||
m.helpScreen = help
|
||||
}
|
||||
}
|
||||
if m.fullHelp != nil {
|
||||
m.fullHelp.width = msg.Width
|
||||
m.fullHelp.height = msg.Height
|
||||
}
|
||||
if m.actionMenu != nil {
|
||||
m.actionMenu.width = msg.Width
|
||||
m.actionMenu.height = msg.Height
|
||||
m.actionMenu.list.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||
}
|
||||
m.templateList.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||
m.tagList.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||
return m, nil
|
||||
|
|
@ -400,34 +323,16 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
|
||||
case templatesLoadedMsg:
|
||||
if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTemplates {
|
||||
m.finishConfirm()
|
||||
}
|
||||
if msg.err != nil {
|
||||
if msg.deleted {
|
||||
m.removeTemplate(msg.deletedName)
|
||||
m.err = nil
|
||||
m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.deletedName, msg.err)
|
||||
} else {
|
||||
m.err = msg.err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.setTemplates(msg.templates)
|
||||
return m, nil
|
||||
|
||||
case tagsLoadedMsg:
|
||||
if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTags {
|
||||
m.finishConfirm()
|
||||
}
|
||||
if msg.err != nil {
|
||||
if msg.deleted {
|
||||
m.removeTag(msg.deletedName)
|
||||
m.err = nil
|
||||
m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.deletedName, msg.err)
|
||||
} else {
|
||||
m.err = msg.err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.setTags(msg.tags)
|
||||
|
|
@ -486,85 +391,20 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
|
||||
case forwardDeletedMsg:
|
||||
m.finishConfirm()
|
||||
if m.forwardScreen != nil {
|
||||
if msg.err != nil {
|
||||
m.forwardScreen.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.forwardScreen.err = nil
|
||||
forwards := make([]*model.Forward, 0, len(m.forwardScreen.list))
|
||||
for _, forward := range m.forwardScreen.list {
|
||||
if forward.ID != msg.id {
|
||||
forwards = append(forwards, forward)
|
||||
}
|
||||
}
|
||||
m.forwardScreen.list = forwards
|
||||
if m.forwardScreen.selected >= len(forwards) {
|
||||
m.forwardScreen.selected = max(0, len(forwards)-1)
|
||||
}
|
||||
if m.forwardScreen != nil && msg.err == nil {
|
||||
return m, m.forwardScreen.loadForwards()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case serverDeletedMsg:
|
||||
m.finishConfirm()
|
||||
if msg.deleted {
|
||||
m.removeServer(msg.alias)
|
||||
}
|
||||
if msg.err != nil {
|
||||
if msg.deleted {
|
||||
m.err = nil
|
||||
m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.alias, msg.err)
|
||||
} else {
|
||||
m.err = msg.err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.servers = msg.servers
|
||||
items := make([]list.Item, len(msg.servers))
|
||||
for i, server := range msg.servers {
|
||||
items[i] = serverItem{server: server}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
delete(m.selected, msg.alias)
|
||||
return m, nil
|
||||
|
||||
case quitAfterDiscardMsg:
|
||||
m.confirm = nil
|
||||
m.form = nil
|
||||
m.forwardForm = nil
|
||||
m.templateForm = nil
|
||||
return m, tea.Quit
|
||||
|
||||
case discardFormMsg:
|
||||
m.finishConfirm()
|
||||
switch msg.origin {
|
||||
case screenForm:
|
||||
m.form = nil
|
||||
case screenForwardForm:
|
||||
m.forwardForm = nil
|
||||
case screenTemplateForm:
|
||||
m.templateForm = nil
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case forwardDeleteConfirmMsg:
|
||||
m.beginConfirm(confirmState{
|
||||
title: "Delete port forward?",
|
||||
target: msg.name,
|
||||
consequence: "This removes the saved forwarding rule.",
|
||||
verb: "Delete",
|
||||
parent: screenForwardList,
|
||||
action: func() tea.Cmd {
|
||||
// Show confirmation screen
|
||||
m.confirmMsg = fmt.Sprintf("Delete forward %q?", msg.name)
|
||||
m.confirmAction = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if DeleteForward == nil {
|
||||
return forwardDeletedMsg{id: msg.id, err: fmt.Errorf("forward deletion is unavailable")}
|
||||
}
|
||||
return forwardDeletedMsg{id: msg.id, err: DeleteForward(msg.id)}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
m.screen = screenConfirm
|
||||
return m, nil
|
||||
|
||||
case forwardEditSignal:
|
||||
|
|
@ -590,13 +430,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
|
||||
case tunnelStoppedMsg:
|
||||
m.finishConfirm()
|
||||
if m.tunnelScreen != nil {
|
||||
if msg.err != nil {
|
||||
m.tunnelScreen.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.tunnelScreen.err = nil
|
||||
if m.tunnelScreen != nil && msg.err == nil {
|
||||
return m, m.tunnelScreen.loadTunnels()
|
||||
}
|
||||
return m, nil
|
||||
|
|
@ -632,7 +466,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case saveDoneMsg:
|
||||
if m.forwardForm != nil {
|
||||
if msg.err != nil {
|
||||
m.forwardForm.applySaveError(msg.err)
|
||||
m.forwardForm.err = msg.err
|
||||
m.forwardForm.saved = false
|
||||
// Stay on screenForwardForm to show error
|
||||
return m, nil
|
||||
|
|
@ -663,38 +497,17 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
if m.form != nil {
|
||||
m.form.saving = false
|
||||
if msg.err != nil {
|
||||
m.form.applySaveError(msg.err)
|
||||
m.form.err = msg.err
|
||||
m.form.saved = false
|
||||
} else {
|
||||
m.form.saved = true
|
||||
m.form.savedTime = time.Now()
|
||||
m.form.err = nil
|
||||
m.form.password.SetValue("")
|
||||
m.form.initial = m.form.snapshot()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.err != nil || m.success != "" {
|
||||
m.err = nil
|
||||
m.success = ""
|
||||
}
|
||||
if msg.Type == tea.KeyF1 && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
|
||||
m.helpParent = m.screen
|
||||
m.fullHelp = newFullHelpModel(m.width, m.height)
|
||||
m.screen = screenFullHelp
|
||||
return m, nil
|
||||
}
|
||||
if msg.Type == tea.KeyCtrlQ && m.screen != screenConfirm {
|
||||
return m.requestQuit()
|
||||
}
|
||||
if msg.Type == tea.KeyRunes && msg.String() == "?" && !m.screenOwnsPrintableInput() && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
|
||||
m.helpParent = m.screen
|
||||
m.helpScreen = newHelpScreenModel(m.width, m.height)
|
||||
m.screen = screenHelp
|
||||
return m, nil
|
||||
}
|
||||
switch m.screen {
|
||||
case screenList:
|
||||
return m.updateList(msg)
|
||||
|
|
@ -781,8 +594,14 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
|
||||
case tea.KeyCtrlD:
|
||||
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||
m.confirmServerDelete(item.server)
|
||||
return m, nil
|
||||
return m, func() tea.Msg {
|
||||
err := DeleteServer(item.server.Alias)
|
||||
if err != nil {
|
||||
return saveDoneMsg{err: err}
|
||||
}
|
||||
servers, err := ListServers()
|
||||
return serversLoadedMsg{servers: servers, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
case tea.KeyCtrlT:
|
||||
|
|
@ -811,12 +630,16 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
|
||||
case tea.KeyRunes:
|
||||
if msg.String() == "?" {
|
||||
m.helpParent = m.screen
|
||||
m.helpScreen = newHelpScreenModel(m.width, m.height)
|
||||
m.screen = screenHelp
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case tea.KeyF1:
|
||||
m.fullHelp = newFullHelpModel(m.width, m.height)
|
||||
m.screen = screenFullHelp
|
||||
return m, nil
|
||||
|
||||
case tea.KeyCtrlW:
|
||||
// Open forward manager for selected server
|
||||
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||
|
|
@ -839,33 +662,6 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
func (m *tuiModel) requestQuit() (tea.Model, tea.Cmd) {
|
||||
dirty := false
|
||||
switch m.screen {
|
||||
case screenForm:
|
||||
dirty = m.form != nil && m.form.Dirty()
|
||||
case screenForwardForm:
|
||||
dirty = m.forwardForm != nil && m.forwardForm.Dirty()
|
||||
case screenTemplateForm:
|
||||
dirty = m.templateForm != nil && m.templateForm.Dirty()
|
||||
}
|
||||
if !dirty {
|
||||
return m, tea.Quit
|
||||
}
|
||||
origin := m.screen
|
||||
m.beginConfirm(confirmState{
|
||||
title: "Discard changes and quit?",
|
||||
target: "Unsaved form changes",
|
||||
consequence: "Your edits will be lost before sshkeeper exits.",
|
||||
verb: "Quit",
|
||||
parent: origin,
|
||||
action: func() tea.Cmd {
|
||||
return func() tea.Msg { return quitAfterDiscardMsg{} }
|
||||
},
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *tuiModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
|
|
@ -918,31 +714,15 @@ func (m *tuiModel) updateTags(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, nil
|
||||
case tea.KeyCtrlD:
|
||||
if item, ok := m.tagList.SelectedItem().(groupItem); ok {
|
||||
if item, ok := m.tagList.SelectedItem().(groupItem); ok && DeleteTag != nil {
|
||||
name := item.name
|
||||
m.beginConfirm(confirmState{
|
||||
title: "Delete tag?",
|
||||
target: fmt.Sprintf("%q", name),
|
||||
consequence: "This removes the tag from every server profile.",
|
||||
verb: "Delete",
|
||||
parent: screenTags,
|
||||
action: func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if DeleteTag == nil {
|
||||
return tagsLoadedMsg{err: fmt.Errorf("tag deletion is unavailable")}
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
if err := DeleteTag(name); err != nil {
|
||||
return tagsLoadedMsg{err: err}
|
||||
}
|
||||
if ListTags == nil {
|
||||
return tagsLoadedMsg{deleted: true, deletedName: name, err: fmt.Errorf("tag reload is unavailable")}
|
||||
}
|
||||
tags, err := ListTags()
|
||||
return tagsLoadedMsg{tags: tags, deleted: true, deletedName: name, err: err}
|
||||
return tagsLoadedMsg{tags: tags, err: err}
|
||||
}
|
||||
},
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
case tea.KeyEnter:
|
||||
if item, ok := m.tagList.SelectedItem().(groupItem); ok && SetServerTags != nil {
|
||||
|
|
@ -1027,31 +807,15 @@ func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, nil
|
||||
case tea.KeyCtrlD:
|
||||
if item, ok := m.templateList.SelectedItem().(templateItem); ok {
|
||||
if item, ok := m.templateList.SelectedItem().(templateItem); ok && DeleteCommandTemplate != nil {
|
||||
name := item.template.Name
|
||||
m.beginConfirm(confirmState{
|
||||
title: "Delete command template?",
|
||||
target: fmt.Sprintf("%q", name),
|
||||
consequence: "This removes the saved command template.",
|
||||
verb: "Delete",
|
||||
parent: screenTemplates,
|
||||
action: func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if DeleteCommandTemplate == nil {
|
||||
return templatesLoadedMsg{err: fmt.Errorf("template deletion is unavailable")}
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
if err := DeleteCommandTemplate(name); err != nil {
|
||||
return templatesLoadedMsg{err: err}
|
||||
}
|
||||
if ListCommandTemplates == nil {
|
||||
return templatesLoadedMsg{deleted: true, deletedName: name, err: fmt.Errorf("template reload is unavailable")}
|
||||
}
|
||||
templates, err := ListCommandTemplates()
|
||||
return templatesLoadedMsg{templates: templates, deleted: true, deletedName: name, err: err}
|
||||
return templatesLoadedMsg{templates: templates, err: err}
|
||||
}
|
||||
},
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
|
|
@ -1061,10 +825,6 @@ func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
|
||||
func (m *tuiModel) updateTemplateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if msg.Type == tea.KeyEsc {
|
||||
if m.templateForm != nil && m.templateForm.Dirty() {
|
||||
m.confirmDiscard("Command template", screenTemplateForm, screenTemplates)
|
||||
return m, nil
|
||||
}
|
||||
m.screen = screenTemplates
|
||||
m.templateForm = nil
|
||||
return m, nil
|
||||
|
|
@ -1180,10 +940,6 @@ func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, cmd
|
||||
}
|
||||
if m.form != nil && m.form.Dirty() {
|
||||
m.confirmDiscard("Server profile", screenForm, screenList)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.screen = screenList
|
||||
m.form = nil
|
||||
|
|
@ -1203,9 +959,6 @@ func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *tuiModel) View() string {
|
||||
if classifyTerminal(m.width, m.height) == sizeBelowFloor {
|
||||
return minimumSizeView(m.width)
|
||||
}
|
||||
var b strings.Builder
|
||||
|
||||
switch m.screen {
|
||||
|
|
@ -1274,11 +1027,13 @@ func (m *tuiModel) View() string {
|
|||
b.WriteString(m.viewConfirm())
|
||||
}
|
||||
|
||||
if m.screen != screenList && m.err != nil {
|
||||
if m.err != nil {
|
||||
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||
m.err = nil
|
||||
}
|
||||
if m.screen != screenList && m.success != "" {
|
||||
if m.success != "" {
|
||||
b.WriteString("\n" + successStyle.Render(m.success))
|
||||
m.success = ""
|
||||
}
|
||||
|
||||
return b.String()
|
||||
|
|
@ -1290,8 +1045,8 @@ func (m *tuiModel) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
m.helpScreen = hs
|
||||
}
|
||||
// Esc or Enter closes help
|
||||
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter || (msg.Type == tea.KeyRunes && (msg.String() == "q" || msg.String() == "Q" || msg.String() == "?")) {
|
||||
m.screen = m.helpParent
|
||||
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter {
|
||||
m.screen = screenList
|
||||
m.helpScreen = nil
|
||||
return m, nil
|
||||
}
|
||||
|
|
@ -1386,9 +1141,16 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
case "delete":
|
||||
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||
m.screen = screenList
|
||||
m.actionMenu = nil
|
||||
m.confirmServerDelete(item.server)
|
||||
return m, nil
|
||||
return m, func() tea.Msg {
|
||||
err := DeleteServer(item.server.Alias)
|
||||
if err != nil {
|
||||
return saveDoneMsg{err: err}
|
||||
}
|
||||
servers, err := ListServers()
|
||||
return serversLoadedMsg{servers: servers, err: err}
|
||||
}
|
||||
}
|
||||
case "import":
|
||||
m.screen = screenList
|
||||
|
|
@ -1416,7 +1178,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
} else if err := LockVault(); err != nil {
|
||||
m.err = err
|
||||
} else {
|
||||
m.vaultUnlocked = false
|
||||
m.success = "Vault locked."
|
||||
}
|
||||
case "vault_change_pw":
|
||||
|
|
@ -1446,7 +1207,13 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
case tea.KeyCtrlD:
|
||||
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
||||
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
||||
m.confirmForwardDelete(fwd)
|
||||
m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name)
|
||||
m.confirmAction = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)}
|
||||
}
|
||||
}
|
||||
m.screen = screenConfirm
|
||||
return m, nil
|
||||
}
|
||||
case tea.KeyCtrlE, tea.KeyEnter:
|
||||
|
|
@ -1464,7 +1231,13 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
case "d", "D":
|
||||
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
||||
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
||||
m.confirmForwardDelete(fwd)
|
||||
m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name)
|
||||
m.confirmAction = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)}
|
||||
}
|
||||
}
|
||||
m.screen = screenConfirm
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1489,8 +1262,9 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
m.tunnelScreen = nil
|
||||
return m, nil
|
||||
case tea.KeyCtrlD:
|
||||
m.confirmTunnelStop()
|
||||
return m, nil
|
||||
if m.tunnelScreen != nil {
|
||||
return m, m.tunnelScreen.stopSelected()
|
||||
}
|
||||
case tea.KeyCtrlR:
|
||||
if m.tunnelScreen != nil {
|
||||
return m, m.tunnelScreen.loadTunnels()
|
||||
|
|
@ -1498,8 +1272,9 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
case tea.KeyRunes:
|
||||
switch msg.String() {
|
||||
case "d", "D", "s", "S":
|
||||
m.confirmTunnelStop()
|
||||
return m, nil
|
||||
if m.tunnelScreen != nil {
|
||||
return m, m.tunnelScreen.stopSelected()
|
||||
}
|
||||
case "r", "R":
|
||||
if m.tunnelScreen != nil {
|
||||
return m, m.tunnelScreen.loadTunnels()
|
||||
|
|
@ -1512,212 +1287,49 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *tuiModel) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.confirm == nil {
|
||||
m.screen = screenList
|
||||
return m, nil
|
||||
}
|
||||
if m.confirm.pending {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
return m.cancelConfirm()
|
||||
case tea.KeyTab, tea.KeyShiftTab, tea.KeyLeft, tea.KeyRight:
|
||||
if m.confirm.focus == confirmCancel {
|
||||
m.confirm.focus = confirmAccept
|
||||
} else {
|
||||
m.confirm.focus = confirmCancel
|
||||
}
|
||||
m.screen = screenList
|
||||
m.confirmMsg = ""
|
||||
m.confirmAction = nil
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
if m.confirm.focus == confirmCancel {
|
||||
return m.cancelConfirm()
|
||||
if m.confirmAction != nil {
|
||||
action := m.confirmAction
|
||||
m.confirmMsg = ""
|
||||
m.confirmAction = nil
|
||||
return m, action()
|
||||
}
|
||||
return m.acceptConfirm()
|
||||
case tea.KeyRunes:
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
return m.acceptConfirm()
|
||||
if m.confirmAction != nil {
|
||||
action := m.confirmAction
|
||||
m.confirmMsg = ""
|
||||
m.confirmAction = nil
|
||||
return m, action()
|
||||
}
|
||||
case "n", "N":
|
||||
return m.cancelConfirm()
|
||||
m.screen = screenList
|
||||
m.confirmMsg = ""
|
||||
m.confirmAction = nil
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *tuiModel) viewConfirm() string {
|
||||
if m.confirm == nil {
|
||||
return ""
|
||||
}
|
||||
width := m.width
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
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 ]"
|
||||
accept := "[ " + m.confirm.verb + " ]"
|
||||
if m.confirm.focus == confirmCancel {
|
||||
cancel = selectedStyle.Render("> " + cancel)
|
||||
} else {
|
||||
accept = errorStyle.Render("> " + accept)
|
||||
}
|
||||
if m.confirm.pending {
|
||||
lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "")
|
||||
} else {
|
||||
lines = append(lines, fitLine(" "+cancel+" "+accept, width), "")
|
||||
}
|
||||
footer := renderHelp([]helpItem{
|
||||
{Key: "Tab", Action: "choose"},
|
||||
{Key: "Enter", Action: "activate"},
|
||||
{Key: "Esc", Action: "cancel"},
|
||||
}, width)
|
||||
lines = append(lines, strings.Split(footer, "\n")...)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m *tuiModel) beginConfirm(state confirmState) {
|
||||
state.focus = confirmCancel
|
||||
m.confirm = &state
|
||||
m.screen = screenConfirm
|
||||
}
|
||||
|
||||
func (m *tuiModel) cancelConfirm() (tea.Model, tea.Cmd) {
|
||||
parent := m.confirm.parent
|
||||
m.confirm = nil
|
||||
m.screen = parent
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *tuiModel) acceptConfirm() (tea.Model, tea.Cmd) {
|
||||
if m.confirm == nil || m.confirm.pending || m.confirm.action == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.confirm.pending = true
|
||||
return m, m.confirm.action()
|
||||
}
|
||||
|
||||
func (m *tuiModel) finishConfirm() {
|
||||
if m.confirm == nil {
|
||||
return
|
||||
}
|
||||
destination := m.confirm.parent
|
||||
if m.confirm.completeSet {
|
||||
destination = m.confirm.complete
|
||||
}
|
||||
m.screen = destination
|
||||
m.confirm = nil
|
||||
}
|
||||
|
||||
func (m *tuiModel) confirmDiscard(title string, origin, destination screen) {
|
||||
m.beginConfirm(confirmState{
|
||||
title: "Discard unsaved changes?",
|
||||
target: title,
|
||||
consequence: "Your edits on this form will be lost.",
|
||||
verb: "Discard",
|
||||
parent: origin,
|
||||
complete: destination,
|
||||
completeSet: true,
|
||||
action: func() tea.Cmd {
|
||||
return func() tea.Msg { return discardFormMsg{origin: origin} }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *tuiModel) confirmServerDelete(server *model.Server) {
|
||||
alias := server.Alias
|
||||
m.beginConfirm(confirmState{
|
||||
title: "Delete server profile?",
|
||||
target: fmt.Sprintf("%q", alias),
|
||||
consequence: "This also removes its saved port forwards and vault secrets.",
|
||||
verb: "Delete",
|
||||
parent: screenList,
|
||||
action: func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if DeleteServer == nil {
|
||||
return serverDeletedMsg{alias: alias, err: fmt.Errorf("server deletion is unavailable")}
|
||||
}
|
||||
if err := DeleteServer(alias); err != nil {
|
||||
return serverDeletedMsg{alias: alias, err: err}
|
||||
}
|
||||
if ListServers == nil {
|
||||
return serverDeletedMsg{alias: alias, deleted: true, err: fmt.Errorf("server reload is unavailable")}
|
||||
}
|
||||
servers, err := ListServers()
|
||||
return serverDeletedMsg{alias: alias, servers: servers, deleted: true, err: err}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *tuiModel) removeServer(alias string) {
|
||||
servers := make([]*model.Server, 0, len(m.servers))
|
||||
for _, server := range m.servers {
|
||||
if server.Alias != alias {
|
||||
servers = append(servers, server)
|
||||
}
|
||||
}
|
||||
m.servers = servers
|
||||
items := make([]list.Item, len(servers))
|
||||
for index, server := range servers {
|
||||
items[index] = serverItem{server: server}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
delete(m.selected, alias)
|
||||
}
|
||||
|
||||
func (m *tuiModel) confirmForwardDelete(fwd *model.Forward) {
|
||||
name := fwd.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = fwd.ForwardListen()
|
||||
}
|
||||
id := fwd.ID
|
||||
m.beginConfirm(confirmState{
|
||||
title: "Delete port forward?",
|
||||
target: fmt.Sprintf("%q · %s → %s", name, fwd.ForwardListen(), fwd.ForwardTarget()),
|
||||
consequence: "This removes the saved forwarding rule. Active tunnels are not stopped.",
|
||||
verb: "Delete",
|
||||
parent: screenForwardList,
|
||||
action: func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if DeleteForward == nil {
|
||||
return forwardDeletedMsg{id: id, err: fmt.Errorf("forward deletion is unavailable")}
|
||||
}
|
||||
return forwardDeletedMsg{id: id, err: DeleteForward(id)}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *tuiModel) confirmTunnelStop() {
|
||||
if m.tunnelScreen == nil {
|
||||
return
|
||||
}
|
||||
item, ok := m.tunnelScreen.list.SelectedItem().(tunnelItem)
|
||||
if !ok || item.state == nil {
|
||||
return
|
||||
}
|
||||
state := item.state
|
||||
m.beginConfirm(confirmState{
|
||||
title: "Stop running tunnel?",
|
||||
target: fmt.Sprintf("%q · PID %d · %s", state.Name, state.PID, state.ServerAlias),
|
||||
consequence: "Active forwarded connections through this process will close.",
|
||||
verb: "Stop",
|
||||
parent: screenTunnelManager,
|
||||
action: func() tea.Cmd {
|
||||
return m.tunnelScreen.stopSelected()
|
||||
},
|
||||
})
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Confirm"))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(" " + m.confirmMsg)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(renderHelp([]helpItem{
|
||||
{Key: "Enter / Y", Action: "yes"},
|
||||
{Key: "Esc / N", Action: "no"},
|
||||
}, m.width))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
|
@ -1725,29 +1337,16 @@ func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
if fh, ok := updated.(*fullHelpModel); ok {
|
||||
m.fullHelp = fh
|
||||
}
|
||||
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter || (msg.Type == tea.KeyRunes && (msg.String() == "q" || msg.String() == "Q")) {
|
||||
m.screen = m.helpParent
|
||||
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter {
|
||||
m.screen = screenList
|
||||
m.fullHelp = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *tuiModel) screenOwnsPrintableInput() bool {
|
||||
switch m.screen {
|
||||
case screenForm, screenSearch, screenTagInput, screenTemplateForm, screenForwardForm:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if msg.Type == tea.KeyEsc {
|
||||
if m.forwardForm != nil && m.forwardForm.Dirty() {
|
||||
m.confirmDiscard("Port forward", screenForwardForm, screenForwardList)
|
||||
return m, nil
|
||||
}
|
||||
m.screen = screenForwardList
|
||||
m.forwardForm = nil
|
||||
return m, nil
|
||||
|
|
@ -1768,7 +1367,97 @@ func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *tuiModel) viewServerList() string {
|
||||
return m.renderServerDashboard()
|
||||
var b strings.Builder
|
||||
selectedAlias := ""
|
||||
if item, ok := m.list.SelectedItem().(serverItem); ok && item.server != nil {
|
||||
selectedAlias = item.server.Alias
|
||||
}
|
||||
|
||||
b.WriteString(titleStyle.Render(fmt.Sprintf("sshkeeper %d servers", len(m.servers))))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("Vault unlocked | %s", testSummary(m.servers))))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-20s %-20s %-34s %-12s %-10s %s", "NAME", "ALIAS", "ROUTE", "AUTH", "GROUP", "STATUS")))
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(m.servers) == 0 {
|
||||
b.WriteString(helpStyle.Render(" No servers yet. Press Ctrl+A to add one."))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
selectedIndex := m.list.Index()
|
||||
start, end := visibleServerRange(len(m.servers), selectedIndex, m.visibleServerRows())
|
||||
for _, server := range m.servers[start:end] {
|
||||
marker := " "
|
||||
rowStyle := normalStyle
|
||||
if server.Alias == selectedAlias {
|
||||
marker = ">"
|
||||
rowStyle = selectedRowStyle
|
||||
}
|
||||
if m.selected[server.Alias] {
|
||||
marker = "*"
|
||||
if server.Alias == selectedAlias {
|
||||
marker = ">*"
|
||||
}
|
||||
}
|
||||
name := server.DisplayName
|
||||
if name == "" {
|
||||
name = server.Alias
|
||||
}
|
||||
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
|
||||
routeStr := server.Route.DisplaySummary(target)
|
||||
// Add visual icon prefix based on connection type
|
||||
if len(server.Route.Hops) == 0 {
|
||||
routeStr = " " + routeStr // direct
|
||||
} else {
|
||||
routeStr = "→ " + routeStr // via/chain
|
||||
}
|
||||
// If too long, collapse middle hops
|
||||
if len(routeStr) > 34 && len(server.Route.Hops) > 2 {
|
||||
first := server.Route.Hops[0]
|
||||
firstName := first.Alias
|
||||
if !first.IsProfile {
|
||||
firstName = first.Raw
|
||||
}
|
||||
routeStr = fmt.Sprintf("→ %s → … → %s", firstName, truncate(target, 34-len(firstName)-8))
|
||||
}
|
||||
group := server.GroupName
|
||||
if group == "" {
|
||||
group = "-"
|
||||
}
|
||||
row := fmt.Sprintf("%s %-20s %-20s %-34s %-12s %-10s %s",
|
||||
marker,
|
||||
truncate(name, 20),
|
||||
truncate(server.Alias, 20),
|
||||
truncate(routeStr, 34),
|
||||
authLabel(server.AuthMethod),
|
||||
truncate(group, 10),
|
||||
testStatusLabel(server),
|
||||
)
|
||||
b.WriteString(rowStyle.Render(row))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(m.servers) > end-start {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf(" Showing %d-%d of %d", start+1, end, len(m.servers))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
if selectedAlias != "" {
|
||||
if selected := m.selectedServer(); selected != nil {
|
||||
b.WriteString(m.viewSelectedServer(selected))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(m.bgResults) > 0 {
|
||||
b.WriteString(m.viewInlineBackgroundResults())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
selectedCount := len(m.selectedServers())
|
||||
footer := m.renderListHelp(selectedCount, len(m.bgResults) > 0)
|
||||
b.WriteString(strings.Repeat("\n", bottomPaddingLines(b.String(), footer, m.height)))
|
||||
b.WriteString(footer)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *tuiModel) viewInlineBackgroundResults() string {
|
||||
|
|
@ -2111,26 +1800,6 @@ func (m *tuiModel) setTags(tags []string) {
|
|||
m.tagList = newStringList(tags, "Tags", m.width, managerListHeight(m.height))
|
||||
}
|
||||
|
||||
func (m *tuiModel) removeTemplate(name string) {
|
||||
templates := make([]*model.CommandTemplate, 0, len(m.templates))
|
||||
for _, template := range m.templates {
|
||||
if template.Name != name {
|
||||
templates = append(templates, template)
|
||||
}
|
||||
}
|
||||
m.setTemplates(templates)
|
||||
}
|
||||
|
||||
func (m *tuiModel) removeTag(name string) {
|
||||
tags := make([]string, 0, len(m.tags))
|
||||
for _, tag := range m.tags {
|
||||
if tag != name {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
m.setTags(tags)
|
||||
}
|
||||
|
||||
// --- Server list footer ---
|
||||
|
||||
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
|
||||
|
|
@ -2263,7 +1932,10 @@ func displayLineCount(s string) int {
|
|||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
return truncateCells(s, maxLen)
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package tui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -63,12 +62,6 @@ 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 {
|
||||
|
|
@ -92,7 +85,6 @@ 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"
|
||||
|
|
@ -130,7 +122,6 @@ func newFormModel(w, h int) *formModel {
|
|||
}
|
||||
|
||||
fm.updateFocus()
|
||||
fm.initial = fm.snapshot()
|
||||
return fm
|
||||
}
|
||||
|
||||
|
|
@ -211,31 +202,9 @@ 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
|
||||
}
|
||||
|
|
@ -257,7 +226,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case saveDoneMsg:
|
||||
fm.saving = false
|
||||
if msg.err != nil {
|
||||
fm.applySaveError(msg.err)
|
||||
fm.err = msg.err
|
||||
fm.saved = false
|
||||
} else {
|
||||
fm.saved = true
|
||||
|
|
@ -401,25 +370,6 @@ 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()
|
||||
|
|
@ -439,14 +389,6 @@ 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)"
|
||||
}
|
||||
|
|
@ -467,10 +409,6 @@ 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()
|
||||
|
||||
|
|
@ -496,9 +434,6 @@ 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()
|
||||
|
||||
|
|
@ -547,7 +482,8 @@ func parseRouteHops(input string) model.Route {
|
|||
}
|
||||
|
||||
func (fm *formModel) buildServer() *model.Server {
|
||||
port, _ := parsePort(fm.inputs[3].Value())
|
||||
port := 22
|
||||
fmt.Sscanf(fm.inputs[3].Value(), "%d", &port)
|
||||
authMethod := model.AuthMethod(fm.inputs[5].Value())
|
||||
if authMethod == "" {
|
||||
authMethod = model.AuthKey
|
||||
|
|
@ -572,131 +508,131 @@ func (fm *formModel) buildServer() *model.Server {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
var b strings.Builder
|
||||
|
||||
title := "Add Server"
|
||||
if fm.edit {
|
||||
title = "Edit Server: " + fm.server.Alias
|
||||
}
|
||||
footer := renderHelp([]helpItem{
|
||||
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{
|
||||
{Key: "Tab/↓", Action: "next"},
|
||||
{Key: "↑", Action: "prev"},
|
||||
{Key: "/", Action: "pick list"},
|
||||
{Key: "Enter", Action: "select"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
}, fm.width)
|
||||
}, fm.width))
|
||||
|
||||
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 ""
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderDropdown(l list.Model) string {
|
||||
|
|
|
|||
|
|
@ -1,275 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,93 +79,65 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
|
|||
}
|
||||
|
||||
func (m *forwardScreenModel) View() string {
|
||||
footer := renderHelp([]helpItem{
|
||||
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{
|
||||
{Key: "Ctrl+A (a)", Action: "add"},
|
||||
{Key: "Ctrl+E/Enter", Action: "edit"},
|
||||
{Key: "Ctrl+D (d)", Action: "delete"},
|
||||
{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))
|
||||
}
|
||||
|
||||
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)
|
||||
}, m.width))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- Forward form screen model ---
|
||||
|
|
@ -185,14 +157,6 @@ 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{
|
||||
|
|
@ -218,7 +182,7 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
|
|||
inputs[i].CharLimit = 128
|
||||
}
|
||||
|
||||
fm := &forwardFormModel{
|
||||
return &forwardFormModel{
|
||||
serverID: serverID,
|
||||
inputs: inputs,
|
||||
focusIdx: 0,
|
||||
|
|
@ -229,9 +193,6 @@ 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 {
|
||||
|
|
@ -242,48 +203,13 @@ 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)
|
||||
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:
|
||||
|
|
@ -328,14 +254,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.applySaveError(msg.err)
|
||||
fm.err = msg.err
|
||||
return fm, nil
|
||||
}
|
||||
|
||||
|
|
@ -439,7 +365,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 {
|
||||
|
|
@ -451,7 +377,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> ")
|
||||
|
|
@ -469,57 +395,47 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
|
|||
return func() tea.Msg {
|
||||
name := strings.TrimSpace(fm.nameInput.Value())
|
||||
desc := strings.TrimSpace(fm.descInput.Value())
|
||||
localAddr, remoteAddr := "", ""
|
||||
localPort, remotePort := 0, 0
|
||||
var err error
|
||||
|
||||
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())
|
||||
|
||||
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")}
|
||||
}
|
||||
remotePort, err = parseNamedPort("Target port", fm.inputs[3].Value())
|
||||
if err != nil {
|
||||
return saveDoneMsg{err: err}
|
||||
if remotePort < 1 || remotePort > 65535 {
|
||||
return saveDoneMsg{err: fmt.Errorf("invalid target port %d: must be 1-65535", remotePort)}
|
||||
}
|
||||
case model.ForwardRemote:
|
||||
remoteAddr = strings.TrimSpace(fm.inputs[0].Value())
|
||||
if remoteAddr == "" {
|
||||
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")}
|
||||
}
|
||||
remotePort, err = parseNamedPort("Remote listen port", fm.inputs[1].Value())
|
||||
if err != nil {
|
||||
return saveDoneMsg{err: err}
|
||||
if remotePort < 1 || remotePort > 65535 {
|
||||
return saveDoneMsg{err: fmt.Errorf("invalid remote port %d: must be 1-65535", remotePort)}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -547,87 +463,70 @@ 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"
|
||||
}
|
||||
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),
|
||||
)
|
||||
b.WriteString(titleStyle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
typeParts := make([]string, len(forwardTypes))
|
||||
for i, forwardType := range forwardTypes {
|
||||
selected := "○"
|
||||
// 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
|
||||
if i == fm.typeIdx {
|
||||
selected = "●"
|
||||
prefix = "▸ "
|
||||
style = selectedRowStyle
|
||||
}
|
||||
focus := " "
|
||||
if fm.focusIdx == 2+i {
|
||||
focus = ">"
|
||||
line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description)
|
||||
b.WriteString(style.Render(line))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
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.",
|
||||
}
|
||||
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)))
|
||||
if exp, ok := explanations[forwardTypes[fm.typeIdx].value]; ok {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf(" %s\n", exp)))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Dynamic fields based on type
|
||||
visible := fm.visibleFields()
|
||||
for _, idx := range visible {
|
||||
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
|
||||
b.WriteString(fm.inputs[idx].View())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Warning for 0.0.0.0
|
||||
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)))
|
||||
b.WriteString(helpStyle.Render(" ⚠ This port will be accessible from the network.\n"))
|
||||
}
|
||||
|
||||
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
|
||||
// Preview
|
||||
if fm.currentType != "" && fm.inputs[1].Value() != "" {
|
||||
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
|
||||
fwd := &model.Forward{
|
||||
Type: fm.currentType,
|
||||
LocalAddr: fm.inputs[0].Value(),
|
||||
|
|
@ -637,34 +536,37 @@ func (fm *forwardFormModel) View() string {
|
|||
}
|
||||
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
|
||||
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
||||
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
||||
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
|
||||
for _, arg := range fwd.ForwardSSHArgs() {
|
||||
b.WriteString(" " + arg + "\n")
|
||||
}
|
||||
b.WriteString(" -o ExitOnForwardFailure=yes\n")
|
||||
}
|
||||
|
||||
// Save button
|
||||
total := 2 + 3 + len(visible) + 1
|
||||
button := " [ Save ]"
|
||||
button := "\n[ Save ]"
|
||||
if fm.focusIdx == total-1 {
|
||||
button = selectedStyle.Render("> [ Save ]")
|
||||
button = selectedStyle.Render(button)
|
||||
}
|
||||
b.WriteString(button)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if fm.err != nil {
|
||||
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n")
|
||||
}
|
||||
if fm.saved {
|
||||
lines = append(lines, successStyle.Render("✓ Saved."))
|
||||
b.WriteString(successStyle.Render("✓ Saved.") + "\n\n")
|
||||
}
|
||||
lines = append(lines, button)
|
||||
footer := renderHelp([]helpItem{
|
||||
|
||||
b.WriteString(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)
|
||||
lines = append(lines, strings.Split(footer, "\n")...)
|
||||
if len(lines) > fm.height && fm.height > 0 {
|
||||
lines = lines[:fm.height]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}, fm.width))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// forwardEditSignal is sent when user wants to edit a forward
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package tui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
|
@ -27,51 +26,6 @@ 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 = ©
|
||||
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
|
||||
|
|
|
|||
|
|
@ -274,7 +274,6 @@ func (i actionMenuItem) FilterValue() string { return i.label }
|
|||
type actionMenuModel struct {
|
||||
list list.Model
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newActionMenuModel(w, h int) *actionMenuModel {
|
||||
|
|
@ -302,7 +301,7 @@ func newActionMenuModel(w, h int) *actionMenuModel {
|
|||
l.SetShowHelp(false)
|
||||
l.Styles.Title = titleStyle
|
||||
|
||||
return &actionMenuModel{list: l, width: w, height: h}
|
||||
return &actionMenuModel{list: l, width: w}
|
||||
}
|
||||
|
||||
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
||||
|
|
@ -324,24 +323,5 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
|||
}
|
||||
|
||||
func (m *actionMenuModel) View() string {
|
||||
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")
|
||||
return m.list.View()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
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:])
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ type templateFormModel struct {
|
|||
saved bool
|
||||
width int
|
||||
height int
|
||||
initial []string
|
||||
}
|
||||
|
||||
func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel {
|
||||
|
|
@ -45,31 +44,9 @@ 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
|
||||
}
|
||||
|
|
@ -112,21 +89,14 @@ 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.labelAt(i) + ": ")
|
||||
tf.inputs[i].Prompt = blurredStyle.Render(tf.labels[i] + ": ")
|
||||
}
|
||||
if tf.focusIdx < len(tf.inputs) {
|
||||
tf.inputs[tf.focusIdx].Focus()
|
||||
tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labelAt(tf.focusIdx) + "> ")
|
||||
tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labels[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 {
|
||||
|
|
@ -156,15 +126,15 @@ func (tf *templateFormModel) View() string {
|
|||
if tf.edit {
|
||||
title = "Edit Template"
|
||||
}
|
||||
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
|
||||
b.WriteString(titleStyle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
for i := range tf.inputs {
|
||||
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
|
||||
b.WriteString(tf.inputs[i].View())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
button := " [ Save ]"
|
||||
button := "[ Save ]"
|
||||
if tf.focusIdx == len(tf.inputs) {
|
||||
button = selectedStyle.Render("> [ Save ]")
|
||||
button = selectedStyle.Render(button)
|
||||
}
|
||||
b.WriteString("\n" + button + "\n\n")
|
||||
if tf.err != nil {
|
||||
|
|
|
|||
|
|
@ -97,10 +97,6 @@ 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"},
|
||||
|
|
|
|||