Compare commits
7 Commits
10bcc07601
...
be800de90b
| Author | SHA1 | Date |
|---|---|---|
|
|
be800de90b | |
|
|
dac7c7d37a | |
|
|
460b4496a0 | |
|
|
16f4f9937f | |
|
|
37c4fd34a9 | |
|
|
84a61d4895 | |
|
|
5391612a14 |
32
README.md
|
|
@ -96,20 +96,20 @@ Running `sshkeeper` without arguments opens the TUI.
|
||||||
### Main Window
|
### Main Window
|
||||||
|
|
||||||
```
|
```
|
||||||
sshkeeper 0 servers
|
sshkeeper / Servers Vault unlocked · 1 profiles
|
||||||
Vault unlocked | 0 OK | 0 FAIL
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
NAME ALIAS ROUTE AUTH GROUP STATUS
|
│1 servers │
|
||||||
|
│ NAME AUTH GROUP STATUS │
|
||||||
No servers yet. Press Ctrl+A to add one.
|
│> Production agent - ? │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit
|
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit | Ctrl+Q: quit
|
||||||
Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Quick Help (?)
|
### Quick Help (?)
|
||||||
|
|
||||||
Press `?` on any screen for a compact hotkey reference.
|
Press `?` outside text editors for a compact hotkey reference. Inside forms and
|
||||||
|
search, `?` remains normal text input.
|
||||||
|
|
||||||
### Full Help (F1)
|
### Full Help (F1)
|
||||||
|
|
||||||
|
|
@ -118,13 +118,13 @@ forwarding, tunnels, and vault.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
| Main list | Actions | Route edit |
|
| Wide dashboard | Dashboard 80x24 | Server form 60x16 |
|
||||||
|-----------|---------|------------|
|
|----------------|-----------------|-------------------|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
| Port forwards | Tunnel manager |
|
| Port forward form | Safe confirmation |
|
||||||
|---------------|----------------|
|
|-------------------|-------------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
### Key Reference
|
### Key Reference
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,9 @@ func runTUI() error {
|
||||||
v.Lock()
|
v.Lock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
tui.VaultUnlocked = func() bool {
|
||||||
|
return getOrCreateVault().IsUnlocked()
|
||||||
|
}
|
||||||
tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error {
|
tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error {
|
||||||
return appDB.UpdateTestResult(alias, status, testErr)
|
return appDB.UpdateTestResult(alias, status, testErr)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,17 +121,32 @@ Vault created and unlocked for this command. You're ready to go!
|
||||||
### Главный экран
|
### Главный экран
|
||||||
|
|
||||||
```
|
```
|
||||||
sshkeeper 0 servers
|
sshkeeper / Servers Vault unlocked · 1 profiles
|
||||||
Vault unlocked | 0 OK | 0 FAIL
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
NAME ALIAS ROUTE AUTH GROUP STATUS
|
│1 servers │
|
||||||
|
│ NAME AUTH GROUP STATUS │
|
||||||
No servers yet. Press Ctrl+A to add one.
|
│> Production agent - ? │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit
|
Selected profile
|
||||||
Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
|
Alias: prod Target: ops@prod.example:22
|
||||||
|
Auth: agent Group: - Status: ?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Интерфейс адаптируется к ширине терминала:
|
||||||
|
|
||||||
|
- от 100 столбцов список и подробности выбранного профиля показаны в двух панелях;
|
||||||
|
- от 70 до 99 столбцов подробности переносятся под список;
|
||||||
|
- от 60 до 69 столбцов остаётся компактная таблица без второстепенных полей;
|
||||||
|
- минимальный поддерживаемый размер — `60x16`; ниже показывается сообщение о необходимом размере.
|
||||||
|
|
||||||
|
Статус vault в заголовке отражает реальное состояние. Выбор и фокус всегда
|
||||||
|
обозначены текстовым маркером `>`, поэтому интерфейс остаётся понятным без цвета.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
**Столбцы:**
|
**Столбцы:**
|
||||||
|
|
||||||
| Столбец | Описание |
|
| Столбец | Описание |
|
||||||
|
|
@ -158,9 +173,13 @@ Vault unlocked | 0 OK | 0 FAIL
|
||||||
| `F1` | Полная справка по приложению |
|
| `F1` | Полная справка по приложению |
|
||||||
| `Ctrl+Q` | Выход |
|
| `Ctrl+Q` | Выход |
|
||||||
|
|
||||||
|
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
|
||||||
|
изменения, сначала открывается безопасное подтверждение с выбранным Cancel.
|
||||||
|
|
||||||
### Быстрая справка по клавишам
|
### Быстрая справка по клавишам
|
||||||
|
|
||||||
Нажмите `?` на любом экране:
|
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
|
||||||
|
остаётся обычным вводом. `F1` открывает полную справку также из форм.
|
||||||
|
|
||||||
```
|
```
|
||||||
sshkeeper — Quick Help
|
sshkeeper — Quick Help
|
||||||
|
|
@ -255,10 +274,10 @@ sshkeeper — Full Help
|
||||||
```
|
```
|
||||||
Add Server
|
Add Server
|
||||||
|
|
||||||
Alias: mail.kp
|
Alias *: mail.kp
|
||||||
Display Name: Production mail
|
Display Name: Production mail
|
||||||
Host: mail.example.org
|
Host *: mail.example.org
|
||||||
Port: 22
|
Port *: 22
|
||||||
User: root
|
User: root
|
||||||
Auth Method: key
|
Auth Method: key
|
||||||
Identity File: ~/.ssh/id_ed25519
|
Identity File: ~/.ssh/id_ed25519
|
||||||
|
|
@ -273,6 +292,13 @@ Add Server
|
||||||
Tab/↓: next | ↑: prev | /: pick list | Enter: select | Esc: back
|
Tab/↓: next | ↑: prev | /: pick list | Enter: select | Esc: back
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Поля с `*` обязательны. Порты принимают только десятичные значения от `1` до
|
||||||
|
`65535`; ошибочный текст остаётся в поле рядом с понятным сообщением. На
|
||||||
|
маленьком экране форма показывает окно полей вокруг текущего фокуса, сохраняя
|
||||||
|
кнопки и подсказку видимыми.
|
||||||
|
|
||||||
|

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

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

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 25 KiB |
|
|
@ -0,0 +1,131 @@
|
||||||
|
# sshkeeper TUI UX Redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Implement the approved safe, truthful, responsive sshkeeper TUI and verify it through automated state transitions and real terminal screenshots.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing Bubble Tea v1 root model and screen enum. Add explicit overlay return/pending state and small pure layout helpers; route keys from overlays and editors outward; keep persistence and process callbacks at the existing command boundary.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.25, Bubble Tea v1.3.10, Bubbles v1.0.0, Lip Gloss v1.1.0, tmux-based runtime capture.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Do not migrate Bubble Tea or add dependencies.
|
||||||
|
- Preserve CLI behavior and callback boundaries.
|
||||||
|
- Supported terminal floor is 60x16.
|
||||||
|
- Rendering must not mutate model state.
|
||||||
|
- Printable runes belong to focused editors.
|
||||||
|
- Every behavior change follows red-green TDD.
|
||||||
|
- Each task is committed and pushed to `codex/tui-ux-redesign` after focused and full tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Safe destructive action state machine
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/tui/app.go`
|
||||||
|
- Modify: `internal/tui/forward.go`
|
||||||
|
- Modify: `internal/tui/app_test.go`
|
||||||
|
- Create: `internal/tui/confirm_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `confirmState` with parent screen, message, consequence, focus, pending, action, and cancel/success return states.
|
||||||
|
- Consumes: existing delete/stop callbacks and result messages.
|
||||||
|
|
||||||
|
- [ ] Add failing tests proving server deletion opens confirmation, Cancel is the default, Esc returns to the recorded parent, Enter cannot execute while Cancel is focused, deletion executes once, forward deletion returns to its list, and errors remain visible in that list.
|
||||||
|
- [ ] Run `go test ./internal/tui -run 'Test(ServerDelete|Confirm|ForwardDelete)' -count=1` and verify the new tests fail for the missing state transitions.
|
||||||
|
- [ ] Replace `confirmMsg`/`confirmAction` with explicit confirmation state and route all server/forward/tag/template/tunnel destructive actions through it.
|
||||||
|
- [ ] Run the focused tests, then `go test ./... -count=1`.
|
||||||
|
- [ ] Commit as `fix: make tui destructive actions safe` and push the feature branch.
|
||||||
|
|
||||||
|
### Task 2: Truthful status, durable notifications, and help return context
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/tui/app.go`
|
||||||
|
- Modify: `internal/tui/help_screen.go`
|
||||||
|
- Modify: `cmd/tui.go`
|
||||||
|
- Modify: `internal/tui/app_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `VaultUnlocked func() bool`, durable `notification` state, and explicit help parent screen.
|
||||||
|
- Consumes: current vault callback setup and existing help models.
|
||||||
|
|
||||||
|
- [ ] Add failing tests proving lock changes the dashboard label, repeated `View()` retains notifications, F1 opens from manager screens, and closing help returns to the originating screen.
|
||||||
|
- [ ] Run `go test ./internal/tui -run 'Test(Vault|Notification|Help)' -count=1` and verify expected failures.
|
||||||
|
- [ ] Wire real vault state, move notification clearing to explicit update events, and store/restore help parent context.
|
||||||
|
- [ ] Propagate resize messages to active help, action menu, forward, form, template, and tunnel children.
|
||||||
|
- [ ] Run focused tests and `go test ./... -count=1`.
|
||||||
|
- [ ] Commit as `fix: keep tui status and help context truthful` and push.
|
||||||
|
|
||||||
|
### Task 3: Strict validation and dirty form exits
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/tui/form.go`
|
||||||
|
- Modify: `internal/tui/forward.go`
|
||||||
|
- Modify: `internal/tui/template_form.go`
|
||||||
|
- Modify: `internal/tui/app.go`
|
||||||
|
- Create: `internal/tui/form_validation_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: strict `parsePort(value string) (int, error)` and `Dirty() bool` methods for all editable forms.
|
||||||
|
- Consumes: the confirmation state from Task 1.
|
||||||
|
|
||||||
|
- [ ] Add failing tests for non-numeric, zero, and 65536 server ports; preserved invalid input; clean Esc; dirty Esc cancel/discard; and return to server/forward/template parent.
|
||||||
|
- [ ] Run `go test ./internal/tui -run 'Test(ServerPort|Dirty|Discard)' -count=1` and verify failure reasons.
|
||||||
|
- [ ] Implement strict parsing, persistent validation error state, form snapshots, and discard confirmation through the common overlay.
|
||||||
|
- [ ] Add required markers without changing stored field names.
|
||||||
|
- [ ] Run focused tests and `go test ./... -count=1`.
|
||||||
|
- [ ] Commit as `fix: validate tui forms and protect edits` and push.
|
||||||
|
|
||||||
|
### Task 4: Responsive dashboard and form layout
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/tui/app.go`
|
||||||
|
- Modify: `internal/tui/form.go`
|
||||||
|
- Modify: `internal/tui/forward.go`
|
||||||
|
- Modify: `internal/tui/template_form.go`
|
||||||
|
- Create: `internal/tui/layout.go`
|
||||||
|
- Create: `internal/tui/layout_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: pure display-cell truncation, size-class, bounded-row, and pane-layout helpers.
|
||||||
|
- Consumes: model width/height and existing view data.
|
||||||
|
|
||||||
|
- [ ] Add failing render tests at 120x40, 80x24, 60x16 and below-floor size, including long Cyrillic, CJK, combining, and emoji values.
|
||||||
|
- [ ] Run `go test ./internal/tui -run 'Test(Layout|Dashboard|FormRender|DisplayWidth)' -count=1` and verify layout/width failures.
|
||||||
|
- [ ] Implement the wide two-pane, medium stacked, and narrow compact dashboard shown in the approved mockup.
|
||||||
|
- [ ] Keep form header, focused field window, inline status, action row, and footer within the height budget; render a minimum-size message below 60x16.
|
||||||
|
- [ ] Honor `NO_COLOR` when constructing styles while retaining textual markers.
|
||||||
|
- [ ] Run focused tests and `go test ./... -count=1`.
|
||||||
|
- [ ] Commit as `feat: add responsive tui layouts` and push.
|
||||||
|
|
||||||
|
### Task 5: Runtime visual verification and documentation alignment
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/guide.md`
|
||||||
|
- Replace as needed: `docs/screenshots/screen_1.png` through `docs/screenshots/screen_5.png`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: completed TUI behavior.
|
||||||
|
- Produces: current screenshots and user-facing key/confirmation/responsive documentation.
|
||||||
|
|
||||||
|
- [ ] Build with `go build -o /tmp/sshkeeper-tui-audit .` and run with fresh isolated `XDG_CONFIG_HOME` and `XDG_DATA_HOME`.
|
||||||
|
- [ ] Capture the main dashboard, server form, forward form, safe confirmation, and manager states at 120x40; capture responsive dashboard/form states at 80x24 and 60x16.
|
||||||
|
- [ ] Inspect every PNG for clipping, cursor/focus visibility, status truthfulness, target naming, and footer visibility; fix defects through a new failing render/state test before code changes.
|
||||||
|
- [ ] Update `docs/guide.md` so shortcuts, confirmation behavior, minimum size, and screenshots agree with implementation.
|
||||||
|
- [ ] Run `gofmt -w` on changed Go files, `go vet ./...`, `go test ./... -count=1`, and a clean `go build ./...`.
|
||||||
|
- [ ] Commit as `docs: refresh tui guide and screenshots` and push.
|
||||||
|
|
||||||
|
### Task 6: Review and integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No planned product file changes unless review finds a defect.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all prior commits.
|
||||||
|
- Produces: reviewed commit range ready for `main`.
|
||||||
|
|
||||||
|
- [ ] Request an independent code review against this design and plan.
|
||||||
|
- [ ] Resolve every Critical or Important issue using a failing regression test first.
|
||||||
|
- [ ] Re-run `go vet ./...`, `go test ./... -count=1`, `go build ./...`, and the three-size runtime capture.
|
||||||
|
- [ ] Verify feature branch is clean and synchronized, fast-forward `main`, push `main` to `origin` and `github`, and verify all three refs resolve to the same SHA.
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
# sshkeeper TUI UX Redesign
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make destructive actions safe, keep security and asynchronous status truthful,
|
||||||
|
make forms predictable, and preserve primary tasks from 120x40 down to 60x16.
|
||||||
|
|
||||||
|
## Evidence and approved direction
|
||||||
|
|
||||||
|
The design is based on a source audit, the existing documentation/screenshots,
|
||||||
|
and runtime checks in isolated XDG directories at 120x40, 80x24, and 60x16.
|
||||||
|
The approved mockups use:
|
||||||
|
|
||||||
|
- a two-pane server dashboard at wide sizes;
|
||||||
|
- a compact single-pane server table at narrow sizes;
|
||||||
|
- persistent breadcrumbs and security status;
|
||||||
|
- explicit non-color focus/selection markers;
|
||||||
|
- field-adjacent validation with preserved input;
|
||||||
|
- confirmation dialogs that name the target and default to Cancel.
|
||||||
|
|
||||||
|
## Interaction contract
|
||||||
|
|
||||||
|
Input ownership is:
|
||||||
|
|
||||||
|
`confirmation or help overlay -> active picker -> active text input -> focused component -> screen -> global`
|
||||||
|
|
||||||
|
- Printable runes always belong to a focused text input.
|
||||||
|
- `Enter` activates the focused row, button, or confirmation choice.
|
||||||
|
- `Esc` closes the most local state and returns to its recorded parent.
|
||||||
|
- `Tab` and `Shift+Tab` traverse controls in forms and dialogs.
|
||||||
|
- `?` opens contextual shortcut help outside text inputs.
|
||||||
|
- `F1` opens full help from every non-editor screen and every form.
|
||||||
|
- `Ctrl+Q` quits only from a clean state; dirty forms require discard confirmation.
|
||||||
|
|
||||||
|
## Destructive actions
|
||||||
|
|
||||||
|
Server, forward, tag, template, and running-tunnel deletion/stop operations use
|
||||||
|
one confirmation state containing:
|
||||||
|
|
||||||
|
- exact target and consequence;
|
||||||
|
- parent screen and return selection;
|
||||||
|
- safe Cancel choice as the initial focus;
|
||||||
|
- pending state that ignores repeated activation;
|
||||||
|
- success and error transitions back to the parent screen.
|
||||||
|
|
||||||
|
Deleting a server must identify that its saved forwards and vault secrets are
|
||||||
|
also removed. Deleting a saved forward must return to the forward list.
|
||||||
|
|
||||||
|
## Status and notifications
|
||||||
|
|
||||||
|
The root model receives the actual vault lock state instead of rendering
|
||||||
|
`unlocked` unconditionally. Notifications are durable model state. Rendering is
|
||||||
|
pure: `View` never clears errors or success messages. A later explicit user
|
||||||
|
event or replacement notification clears them.
|
||||||
|
|
||||||
|
Loading, pending, success, and error are distinct. Actions that may take time
|
||||||
|
show their target and disable duplicate execution.
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
Server and forward ports use strict decimal parsing and the range 1 through
|
||||||
|
65535. Invalid text remains in the field and produces an actionable error.
|
||||||
|
Required fields are marked with `*`.
|
||||||
|
|
||||||
|
Every editable form stores an initial snapshot. `Esc` returns immediately when
|
||||||
|
unchanged; otherwise it opens a discard confirmation and returns to the correct
|
||||||
|
parent only after confirmation.
|
||||||
|
|
||||||
|
The server and forward forms use a viewport-like visible window centered on
|
||||||
|
the focused control. Header, validation/status area, action row, and footer
|
||||||
|
remain visible. Type selector shortcuts do not consume digits while an editor
|
||||||
|
owns input.
|
||||||
|
|
||||||
|
## Responsive layout
|
||||||
|
|
||||||
|
### 100 columns and wider
|
||||||
|
|
||||||
|
The server screen shows a list pane and a details pane. Columns are Name,
|
||||||
|
Target/Route, Auth, and Status. The details pane repeats the exact selected
|
||||||
|
target and exposes the two primary actions.
|
||||||
|
|
||||||
|
### 70 through 99 columns
|
||||||
|
|
||||||
|
The details pane moves below the list. Secondary fields are omitted from the
|
||||||
|
table, not clipped.
|
||||||
|
|
||||||
|
### 60 through 69 columns
|
||||||
|
|
||||||
|
The table contains Name, Auth, and Status. The focused row and footer remain
|
||||||
|
visible. Details are available through the action menu/help rather than taking
|
||||||
|
vertical space.
|
||||||
|
|
||||||
|
The declared supported floor is 60x16. Below it, the TUI renders a minimum-size
|
||||||
|
message rather than a misleading clipped form.
|
||||||
|
|
||||||
|
## Accessibility and compatibility
|
||||||
|
|
||||||
|
- Selection, focus, status, and severity never depend on color alone.
|
||||||
|
- Layout measurements use terminal display width, not byte length.
|
||||||
|
- Decorative color honors `NO_COLOR`; textual markers remain.
|
||||||
|
- Unicode content is truncated by display cells without splitting runes.
|
||||||
|
- Mouse remains optional; every primary task is keyboard accessible.
|
||||||
|
|
||||||
|
## Verification contract
|
||||||
|
|
||||||
|
Automated state-transition tests cover confirmation yes/no/error/repeat,
|
||||||
|
printable input ownership, form validation and dirty return paths, help return
|
||||||
|
context, vault status, resize propagation, and Unicode truncation.
|
||||||
|
|
||||||
|
Runtime checks use an isolated XDG profile and real Bubble Tea execution at
|
||||||
|
120x40, 80x24, and 60x16. Each size is captured and visually inspected for
|
||||||
|
clipping, missing focus, missing actions, and false status.
|
||||||
2
go.mod
|
|
@ -7,6 +7,7 @@ require (
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
|
|
@ -19,7 +20,6 @@ require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.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/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
|
|
||||||
|
|
@ -62,11 +62,15 @@ type saveDoneMsg struct {
|
||||||
|
|
||||||
type templatesLoadedMsg struct {
|
type templatesLoadedMsg struct {
|
||||||
templates []*model.CommandTemplate
|
templates []*model.CommandTemplate
|
||||||
|
deleted bool
|
||||||
|
deletedName string
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type tagsLoadedMsg struct {
|
type tagsLoadedMsg struct {
|
||||||
tags []string
|
tags []string
|
||||||
|
deleted bool
|
||||||
|
deletedName string
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +98,19 @@ type forwardDeletedMsg struct {
|
||||||
err error
|
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 {
|
type importDoneMsg struct {
|
||||||
servers []*model.Server
|
servers []*model.Server
|
||||||
count int
|
count int
|
||||||
|
|
@ -179,6 +196,7 @@ var (
|
||||||
DeleteForward func(forwardID int64) error
|
DeleteForward func(forwardID int64) error
|
||||||
ImportServers func() (int, error)
|
ImportServers func() (int, error)
|
||||||
LockVault func() error
|
LockVault func() error
|
||||||
|
VaultUnlocked func() bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Screen type ---
|
// --- Screen type ---
|
||||||
|
|
@ -205,6 +223,26 @@ const (
|
||||||
screenFullHelp
|
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 ---
|
// --- Result type — returned from TUI to caller ---
|
||||||
|
|
||||||
type TUIResult struct {
|
type TUIResult struct {
|
||||||
|
|
@ -244,9 +282,10 @@ type tuiModel struct {
|
||||||
actionMenu *actionMenuModel
|
actionMenu *actionMenuModel
|
||||||
forwardScreen *forwardScreenModel
|
forwardScreen *forwardScreenModel
|
||||||
forwardForm *forwardFormModel
|
forwardForm *forwardFormModel
|
||||||
confirmMsg string
|
confirm *confirmState
|
||||||
confirmAction func() tea.Cmd
|
|
||||||
fullHelp *fullHelpModel
|
fullHelp *fullHelpModel
|
||||||
|
helpParent screen
|
||||||
|
vaultUnlocked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(servers []*model.Server) *tuiModel {
|
func New(servers []*model.Server) *tuiModel {
|
||||||
|
|
@ -274,6 +313,11 @@ func New(servers []*model.Server) *tuiModel {
|
||||||
templateList.SetShowHelp(false)
|
templateList.SetShowHelp(false)
|
||||||
tagList := newStringList(nil, "Tags", 0, 0)
|
tagList := newStringList(nil, "Tags", 0, 0)
|
||||||
|
|
||||||
|
vaultIsUnlocked := true
|
||||||
|
if VaultUnlocked != nil {
|
||||||
|
vaultIsUnlocked = VaultUnlocked()
|
||||||
|
}
|
||||||
|
|
||||||
return &tuiModel{
|
return &tuiModel{
|
||||||
screen: screenList,
|
screen: screenList,
|
||||||
list: l,
|
list: l,
|
||||||
|
|
@ -283,6 +327,7 @@ func New(servers []*model.Server) *tuiModel {
|
||||||
tagInput: tagInput,
|
tagInput: tagInput,
|
||||||
templateList: templateList,
|
templateList: templateList,
|
||||||
tagList: tagList,
|
tagList: tagList,
|
||||||
|
vaultUnlocked: vaultIsUnlocked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,6 +350,38 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.form.width = msg.Width
|
m.form.width = msg.Width
|
||||||
m.form.height = msg.Height
|
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.templateList.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||||
m.tagList.SetSize(msg.Width, managerListHeight(msg.Height))
|
m.tagList.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -323,16 +400,34 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case templatesLoadedMsg:
|
case templatesLoadedMsg:
|
||||||
|
if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTemplates {
|
||||||
|
m.finishConfirm()
|
||||||
|
}
|
||||||
if msg.err != nil {
|
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
|
m.err = msg.err
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.setTemplates(msg.templates)
|
m.setTemplates(msg.templates)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tagsLoadedMsg:
|
case tagsLoadedMsg:
|
||||||
|
if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTags {
|
||||||
|
m.finishConfirm()
|
||||||
|
}
|
||||||
if msg.err != nil {
|
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
|
m.err = msg.err
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.setTags(msg.tags)
|
m.setTags(msg.tags)
|
||||||
|
|
@ -391,20 +486,85 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case forwardDeletedMsg:
|
case forwardDeletedMsg:
|
||||||
if m.forwardScreen != nil && msg.err == nil {
|
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)
|
||||||
|
}
|
||||||
return m, m.forwardScreen.loadForwards()
|
return m, m.forwardScreen.loadForwards()
|
||||||
}
|
}
|
||||||
return m, nil
|
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:
|
case forwardDeleteConfirmMsg:
|
||||||
// Show confirmation screen
|
m.beginConfirm(confirmState{
|
||||||
m.confirmMsg = fmt.Sprintf("Delete forward %q?", msg.name)
|
title: "Delete port forward?",
|
||||||
m.confirmAction = func() tea.Cmd {
|
target: msg.name,
|
||||||
|
consequence: "This removes the saved forwarding rule.",
|
||||||
|
verb: "Delete",
|
||||||
|
parent: screenForwardList,
|
||||||
|
action: func() tea.Cmd {
|
||||||
return func() tea.Msg {
|
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)}
|
return forwardDeletedMsg{id: msg.id, err: DeleteForward(msg.id)}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
m.screen = screenConfirm
|
})
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case forwardEditSignal:
|
case forwardEditSignal:
|
||||||
|
|
@ -430,7 +590,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tunnelStoppedMsg:
|
case tunnelStoppedMsg:
|
||||||
if m.tunnelScreen != nil && msg.err == nil {
|
m.finishConfirm()
|
||||||
|
if m.tunnelScreen != nil {
|
||||||
|
if msg.err != nil {
|
||||||
|
m.tunnelScreen.err = msg.err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.tunnelScreen.err = nil
|
||||||
return m, m.tunnelScreen.loadTunnels()
|
return m, m.tunnelScreen.loadTunnels()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -466,7 +632,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case saveDoneMsg:
|
case saveDoneMsg:
|
||||||
if m.forwardForm != nil {
|
if m.forwardForm != nil {
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.forwardForm.err = msg.err
|
m.forwardForm.applySaveError(msg.err)
|
||||||
m.forwardForm.saved = false
|
m.forwardForm.saved = false
|
||||||
// Stay on screenForwardForm to show error
|
// Stay on screenForwardForm to show error
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -497,17 +663,38 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if m.form != nil {
|
if m.form != nil {
|
||||||
m.form.saving = false
|
m.form.saving = false
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.form.err = msg.err
|
m.form.applySaveError(msg.err)
|
||||||
m.form.saved = false
|
m.form.saved = false
|
||||||
} else {
|
} else {
|
||||||
m.form.saved = true
|
m.form.saved = true
|
||||||
m.form.savedTime = time.Now()
|
m.form.savedTime = time.Now()
|
||||||
m.form.err = nil
|
m.form.err = nil
|
||||||
|
m.form.password.SetValue("")
|
||||||
|
m.form.initial = m.form.snapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
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 {
|
switch m.screen {
|
||||||
case screenList:
|
case screenList:
|
||||||
return m.updateList(msg)
|
return m.updateList(msg)
|
||||||
|
|
@ -594,14 +781,8 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
case tea.KeyCtrlD:
|
case tea.KeyCtrlD:
|
||||||
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||||
return m, func() tea.Msg {
|
m.confirmServerDelete(item.server)
|
||||||
err := DeleteServer(item.server.Alias)
|
return m, nil
|
||||||
if err != nil {
|
|
||||||
return saveDoneMsg{err: err}
|
|
||||||
}
|
|
||||||
servers, err := ListServers()
|
|
||||||
return serversLoadedMsg{servers: servers, err: err}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyCtrlT:
|
case tea.KeyCtrlT:
|
||||||
|
|
@ -630,16 +811,12 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
case tea.KeyRunes:
|
case tea.KeyRunes:
|
||||||
if msg.String() == "?" {
|
if msg.String() == "?" {
|
||||||
|
m.helpParent = m.screen
|
||||||
m.helpScreen = newHelpScreenModel(m.width, m.height)
|
m.helpScreen = newHelpScreenModel(m.width, m.height)
|
||||||
m.screen = screenHelp
|
m.screen = screenHelp
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyF1:
|
|
||||||
m.fullHelp = newFullHelpModel(m.width, m.height)
|
|
||||||
m.screen = screenFullHelp
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case tea.KeyCtrlW:
|
case tea.KeyCtrlW:
|
||||||
// Open forward manager for selected server
|
// Open forward manager for selected server
|
||||||
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||||
|
|
@ -662,6 +839,33 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
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) {
|
func (m *tuiModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case tea.KeyEsc:
|
case tea.KeyEsc:
|
||||||
|
|
@ -714,15 +918,31 @@ func (m *tuiModel) updateTags(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlD:
|
case tea.KeyCtrlD:
|
||||||
if item, ok := m.tagList.SelectedItem().(groupItem); ok && DeleteTag != nil {
|
if item, ok := m.tagList.SelectedItem().(groupItem); ok {
|
||||||
name := item.name
|
name := item.name
|
||||||
return m, func() tea.Msg {
|
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")}
|
||||||
|
}
|
||||||
if err := DeleteTag(name); err != nil {
|
if err := DeleteTag(name); err != nil {
|
||||||
return tagsLoadedMsg{err: err}
|
return tagsLoadedMsg{err: err}
|
||||||
}
|
}
|
||||||
tags, err := ListTags()
|
if ListTags == nil {
|
||||||
return tagsLoadedMsg{tags: tags, err: err}
|
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 m, nil
|
||||||
}
|
}
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
if item, ok := m.tagList.SelectedItem().(groupItem); ok && SetServerTags != nil {
|
if item, ok := m.tagList.SelectedItem().(groupItem); ok && SetServerTags != nil {
|
||||||
|
|
@ -807,15 +1027,31 @@ func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlD:
|
case tea.KeyCtrlD:
|
||||||
if item, ok := m.templateList.SelectedItem().(templateItem); ok && DeleteCommandTemplate != nil {
|
if item, ok := m.templateList.SelectedItem().(templateItem); ok {
|
||||||
name := item.template.Name
|
name := item.template.Name
|
||||||
return m, func() tea.Msg {
|
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")}
|
||||||
|
}
|
||||||
if err := DeleteCommandTemplate(name); err != nil {
|
if err := DeleteCommandTemplate(name); err != nil {
|
||||||
return templatesLoadedMsg{err: err}
|
return templatesLoadedMsg{err: err}
|
||||||
}
|
}
|
||||||
templates, err := ListCommandTemplates()
|
if ListCommandTemplates == nil {
|
||||||
return templatesLoadedMsg{templates: templates, err: err}
|
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 m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
@ -825,6 +1061,10 @@ func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
func (m *tuiModel) updateTemplateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *tuiModel) updateTemplateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if msg.Type == tea.KeyEsc {
|
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.screen = screenTemplates
|
||||||
m.templateForm = nil
|
m.templateForm = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -940,6 +1180,10 @@ func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
if m.form != nil && m.form.Dirty() {
|
||||||
|
m.confirmDiscard("Server profile", screenForm, screenList)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
m.screen = screenList
|
m.screen = screenList
|
||||||
m.form = nil
|
m.form = nil
|
||||||
|
|
@ -959,6 +1203,9 @@ func (m *tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) View() string {
|
func (m *tuiModel) View() string {
|
||||||
|
if classifyTerminal(m.width, m.height) == sizeBelowFloor {
|
||||||
|
return minimumSizeView(m.width)
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
switch m.screen {
|
switch m.screen {
|
||||||
|
|
@ -1027,13 +1274,11 @@ func (m *tuiModel) View() string {
|
||||||
b.WriteString(m.viewConfirm())
|
b.WriteString(m.viewConfirm())
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.err != nil {
|
if m.screen != screenList && m.err != nil {
|
||||||
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||||
m.err = nil
|
|
||||||
}
|
}
|
||||||
if m.success != "" {
|
if m.screen != screenList && m.success != "" {
|
||||||
b.WriteString("\n" + successStyle.Render(m.success))
|
b.WriteString("\n" + successStyle.Render(m.success))
|
||||||
m.success = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
|
|
@ -1045,8 +1290,8 @@ func (m *tuiModel) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.helpScreen = hs
|
m.helpScreen = hs
|
||||||
}
|
}
|
||||||
// Esc or Enter closes help
|
// Esc or Enter closes help
|
||||||
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter {
|
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter || (msg.Type == tea.KeyRunes && (msg.String() == "q" || msg.String() == "Q" || msg.String() == "?")) {
|
||||||
m.screen = screenList
|
m.screen = m.helpParent
|
||||||
m.helpScreen = nil
|
m.helpScreen = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1141,16 +1386,9 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
case "delete":
|
case "delete":
|
||||||
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||||
m.screen = screenList
|
|
||||||
m.actionMenu = nil
|
m.actionMenu = nil
|
||||||
return m, func() tea.Msg {
|
m.confirmServerDelete(item.server)
|
||||||
err := DeleteServer(item.server.Alias)
|
return m, nil
|
||||||
if err != nil {
|
|
||||||
return saveDoneMsg{err: err}
|
|
||||||
}
|
|
||||||
servers, err := ListServers()
|
|
||||||
return serversLoadedMsg{servers: servers, err: err}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case "import":
|
case "import":
|
||||||
m.screen = screenList
|
m.screen = screenList
|
||||||
|
|
@ -1178,6 +1416,7 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
} else if err := LockVault(); err != nil {
|
} else if err := LockVault(); err != nil {
|
||||||
m.err = err
|
m.err = err
|
||||||
} else {
|
} else {
|
||||||
|
m.vaultUnlocked = false
|
||||||
m.success = "Vault locked."
|
m.success = "Vault locked."
|
||||||
}
|
}
|
||||||
case "vault_change_pw":
|
case "vault_change_pw":
|
||||||
|
|
@ -1207,13 +1446,7 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
case tea.KeyCtrlD:
|
case tea.KeyCtrlD:
|
||||||
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
||||||
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
||||||
m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name)
|
m.confirmForwardDelete(fwd)
|
||||||
m.confirmAction = func() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.screen = screenConfirm
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case tea.KeyCtrlE, tea.KeyEnter:
|
case tea.KeyCtrlE, tea.KeyEnter:
|
||||||
|
|
@ -1231,13 +1464,7 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
case "d", "D":
|
case "d", "D":
|
||||||
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
||||||
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
||||||
m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name)
|
m.confirmForwardDelete(fwd)
|
||||||
m.confirmAction = func() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.screen = screenConfirm
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1262,9 +1489,8 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.tunnelScreen = nil
|
m.tunnelScreen = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlD:
|
case tea.KeyCtrlD:
|
||||||
if m.tunnelScreen != nil {
|
m.confirmTunnelStop()
|
||||||
return m, m.tunnelScreen.stopSelected()
|
return m, nil
|
||||||
}
|
|
||||||
case tea.KeyCtrlR:
|
case tea.KeyCtrlR:
|
||||||
if m.tunnelScreen != nil {
|
if m.tunnelScreen != nil {
|
||||||
return m, m.tunnelScreen.loadTunnels()
|
return m, m.tunnelScreen.loadTunnels()
|
||||||
|
|
@ -1272,9 +1498,8 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
case tea.KeyRunes:
|
case tea.KeyRunes:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "d", "D", "s", "S":
|
case "d", "D", "s", "S":
|
||||||
if m.tunnelScreen != nil {
|
m.confirmTunnelStop()
|
||||||
return m, m.tunnelScreen.stopSelected()
|
return m, nil
|
||||||
}
|
|
||||||
case "r", "R":
|
case "r", "R":
|
||||||
if m.tunnelScreen != nil {
|
if m.tunnelScreen != nil {
|
||||||
return m, m.tunnelScreen.loadTunnels()
|
return m, m.tunnelScreen.loadTunnels()
|
||||||
|
|
@ -1287,49 +1512,212 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) updateConfirm(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 {
|
switch msg.Type {
|
||||||
case tea.KeyEsc:
|
case tea.KeyEsc:
|
||||||
m.screen = screenList
|
return m.cancelConfirm()
|
||||||
m.confirmMsg = ""
|
case tea.KeyTab, tea.KeyShiftTab, tea.KeyLeft, tea.KeyRight:
|
||||||
m.confirmAction = nil
|
if m.confirm.focus == confirmCancel {
|
||||||
|
m.confirm.focus = confirmAccept
|
||||||
|
} else {
|
||||||
|
m.confirm.focus = confirmCancel
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
if m.confirmAction != nil {
|
if m.confirm.focus == confirmCancel {
|
||||||
action := m.confirmAction
|
return m.cancelConfirm()
|
||||||
m.confirmMsg = ""
|
|
||||||
m.confirmAction = nil
|
|
||||||
return m, action()
|
|
||||||
}
|
}
|
||||||
|
return m.acceptConfirm()
|
||||||
case tea.KeyRunes:
|
case tea.KeyRunes:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "y", "Y":
|
case "y", "Y":
|
||||||
if m.confirmAction != nil {
|
return m.acceptConfirm()
|
||||||
action := m.confirmAction
|
|
||||||
m.confirmMsg = ""
|
|
||||||
m.confirmAction = nil
|
|
||||||
return m, action()
|
|
||||||
}
|
|
||||||
case "n", "N":
|
case "n", "N":
|
||||||
m.screen = screenList
|
return m.cancelConfirm()
|
||||||
m.confirmMsg = ""
|
|
||||||
m.confirmAction = nil
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewConfirm() string {
|
func (m *tuiModel) viewConfirm() string {
|
||||||
var b strings.Builder
|
if m.confirm == nil {
|
||||||
b.WriteString(titleStyle.Render("Confirm"))
|
return ""
|
||||||
b.WriteString("\n\n")
|
}
|
||||||
b.WriteString(" " + m.confirmMsg)
|
width := m.width
|
||||||
b.WriteString("\n\n")
|
if width <= 0 {
|
||||||
b.WriteString(renderHelp([]helpItem{
|
width = 80
|
||||||
{Key: "Enter / Y", Action: "yes"},
|
}
|
||||||
{Key: "Esc / N", Action: "no"},
|
innerWidth := max(1, width-2)
|
||||||
}, m.width))
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(m.confirm.title, width)), ""}
|
||||||
return b.String()
|
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()
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
|
@ -1337,16 +1725,29 @@ func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if fh, ok := updated.(*fullHelpModel); ok {
|
if fh, ok := updated.(*fullHelpModel); ok {
|
||||||
m.fullHelp = fh
|
m.fullHelp = fh
|
||||||
}
|
}
|
||||||
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter {
|
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEnter || (msg.Type == tea.KeyRunes && (msg.String() == "q" || msg.String() == "Q")) {
|
||||||
m.screen = screenList
|
m.screen = m.helpParent
|
||||||
m.fullHelp = nil
|
m.fullHelp = nil
|
||||||
return m, nil
|
return m, 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) {
|
func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if msg.Type == tea.KeyEsc {
|
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.screen = screenForwardList
|
||||||
m.forwardForm = nil
|
m.forwardForm = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -1367,97 +1768,7 @@ func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewServerList() string {
|
func (m *tuiModel) viewServerList() string {
|
||||||
var b strings.Builder
|
return m.renderServerDashboard()
|
||||||
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 {
|
func (m *tuiModel) viewInlineBackgroundResults() string {
|
||||||
|
|
@ -1800,6 +2111,26 @@ func (m *tuiModel) setTags(tags []string) {
|
||||||
m.tagList = newStringList(tags, "Tags", m.width, managerListHeight(m.height))
|
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 ---
|
// --- Server list footer ---
|
||||||
|
|
||||||
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
|
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
|
||||||
|
|
@ -1932,10 +2263,7 @@ func displayLineCount(s string) int {
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncate(s string, maxLen int) string {
|
func truncate(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
return truncateCells(s, maxLen)
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:maxLen-3] + "..."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitCSV(value string) []string {
|
func splitCSV(value string) []string {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServerDeleteRequiresExplicitConfirmation(t *testing.T) {
|
||||||
|
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
||||||
|
m := New([]*model.Server{server})
|
||||||
|
deleted := 0
|
||||||
|
oldDelete, oldList := DeleteServer, ListServers
|
||||||
|
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
|
||||||
|
DeleteServer = func(alias string) error {
|
||||||
|
deleted++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ListServers = func() ([]*model.Server, error) { return nil, nil }
|
||||||
|
|
||||||
|
updated, cmd := m.updateList(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd != nil || deleted != 0 {
|
||||||
|
t.Fatalf("delete ran before confirmation: cmd=%v deleted=%d", cmd != nil, deleted)
|
||||||
|
}
|
||||||
|
if m.screen != screenConfirm || m.confirm == nil {
|
||||||
|
t.Fatalf("expected confirmation screen, got screen=%v confirm=%v", m.screen, m.confirm)
|
||||||
|
}
|
||||||
|
if m.confirm.focus != confirmCancel {
|
||||||
|
t.Fatalf("default focus = %v, want Cancel", m.confirm.focus)
|
||||||
|
}
|
||||||
|
view := m.View()
|
||||||
|
for _, want := range []string{"prod", "saved port forwards", "vault secrets", "> [ Cancel ]", "[ Delete ]"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("confirmation missing %q:\n%s", want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, cmd = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd != nil || deleted != 0 || m.screen != screenList {
|
||||||
|
t.Fatalf("Enter on default Cancel must cancel: screen=%v cmd=%v deleted=%d", m.screen, cmd != nil, deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerDeleteRunsOnceAndReturnsToList(t *testing.T) {
|
||||||
|
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
||||||
|
m := New([]*model.Server{server})
|
||||||
|
deleted := 0
|
||||||
|
oldDelete, oldList := DeleteServer, ListServers
|
||||||
|
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
|
||||||
|
DeleteServer = func(alias string) error {
|
||||||
|
deleted++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ListServers = func() ([]*model.Server, error) { return nil, nil }
|
||||||
|
|
||||||
|
updated, _ := m.updateList(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd == nil || m.confirm == nil || !m.confirm.pending {
|
||||||
|
t.Fatal("expected pending destructive command")
|
||||||
|
}
|
||||||
|
_, duplicate := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
if duplicate != nil {
|
||||||
|
t.Fatal("repeated Enter must not start a duplicate delete")
|
||||||
|
}
|
||||||
|
msg := cmd()
|
||||||
|
updated, _ = m.Update(msg)
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Fatalf("delete calls = %d, want 1", deleted)
|
||||||
|
}
|
||||||
|
if m.screen != screenList || m.confirm != nil || len(m.servers) != 0 {
|
||||||
|
t.Fatalf("unexpected completion state: screen=%v confirm=%v servers=%d", m.screen, m.confirm, len(m.servers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerDeletePartialSuccessRemovesLocalRow(t *testing.T) {
|
||||||
|
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22}
|
||||||
|
m := New([]*model.Server{server})
|
||||||
|
oldDelete, oldList := DeleteServer, ListServers
|
||||||
|
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
|
||||||
|
DeleteServer = func(string) error { return nil }
|
||||||
|
ListServers = func() ([]*model.Server, error) { return nil, errors.New("reload unavailable") }
|
||||||
|
|
||||||
|
m.confirmServerDelete(server)
|
||||||
|
m.confirm.focus = confirmAccept
|
||||||
|
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
updated, _ = m.Update(cmd())
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if len(m.servers) != 0 || len(m.list.Items()) != 0 {
|
||||||
|
t.Fatalf("deleted server remained visible: servers=%d items=%d", len(m.servers), len(m.list.Items()))
|
||||||
|
}
|
||||||
|
if m.err != nil || !strings.Contains(m.success, "refresh failed") {
|
||||||
|
t.Fatalf("partial success was misreported: err=%v success=%q", m.err, m.success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagAndTemplateDeletePartialSuccessRemovesLocalRow(t *testing.T) {
|
||||||
|
t.Run("tag", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenTags
|
||||||
|
m.setTags([]string{"prod"})
|
||||||
|
oldDelete, oldList := DeleteTag, ListTags
|
||||||
|
t.Cleanup(func() { DeleteTag, ListTags = oldDelete, oldList })
|
||||||
|
DeleteTag = func(string) error { return nil }
|
||||||
|
ListTags = func() ([]string, error) { return nil, errors.New("reload unavailable") }
|
||||||
|
|
||||||
|
updated, _ := m.updateTags(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
m.confirm.focus = confirmAccept
|
||||||
|
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
updated, _ = m.Update(cmd())
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if len(m.tags) != 0 || !strings.Contains(m.success, "refresh failed") {
|
||||||
|
t.Fatalf("tag partial success: tags=%v success=%q err=%v", m.tags, m.success, m.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("template", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenTemplates
|
||||||
|
m.setTemplates([]*model.CommandTemplate{{Name: "uptime", Command: "uptime"}})
|
||||||
|
oldDelete, oldList := DeleteCommandTemplate, ListCommandTemplates
|
||||||
|
t.Cleanup(func() { DeleteCommandTemplate, ListCommandTemplates = oldDelete, oldList })
|
||||||
|
DeleteCommandTemplate = func(string) error { return nil }
|
||||||
|
ListCommandTemplates = func() ([]*model.CommandTemplate, error) { return nil, errors.New("reload unavailable") }
|
||||||
|
|
||||||
|
updated, _ := m.updateTemplates(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
m.confirm.focus = confirmAccept
|
||||||
|
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
updated, _ = m.Update(cmd())
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if len(m.templates) != 0 || !strings.Contains(m.success, "refresh failed") {
|
||||||
|
t.Fatalf("template partial success: templates=%v success=%q err=%v", m.templates, m.success, m.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardDeleteReturnsToForwardListAndRetainsError(t *testing.T) {
|
||||||
|
server := &model.Server{ID: 1, Alias: "prod"}
|
||||||
|
fwd := &model.Forward{ID: 7, ServerID: 1, Name: "postgres", Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "db", RemotePort: 5432, Enabled: true}
|
||||||
|
m := New([]*model.Server{server})
|
||||||
|
m.screen = screenForwardList
|
||||||
|
m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, 80, 24)
|
||||||
|
m.forwardScreen.list = []*model.Forward{fwd}
|
||||||
|
m.forwardScreen.selected = 0
|
||||||
|
|
||||||
|
oldDelete := DeleteForward
|
||||||
|
t.Cleanup(func() { DeleteForward = oldDelete })
|
||||||
|
DeleteForward = func(id int64) error { return errors.New("database is read-only") }
|
||||||
|
|
||||||
|
updated, _ := m.updateForwardList(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.confirm == nil || m.confirm.parent != screenForwardList {
|
||||||
|
t.Fatalf("expected forward-list parent, confirm=%#v", m.confirm)
|
||||||
|
}
|
||||||
|
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected delete command")
|
||||||
|
}
|
||||||
|
updated, _ = m.Update(cmd())
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenForwardList || m.confirm != nil {
|
||||||
|
t.Fatalf("expected return to forwards, screen=%v confirm=%v", m.screen, m.confirm)
|
||||||
|
}
|
||||||
|
if m.forwardScreen == nil || m.forwardScreen.err == nil || !strings.Contains(m.forwardScreen.View(), "database is read-only") {
|
||||||
|
t.Fatalf("forward error not retained: model=%#v view=%q", m.forwardScreen, m.forwardScreen.View())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOtherDestructiveActionsOpenConfirmation(t *testing.T) {
|
||||||
|
t.Run("tag", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenTags
|
||||||
|
m.setTags([]string{"prod"})
|
||||||
|
updated, cmd := m.updateTags(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTags {
|
||||||
|
t.Fatalf("tag deletion did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("template", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenTemplates
|
||||||
|
m.setTemplates([]*model.CommandTemplate{{Name: "uptime", Command: "uptime"}})
|
||||||
|
updated, cmd := m.updateTemplates(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTemplates {
|
||||||
|
t.Fatalf("template deletion did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tunnel", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenTunnelManager
|
||||||
|
m.tunnelScreen = newTunnelScreenModel(80, 24)
|
||||||
|
m.tunnelScreen.tunnels = []*model.TunnelState{{ID: 11, Name: "prod tunnel", ServerAlias: "prod"}}
|
||||||
|
m.tunnelScreen.rebuildList()
|
||||||
|
updated, cmd := m.updateTunnelManager(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTunnelManager {
|
||||||
|
t.Fatalf("tunnel stop did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *tuiModel) renderServerDashboard() string {
|
||||||
|
width, height := m.width, m.height
|
||||||
|
if width <= 0 {
|
||||||
|
width = 120
|
||||||
|
}
|
||||||
|
if height <= 0 {
|
||||||
|
height = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
header := m.renderDashboardHeader(width)
|
||||||
|
notification := m.renderDashboardNotification(width)
|
||||||
|
footer := m.renderListHelp(len(m.selectedServers()), len(m.bgResults) > 0)
|
||||||
|
headerHeight := displayLineCount(header)
|
||||||
|
notificationHeight := displayLineCount(notification)
|
||||||
|
footerHeight := displayLineCount(footer)
|
||||||
|
bodyHeight := height - headerHeight - notificationHeight - footerHeight
|
||||||
|
if bodyHeight < 5 {
|
||||||
|
bodyHeight = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
var body string
|
||||||
|
switch classifyTerminal(width, height) {
|
||||||
|
case sizeWide:
|
||||||
|
leftWidth := width * 62 / 100
|
||||||
|
rightWidth := width - leftWidth - 1
|
||||||
|
left := m.renderServerPanel(leftWidth, bodyHeight, true)
|
||||||
|
right := m.renderSelectedPanel(rightWidth, bodyHeight)
|
||||||
|
body = joinPanelColumns(left, leftWidth, right, rightWidth)
|
||||||
|
case sizeMedium:
|
||||||
|
detailsHeight := 6
|
||||||
|
listHeight := bodyHeight - detailsHeight
|
||||||
|
if listHeight < 5 {
|
||||||
|
listHeight = 5
|
||||||
|
}
|
||||||
|
body = m.renderServerPanel(width, listHeight, false)
|
||||||
|
if selected := m.selectedServer(); selected != nil && listHeight+detailsHeight <= bodyHeight {
|
||||||
|
body += "\n" + m.renderCompactSelected(selected, width, detailsHeight-1)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
body = m.renderServerPanel(width, bodyHeight, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
view := header + notification + body
|
||||||
|
padding := height - displayLineCount(view) - footerHeight
|
||||||
|
if padding > 0 {
|
||||||
|
view += strings.Repeat("\n", padding)
|
||||||
|
}
|
||||||
|
return view + "\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) renderDashboardNotification(width int) string {
|
||||||
|
if m.err != nil {
|
||||||
|
return fitLine(errorStyle.Render("Error: "+m.err.Error()), width) + "\n"
|
||||||
|
}
|
||||||
|
if m.success != "" {
|
||||||
|
return fitLine(successStyle.Render(m.success), width) + "\n"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) renderDashboardHeader(width int) string {
|
||||||
|
left := "sshkeeper / Servers"
|
||||||
|
vault := "Vault locked"
|
||||||
|
if m.vaultUnlocked {
|
||||||
|
vault = "Vault unlocked"
|
||||||
|
}
|
||||||
|
right := fmt.Sprintf("%s · %d profiles", vault, len(m.servers))
|
||||||
|
if selected := len(m.selectedServers()); selected > 0 {
|
||||||
|
right += fmt.Sprintf(" · %d selected", selected)
|
||||||
|
}
|
||||||
|
line := left + " " + right
|
||||||
|
if lipgloss.Width(left)+lipgloss.Width(right)+1 <= width {
|
||||||
|
line = left + strings.Repeat(" ", width-lipgloss.Width(left)-lipgloss.Width(right)) + right
|
||||||
|
}
|
||||||
|
headerStyle := titleStyle.Copy().MarginLeft(0)
|
||||||
|
separatorStyle := helpStyle.Copy().MarginLeft(0)
|
||||||
|
return headerStyle.Render(fitLine(line, width)) + "\n" + separatorStyle.Render(strings.Repeat("─", width)) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) renderServerPanel(width, height int, showTarget bool) string {
|
||||||
|
innerWidth := max(1, width-2)
|
||||||
|
innerHeight := max(1, height-2)
|
||||||
|
lines := make([]string, 0, innerHeight)
|
||||||
|
lines = append(lines, listHeaderStyle.Render(fitLine(fmt.Sprintf("%d servers", len(m.servers)), innerWidth)))
|
||||||
|
lines = append(lines, m.renderServerColumns(innerWidth, showTarget, nil, true))
|
||||||
|
|
||||||
|
rowCapacity := max(0, innerHeight-len(lines))
|
||||||
|
showRange := len(m.servers) > rowCapacity
|
||||||
|
if showRange {
|
||||||
|
rowCapacity = max(1, rowCapacity-1)
|
||||||
|
}
|
||||||
|
if len(m.servers) == 0 {
|
||||||
|
lines = append(lines, helpStyle.Render(fitLine("No servers yet. Ctrl+A adds the first profile.", innerWidth)))
|
||||||
|
} else if rowCapacity > 0 {
|
||||||
|
start, end := visibleServerRange(len(m.servers), m.list.Index(), rowCapacity)
|
||||||
|
selected := m.selectedServer()
|
||||||
|
for _, server := range m.servers[start:end] {
|
||||||
|
lines = append(lines, m.renderServerColumns(innerWidth, showTarget, server, selected != nil && server.Alias == selected.Alias))
|
||||||
|
}
|
||||||
|
if showRange {
|
||||||
|
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.servers))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(lines) < innerHeight {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
if len(lines) > innerHeight {
|
||||||
|
lines = lines[:innerHeight]
|
||||||
|
}
|
||||||
|
return renderPanel(width, height, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) renderServerColumns(width int, showTarget bool, server *model.Server, selected bool) string {
|
||||||
|
marker, name, target, auth, group, status := "", "NAME", "TARGET / ROUTE", "AUTH", "GROUP", "STATUS"
|
||||||
|
style := normalStyle
|
||||||
|
if server != nil {
|
||||||
|
marker = " "
|
||||||
|
if selected {
|
||||||
|
marker = ">"
|
||||||
|
style = selectedRowStyle
|
||||||
|
}
|
||||||
|
if m.selected[server.Alias] {
|
||||||
|
marker = "*"
|
||||||
|
if selected {
|
||||||
|
marker = ">*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name = server.DisplayName
|
||||||
|
if name == "" {
|
||||||
|
name = server.Alias
|
||||||
|
}
|
||||||
|
target = fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
|
||||||
|
if len(server.Route.Hops) > 0 {
|
||||||
|
target = server.Route.DisplaySummary(target)
|
||||||
|
}
|
||||||
|
auth = authLabel(server.AuthMethod)
|
||||||
|
group = server.GroupName
|
||||||
|
if group == "" {
|
||||||
|
group = "-"
|
||||||
|
}
|
||||||
|
status = testStatusLabel(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
markerWidth, authWidth, groupWidth, statusWidth := 2, 10, 10, 7
|
||||||
|
nameWidth := width - markerWidth - authWidth - groupWidth - statusWidth - 4
|
||||||
|
if showTarget {
|
||||||
|
nameWidth = min(18, max(10, nameWidth/3))
|
||||||
|
targetWidth := width - markerWidth - nameWidth - authWidth - groupWidth - statusWidth - 5
|
||||||
|
line := padCells(marker, markerWidth) + " " + padCells(name, nameWidth) + " " + padCells(target, targetWidth) + " " + padCells(auth, authWidth) + " " + padCells(group, groupWidth) + " " + padCells(status, statusWidth)
|
||||||
|
if server == nil {
|
||||||
|
return listHeaderStyle.Render(fitLine(line, width))
|
||||||
|
}
|
||||||
|
return style.Render(fitLine(line, width))
|
||||||
|
}
|
||||||
|
line := padCells(marker, markerWidth) + " " + padCells(name, nameWidth) + " " + padCells(auth, authWidth) + " " + padCells(group, groupWidth) + " " + padCells(status, statusWidth)
|
||||||
|
if server == nil {
|
||||||
|
return listHeaderStyle.Render(fitLine(line, width))
|
||||||
|
}
|
||||||
|
return style.Render(fitLine(line, width))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) renderSelectedPanel(width, height int) string {
|
||||||
|
innerWidth := max(1, width-2)
|
||||||
|
innerHeight := max(1, height-2)
|
||||||
|
lines := make([]string, 0, innerHeight)
|
||||||
|
selected := m.selectedServer()
|
||||||
|
if selected == nil {
|
||||||
|
lines = append(lines, dashboardSection("Selected profile"), dashboardHelp("No profile selected."))
|
||||||
|
} else {
|
||||||
|
target := fmt.Sprintf("%s@%s:%d", selected.User, selected.Host, selected.Port)
|
||||||
|
route := "direct"
|
||||||
|
if len(selected.Route.Hops) > 0 {
|
||||||
|
route = selected.Route.DisplaySummary(target)
|
||||||
|
}
|
||||||
|
group := selected.GroupName
|
||||||
|
if group == "" {
|
||||||
|
group = "-"
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
dashboardSection("Selected profile"),
|
||||||
|
fitLine("Alias: "+selected.Alias, innerWidth),
|
||||||
|
fitLine("Display Name: "+selected.DisplayName, innerWidth),
|
||||||
|
fitLine("Host: "+selected.Host, innerWidth),
|
||||||
|
fitLine(fmt.Sprintf("Port: %d User: %s", selected.Port, selected.User), innerWidth),
|
||||||
|
fitLine(target, innerWidth),
|
||||||
|
fitLine("Route "+route, innerWidth),
|
||||||
|
fitLine("Group "+group, innerWidth),
|
||||||
|
fitLine("Tags "+strings.Join(selected.Tags, ", "), innerWidth),
|
||||||
|
fitLine("Last test "+testStatusLabel(selected), innerWidth),
|
||||||
|
"",
|
||||||
|
dashboardSection("Primary actions"),
|
||||||
|
"Enter Connect",
|
||||||
|
"Ctrl+X More actions…",
|
||||||
|
)
|
||||||
|
lines = append(lines, m.backgroundPanelLines(selected.Alias, innerWidth)...)
|
||||||
|
}
|
||||||
|
for len(lines) < innerHeight {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
if len(lines) > innerHeight {
|
||||||
|
lines = lines[:innerHeight]
|
||||||
|
}
|
||||||
|
return renderPanel(width, height, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) backgroundPanelLines(alias string, width int) []string {
|
||||||
|
if len(m.bgResults) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lines := []string{"", dashboardSection("Last Background Run")}
|
||||||
|
for _, result := range m.bgResults {
|
||||||
|
status := "OK"
|
||||||
|
if result.Err != "" {
|
||||||
|
status = "FAIL"
|
||||||
|
}
|
||||||
|
lines = append(lines, fitLine(result.Alias+" "+status, width))
|
||||||
|
}
|
||||||
|
result := m.backgroundResultForAlias(alias)
|
||||||
|
if result == nil && len(m.bgResults) == 1 {
|
||||||
|
result = &m.bgResults[0]
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
output := strings.TrimSpace(result.Output)
|
||||||
|
if output == "" {
|
||||||
|
output = result.Err
|
||||||
|
}
|
||||||
|
if output != "" {
|
||||||
|
lines = append(lines, dashboardHelp("Output: "+result.Alias))
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
lines = append(lines, fitLine(strings.ReplaceAll(line, "\t", " "), width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) renderCompactSelected(server *model.Server, width, height int) string {
|
||||||
|
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
|
||||||
|
group := server.GroupName
|
||||||
|
if group == "" {
|
||||||
|
group = "-"
|
||||||
|
}
|
||||||
|
lines := []string{
|
||||||
|
dashboardSection("Selected profile"),
|
||||||
|
fitLine("Alias: "+server.Alias+" Target: "+target, width),
|
||||||
|
fitLine("Auth: "+authLabel(server.AuthMethod)+" Group: "+group+" Status: "+testStatusLabel(server), width),
|
||||||
|
fitLine("Enter: Connect Ctrl+X: More actions…", width),
|
||||||
|
}
|
||||||
|
if len(lines) > height {
|
||||||
|
lines = lines[:height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardSection(value string) string {
|
||||||
|
return sectionStyle.Copy().MarginTop(0).Render(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardHelp(value string) string {
|
||||||
|
return helpStyle.Copy().MarginLeft(0).Render(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPanel(width, height int, lines []string) string {
|
||||||
|
if width < 2 || height < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
innerWidth := width - 2
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("┌" + strings.Repeat("─", innerWidth) + "┐\n")
|
||||||
|
for row := 0; row < height-2; row++ {
|
||||||
|
line := ""
|
||||||
|
if row < len(lines) {
|
||||||
|
line = lines[row]
|
||||||
|
}
|
||||||
|
b.WriteString("│" + padCells(line, innerWidth) + "│\n")
|
||||||
|
}
|
||||||
|
b.WriteString("└" + strings.Repeat("─", innerWidth) + "┘")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPanelColumns(left string, leftWidth int, right string, rightWidth int) string {
|
||||||
|
leftLines := strings.Split(left, "\n")
|
||||||
|
rightLines := strings.Split(right, "\n")
|
||||||
|
rows := max(len(leftLines), len(rightLines))
|
||||||
|
joined := make([]string, rows)
|
||||||
|
for row := 0; row < rows; row++ {
|
||||||
|
leftLine, rightLine := "", ""
|
||||||
|
if row < len(leftLines) {
|
||||||
|
leftLine = leftLines[row]
|
||||||
|
}
|
||||||
|
if row < len(rightLines) {
|
||||||
|
rightLine = rightLines[row]
|
||||||
|
}
|
||||||
|
joined[row] = padCells(leftLine, leftWidth) + " " + padCells(rightLine, rightWidth)
|
||||||
|
}
|
||||||
|
return strings.Join(joined, "\n")
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -62,6 +63,12 @@ type formModel struct {
|
||||||
showGroupList bool
|
showGroupList bool
|
||||||
authList list.Model
|
authList list.Model
|
||||||
showAuthList bool
|
showAuthList bool
|
||||||
|
initial formSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
type formSnapshot struct {
|
||||||
|
values []string
|
||||||
|
password string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFormModel(w, h int) *formModel {
|
func newFormModel(w, h int) *formModel {
|
||||||
|
|
@ -85,6 +92,7 @@ func newFormModel(w, h int) *formModel {
|
||||||
inputs[i].Placeholder = placeholderForLabel(label)
|
inputs[i].Placeholder = placeholderForLabel(label)
|
||||||
inputs[i].CharLimit = 128
|
inputs[i].CharLimit = 128
|
||||||
}
|
}
|
||||||
|
inputs[3].SetValue("22")
|
||||||
|
|
||||||
pw := textinput.New()
|
pw := textinput.New()
|
||||||
pw.Placeholder = "optional"
|
pw.Placeholder = "optional"
|
||||||
|
|
@ -122,6 +130,7 @@ func newFormModel(w, h int) *formModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
fm.updateFocus()
|
fm.updateFocus()
|
||||||
|
fm.initial = fm.snapshot()
|
||||||
return fm
|
return fm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,9 +211,31 @@ func newEditFormModel(s *model.Server, w, h int) *formModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fm.updateFocus()
|
fm.updateFocus()
|
||||||
|
fm.initial = fm.snapshot()
|
||||||
return fm
|
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 {
|
func (fm *formModel) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +257,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case saveDoneMsg:
|
case saveDoneMsg:
|
||||||
fm.saving = false
|
fm.saving = false
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
fm.err = msg.err
|
fm.applySaveError(msg.err)
|
||||||
fm.saved = false
|
fm.saved = false
|
||||||
} else {
|
} else {
|
||||||
fm.saved = true
|
fm.saved = true
|
||||||
|
|
@ -370,6 +401,25 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return fm, nil
|
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() {
|
func (fm *formModel) updateFocus() {
|
||||||
for i := range fm.inputs {
|
for i := range fm.inputs {
|
||||||
fm.inputs[i].Blur()
|
fm.inputs[i].Blur()
|
||||||
|
|
@ -389,6 +439,14 @@ func (fm *formModel) updateFocus() {
|
||||||
|
|
||||||
func (fm *formModel) labelAt(index int) string {
|
func (fm *formModel) labelAt(index int) string {
|
||||||
if index >= 0 && index < len(fm.labels) {
|
if index >= 0 && index < len(fm.labels) {
|
||||||
|
switch index {
|
||||||
|
case 0:
|
||||||
|
return "Alias *"
|
||||||
|
case 2:
|
||||||
|
return "Host *"
|
||||||
|
case 3:
|
||||||
|
return "Port *"
|
||||||
|
}
|
||||||
if index == 5 {
|
if index == 5 {
|
||||||
return "Auth Method (/ pick)"
|
return "Auth Method (/ pick)"
|
||||||
}
|
}
|
||||||
|
|
@ -409,6 +467,10 @@ func (fm *formModel) runTest() tea.Cmd {
|
||||||
fm.err = nil
|
fm.err = nil
|
||||||
fm.saved = false
|
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()
|
s := fm.buildServer()
|
||||||
pw := fm.password.Value()
|
pw := fm.password.Value()
|
||||||
|
|
||||||
|
|
@ -434,6 +496,9 @@ func (fm *formModel) runSave() tea.Cmd {
|
||||||
fm.saved = false
|
fm.saved = false
|
||||||
fm.testResult = ""
|
fm.testResult = ""
|
||||||
|
|
||||||
|
if _, err := parsePort(fm.inputs[3].Value()); err != nil {
|
||||||
|
return func() tea.Msg { return saveDoneMsg{err: err} }
|
||||||
|
}
|
||||||
s := fm.buildServer()
|
s := fm.buildServer()
|
||||||
pw := fm.password.Value()
|
pw := fm.password.Value()
|
||||||
|
|
||||||
|
|
@ -482,8 +547,7 @@ func parseRouteHops(input string) model.Route {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fm *formModel) buildServer() *model.Server {
|
func (fm *formModel) buildServer() *model.Server {
|
||||||
port := 22
|
port, _ := parsePort(fm.inputs[3].Value())
|
||||||
fmt.Sscanf(fm.inputs[3].Value(), "%d", &port)
|
|
||||||
authMethod := model.AuthMethod(fm.inputs[5].Value())
|
authMethod := model.AuthMethod(fm.inputs[5].Value())
|
||||||
if authMethod == "" {
|
if authMethod == "" {
|
||||||
authMethod = model.AuthKey
|
authMethod = model.AuthKey
|
||||||
|
|
@ -508,131 +572,131 @@ func (fm *formModel) buildServer() *model.Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fm *formModel) View() string {
|
func parsePort(value string) (int, error) {
|
||||||
var b strings.Builder
|
value = strings.TrimSpace(value)
|
||||||
|
port, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("Port must be a number from 1 to 65535")
|
||||||
|
}
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return 0, fmt.Errorf("Port must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) View() string {
|
||||||
title := "Add Server"
|
title := "Add Server"
|
||||||
if fm.edit {
|
if fm.edit {
|
||||||
title = "Edit Server: " + fm.server.Alias
|
title = "Edit Server: " + fm.server.Alias
|
||||||
}
|
}
|
||||||
b.WriteString(titleStyle.Render(title))
|
footer := renderHelp([]helpItem{
|
||||||
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: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "/", Action: "pick list"},
|
{Key: "/", Action: "pick list"},
|
||||||
{Key: "Enter", Action: "select"},
|
{Key: "Enter", Action: "select"},
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
}, fm.width))
|
}, fm.width)
|
||||||
|
|
||||||
return b.String()
|
if fm.showAuthList || fm.showGroupList {
|
||||||
|
var dropdown list.Model
|
||||||
|
fieldIndex := 8
|
||||||
|
if fm.showAuthList {
|
||||||
|
dropdown = fm.authList
|
||||||
|
fieldIndex = 5
|
||||||
|
} else {
|
||||||
|
dropdown = fm.groupList
|
||||||
|
}
|
||||||
|
return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" +
|
||||||
|
fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" +
|
||||||
|
fitLine(renderDropdown(dropdown), fm.width) + "\n" +
|
||||||
|
renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := fm.formStatusLine()
|
||||||
|
testBtn, saveBtn := " [ Test ]", " [ Save ]"
|
||||||
|
if fm.focusIdx == len(fm.inputs)+1 {
|
||||||
|
testBtn = selectedStyle.Render("> [ Test ]")
|
||||||
|
}
|
||||||
|
if fm.focusIdx == len(fm.inputs)+2 {
|
||||||
|
saveBtn = selectedStyle.Render("> [ Save ]")
|
||||||
|
}
|
||||||
|
actions := fitLine(testBtn+" "+saveBtn, fm.width)
|
||||||
|
|
||||||
|
reserved := 1 + displayLineCount(footer) + 1
|
||||||
|
if status != "" {
|
||||||
|
reserved++
|
||||||
|
}
|
||||||
|
fieldRows := max(4, fm.height-reserved)
|
||||||
|
richLayout := fm.width >= 90 && fm.height >= 24
|
||||||
|
allFields := make([]string, 0, len(fm.inputs)+5)
|
||||||
|
focusRows := make([]int, len(fm.inputs)+1)
|
||||||
|
for i := range fm.inputs {
|
||||||
|
if richLayout {
|
||||||
|
if section := formSectionTitle(i); section != "" {
|
||||||
|
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i == 5 {
|
||||||
|
fm.inputs[i].Placeholder = "password/key/key_passphrase/agent"
|
||||||
|
}
|
||||||
|
if i == 8 && len(fm.groups) > 0 {
|
||||||
|
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
|
||||||
|
}
|
||||||
|
focusRows[i] = len(allFields)
|
||||||
|
allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width))
|
||||||
|
}
|
||||||
|
focusRows[len(fm.inputs)] = len(allFields)
|
||||||
|
allFields = append(allFields, fitLine(fm.password.View(), fm.width))
|
||||||
|
focusField := len(allFields) - 1
|
||||||
|
if fm.focusIdx <= len(fm.inputs) {
|
||||||
|
focusField = focusRows[fm.focusIdx]
|
||||||
|
}
|
||||||
|
start, end := visibleServerRange(len(allFields), focusField, fieldRows)
|
||||||
|
visible := append([]string(nil), allFields[start:end]...)
|
||||||
|
if start > 0 && len(visible) > 0 {
|
||||||
|
visible[0] = fitLine("↑ more fields · "+visible[0], fm.width)
|
||||||
|
}
|
||||||
|
if end < len(allFields) && len(visible) > 0 {
|
||||||
|
visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
|
||||||
|
lines = append(lines, visible...)
|
||||||
|
if status != "" {
|
||||||
|
lines = append(lines, fitLine(status, fm.width))
|
||||||
|
}
|
||||||
|
if richLayout {
|
||||||
|
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
|
||||||
|
}
|
||||||
|
lines = append(lines, actions)
|
||||||
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
|
if len(lines) > fm.height && fm.height > 0 {
|
||||||
|
lines = lines[:fm.height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) formStatusLine() string {
|
||||||
|
if fm.err != nil {
|
||||||
|
return errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
|
||||||
|
}
|
||||||
|
if fm.testing {
|
||||||
|
return fm.spinner.View() + " Testing connection..."
|
||||||
|
}
|
||||||
|
if fm.saving {
|
||||||
|
return fm.spinner.View() + " Saving..."
|
||||||
|
}
|
||||||
|
showResults := time.Since(fm.testResultTime) < 10*time.Second || time.Since(fm.savedTime) < 10*time.Second
|
||||||
|
if showResults && fm.testResult != "" {
|
||||||
|
if fm.testOK {
|
||||||
|
return testOKStyle.Render("✓ " + fm.testResult)
|
||||||
|
}
|
||||||
|
return testFailStyle.Render("✗ " + strings.ReplaceAll(fm.testResult, "\n", " "))
|
||||||
|
}
|
||||||
|
if showResults && fm.saved {
|
||||||
|
return successStyle.Render("✓ Saved.")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDropdown(l list.Model) string {
|
func renderDropdown(l list.Model) string {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePortRejectsInvalidValues(t *testing.T) {
|
||||||
|
for _, value := range []string{"", "abc", "0", "65536", "22x"} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
if _, err := parsePort(value); err == nil {
|
||||||
|
t.Fatalf("parsePort(%q) succeeded", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if port, err := parsePort("22"); err != nil || port != 22 {
|
||||||
|
t.Fatalf("parsePort(22) = %d, %v", port, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerFormPreservesInvalidPortAndDoesNotSave(t *testing.T) {
|
||||||
|
fm := newFormModel(80, 24)
|
||||||
|
fm.inputs[0].SetValue("prod")
|
||||||
|
fm.inputs[2].SetValue("prod.example")
|
||||||
|
fm.inputs[3].SetValue("abc")
|
||||||
|
oldSave := SaveServer
|
||||||
|
t.Cleanup(func() { SaveServer = oldSave })
|
||||||
|
saves := 0
|
||||||
|
SaveServer = func(*model.Server, string, string) error {
|
||||||
|
saves++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fm.runSave()
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected validation result command")
|
||||||
|
}
|
||||||
|
updated, _ := fm.Update(cmd())
|
||||||
|
fm = updated.(*formModel)
|
||||||
|
if saves != 0 || fm.inputs[3].Value() != "abc" {
|
||||||
|
t.Fatalf("invalid input was lost or saved: saves=%d value=%q", saves, fm.inputs[3].Value())
|
||||||
|
}
|
||||||
|
if fm.err == nil || !strings.Contains(fm.err.Error(), "Port") {
|
||||||
|
t.Fatalf("missing actionable port error: %v", fm.err)
|
||||||
|
}
|
||||||
|
if fm.focusIdx != 3 {
|
||||||
|
t.Fatalf("invalid port focus = %d, want 3", fm.focusIdx)
|
||||||
|
}
|
||||||
|
if view := fm.View(); !strings.Contains(view, "Port must be a number") {
|
||||||
|
t.Fatalf("validation error is not rendered:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerSaveSuccessResetsDirtySnapshot(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenForm
|
||||||
|
m.form = newFormModel(80, 24)
|
||||||
|
m.form.inputs[0].SetValue("prod")
|
||||||
|
m.form.inputs[2].SetValue("prod.example")
|
||||||
|
m.form.password.SetValue("secret")
|
||||||
|
|
||||||
|
updated, _ := m.Update(saveDoneMsg{})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.form == nil || m.form.Dirty() {
|
||||||
|
t.Fatalf("saved form remains dirty: %#v", m.form)
|
||||||
|
}
|
||||||
|
if m.form.password.Value() != "" {
|
||||||
|
t.Fatal("saved secret remained in the form")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardValidationMovesFocusToInvalidPort(t *testing.T) {
|
||||||
|
fm := newForwardFormModel(1, 60, 16)
|
||||||
|
fm.nameInput.SetValue("postgres")
|
||||||
|
fm.inputs[0].SetValue("127.0.0.1")
|
||||||
|
fm.inputs[1].SetValue("bad")
|
||||||
|
fm.inputs[2].SetValue("db")
|
||||||
|
fm.inputs[3].SetValue("5432")
|
||||||
|
updated, _ := fm.Update(fm.runSave()())
|
||||||
|
fm = updated.(*forwardFormModel)
|
||||||
|
if fm.focusIdx != 6 {
|
||||||
|
t.Fatalf("invalid listen port focus = %d, want 6", fm.focusIdx)
|
||||||
|
}
|
||||||
|
view := fm.View()
|
||||||
|
if !strings.Contains(view, "Listen Port") || !strings.Contains(view, "must be a number") {
|
||||||
|
t.Fatalf("invalid port and error are not visible together:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCtrlQQuitsCleanStateAndConfirmsDirtyForm(t *testing.T) {
|
||||||
|
t.Run("clean manager", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenForwardList
|
||||||
|
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
||||||
|
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("Ctrl+Q did not quit clean manager")
|
||||||
|
}
|
||||||
|
if _, ok := cmd().(tea.QuitMsg); !ok {
|
||||||
|
t.Fatalf("Ctrl+Q command returned %T", cmd())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dirty server form", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenForm
|
||||||
|
m.form = newFormModel(80, 24)
|
||||||
|
m.form.inputs[0].SetValue("prod")
|
||||||
|
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.focus != confirmCancel {
|
||||||
|
t.Fatalf("dirty Ctrl+Q did not open safe discard: screen=%v confirm=%#v", m.screen, m.confirm)
|
||||||
|
}
|
||||||
|
m.confirm.focus = confirmAccept
|
||||||
|
updated, cmd = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("accepted quit confirmation produced no command")
|
||||||
|
}
|
||||||
|
updated, quitCmd := m.Update(cmd())
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if quitCmd == nil || m.confirm != nil || m.form != nil {
|
||||||
|
t.Fatalf("accepted dirty quit did not clear state: confirm=%v form=%v", m.confirm, m.form)
|
||||||
|
}
|
||||||
|
if _, ok := quitCmd().(tea.QuitMsg); !ok {
|
||||||
|
t.Fatalf("accepted dirty quit returned %T", quitCmd())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirtyServerFormRequiresDiscardConfirmation(t *testing.T) {
|
||||||
|
oldList := ListServers
|
||||||
|
t.Cleanup(func() { ListServers = oldList })
|
||||||
|
ListServers = func() ([]*model.Server, error) { return nil, nil }
|
||||||
|
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenForm
|
||||||
|
m.form = newFormModel(80, 24)
|
||||||
|
m.form.inputs[0].SetValue("prod")
|
||||||
|
|
||||||
|
updated, cmd := m.updateForm(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenForm {
|
||||||
|
t.Fatalf("dirty form did not open discard confirmation: screen=%v confirm=%#v", m.screen, m.confirm)
|
||||||
|
}
|
||||||
|
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenForm || m.form == nil || m.form.inputs[0].Value() != "prod" {
|
||||||
|
t.Fatalf("Cancel did not preserve form: screen=%v form=%#v", m.screen, m.form)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ = m.updateForm(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
updated, cmd = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected discard command")
|
||||||
|
}
|
||||||
|
updated, _ = m.Update(cmd())
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenList || m.form != nil || m.confirm != nil {
|
||||||
|
t.Fatalf("discard did not return to list: screen=%v form=%v confirm=%v", m.screen, m.form, m.confirm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanFormsExitWithoutConfirmation(t *testing.T) {
|
||||||
|
oldList := ListServers
|
||||||
|
t.Cleanup(func() { ListServers = oldList })
|
||||||
|
ListServers = func() ([]*model.Server, error) { return nil, nil }
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
screen screen
|
||||||
|
setup func(*tuiModel)
|
||||||
|
exit func(*tuiModel) (tea.Model, tea.Cmd)
|
||||||
|
want screen
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "server",
|
||||||
|
screen: screenForm,
|
||||||
|
setup: func(m *tuiModel) { m.form = newFormModel(80, 24) },
|
||||||
|
exit: func(m *tuiModel) (tea.Model, tea.Cmd) { return m.updateForm(tea.KeyMsg{Type: tea.KeyEsc}) },
|
||||||
|
want: screenList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forward",
|
||||||
|
screen: screenForwardForm,
|
||||||
|
setup: func(m *tuiModel) {
|
||||||
|
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
||||||
|
m.forwardForm = newForwardFormModel(1, 80, 24)
|
||||||
|
},
|
||||||
|
exit: func(m *tuiModel) (tea.Model, tea.Cmd) {
|
||||||
|
return m.updateForwardForm(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
},
|
||||||
|
want: screenForwardList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "template",
|
||||||
|
screen: screenTemplateForm,
|
||||||
|
setup: func(m *tuiModel) { m.templateForm = newTemplateFormModel(nil, 80, 24) },
|
||||||
|
exit: func(m *tuiModel) (tea.Model, tea.Cmd) {
|
||||||
|
return m.updateTemplateForm(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
},
|
||||||
|
want: screenTemplates,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = tt.screen
|
||||||
|
tt.setup(m)
|
||||||
|
updated, _ := tt.exit(m)
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != tt.want || m.confirm != nil {
|
||||||
|
t.Fatalf("clean exit: screen=%v confirm=%v", m.screen, m.confirm)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirtyForwardAndTemplateFormsRequireConfirmation(t *testing.T) {
|
||||||
|
t.Run("forward", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenForwardForm
|
||||||
|
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
||||||
|
m.forwardForm = newForwardFormModel(1, 80, 24)
|
||||||
|
m.forwardForm.nameInput.SetValue("postgres")
|
||||||
|
updated, _ := m.updateForwardForm(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenForwardForm {
|
||||||
|
t.Fatalf("dirty forward form did not confirm: screen=%v confirm=%#v", m.screen, m.confirm)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("template", func(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenTemplateForm
|
||||||
|
m.templateForm = newTemplateFormModel(nil, 80, 24)
|
||||||
|
m.templateForm.inputs[0].SetValue("uptime")
|
||||||
|
updated, _ := m.updateTemplateForm(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTemplateForm {
|
||||||
|
t.Fatalf("dirty template form did not confirm: screen=%v confirm=%#v", m.screen, m.confirm)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiredFieldsAreMarked(t *testing.T) {
|
||||||
|
serverView := newFormModel(100, 30).View()
|
||||||
|
for _, want := range []string{"Alias *", "Host *", "Port *"} {
|
||||||
|
if !strings.Contains(serverView, want) {
|
||||||
|
t.Fatalf("server form missing %q:\n%s", want, serverView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardView := newForwardFormModel(1, 100, 30).View()
|
||||||
|
for _, want := range []string{"Name *", "Listen Port *", "Target Host *", "Target Port *"} {
|
||||||
|
if !strings.Contains(forwardView, want) {
|
||||||
|
t.Fatalf("forward form missing %q:\n%s", want, forwardView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templateView := newTemplateFormModel(nil, 100, 30).View()
|
||||||
|
for _, want := range []string{"Name *", "Command *"} {
|
||||||
|
if !strings.Contains(templateView, want) {
|
||||||
|
t.Fatalf("template form missing %q:\n%s", want, templateView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,65 +79,93 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) View() string {
|
func (m *forwardScreenModel) View() string {
|
||||||
var b strings.Builder
|
footer := renderHelp([]helpItem{
|
||||||
|
|
||||||
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+A (a)", Action: "add"},
|
||||||
{Key: "Ctrl+E/Enter", Action: "edit"},
|
{Key: "Ctrl+E/Enter", Action: "edit"},
|
||||||
{Key: "Ctrl+D (d)", Action: "delete"},
|
{Key: "Ctrl+D (d)", Action: "delete"},
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
}, m.width))
|
}, m.width)
|
||||||
return b.String()
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine("Port Forwards — "+m.serverAlias, m.width))}
|
||||||
|
if m.err != nil {
|
||||||
|
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)), m.width))
|
||||||
|
}
|
||||||
|
|
||||||
|
footerRows := displayLineCount(footer)
|
||||||
|
detailRows := 0
|
||||||
|
if len(m.list) > 0 && m.height-footerRows >= 7 {
|
||||||
|
detailRows = 3
|
||||||
|
}
|
||||||
|
rowCapacity := max(1, m.height-len(lines)-footerRows-detailRows-1)
|
||||||
|
if len(m.list) == 0 {
|
||||||
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, m.renderForwardRow(nil, false))
|
||||||
|
rowCapacity--
|
||||||
|
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
|
||||||
|
}
|
||||||
|
if end < len(m.list) || start > 0 {
|
||||||
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
|
||||||
|
}
|
||||||
|
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
|
||||||
|
forward := m.list[m.selected]
|
||||||
|
lines = append(lines,
|
||||||
|
sectionStyle.Copy().MarginTop(0).Render("Selected"),
|
||||||
|
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
|
||||||
|
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
|
if len(lines) > m.height && m.height > 0 {
|
||||||
|
lines = lines[:m.height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
|
||||||
|
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
|
||||||
|
if forward != nil {
|
||||||
|
if selected {
|
||||||
|
marker = ">"
|
||||||
|
}
|
||||||
|
name = forward.Name
|
||||||
|
if name == "" {
|
||||||
|
name = forward.ForwardListen()
|
||||||
|
}
|
||||||
|
kind = string(forward.Type)
|
||||||
|
listen = forward.ForwardListen()
|
||||||
|
target = forward.ForwardTarget()
|
||||||
|
enabled = "yes"
|
||||||
|
if !forward.Enabled {
|
||||||
|
enabled = "no"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wide := m.width >= 70
|
||||||
|
typeWidth, enabledWidth := 8, 3
|
||||||
|
if wide {
|
||||||
|
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
|
||||||
|
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
|
||||||
|
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
|
||||||
|
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
|
||||||
|
if forward == nil {
|
||||||
|
return listHeaderStyle.Render(fitLine(line, m.width))
|
||||||
|
}
|
||||||
|
if selected {
|
||||||
|
return selectedRowStyle.Render(fitLine(line, m.width))
|
||||||
|
}
|
||||||
|
return fitLine(line, m.width)
|
||||||
|
}
|
||||||
|
nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
|
||||||
|
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
|
||||||
|
if forward == nil {
|
||||||
|
return listHeaderStyle.Render(fitLine(line, m.width))
|
||||||
|
}
|
||||||
|
if selected {
|
||||||
|
return selectedRowStyle.Render(fitLine(line, m.width))
|
||||||
|
}
|
||||||
|
return fitLine(line, m.width)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Forward form screen model ---
|
// --- Forward form screen model ---
|
||||||
|
|
@ -157,6 +185,14 @@ type forwardFormModel struct {
|
||||||
typeIdx int // 0=local, 1=remote, 2=socks
|
typeIdx int // 0=local, 1=remote, 2=socks
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
initial forwardFormSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
type forwardFormSnapshot struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
values []string
|
||||||
|
forwardType model.ForwardType
|
||||||
}
|
}
|
||||||
|
|
||||||
var forwardTypes = []forwardTypeItem{
|
var forwardTypes = []forwardTypeItem{
|
||||||
|
|
@ -182,7 +218,7 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
|
||||||
inputs[i].CharLimit = 128
|
inputs[i].CharLimit = 128
|
||||||
}
|
}
|
||||||
|
|
||||||
return &forwardFormModel{
|
fm := &forwardFormModel{
|
||||||
serverID: serverID,
|
serverID: serverID,
|
||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
focusIdx: 0,
|
focusIdx: 0,
|
||||||
|
|
@ -193,6 +229,9 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
}
|
}
|
||||||
|
fm.updateFocus()
|
||||||
|
fm.initial = fm.snapshot()
|
||||||
|
return fm
|
||||||
}
|
}
|
||||||
|
|
||||||
func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardFormModel {
|
func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardFormModel {
|
||||||
|
|
@ -203,13 +242,48 @@ func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardF
|
||||||
fm.descInput.SetValue(fwd.Description)
|
fm.descInput.SetValue(fwd.Description)
|
||||||
fm.currentType = fwd.Type
|
fm.currentType = fwd.Type
|
||||||
fm.typeIdx = typeIndex(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[0].SetValue(fwd.LocalAddr)
|
||||||
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
|
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
|
||||||
fm.inputs[2].SetValue(fwd.RemoteAddr)
|
fm.inputs[2].SetValue(fwd.RemoteAddr)
|
||||||
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
|
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
|
||||||
|
}
|
||||||
|
fm.updateFocus()
|
||||||
|
fm.initial = fm.snapshot()
|
||||||
return fm
|
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 {
|
func typeIndex(t model.ForwardType) int {
|
||||||
switch t {
|
switch t {
|
||||||
case model.ForwardLocal:
|
case model.ForwardLocal:
|
||||||
|
|
@ -254,14 +328,14 @@ func (fm *forwardFormModel) labelForField(idx int) string {
|
||||||
if idx < 0 || idx >= len(labels) {
|
if idx < 0 || idx >= len(labels) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return labels[idx]
|
return labels[idx] + " *"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case saveDoneMsg:
|
case saveDoneMsg:
|
||||||
fm.saved = (msg.err == nil)
|
fm.saved = (msg.err == nil)
|
||||||
fm.err = msg.err
|
fm.applySaveError(msg.err)
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,7 +439,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
func (fm *forwardFormModel) updateFocus() {
|
func (fm *forwardFormModel) updateFocus() {
|
||||||
fm.nameInput.Blur()
|
fm.nameInput.Blur()
|
||||||
fm.nameInput.Prompt = blurredStyle.Render("Name: ")
|
fm.nameInput.Prompt = blurredStyle.Render("Name *: ")
|
||||||
fm.descInput.Blur()
|
fm.descInput.Blur()
|
||||||
fm.descInput.Prompt = blurredStyle.Render("Description: ")
|
fm.descInput.Prompt = blurredStyle.Render("Description: ")
|
||||||
for i := range fm.inputs {
|
for i := range fm.inputs {
|
||||||
|
|
@ -377,7 +451,7 @@ func (fm *forwardFormModel) updateFocus() {
|
||||||
switch {
|
switch {
|
||||||
case fm.focusIdx == 0:
|
case fm.focusIdx == 0:
|
||||||
fm.nameInput.Focus()
|
fm.nameInput.Focus()
|
||||||
fm.nameInput.Prompt = focusedStyle.Render("Name> ")
|
fm.nameInput.Prompt = focusedStyle.Render("Name *> ")
|
||||||
case fm.focusIdx == 1:
|
case fm.focusIdx == 1:
|
||||||
fm.descInput.Focus()
|
fm.descInput.Focus()
|
||||||
fm.descInput.Prompt = focusedStyle.Render("Description> ")
|
fm.descInput.Prompt = focusedStyle.Render("Description> ")
|
||||||
|
|
@ -395,47 +469,57 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
name := strings.TrimSpace(fm.nameInput.Value())
|
name := strings.TrimSpace(fm.nameInput.Value())
|
||||||
desc := strings.TrimSpace(fm.descInput.Value())
|
desc := strings.TrimSpace(fm.descInput.Value())
|
||||||
|
localAddr, remoteAddr := "", ""
|
||||||
localPort := 0
|
localPort, remotePort := 0, 0
|
||||||
fmt.Sscanf(fm.inputs[1].Value(), "%d", &localPort)
|
var err error
|
||||||
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 == "" {
|
if name == "" {
|
||||||
return saveDoneMsg{err: fmt.Errorf("name is required")}
|
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 {
|
switch fm.currentType {
|
||||||
case model.ForwardLocal:
|
case model.ForwardLocal:
|
||||||
|
localAddr = strings.TrimSpace(fm.inputs[0].Value())
|
||||||
if localAddr == "" {
|
if localAddr == "" {
|
||||||
localAddr = "127.0.0.1"
|
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 == "" {
|
if remoteAddr == "" {
|
||||||
return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")}
|
return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")}
|
||||||
}
|
}
|
||||||
if remotePort < 1 || remotePort > 65535 {
|
remotePort, err = parseNamedPort("Target port", fm.inputs[3].Value())
|
||||||
return saveDoneMsg{err: fmt.Errorf("invalid target port %d: must be 1-65535", remotePort)}
|
if err != nil {
|
||||||
|
return saveDoneMsg{err: err}
|
||||||
}
|
}
|
||||||
case model.ForwardRemote:
|
case model.ForwardRemote:
|
||||||
|
remoteAddr = strings.TrimSpace(fm.inputs[0].Value())
|
||||||
if remoteAddr == "" {
|
if remoteAddr == "" {
|
||||||
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")}
|
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")}
|
||||||
}
|
}
|
||||||
if remotePort < 1 || remotePort > 65535 {
|
remotePort, err = parseNamedPort("Remote listen port", fm.inputs[1].Value())
|
||||||
return saveDoneMsg{err: fmt.Errorf("invalid remote port %d: must be 1-65535", remotePort)}
|
if err != nil {
|
||||||
|
return saveDoneMsg{err: err}
|
||||||
}
|
}
|
||||||
|
localAddr = strings.TrimSpace(fm.inputs[2].Value())
|
||||||
if localAddr == "" {
|
if localAddr == "" {
|
||||||
localAddr = "127.0.0.1"
|
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:
|
case model.ForwardDynamic:
|
||||||
|
localAddr = strings.TrimSpace(fm.inputs[0].Value())
|
||||||
if localAddr == "" {
|
if localAddr == "" {
|
||||||
localAddr = "127.0.0.1"
|
localAddr = "127.0.0.1"
|
||||||
}
|
}
|
||||||
|
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
|
||||||
|
if err != nil {
|
||||||
|
return saveDoneMsg{err: err}
|
||||||
|
}
|
||||||
remoteAddr = ""
|
remoteAddr = ""
|
||||||
remotePort = 0
|
remotePort = 0
|
||||||
}
|
}
|
||||||
|
|
@ -463,70 +547,87 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
|
||||||
if SaveForward == nil {
|
if SaveForward == nil {
|
||||||
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
|
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
|
||||||
}
|
}
|
||||||
err := SaveForward(fwd)
|
err = SaveForward(fwd)
|
||||||
return saveDoneMsg{err: err}
|
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 {
|
func (fm *forwardFormModel) View() string {
|
||||||
var b strings.Builder
|
|
||||||
title := "Add Port Forward"
|
title := "Add Port Forward"
|
||||||
if fm.editMode {
|
if fm.editMode {
|
||||||
title = "Edit Port Forward"
|
title = "Edit Port Forward"
|
||||||
}
|
}
|
||||||
b.WriteString(titleStyle.Render(title))
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
|
||||||
b.WriteString("\n\n")
|
lines = append(lines,
|
||||||
|
fitLine(fm.nameInput.View(), fm.width),
|
||||||
|
fitLine(fm.descInput.View(), fm.width),
|
||||||
|
)
|
||||||
|
|
||||||
// Name
|
typeParts := make([]string, len(forwardTypes))
|
||||||
b.WriteString(fm.nameInput.View())
|
for i, forwardType := range forwardTypes {
|
||||||
b.WriteString("\n")
|
selected := "○"
|
||||||
|
|
||||||
// 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 {
|
if i == fm.typeIdx {
|
||||||
prefix = "▸ "
|
selected = "●"
|
||||||
style = selectedRowStyle
|
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description)
|
focus := " "
|
||||||
b.WriteString(style.Render(line))
|
if fm.focusIdx == 2+i {
|
||||||
b.WriteString("\n")
|
focus = ">"
|
||||||
}
|
}
|
||||||
// Show human-readable explanation for selected type
|
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
|
||||||
if fm.typeIdx >= 0 && fm.typeIdx < len(forwardTypes) {
|
|
||||||
explanations := map[model.ForwardType]string{
|
|
||||||
model.ForwardLocal: "Opens a local port on this machine and forwards it through SSH to the target address.",
|
|
||||||
model.ForwardRemote: "Opens a port on the remote SSH server and forwards it back to this machine.",
|
|
||||||
model.ForwardDynamic: "Creates a local SOCKS proxy that routes all traffic through the SSH server.",
|
|
||||||
}
|
}
|
||||||
if exp, ok := explanations[forwardTypes[fm.typeIdx].value]; ok {
|
lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
|
||||||
b.WriteString(helpStyle.Render(fmt.Sprintf(" %s\n", exp)))
|
if fm.width >= 100 {
|
||||||
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Dynamic fields based on type
|
|
||||||
visible := fm.visibleFields()
|
visible := fm.visibleFields()
|
||||||
for _, idx := range visible {
|
for _, idx := range visible {
|
||||||
b.WriteString(fm.inputs[idx].View())
|
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warning for 0.0.0.0
|
|
||||||
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
|
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
|
||||||
b.WriteString(helpStyle.Render(" ⚠ This port will be accessible from the network.\n"))
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview
|
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
|
||||||
if fm.currentType != "" && fm.inputs[1].Value() != "" {
|
|
||||||
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
|
|
||||||
fwd := &model.Forward{
|
fwd := &model.Forward{
|
||||||
Type: fm.currentType,
|
Type: fm.currentType,
|
||||||
LocalAddr: fm.inputs[0].Value(),
|
LocalAddr: fm.inputs[0].Value(),
|
||||||
|
|
@ -536,37 +637,34 @@ func (fm *forwardFormModel) View() string {
|
||||||
}
|
}
|
||||||
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
|
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
|
||||||
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
||||||
for _, arg := range fwd.ForwardSSHArgs() {
|
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
||||||
b.WriteString(" " + arg + "\n")
|
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
|
||||||
}
|
|
||||||
b.WriteString(" -o ExitOnForwardFailure=yes\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save button
|
|
||||||
total := 2 + 3 + len(visible) + 1
|
total := 2 + 3 + len(visible) + 1
|
||||||
button := "\n[ Save ]"
|
button := " [ Save ]"
|
||||||
if fm.focusIdx == total-1 {
|
if fm.focusIdx == total-1 {
|
||||||
button = selectedStyle.Render(button)
|
button = selectedStyle.Render("> [ Save ]")
|
||||||
}
|
}
|
||||||
b.WriteString(button)
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
if fm.err != nil {
|
if fm.err != nil {
|
||||||
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n")
|
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
|
||||||
}
|
}
|
||||||
if fm.saved {
|
if fm.saved {
|
||||||
b.WriteString(successStyle.Render("✓ Saved.") + "\n\n")
|
lines = append(lines, successStyle.Render("✓ Saved."))
|
||||||
}
|
}
|
||||||
|
lines = append(lines, button)
|
||||||
b.WriteString(renderHelp([]helpItem{
|
footer := renderHelp([]helpItem{
|
||||||
{Key: "Tab/↓", Action: "next"},
|
{Key: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "1/2/3", Action: "select type"},
|
{Key: "1/2/3", Action: "select type"},
|
||||||
{Key: "Enter", Action: "save"},
|
{Key: "Enter", Action: "save"},
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
}, fm.width))
|
}, fm.width)
|
||||||
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
return b.String()
|
if len(lines) > fm.height && fm.height > 0 {
|
||||||
|
lines = lines[:fm.height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// forwardEditSignal is sent when user wants to edit a forward
|
// forwardEditSignal is sent when user wants to edit a forward
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
@ -26,6 +27,51 @@ func TestForwardFormDigitsReachFocusedInput(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteForwardFormMapsListenAndTargetEndpoints(t *testing.T) {
|
||||||
|
oldSave := SaveForward
|
||||||
|
t.Cleanup(func() { SaveForward = oldSave })
|
||||||
|
var saved *model.Forward
|
||||||
|
SaveForward = func(forward *model.Forward) error {
|
||||||
|
copy := *forward
|
||||||
|
saved = ©
|
||||||
|
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) {
|
func TestForwardFormDigitShortcutsWorkOnTypeSelector(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
digit rune
|
digit rune
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,7 @@ func (i actionMenuItem) FilterValue() string { return i.label }
|
||||||
type actionMenuModel struct {
|
type actionMenuModel struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
width int
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newActionMenuModel(w, h int) *actionMenuModel {
|
func newActionMenuModel(w, h int) *actionMenuModel {
|
||||||
|
|
@ -301,7 +302,7 @@ func newActionMenuModel(w, h int) *actionMenuModel {
|
||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
l.Styles.Title = titleStyle
|
l.Styles.Title = titleStyle
|
||||||
|
|
||||||
return &actionMenuModel{list: l, width: w}
|
return &actionMenuModel{list: l, width: w, height: h}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
||||||
|
|
@ -323,5 +324,24 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *actionMenuModel) View() string {
|
func (m *actionMenuModel) View() string {
|
||||||
return m.list.View()
|
footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
|
||||||
|
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
|
||||||
|
capacity := max(1, m.height-displayLineCount(footer)-1)
|
||||||
|
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
item, ok := m.list.Items()[index].(actionMenuItem)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
if index == m.list.Index() {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
lines = append(lines, fitLine(marker+item.label, m.width))
|
||||||
|
}
|
||||||
|
lines = append(lines, strings.Split(footer, "\n")...)
|
||||||
|
if len(lines) > m.height && m.height > 0 {
|
||||||
|
lines = lines[:m.height]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minimumTUIWidth = 60
|
||||||
|
minimumTUIHeight = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
type terminalSizeClass int
|
||||||
|
|
||||||
|
const (
|
||||||
|
sizeBelowFloor terminalSizeClass = iota
|
||||||
|
sizeNarrow
|
||||||
|
sizeMedium
|
||||||
|
sizeWide
|
||||||
|
)
|
||||||
|
|
||||||
|
func classifyTerminal(width, height int) terminalSizeClass {
|
||||||
|
if width > 0 && height > 0 && (width < minimumTUIWidth || height < minimumTUIHeight) {
|
||||||
|
return sizeBelowFloor
|
||||||
|
}
|
||||||
|
if width >= 100 {
|
||||||
|
return sizeWide
|
||||||
|
}
|
||||||
|
if width >= 70 {
|
||||||
|
return sizeMedium
|
||||||
|
}
|
||||||
|
return sizeNarrow
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateCells(value string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ansi.StringWidth(value) <= width {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if width == 1 {
|
||||||
|
return "…"
|
||||||
|
}
|
||||||
|
return ansi.Truncate(value, width, "…")
|
||||||
|
}
|
||||||
|
|
||||||
|
func padCells(value string, width int) string {
|
||||||
|
value = truncateCells(value, width)
|
||||||
|
missing := width - ansi.StringWidth(value)
|
||||||
|
if missing > 0 {
|
||||||
|
value += strings.Repeat(" ", missing)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func fitLine(value string, width int) string {
|
||||||
|
return truncateCells(value, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapCells(value string, width int) []string {
|
||||||
|
if width <= 0 {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
return strings.Split(ansi.Wrap(value, width, ""), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimumSizeView(width int) string {
|
||||||
|
message := "sshkeeper needs at least 60x16"
|
||||||
|
if width <= 0 {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return truncateCells(message, width)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTruncateCellsHandlesUnicodeDisplayWidth(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"production-сервер",
|
||||||
|
"数据库服务器",
|
||||||
|
"e\u0301-combining",
|
||||||
|
"🔐 gateway",
|
||||||
|
}
|
||||||
|
for _, value := range tests {
|
||||||
|
got := truncateCells(value, 8)
|
||||||
|
if width := ansi.StringWidth(got); width > 8 {
|
||||||
|
t.Fatalf("truncateCells(%q) width=%d result=%q", value, width, got)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "…") {
|
||||||
|
t.Fatalf("truncated value has no indicator: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
servers := []*model.Server{
|
||||||
|
{
|
||||||
|
Alias: "staging-数据库-bastion",
|
||||||
|
DisplayName: "Production сервер 🔐 with a very long display name",
|
||||||
|
Host: "bastion.staging.example.net",
|
||||||
|
Port: 2222,
|
||||||
|
User: "operations",
|
||||||
|
AuthMethod: model.AuthAgent,
|
||||||
|
GroupName: "STAGING-LONG",
|
||||||
|
Tags: []string{"stage", "bastion", "кириллица"},
|
||||||
|
},
|
||||||
|
{Alias: "db", DisplayName: "Database", Host: "db.internal", Port: 22, User: "postgres", AuthMethod: model.AuthKeyPassphrase},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
t.Run(strings.Join([]string{itoa(size.width), "x", itoa(size.height)}, ""), func(t *testing.T) {
|
||||||
|
m := New(servers)
|
||||||
|
m.width, m.height = size.width, size.height
|
||||||
|
assertViewFits(t, m.View(), size.width, size.height)
|
||||||
|
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
|
||||||
|
if !strings.Contains(m.View(), want) {
|
||||||
|
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardNotificationFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
m := New([]*model.Server{{Alias: "prod", Host: "prod.example", Port: 22, User: "ops", AuthMethod: model.AuthAgent}})
|
||||||
|
m.width, m.height = size.width, size.height
|
||||||
|
m.err = errText("vault reload failed and this message must remain visible")
|
||||||
|
view := m.View()
|
||||||
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
if !strings.Contains(view, "vault reload failed") {
|
||||||
|
t.Fatalf("dashboard at %dx%d lost notification:\n%s", size.width, size.height, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreensBelowSupportedFloorShowSizeMessage(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.width, m.height = 59, 15
|
||||||
|
view := m.View()
|
||||||
|
if !strings.Contains(view, "60x16") {
|
||||||
|
t.Fatalf("missing supported-size message:\n%s", view)
|
||||||
|
}
|
||||||
|
assertViewFits(t, view, 59, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
t.Run(itoa(size.width), func(t *testing.T) {
|
||||||
|
fm := newFormModel(size.width, size.height)
|
||||||
|
fm.inputs[0].SetValue("prod")
|
||||||
|
fm.inputs[2].SetValue("prod.example")
|
||||||
|
fm.inputs[3].SetValue("not-a-port")
|
||||||
|
fm.err = errText("Port must be a number from 1 to 65535")
|
||||||
|
fm.focusIdx = 3
|
||||||
|
fm.updateFocus()
|
||||||
|
view := fm.View()
|
||||||
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
fm := newForwardFormModel(1, size.width, size.height)
|
||||||
|
fm.nameInput.SetValue("Local PostgreSQL")
|
||||||
|
fm.inputs[0].SetValue("127.0.0.1")
|
||||||
|
fm.inputs[1].SetValue("15432")
|
||||||
|
fm.inputs[2].SetValue("database.internal.example")
|
||||||
|
fm.inputs[3].SetValue("5432")
|
||||||
|
assertViewFits(t, fm.View(), size.width, size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
fm := newForwardScreenModel(1, "production-数据库-bastion", size.width, size.height)
|
||||||
|
fm.list = []*model.Forward{
|
||||||
|
{Name: "Local PostgreSQL with a very long name", Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "database.internal.example", RemotePort: 5432, Enabled: true},
|
||||||
|
{Name: "SOCKS proxy", Type: model.ForwardDynamic, LocalAddr: "127.0.0.1", LocalPort: 1080, Enabled: false},
|
||||||
|
}
|
||||||
|
view := fm.View()
|
||||||
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
menu := newActionMenuModel(size.width, size.height)
|
||||||
|
view := menu.View()
|
||||||
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
m := New(nil)
|
||||||
|
m.width, m.height = size.width, size.height
|
||||||
|
m.beginConfirm(confirmState{
|
||||||
|
title: "Delete port forward?",
|
||||||
|
target: `"Очень длинный Local PostgreSQL 数据库 forward" · 127.0.0.1:15432 → database.internal.example:5432`,
|
||||||
|
consequence: "This removes the saved forwarding rule. Active tunnels are not stopped.",
|
||||||
|
verb: "Delete",
|
||||||
|
parent: screenForwardList,
|
||||||
|
})
|
||||||
|
view := m.View()
|
||||||
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
form := newTemplateFormModel(nil, size.width, size.height)
|
||||||
|
form.inputs[0].SetValue("проверка-数据库")
|
||||||
|
form.inputs[1].SetValue("printf 'a very long command that remains editable'")
|
||||||
|
view := form.View()
|
||||||
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertViewFits(t *testing.T, view string, width, height int) {
|
||||||
|
t.Helper()
|
||||||
|
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
|
||||||
|
if len(lines) > height {
|
||||||
|
t.Fatalf("view has %d lines, terminal height is %d:\n%s", len(lines), height, view)
|
||||||
|
}
|
||||||
|
for index, line := range lines {
|
||||||
|
if lineWidth := ansi.StringWidth(line); lineWidth > width {
|
||||||
|
t.Fatalf("line %d has width %d, terminal width is %d: %q", index+1, lineWidth, width, ansi.Strip(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errText string
|
||||||
|
|
||||||
|
func (e errText) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func itoa(value int) string {
|
||||||
|
const digits = "0123456789"
|
||||||
|
if value == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var out [20]byte
|
||||||
|
index := len(out)
|
||||||
|
for value > 0 {
|
||||||
|
index--
|
||||||
|
out[index] = digits[value%10]
|
||||||
|
value /= 10
|
||||||
|
}
|
||||||
|
return string(out[index:])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVaultStatusTracksSuccessfulLock(t *testing.T) {
|
||||||
|
oldUnlocked, oldLock := VaultUnlocked, LockVault
|
||||||
|
t.Cleanup(func() { VaultUnlocked, LockVault = oldUnlocked, oldLock })
|
||||||
|
VaultUnlocked = func() bool { return true }
|
||||||
|
LockVault = func() error { return nil }
|
||||||
|
|
||||||
|
m := New(nil)
|
||||||
|
if !strings.Contains(m.View(), "Vault unlocked") {
|
||||||
|
t.Fatalf("initial status is not unlocked:\n%s", m.View())
|
||||||
|
}
|
||||||
|
m.actionMenu = newActionMenuModel(80, 24)
|
||||||
|
m.screen = screenActionMenu
|
||||||
|
for i := range m.actionMenu.list.Items() {
|
||||||
|
m.actionMenu.list.Select(i)
|
||||||
|
item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem)
|
||||||
|
if ok && item.action == "vault_lock" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
view := m.View()
|
||||||
|
if !strings.Contains(view, "Vault locked") || strings.Contains(view, "Vault unlocked") {
|
||||||
|
t.Fatalf("status after lock is false:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationSurvivesRepeatedView(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.err = errors.New("reload failed")
|
||||||
|
first := m.View()
|
||||||
|
second := m.View()
|
||||||
|
if !strings.Contains(first, "reload failed") || !strings.Contains(second, "reload failed") {
|
||||||
|
t.Fatalf("notification was consumed by View: first=%q second=%q", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenForwardList
|
||||||
|
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
||||||
|
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenFullHelp || m.fullHelp == nil {
|
||||||
|
t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen)
|
||||||
|
}
|
||||||
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenForwardList || m.fullHelp != nil {
|
||||||
|
t.Fatalf("help did not return to forward list: screen=%v help=%v", m.screen, m.fullHelp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenForwardList
|
||||||
|
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
||||||
|
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenHelp || m.helpScreen == nil {
|
||||||
|
t.Fatalf("? did not open help from manager: screen=%v", m.screen)
|
||||||
|
}
|
||||||
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenForwardList || m.helpScreen != nil {
|
||||||
|
t.Fatalf("q did not return to manager: screen=%v help=%v", m.screen, m.helpScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpShortcutDoesNotStealQuestionMarkFromSearch(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenSearch
|
||||||
|
m.searchInput.Focus()
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenSearch || m.searchInput.Value() != "?" {
|
||||||
|
t.Fatalf("search input lost printable rune: screen=%v value=%q", m.screen, m.searchInput.Value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResizePropagatesToActiveChildren(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.form = newFormModel(80, 24)
|
||||||
|
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
|
||||||
|
m.forwardForm = newForwardFormModel(1, 80, 24)
|
||||||
|
m.templateForm = newTemplateFormModel(nil, 80, 24)
|
||||||
|
m.tunnelScreen = newTunnelScreenModel(80, 24)
|
||||||
|
m.helpScreen = newHelpScreenModel(80, 24)
|
||||||
|
m.fullHelp = newFullHelpModel(80, 24)
|
||||||
|
m.actionMenu = newActionMenuModel(80, 24)
|
||||||
|
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 16})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.form.width != 60 || m.form.height != 16 ||
|
||||||
|
m.forwardScreen.width != 60 || m.forwardScreen.height != 16 ||
|
||||||
|
m.forwardForm.width != 60 || m.forwardForm.height != 16 ||
|
||||||
|
m.templateForm.width != 60 || m.templateForm.height != 16 ||
|
||||||
|
m.tunnelScreen.width != 60 || m.tunnelScreen.height != 16 ||
|
||||||
|
m.helpScreen.width != 60 || m.fullHelp.width != 60 || m.fullHelp.height != 16 {
|
||||||
|
t.Fatalf("resize did not reach every child: %#v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ type templateFormModel struct {
|
||||||
saved bool
|
saved bool
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
initial []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel {
|
func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel {
|
||||||
|
|
@ -44,9 +45,31 @@ func newTemplateFormModel(t *model.CommandTemplate, w, h int) *templateFormModel
|
||||||
inputs[2].SetValue(t.Description)
|
inputs[2].SetValue(t.Description)
|
||||||
}
|
}
|
||||||
tf.updateFocus()
|
tf.updateFocus()
|
||||||
|
tf.initial = tf.snapshot()
|
||||||
return tf
|
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 {
|
func (tf *templateFormModel) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -89,14 +112,21 @@ func (tf *templateFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
func (tf *templateFormModel) updateFocus() {
|
func (tf *templateFormModel) updateFocus() {
|
||||||
for i := range tf.inputs {
|
for i := range tf.inputs {
|
||||||
tf.inputs[i].Blur()
|
tf.inputs[i].Blur()
|
||||||
tf.inputs[i].Prompt = blurredStyle.Render(tf.labels[i] + ": ")
|
tf.inputs[i].Prompt = blurredStyle.Render(tf.labelAt(i) + ": ")
|
||||||
}
|
}
|
||||||
if tf.focusIdx < len(tf.inputs) {
|
if tf.focusIdx < len(tf.inputs) {
|
||||||
tf.inputs[tf.focusIdx].Focus()
|
tf.inputs[tf.focusIdx].Focus()
|
||||||
tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labels[tf.focusIdx] + "> ")
|
tf.inputs[tf.focusIdx].Prompt = focusedStyle.Render(tf.labelAt(tf.focusIdx) + "> ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (tf *templateFormModel) labelAt(index int) string {
|
||||||
|
if index == 0 || index == 1 {
|
||||||
|
return tf.labels[index] + " *"
|
||||||
|
}
|
||||||
|
return tf.labels[index]
|
||||||
|
}
|
||||||
|
|
||||||
func (tf *templateFormModel) save() tea.Cmd {
|
func (tf *templateFormModel) save() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if SaveCommandTemplate == nil {
|
if SaveCommandTemplate == nil {
|
||||||
|
|
@ -126,15 +156,15 @@ func (tf *templateFormModel) View() string {
|
||||||
if tf.edit {
|
if tf.edit {
|
||||||
title = "Edit Template"
|
title = "Edit Template"
|
||||||
}
|
}
|
||||||
b.WriteString(titleStyle.Render(title))
|
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
for i := range tf.inputs {
|
for i := range tf.inputs {
|
||||||
b.WriteString(tf.inputs[i].View())
|
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
button := " [ Save ]"
|
button := " [ Save ]"
|
||||||
if tf.focusIdx == len(tf.inputs) {
|
if tf.focusIdx == len(tf.inputs) {
|
||||||
button = selectedStyle.Render(button)
|
button = selectedStyle.Render("> [ Save ]")
|
||||||
}
|
}
|
||||||
b.WriteString("\n" + button + "\n\n")
|
b.WriteString("\n" + button + "\n\n")
|
||||||
if tf.err != nil {
|
if tf.err != nil {
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,10 @@ func (m *tunnelScreenModel) View() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(m.list.View())
|
b.WriteString(m.list.View())
|
||||||
b.WriteString("\n\n")
|
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{
|
b.WriteString(renderHelp([]helpItem{
|
||||||
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
|
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
|
||||||
{Key: "Ctrl+R (r)", Action: "refresh"},
|
{Key: "Ctrl+R (r)", Action: "refresh"},
|
||||||
|
|
|
||||||