Compare commits

..

No commits in common. "main" and "codex/tui-ux-redesign" have entirely different histories.

30 changed files with 464 additions and 1884 deletions

View File

@ -1,71 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Formatting is platform independent, so check it once rather than twice.
- name: gofmt
if: matrix.os == 'ubuntu-latest'
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:" >&2
echo "$unformatted" >&2
exit 1
fi
- name: go vet
run: go vet ./...
- name: go test
run: go test ./... -count=1
cross-build:
name: cross-build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Mirrors the release targets, so a platform-specific break surfaces on
# the pull request rather than at tag time.
- name: build all release targets
run: |
set -euo pipefail
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
goos="${target%/*}"
goarch="${target#*/}"
echo "==> ${goos}/${goarch}"
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
go build -trimpath -o /tmp/sshkeeper-ci-build .
done

View File

@ -1,94 +0,0 @@
name: Nightly
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
# Two pushes in quick succession must not race for the rolling tag. Let the
# newer commit win rather than publishing a nightly built from older code.
concurrency:
group: nightly
cancel-in-progress: true
jobs:
nightly:
name: publish nightly
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Cheaper than the full release-check, but still refuses to publish a
# broken build.
- name: test
run: |
go vet ./...
go test ./... -count=1
# Version discovery is pinned to v* tags (see build.sh), so the rolling
# nightly tag below cannot hijack this value.
- name: resolve version
id: version
run: echo "value=$(git describe --tags --match 'v*' --always)" >> "$GITHUB_OUTPUT"
- name: build artifacts
env:
VERSION: ${{ steps.version.outputs.value }}
run: ./release.sh "$VERSION"
# Move the rolling tag before touching the release: a GitHub release must
# point at a tag, and this one always tracks the tip of main.
- name: move nightly tag
run: |
set -euo pipefail
git tag -f nightly
git push -f origin nightly
# Replace rather than update: assets are immutable once uploaded, so the
# old release has to go before the new archives can take its name.
- name: replace nightly release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.value }}
run: |
set -euo pipefail
# Heredoc, not an inline string: the notes are markdown and must not
# inherit this file's YAML indentation.
cat > /tmp/nightly-notes.md <<EOF
Automated build from the tip of \`main\`, rebuilt on every push.
**This is not a stable release.** It is untagged, unannounced and may be
broken. The \`Latest\` badge stays on the newest \`v*\` release, which is
what you want for normal use.
| | |
|---|---|
| Version | \`${VERSION}\` |
| Commit | ${GITHUB_SHA} |
| Built | $(date -u '+%Y-%m-%d %H:%M UTC') |
Verify downloads against \`checksums.txt\`.
EOF
gh release delete nightly --yes || echo "no previous nightly release"
gh release create nightly \
--prerelease \
--title "sshkeeper nightly (${VERSION})" \
--notes-file /tmp/nightly-notes.md \
"dist/sshkeeper_${VERSION}_linux_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_linux_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
dist/checksums.txt

View File

@ -1,65 +0,0 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
name: publish ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
# Full history and tags: release.sh derives SOURCE_DATE_EPOCH from the
# tagged commit, and version discovery needs the v* tags to be present.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Gate the release on the same checks used locally. A red suite must not
# be able to publish.
- name: release checks
run: make release-check
# Build through release.sh rather than reimplementing packaging here, so
# CI and a local ./release.sh produce byte-identical archives.
- name: build artifacts
env:
VERSION: ${{ github.ref_name }}
run: ./release.sh "$VERSION"
- name: publish
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ github.ref_name }}
run: |
set -euo pipefail
# A hand-written docs/releases/<tag>.md wins; otherwise fall back to
# GitHub's generated changelog.
notes="docs/releases/${VERSION}.md"
if [ -f "$notes" ]; then
echo "Using hand-written notes from $notes"
set -- --notes-file "$notes"
else
echo "No $notes, generating notes from commit history"
set -- --generate-notes
fi
gh release create "$VERSION" \
--title "sshkeeper $VERSION" \
--verify-tag \
"$@" \
"dist/sshkeeper_${VERSION}_linux_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_linux_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
dist/checksums.txt

View File

@ -111,16 +111,11 @@ sshkeeper / Servers Vault unlocked · 1 profile
Press `?` outside text editors for a compact hotkey reference. Inside forms and Press `?` outside text editors for a compact hotkey reference. Inside forms and
search, `?` remains normal text input. search, `?` remains normal text input.
### Full Help (Ctrl+H) ### Full Help (F1)
Press `Ctrl+H` on any screen for full documentation including routes, port Press `F1` on any screen for full documentation including routes, port
forwarding, tunnels, and vault. forwarding, tunnels, and vault.
`Ctrl+H` is the BS control character (0x08). xterm and most modern emulators
send DEL (0x7F) for Backspace, so help and text editing never collide. A
terminal configured to send BS for Backspace cannot distinguish the two; switch
it to DEL (in xterm, `backarrowKey: false`).
### Screenshots ### Screenshots
| Wide dashboard | Dashboard 80x24 | Server form 60x16 | | Wide dashboard | Dashboard 80x24 | Server form 60x16 |
@ -143,7 +138,7 @@ it to DEL (in xterm, `backarrowKey: false`).
| Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete, import/export, vault actions) | | Ctrl+X | Action menu (connect, tunnels, forwards, route, test, edit, delete, import/export, vault actions) |
| Ins | Select / deselect a server | | Ins | Select / deselect a server |
| ? | Quick help (hotkeys) | | ? | Quick help (hotkeys) |
| Ctrl+H | Full documentation | | F1 | Full documentation |
| Ctrl+Q / Ctrl+C | Quit | | Ctrl+Q / Ctrl+C | Quit |
Templates are global entities and can run on any server. Foreground template Templates are global entities and can run on any server. Foreground template

View File

@ -4,10 +4,7 @@ set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
APP=sshkeeper APP=sshkeeper
# --match 'v*' keeps the rolling `nightly` tag from hijacking the version: a VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
# plain `git describe --tags` picks whichever tag is nearest, so a nightly build
# would otherwise stamp binaries "nightly" instead of v<last release>-N-g<sha>.
VERSION=$(git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo "dev")
LDFLAGS="-s -w -X main.version=${VERSION}" LDFLAGS="-s -w -X main.version=${VERSION}"
echo "==> Building ${APP} ${VERSION}..." echo "==> Building ${APP} ${VERSION}..."

View File

@ -199,7 +199,6 @@ func init() {
forwardAddCmd.Flags().String("name", "", "Forward name") forwardAddCmd.Flags().String("name", "", "Forward name")
forwardAddCmd.Flags().String("description", "", "Forward description") forwardAddCmd.Flags().String("description", "", "Forward description")
forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address") forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
forwardAddCmd.Flags().Int("local-port", 0, "Listen port")
forwardAddCmd.MarkFlagRequired("local-port") forwardAddCmd.MarkFlagRequired("local-port")
forwardAddCmd.Flags().String("remote-addr", "", "Target address") forwardAddCmd.Flags().String("remote-addr", "", "Target address")
forwardAddCmd.Flags().Int("remote-port", 0, "Target port") forwardAddCmd.Flags().Int("remote-port", 0, "Target port")

View File

@ -7,22 +7,8 @@ import (
"github.com/mirivlad/sshkeeper/internal/db" "github.com/mirivlad/sshkeeper/internal/db"
"github.com/mirivlad/sshkeeper/internal/model" "github.com/mirivlad/sshkeeper/internal/model"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
// restoreFlags returns every flag touched during the test back to its default.
// The cobra commands are package-level singletons, so parsing argv into one
// leaks state into whatever test runs next.
func restoreFlags(t *testing.T, cmd *cobra.Command) {
t.Helper()
t.Cleanup(func() {
cmd.Flags().Visit(func(f *pflag.Flag) {
_ = f.Value.Set(f.DefValue)
f.Changed = false
})
})
}
func TestForwardEditUpdatesEnabledFlag(t *testing.T) { func TestForwardEditUpdatesEnabledFlag(t *testing.T) {
testDB, err := db.Open(t.TempDir()) testDB, err := db.Open(t.TempDir())
if err != nil { if err != nil {
@ -124,70 +110,3 @@ func TestForwardAddStoresNameAndDescription(t *testing.T) {
t.Fatalf("unexpected forward metadata: %#v", forwards[0]) t.Fatalf("unexpected forward metadata: %#v", forwards[0])
} }
} }
// TestForwardAddParsesItsOwnFlags drives the real forwardAddCmd flag set the
// way the CLI does, instead of handing RunE a command built by the test.
//
// Regression: RunE read --local-port and init() marked it required, but the
// flag was never registered on forwardAddCmd. Cobra silently ignores
// MarkFlagRequired for an unknown flag and GetInt returns 0 for one, so every
// real invocation died on "invalid local port 0" while the sibling tests --
// which registered the flag on a throwaway command themselves -- kept passing.
func TestForwardAddParsesItsOwnFlags(t *testing.T) {
testDB, err := db.Open(t.TempDir())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer testDB.Close()
previousDB := appDB
appDB = testDB
t.Cleanup(func() { appDB = previousDB })
server := &model.Server{Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
if err := appDB.CreateServer(server); err != nil {
t.Fatalf("create server: %v", err)
}
restoreFlags(t, forwardAddCmd)
if err := forwardAddCmd.Flags().Parse([]string{
"--name", "Local PostgreSQL",
"--type", "local",
"--local-port", "15432",
"--remote-addr", "db01.internal.example.com",
"--remote-port", "5432",
}); err != nil {
t.Fatalf("parse forward add flags: %v", err)
}
if err := forwardAddCmd.RunE(forwardAddCmd, []string{"web"}); err != nil {
t.Fatalf("add forward: %v", err)
}
forwards, err := appDB.GetForwards(server.ID)
if err != nil {
t.Fatalf("get forwards: %v", err)
}
if len(forwards) != 1 {
t.Fatalf("expected one forward, got %d", len(forwards))
}
got := forwards[0]
if got.LocalPort != 15432 {
t.Fatalf("local port not carried through: got %d, want 15432", got.LocalPort)
}
if got.Type != model.ForwardLocal || got.RemoteAddr != "db01.internal.example.com" || got.RemotePort != 5432 {
t.Fatalf("unexpected forward: %#v", got)
}
}
// TestForwardAddRequiresLocalPort pins the flag's registration and its required
// annotation, which is what MarkFlagRequired silently failed to attach.
func TestForwardAddRequiresLocalPort(t *testing.T) {
flag := forwardAddCmd.Flags().Lookup("local-port")
if flag == nil {
t.Fatal("forward add does not register --local-port")
}
if _, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]; !ok {
t.Fatal("--local-port is registered but not marked required")
}
}

View File

@ -170,7 +170,7 @@ Auth: agent Group: - Status: ?
| `Ctrl+F` | Поиск | | `Ctrl+F` | Поиск |
| `Ins` | Выбрать/снять выбор | | `Ins` | Выбрать/снять выбор |
| `?` | Краткая справка по клавишам | | `?` | Краткая справка по клавишам |
| `Ctrl+H` | Полная справка по приложению | | `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход | | `Ctrl+Q` | Выход |
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые `Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
@ -179,7 +179,7 @@ Auth: agent Group: - Status: ?
### Быстрая справка по клавишам ### Быстрая справка по клавишам
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?` Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
остаётся обычным вводом. `Ctrl+H` открывает полную справку также из форм. остаётся обычным вводом. `F1` открывает полную справку также из форм.
``` ```
sshkeeper — Quick Help sshkeeper — Quick Help
@ -211,20 +211,14 @@ sshkeeper — Quick Help
Other Other
? This quick help ? This quick help
Ctrl+H Full documentation F1 Full documentation
Esc / Enter / ? / q — close Esc / Enter / ? / q — close
``` ```
### Полная справка по приложению ### Полная справка по приложению
Нажмите `Ctrl+H` на любом экране. Это полная документация по sshkeeper: Нажмите `F1` на любом экране. Это полная документация по sshkeeper:
> **Про терминалы.** `Ctrl+H` — это управляющий символ BS (0x08), а Backspace в
> xterm и большинстве современных эмуляторов шлёт DEL (0x7F), поэтому справка и
> редактирование текста не конфликтуют. Если ваш терминал настроен отправлять BS
> по Backspace, различить их невозможно: Backspace начнёт открывать справку.
> В этом случае переключите терминал на DEL (в xterm — `backarrowKey: false`).
``` ```
sshkeeper — Full Help sshkeeper — Full Help
@ -244,7 +238,7 @@ sshkeeper — Full Help
Enter Select / Confirm / Open Enter Select / Confirm / Open
Esc Back / Cancel / Close Esc Back / Cancel / Close
? Quick help (hotkeys) ? Quick help (hotkeys)
Ctrl+H Full documentation F1 Full documentation
Ctrl+Q Quit Ctrl+Q Quit
Server list Server list
@ -749,7 +743,7 @@ sshkeeper connect secure
| `Ctrl+X` | Меню действий | | `Ctrl+X` | Меню действий |
| `Ins` | Выбрать/снять выбор | | `Ins` | Выбрать/снять выбор |
| `?` | Краткая справка по клавишам | | `?` | Краткая справка по клавишам |
| `Ctrl+H` | Полная справка по приложению | | `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход | | `Ctrl+Q` | Выход |
### Меню действий (Ctrl+X) ### Меню действий (Ctrl+X)

View File

@ -1,41 +1,10 @@
# Release Packaging # Release Packaging
Releases are published by GitHub Actions. Pushing a `v*` tag is the whole This document describes the manual release flow for sshkeeper.
release procedure; the rest of this document describes what that automation
runs, and how to reproduce it by hand when needed.
## Workflows
| Workflow | Trigger | Result |
|----------|---------|--------|
| `ci.yml` | push to `main`, every pull request | `gofmt`, `go vet`, `go test` on Linux and macOS, plus a cross-build of all five release targets |
| `release.yml` | push of a `v*` tag | runs `make release-check`, then `release.sh`, then publishes the GitHub release |
| `nightly.yml` | push to `main` | rebuilds the tip of `main` and replaces the `nightly` prerelease |
`release.yml` builds through `release.sh` rather than reimplementing packaging,
so CI and a local run stay in step. See [Reproducibility](#reproducibility) for
what that guarantees.
### Release notes
`release.yml` looks for `docs/releases/<tag>.md`. If that file exists it becomes
the release body; otherwise GitHub generates notes from commit history. Write
the file before pushing the tag when a release deserves a real description.
### The nightly prerelease
`nightly.yml` force-moves a rolling `nightly` tag to the tip of `main` and
republishes a prerelease from it. It is marked prerelease deliberately, so
GitHub's `Latest` badge stays on the newest `v*` release.
Because that tag moves, version discovery in `build.sh` and `release.sh` is
pinned with `--match 'v*'`. Without the filter `git describe` would select
`nightly` and stamp binaries with it instead of `v<last release>-<n>-g<sha>`.
Keep the filter if you touch those scripts.
## Create a Tag ## Create a Tag
Use a semantic version tag. Pushing it is what triggers `release.yml`: Use a semantic version tag:
```bash ```bash
git status --short git status --short
@ -43,14 +12,8 @@ git tag -a v0.2.0 -m "sshkeeper v0.2.0"
git push origin v0.2.0 git push origin v0.2.0
``` ```
The remaining sections describe the manual equivalent, which is still the way The release script uses `git describe --tags --always --dirty` by default. You
to test packaging locally or to recover if Actions is unavailable. can also pass the version explicitly:
The release script uses `git describe --tags --match 'v*' --always --dirty` by
default. The `--match 'v*'` filter matters: nightly builds move a `nightly` tag
across `main`, and without the filter `git describe` would pick that tag and
stamp binaries `nightly` instead of `v<last release>-<n>-g<sha>`. You can also
pass the version explicitly:
```bash ```bash
./release.sh v0.2.0 ./release.sh v0.2.0
@ -124,40 +87,9 @@ sha256sum -c checksums.txt
Expected result: every archive reports `OK`. Expected result: every archive reports `OK`.
## Reproducibility
Rebuilding the same commit with the same Go version reproduces the **binaries**
byte for byte. `release.sh` pins everything that would otherwise vary:
- `-trimpath` and `CGO_ENABLED=0` keep build paths and the host toolchain out
of the binary;
- `SOURCE_DATE_EPOCH` (the commit timestamp) sets every archive mtime;
- `tar --sort=name --owner=0 --group=0 --numeric-owner` fixes entry order and
ownership, and `gzip -n` drops the compression timestamp;
- `normalize_package` forces 755 on directories and the program and 644 on
everything else, so the builder's umask cannot leak into the archive.
- the Windows zip is packaged under `LC_ALL=C` and `TZ=UTC`, because `sort`
orders entries by locale and zip stores DOS local time with no zone.
With those in place the archives themselves reproduce across hosts: a build on
`ubuntu-latest` (umask 022, C locale, UTC) and one on a workstation (umask 002,
`ru_RU.UTF-8`, UTC+08) produce identical checksums for all five archives.
The strongest check is still the binary, since it does not depend on the host's
`tar` and `gzip` at all:
```bash
tar -xzf sshkeeper_<version>_linux_amd64.tar.gz
sha256sum sshkeeper_<version>_linux_amd64/sshkeeper
```
Comparing whole-archive hashes works too, as long as both builds used the same
Go version.
## Publish in GitHub Release ## Publish in GitHub Release
`release.yml` does this automatically on tag push. To publish by hand, upload: Upload these files to the release:
- all five platform archives - all five platform archives
- `checksums.txt` - `checksums.txt`

View File

@ -1,140 +0,0 @@
Release automation and reproducible packaging. sshkeeper itself behaves exactly
as in v0.3.1 — no functional changes to the TUI or the CLI.
This is also the first release published by GitHub Actions rather than by hand.
## In this release
**Archives are now reproducible.** Rebuilding a tag on a different machine used
to produce different checksums even when every packaged file was byte-identical,
because three host properties leaked into the archives:
| Leak | Effect |
|------|--------|
| File modes followed the builder's umask | umask 002 packaged `664`/`775`, umask 022 packaged `644`/`755` |
| `sort` orders entries by locale | a `ru_RU.UTF-8` host emitted `docs/` before `LICENSE`, a C locale the reverse |
| zip stores DOS local time with no zone | the same commit embedded `19:06` at UTC+08 and `11:06` at UTC |
All three are pinned now. A build on `ubuntu-latest` and one on a workstation
with a different umask, locale and timezone produce identical checksums for all
five archives. The binaries were always reproducible; only the packaging varied.
**CI.** The repository previously had no automation. It now runs `gofmt`,
`go vet` and `go test` on Linux *and* macOS for every push and pull request,
plus a cross-build of all five release targets. macOS is a stated release
target that until now was only ever cross-compiled, never tested.
**Releases are automated.** Pushing a `v*` tag runs the release checks, builds
through the same `release.sh` used locally, and publishes. Nightly builds from
`main` are published as a separate `nightly` prerelease, so the `Latest` badge
always points at a real release.
---
# Everything since v0.2.0
## Breaking change: full help moved off F1
**`Ctrl+H` opens full help. `F1` no longer has any binding.** `?` still opens
contextual quick help outside text editors. This landed in v0.3.0.
`Ctrl+H` is the BS control character (0x08). xterm and most modern emulators
send DEL (0x7F) for Backspace, so help and text editing do not collide. A
terminal configured to send BS for Backspace cannot tell them apart; switch it
to DEL (in xterm, `backarrowKey: false`).
Nothing else requires action when upgrading. Vaults, server profiles and stored
port forwards are unchanged, and no migration runs.
## The TUI was rebuilt around one shell (v0.3.0)
In v0.2.0 only the server dashboard had a real layout. Other screens rendered
free-form strings or the default Bubbles list frame, so they had no shared
width budget, no borders, and footers that floated wherever the content ended.
Every full-screen state now shares one contract: a header with breadcrumb and
truthful vault status, a separator, framed content panels, and a contextual
footer anchored to the last terminal row.
- Actions, search, tag input, confirmations and both help screens render inside
the shell.
- The port forward manager and editor use framed, width-budgeted layouts. Column
widths derive from the panel's inner width, so no row consumes the terminal's
last column.
- Tag, command template, template picker/mode/results and tunnel managers use
framed lists with a `>` selection marker, so selection never depends on colour
alone.
- Server and template editors use a framed form panel with the title moved into
the breadcrumb. Required markers, validation and dirty-state confirmation are
unchanged.
**Responsive layouts.** The supported floor is `60x16`. Wide (100+ columns)
shows two panels, medium (7099) stacks them, narrow (6069) keeps a single
compact panel. Below the floor only the minimum-size message renders. Long
ASCII, Cyrillic, CJK, combining and emoji content truncates by display cells
rather than byte count.
**Safety.** Destructive actions confirm with Cancel selected first and name the
exact target and its consequence. Status and help context stay truthful to
actual vault and connection state. Form validation prevents silent loss of
edits.
## `forward add` was completely broken (fixed in v0.3.1)
`sshkeeper forward add` could never succeed in v0.2.0 or v0.3.0. The command
read `--local-port` and marked it required, but the flag was never registered,
so cobra rejected it as unknown and the value fell back to 0:
```
$ sshkeeper forward add web --type local --local-port 15432 \
--remote-addr 127.0.0.1 --remote-port 5432
unknown flag: --local-port
```
No combination of arguments worked. Both documented forms work now:
```
$ sshkeeper forward add web --name "Local PostgreSQL" --type local \
--local-port 15432 --remote-addr db01.internal.example.com --remote-port 5432
✓ Forward added [1]
$ sshkeeper forward add web --name "SOCKS proxy" --type dynamic --local-port 1080
✓ Forward added [2]
```
Omitting the flag now reports `required flag(s) "local-port" not set` instead of
a misleading port-range error. The TUI (`Ctrl+W`) was never affected.
The command's tests had constructed their own throwaway cobra command and
registered the flags by hand, so the real command's registration was never
exercised and the suite passed against a broken command. Coverage now parses
argv into the actual command. An audit of every other command found no further
flag that is read but never registered.
## Also fixed since v0.2.0
- Port forward fields accept digits correctly (`10bcc07`).
- Platform and repository status are stated accurately in the docs: Linux and
macOS are primary release targets, Windows is experimental.
## Release-by-release
| Version | Contents |
|---------|----------|
| v0.3.0 | Unified TUI shell, responsive layouts, `F1``Ctrl+H` |
| v0.3.1 | `forward add` fix |
| v0.3.2 | Reproducible packaging, CI, automated releases |
## Install
```bash
tar -xzf sshkeeper_v0.3.2_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.3.2_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
```
Verify downloads against `checksums.txt`. Linux and macOS are the primary
release targets; the Windows build is experimental and needs OpenSSH Client
available as `ssh.exe` on `PATH`.
To verify a build yourself, check out the tag and run `./release.sh v0.3.2`
the checksums should match this release exactly, given the same Go version.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,141 +0,0 @@
# Unified sshkeeper TUI Shell Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Apply the approved dashboard-style full-screen shell to every sshkeeper TUI screen and replace F1 help with Ctrl+H.
**Architecture:** Add one pure screen-shell renderer that owns header, notification, bordered content height, inner width, and bottom footer. Refactor each child screen to supply bounded body panels and contextual help rather than free-form terminal strings; retain existing Bubble Tea state and callback boundaries.
**Tech Stack:** Go 1.25, Bubble Tea v1.3.10, Bubbles v1.0.0, Lip Gloss v1.1.0, charmbracelet/x/ansi v0.11.6, tmux/xterm/Xvfb runtime capture.
## Global Constraints
- The existing server dashboard is the visual reference.
- Supported terminal floor is exactly `60x16`.
- Breakpoints are narrow 60-69, medium 70-99, and wide 100+ columns.
- Every screen has a header, framed bounded content, and footer anchored to the bottom.
- No rendered row directly consumes the last terminal column.
- `Ctrl+H` is global full help; remove `F1` from runtime and documentation.
- Printable input ownership, destructive safety, validation, dirty state, and callback boundaries remain intact.
- No release publication or release-metadata changes.
- Commit and push after every task.
---
### Task 1: Shared shell and help binding
**Files:**
- Create: `internal/tui/shell.go`
- Create: `internal/tui/shell_test.go`
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/help.go`
- Modify: `internal/tui/help_screen.go`
- Modify: `internal/tui/status_help_test.go`
**Interfaces:**
- Produces: `screenShell`, `renderScreenShell(screenShell) string`, `renderBodyPanel(width, height int, lines []string) string`, and `footerAtBottom(body, footer string, height int) string`.
- Consumes: existing `renderPanel`, display-cell helpers, root vault/notification state, and child body strings.
- [ ] Add failing tests that render representative action/help/confirm states at 120x40, 80x24, and 60x16 and assert header, border, exact height, last-row footer, one-cell right safety margin, and no `F1` text.
- [ ] Add failing root-event tests proving `tea.KeyCtrlH` opens full help from list, manager, and form; `tea.KeyBackspace` still reaches a focused text input; and closing help restores its parent.
- [ ] Run `go test ./internal/tui -run 'Test(ScreenShell|CtrlH|Backspace|NoF1)' -count=1` and confirm failures identify missing shell/binding behavior.
- [ ] Implement the shell with a spec carrying `breadcrumb`, `status`, `notification`, `body`, `footer`, `width`, and `height`; calculate body height after wrapped footer rows and render content inside a bounded panel.
- [ ] Replace the root F1 branch with `tea.KeyCtrlH`, remove F1 entries from quick/full help content, and preserve active confirmation ownership.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `feat: add unified tui screen shell` and push.
### Task 2: Actions, help, search, and confirmations
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/help_screen.go`
- Modify: `internal/tui/layout_test.go`
- Modify: `internal/tui/status_help_test.go`
**Interfaces:**
- Consumes: `renderScreenShell` and `renderBodyPanel` from Task 1.
- Produces: shell-backed action menu, quick/full help, search, tag input, and confirmation views.
- [ ] Add failing render tests for action selection at every breakpoint, long help rows, search input, tag input, and long Unicode confirmation content; assert selection/focus markers and bottom footer.
- [ ] Run `go test ./internal/tui -run 'Test(ActionShell|HelpShell|InputShell|ConfirmationShell)' -count=1` and confirm current free-form views fail.
- [ ] Render actions as a bounded list with a wide description panel and stacked medium description; render help as a scrolling framed body; render search/tag input inside a form panel; render confirmation as a bounded dialog within the shell.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture action, help, and confirmation in real xterm at 120x40, 80x24, and 60x16; inspect header, border, focus, footer, and right margin.
- [ ] Commit as `feat: unify tui actions and help screens` and push.
### Task 3: Port forward manager and form
**Files:**
- Modify: `internal/tui/forward.go`
- Modify: `internal/tui/forward_test.go`
- Modify: `internal/tui/layout_test.go`
**Interfaces:**
- Consumes: shared shell and panel primitives.
- Produces: responsive framed forward table/details and framed forward editor.
- [ ] Add failing tests with long ASCII/Cyrillic/CJK/emoji names and endpoints at all three sizes. Assert every ANSI-stripped line is at most `width-1`, both table and footer stay within height, and footer occupies the final rows.
- [ ] Add table-driven tests for wide two-panel, medium stacked, and narrow compact column sets.
- [ ] Run `go test ./internal/tui -run 'Test(ForwardManagerShell|ForwardFormShell|ForwardColumns)' -count=1` and confirm overflow/frame/footer failures.
- [ ] Derive every table width from panel inner width with a one-cell safety margin; add framed table/details layouts per breakpoint and move form content into the shared framed shell.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture forward list and form at all three sizes in xterm, inspect for wrapping/overflow, and add a failing regression test before correcting any observed defect.
- [ ] Commit as `feat: redesign tui port forward screens` and push.
### Task 4: Tags, templates, results, and tunnels
**Files:**
- Modify: `internal/tui/app.go`
- Modify: `internal/tui/tunnel.go`
- Modify: `internal/tui/template_form.go`
- Modify: `internal/tui/layout_test.go`
**Interfaces:**
- Consumes: shared shell and bounded list/panel primitives.
- Produces: shell-backed tag manager, template manager/form/picker/mode/results, and tunnel manager.
- [ ] Add a screen-inventory render test covering normal, empty, error, and selected states for every manager at 120x40, 80x24, and 60x16.
- [ ] Run `go test ./internal/tui -run 'Test(ManagerScreenInventory|TunnelShell|TemplateShell|TagShell)' -count=1` and confirm missing frames/footer anchoring.
- [ ] Replace default Bubbles list rendering and free-form strings with bounded viewport rows inside framed panels. Keep exact selection, scroll index, and contextual actions.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Capture every manager family at 80x24 and its densest/longest state at 60x16; inspect borders, focus, footer, empty/error copy, and truncation.
- [ ] Commit as `feat: unify tui manager screens` and push.
### Task 5: Server form and complete screen matrix
**Files:**
- Modify: `internal/tui/form.go`
- Modify: `internal/tui/template_form.go`
- Modify: `internal/tui/layout_test.go`
- Modify: `internal/tui/form_validation_test.go`
**Interfaces:**
- Consumes: shared shell and existing focus-centered form viewport.
- Produces: framed server/template forms plus a complete enum-state layout matrix.
- [ ] Add failing tests proving server/template form breadcrumbs, panel borders, focused field visibility, validation visibility, action row, and bottom footer at all sizes.
- [ ] Add one exhaustive table listing every `screen` enum value with a representative model builder; assert all non-below-floor screens satisfy shell invariants.
- [ ] Run `go test ./internal/tui -run 'Test(FormShell|AllScreensUseShell)' -count=1` and confirm remaining non-shell states fail.
- [ ] Move form viewport content into the shared shell without changing navigation or save behavior; close all inventory gaps.
- [ ] Run focused tests and `go test ./... -count=1`.
- [ ] Commit as `feat: finish unified tui screen coverage` and push.
### Task 6: Runtime verification, documentation, and binary
**Files:**
- Modify: `README.md`
- Modify: `docs/guide.md`
- Replace after runtime verification: `docs/screenshots/screen_1.png` through `docs/screenshots/screen_5.png`
**Interfaces:**
- Consumes: completed TUI and isolated XDG audit profile.
- Produces: verified screenshots, synchronized documentation, and `bin/sshkeeper`.
- [ ] Build `/tmp/sshkeeper-unified-audit` and run it in tmux/xterm with the isolated audit profile.
- [ ] Capture dashboard, actions, forwards, forward form, tunnels, server form, tags/templates, confirmation, quick help, and full help at 120x40, 80x24, and 60x16.
- [ ] Inspect every capture for border continuity, right-column overflow, focus, notification truth, bottom footer, and correct Ctrl+H copy. For any defect, add a failing automated test before editing code.
- [ ] Verify real xterm sends Backspace as `KeyBackspace` and Ctrl+H as `KeyCtrlH`; document the terminal mapping constraint without restoring F1.
- [ ] Update README and guide only after runtime behavior is verified; refresh repository screenshots from the verified binary.
- [ ] Run `gofmt`, `git diff --check`, `go vet ./...`, `go test ./... -count=1`, and `go build ./...`.
- [ ] Commit as `docs: refresh unified tui screenshots` and push.
- [ ] Run `./build.sh`, report the absolute binary path, version, SHA-256, feature-branch HEAD, remote synchronization, and clean worktree. Do not publish a release.

View File

@ -1,138 +0,0 @@
# Unified sshkeeper TUI Shell
## Goal
Bring every sshkeeper TUI screen to the same visual and behavioral standard as
the approved server dashboard. Every screen must have a stable header, bounded
framed content, and a contextual footer anchored to the bottom of the terminal.
## User-approved direction
The existing server dashboard is the visual reference. Do not introduce a new
visual language or browser mockups. Apply its hierarchy, border treatment,
selection markers, spacing, colors, and responsive degradation to all child
screens.
`Ctrl+H` replaces `F1` as the global full-help binding. `?` remains contextual
quick help outside text editors. Remove `F1` from runtime help, footers, README,
and the user guide.
## Root cause
The dashboard owns a height-aware renderer with a header, panels, and footer.
Most child screens still render independent free-form strings or a default
Bubbles list. They therefore do not share inner-width budgeting, borders,
viewport height, or bottom-footer placement. The correction is a shared shell,
not per-screen blank-line padding.
## Shared screen shell
Every full-screen state uses this vertical contract:
1. Header: `sshkeeper / <breadcrumb>` on the left and truthful vault/context
status on the right.
2. Separator: one display-cell-bounded horizontal line.
3. Content: one or two bordered panels filling all available rows.
4. Footer: only the current screen's primary shortcuts, wrapped by display
width and anchored to the last terminal row.
The shell computes content height as terminal height minus header, separator,
notification, and wrapped-footer rows. A panel owns a one-cell border and at
least one-cell inner horizontal padding. No content row may consume the
terminal's last column directly.
Errors, success messages, pending state, and partial success appear in a
dedicated notification row below the separator. Rendering remains pure.
## Screen families
### Actions
Actions use a framed selectable list. At 100 columns and wider, a second panel
describes the selected action and its target. At 70-99 columns, the description
appears below the list when height permits. At 60-69 columns, only the framed
list remains. The footer is always at the bottom.
### Port forwards
At 100 columns and wider, forwards use a framed table and a framed selected-rule
panel. At 70-99 columns, the selected-rule panel is stacked below the table. At
60-69 columns, the table contains Name, Type, and On; details stay available
through the selected-rule panel only when vertical space permits.
All column widths are derived from the panel's inner width. The table must
leave an inner right margin, so an 80-column terminal never renders a row at 80
display cells. Long names, endpoints, explanations, and SSH arguments truncate
by display cells with an ellipsis.
The forward form uses the shared shell, a framed form panel, radio markers for
type, and an action row. Focused fields and validation remain visible.
### Managers and pickers
Tags, command templates, template picker/mode/results, and tunnel manager use a
framed list or result panel. Selection uses `>` as well as color. Empty and
error states remain inside the panel. Lists viewport around the selected item
and never rely on the default Bubbles frame or footer.
### Forms and text entry
Server, template, tag, search, and forward editors use a framed form panel.
Their title moves into the common breadcrumb. Required markers, validation,
dirty confirmation, and input ownership do not change. The focused control,
status/error, and action row remain in the panel's visible window.
### Confirmations
Confirmations use the same application header and a centered or width-bounded
framed dialog panel. Exact target, consequence, Cancel-first action row, and
footer remain visible at 60x16. Long Unicode text wraps by display cells.
### Help
`Ctrl+H` opens full help from every state except a confirmation overlay. `?`
opens contextual quick help only when a text editor does not own printable
input. `F1` has no documented or runtime binding.
Bubble Tea v1 distinguishes `KeyCtrlH` (ASCII BS) from `KeyBackspace` (DEL).
Automated and PTY checks must prove that xterm Backspace edits text while
`Ctrl+H` opens help. Terminals configured to emit BS for Backspace cannot
distinguish the two; the supported runtime check uses xterm's default DEL
Backspace mapping.
Both help screens use the shared shell and a framed, scrolling body. Their
footer stays at the bottom and states how to close/scroll.
## Responsive contract
- Supported floor: `60x16`.
- Wide: `width >= 100`, two panels where useful.
- Medium: `70 <= width < 100`, stacked panels.
- Narrow: `60 <= width < 70`, compact single panel.
- Below the floor: render only the minimum-size message.
Every screen family is tested at 120x40, 80x24, and 60x16 with long ASCII,
Cyrillic, CJK, combining, and emoji content. Assertions measure ANSI-aware
display cells and exact maximum height.
## Verification
Automated coverage must inventory every `screen` enum value and prove that its
normal representative state:
- fits terminal width and height;
- contains a top header and at least one border;
- keeps the contextual footer on the final rendered row(s);
- retains a non-color selection/focus marker where applicable;
- does not expose `F1` and does expose `Ctrl+H` help where applicable.
Runtime verification uses the freshly built binary in an isolated XDG profile.
Capture and inspect dashboard, actions, forwards, forward form, tunnel manager,
server form, template/tag managers, confirmation, and help at all three sizes.
## Delivery boundary
Commit and push each implementation stage. Finish with a fresh binary under
`bin/sshkeeper` and report its absolute path and checksum. Do not publish a
release or update release metadata until the user explicitly approves the
binary.

View File

@ -680,7 +680,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil m.err = nil
m.success = "" m.success = ""
} }
if msg.Type == tea.KeyCtrlH && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm { if msg.Type == tea.KeyF1 && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
m.helpParent = m.screen m.helpParent = m.screen
m.fullHelp = newFullHelpModel(m.width, m.height) m.fullHelp = newFullHelpModel(m.width, m.height)
m.screen = screenFullHelp m.screen = screenFullHelp
@ -1213,7 +1213,8 @@ func (m *tuiModel) View() string {
b.WriteString(m.viewServerList()) b.WriteString(m.viewServerList())
case screenSearch: case screenSearch:
b.WriteString(m.viewSearch()) b.WriteString("Search: " + m.searchInput.View() + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Esc", Action: "cancel"}}, m.width))
case screenForm: case screenForm:
b.WriteString(m.form.View()) b.WriteString(m.form.View())
@ -1273,6 +1274,13 @@ func (m *tuiModel) View() string {
b.WriteString(m.viewConfirm()) b.WriteString(m.viewConfirm())
} }
if m.screen != screenList && m.err != nil {
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
}
if m.screen != screenList && m.success != "" {
b.WriteString("\n" + successStyle.Render(m.success))
}
return b.String() return b.String()
} }
@ -1541,14 +1549,22 @@ func (m *tuiModel) viewConfirm() string {
if m.confirm == nil { if m.confirm == nil {
return "" return ""
} }
body := func(width, height int) string { width := m.width
innerWidth := max(1, width-4) if width <= 0 {
innerHeight := max(1, height-2) width = 80
message := wrapCells(m.confirm.target, innerWidth)
if m.confirm.consequence != "" {
message = append(message, "")
message = append(message, wrapCells(m.confirm.consequence, innerWidth)...)
} }
innerWidth := max(1, width-2)
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(m.confirm.title, width)), ""}
for _, line := range wrapCells(m.confirm.target, innerWidth) {
lines = append(lines, " "+line)
}
if m.confirm.consequence != "" {
lines = append(lines, "")
for _, line := range wrapCells(m.confirm.consequence, innerWidth) {
lines = append(lines, " "+line)
}
}
lines = append(lines, "")
cancel := "[ Cancel ]" cancel := "[ Cancel ]"
accept := "[ " + m.confirm.verb + " ]" accept := "[ " + m.confirm.verb + " ]"
if m.confirm.focus == confirmCancel { if m.confirm.focus == confirmCancel {
@ -1556,37 +1572,18 @@ func (m *tuiModel) viewConfirm() string {
} else { } else {
accept = errorStyle.Render("> " + accept) accept = errorStyle.Render("> " + accept)
} }
action := cancel + " " + accept
if m.confirm.pending { if m.confirm.pending {
action = m.confirm.verb + " in progress…" lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "")
} else {
lines = append(lines, fitLine(" "+cancel+" "+accept, width), "")
} }
messageRows := max(0, innerHeight-2) footer := renderHelp([]helpItem{
if len(message) > messageRows {
message = message[:messageRows]
if len(message) > 0 {
message[len(message)-1] = truncateCells(strings.TrimSpace(message[len(message)-1])+" …", innerWidth)
}
}
lines := []string{dashboardSection(m.confirm.title)}
lines = append(lines, message...)
for len(lines) < innerHeight-1 {
lines = append(lines, "")
}
lines = append(lines, action)
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Confirm",
status: shellStatus(m.vaultUnlocked, "Action required"),
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Tab", Action: "choose"}, {Key: "Tab", Action: "choose"},
{Key: "Enter", Action: "activate"}, {Key: "Enter", Action: "activate"},
{Key: "Esc", Action: "cancel"}, {Key: "Esc", Action: "cancel"},
}, }, width)
}) lines = append(lines, strings.Split(footer, "\n")...)
return strings.Join(lines, "\n")
} }
func (m *tuiModel) beginConfirm(state confirmState) { func (m *tuiModel) beginConfirm(state confirmState) {
@ -1774,36 +1771,6 @@ func (m *tuiModel) viewServerList() string {
return m.renderServerDashboard() return m.renderServerDashboard()
} }
func (m *tuiModel) rootNotification() string {
if m.err != nil {
return errorStyle.Render("Error: " + m.err.Error())
}
if m.success != "" {
return successStyle.Render(m.success)
}
return ""
}
func (m *tuiModel) viewSearch() string {
return renderScreenShell(screenShell{
breadcrumb: "Search",
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d profiles", len(m.servers))),
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{
dashboardSection("Find server"),
"",
m.searchInput.View(),
"",
dashboardHelp("Search alias, host, display name, group, tags, notes, and route."),
})
},
footer: []helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
}
func (m *tuiModel) viewInlineBackgroundResults() string { func (m *tuiModel) viewInlineBackgroundResults() string {
var b strings.Builder var b strings.Builder
b.WriteString(sectionStyle.Render("Last Background Run")) b.WriteString(sectionStyle.Render("Last Background Run"))
@ -1924,34 +1891,37 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string {
} }
func (m *tuiModel) viewTags() string { func (m *tuiModel) viewTags() string {
return renderScreenShell(screenShell{ var b strings.Builder
breadcrumb: "Tags", b.WriteString(titleStyle.Render("Tags"))
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d tags", len(m.tags))), b.WriteString("\n\n")
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
if len(m.tags) == 0 { if len(m.tags) == 0 {
return renderPaddedPanel(width, height, []string{dashboardHelp("No tags yet. Ctrl+A adds one to the selected servers.")}) b.WriteString(helpStyle.Render(" No tags yet. Press Ctrl+A to add one to the selected servers."))
} b.WriteString("\n")
capacity := max(1, height-2) } else {
start, end := visibleServerRange(len(m.tagList.Items()), m.tagList.Index(), capacity) for i, item := range m.tagList.Items() {
lines := make([]string, 0, capacity) tag, ok := item.(groupItem)
for index := start; index < end; index++ {
tag, ok := m.tagList.Items()[index].(groupItem)
if !ok { if !ok {
continue continue
} }
marker := " " marker := " "
if index == m.tagList.Index() { style := normalStyle
if i == m.tagList.Index() {
marker = "> " marker = "> "
style = selectedRowStyle
} }
lines = append(lines, marker+tag.name) b.WriteString(style.Render(marker + tag.name))
b.WriteString("\n")
} }
return renderPaddedPanel(width, height, lines) }
}, b.WriteString("\n")
footer: []helpItem{{Key: "Enter", Action: "toggle"}, {Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "rename"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}}, b.WriteString(renderHelp([]helpItem{
}) {Key: "Enter", Action: "toggle for selected/current"},
{Key: "Ctrl+A", Action: "add"},
{Key: "Ctrl+E", Action: "rename"},
{Key: "Ctrl+D", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
} }
func (m *tuiModel) viewTagInput() string { func (m *tuiModel) viewTagInput() string {
@ -1959,83 +1929,74 @@ func (m *tuiModel) viewTagInput() string {
if m.tagMode == "rename" { if m.tagMode == "rename" {
title = "Rename Tag" title = "Rename Tag"
} }
return renderScreenShell(screenShell{ return titleStyle.Render(title) + "\n\n" + m.tagInput.View() + "\n\n" + renderHelp([]helpItem{{Key: "Enter", Action: "save"}, {Key: "Esc", Action: "cancel"}}, m.width)
breadcrumb: title,
status: shellStatus(m.vaultUnlocked, "Tag editor"),
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{dashboardSection(title), "", m.tagInput.View()})
},
footer: []helpItem{{Key: "Enter", Action: "save"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
} }
func (m *tuiModel) viewTemplates() string { func (m *tuiModel) viewTemplates() string {
return renderScreenShell(screenShell{ var b strings.Builder
breadcrumb: "Command Templates", b.WriteString(titleStyle.Render("Command Templates"))
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d templates", len(m.templates))), b.WriteString("\n\n")
notification: m.rootNotification(),
width: m.width,
height: m.height,
body: func(width, height int) string {
if len(m.templates) == 0 { if len(m.templates) == 0 {
return renderPaddedPanel(width, height, []string{dashboardHelp("No command templates yet. Ctrl+A adds one.")}) b.WriteString(helpStyle.Render(" No command templates yet. Press Ctrl+A to add one."))
} b.WriteString("\n")
capacity := max(1, height-2) } else {
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity) for i, item := range m.templateList.Items() {
lines := make([]string, 0, capacity) tpl, ok := item.(templateItem)
for index := start; index < end; index++ {
tpl, ok := m.templateList.Items()[index].(templateItem)
if !ok { if !ok {
continue continue
} }
marker := " " marker := " "
if index == m.templateList.Index() { style := normalStyle
if i == m.templateList.Index() {
marker = "> " marker = "> "
style = selectedRowStyle
} }
line := marker + tpl.template.Name + " " + tpl.template.Command line := fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)
if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow { b.WriteString(style.Render(line))
line += " — " + tpl.template.Description b.WriteString("\n")
if tpl.template.Description != "" {
b.WriteString(helpStyle.Render(" " + tpl.template.Description))
b.WriteString("\n")
} }
lines = append(lines, line)
} }
return renderPaddedPanel(width, height, lines) }
}, b.WriteString("\n")
footer: []helpItem{{Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "edit"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}}, b.WriteString(renderHelp([]helpItem{
}) {Key: "Ctrl+A", Action: "add"},
{Key: "Ctrl+E", Action: "edit"},
{Key: "Ctrl+D", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
} }
func (m *tuiModel) viewTemplatePicker() string { func (m *tuiModel) viewTemplatePicker() string {
targets := strings.Join(serverAliases(m.targetServers()), ", ") var b strings.Builder
return renderScreenShell(screenShell{ b.WriteString(titleStyle.Render("Run Template"))
breadcrumb: "Run Template", b.WriteString("\n")
status: "Targets: " + targets, b.WriteString(helpStyle.Render(fmt.Sprintf("Targets: %s", strings.Join(serverAliases(m.targetServers()), ", "))))
width: m.width, b.WriteString("\n\n")
height: m.height,
body: func(width, height int) string {
lines := []string{dashboardSection("Choose template"), dashboardHelp("Targets: " + targets), ""}
if len(m.templates) == 0 { if len(m.templates) == 0 {
lines = append(lines, dashboardHelp("No command templates. Press Esc, then Ctrl+P to add one.")) b.WriteString(helpStyle.Render(" No command templates. Press Esc, then Ctrl+P to add one."))
} else { } else {
capacity := max(1, height-len(lines)-2) for i, item := range m.templateList.Items() {
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity) tpl, ok := item.(templateItem)
for index := start; index < end; index++ {
tpl, ok := m.templateList.Items()[index].(templateItem)
if !ok { if !ok {
continue continue
} }
marker := " " marker := " "
if index == m.templateList.Index() { style := normalStyle
if i == m.templateList.Index() {
marker = "> " marker = "> "
style = selectedRowStyle
} }
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command) b.WriteString(style.Render(fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)))
b.WriteString("\n")
} }
} }
return renderPaddedPanel(width, height, lines) b.WriteString("\n")
}, b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "choose"}, {Key: "Esc", Action: "back"}}, m.width))
footer: []helpItem{{Key: "Enter", Action: "choose"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}}, return b.String()
})
} }
func (m *tuiModel) viewTemplateMode() string { func (m *tuiModel) viewTemplateMode() string {
@ -2045,44 +2006,30 @@ func (m *tuiModel) viewTemplateMode() string {
name = m.pendingTemplate.Name name = m.pendingTemplate.Name
command = m.pendingTemplate.Command command = m.pendingTemplate.Command
} }
targets := strings.Join(serverAliases(m.targetServers()), ", ") return titleStyle.Render("Run Mode") + "\n\n" +
return renderScreenShell(screenShell{ fmt.Sprintf("Template: %s\nCommand: %s\nTargets: %s\n\n", name, command, strings.Join(serverAliases(m.targetServers()), ", ")) +
breadcrumb: "Run Template / Mode", renderHelp([]helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Esc", Action: "back"}}, m.width)
status: "Targets: " + targets,
width: m.width,
height: m.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{dashboardSection("Execution"), "", "Template: " + name, "Command: " + command, "Targets: " + targets, "", "Choose foreground for an interactive run or background to keep using sshkeeper."})
},
footer: []helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
} }
func (m *tuiModel) viewBackgroundResults() string { func (m *tuiModel) viewBackgroundResults() string {
return renderScreenShell(screenShell{ var b strings.Builder
breadcrumb: "Background Results", b.WriteString(titleStyle.Render("Background Results"))
status: fmt.Sprintf("%d results", len(m.bgResults)), b.WriteString("\n\n")
width: m.width,
height: m.height,
body: func(width, height int) string {
lines := make([]string, 0)
for _, result := range m.bgResults { for _, result := range m.bgResults {
status := "OK" status := "OK"
if result.Err != "" { if result.Err != "" {
status = "FAIL: " + result.Err status = "FAIL: " + result.Err
} }
lines = append(lines, dashboardSection(result.Alias+" "+status)) b.WriteString(sectionStyle.Render(result.Alias + " " + status))
b.WriteString("\n")
if result.Output != "" { if result.Output != "" {
for _, line := range strings.Split(result.Output, "\n") { b.WriteString(result.Output)
lines = append(lines, strings.ReplaceAll(line, "\t", " ")) b.WriteString("\n")
} }
} }
lines = append(lines, "") b.WriteString("\n")
} b.WriteString(renderHelp([]helpItem{{Key: "Enter/Esc", Action: "back"}}, m.width))
return renderPaddedPanel(width, height, lines) return b.String()
},
footer: []helpItem{{Key: "Enter/Esc", Action: "back"}, {Key: "Ctrl+H", Action: "help"}},
})
} }
func (m *tuiModel) selectedServers() []*model.Server { func (m *tuiModel) selectedServers() []*model.Server {
@ -2187,7 +2134,7 @@ func (m *tuiModel) removeTag(name string) {
// --- Server list footer --- // --- Server list footer ---
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string { func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {
width := m.width - 3 width := m.width - 2
if width <= 0 { if width <= 0 {
width = 80 width = 80
} }
@ -2216,7 +2163,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
helpItem{Key: "Ctrl+F", Action: "search"}, helpItem{Key: "Ctrl+F", Action: "search"},
helpItem{Key: "Ins", Action: insAction}, helpItem{Key: "Ins", Action: insAction},
helpItem{Key: "?", Action: "hotkeys"}, helpItem{Key: "?", Action: "hotkeys"},
helpItem{Key: "Ctrl+H", Action: "help"}, helpItem{Key: "F1", Action: "help"},
helpItem{Key: "Ctrl+Q", Action: "quit"}, helpItem{Key: "Ctrl+Q", Action: "quit"},
) )
return items return items

View File

@ -342,8 +342,8 @@ func TestAuthMethodListViewShowsAllOptions(t *testing.T) {
if between := view[authPos:listPos]; strings.Contains(between, "Identity File") { if between := view[authPos:listPos]; strings.Contains(between, "Identity File") {
t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view) t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view)
} }
if !strings.Contains(view, "│") { if strings.Contains(view, "│") {
t.Fatalf("expected auth method dropdown inside the unified frame\nview:\n%s", view) t.Fatalf("expected compact auth method dropdown without default list border\nview:\n%s", view)
} }
for _, method := range []model.AuthMethod{ for _, method := range []model.AuthMethod{
model.AuthPassword, model.AuthPassword,
@ -383,8 +383,8 @@ func TestGroupListViewRendersDirectlyUnderGroupField(t *testing.T) {
if between := view[groupPos:listPos]; strings.Contains(between, "Password") { if between := view[groupPos:listPos]; strings.Contains(between, "Password") {
t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view) t.Fatalf("expected group dropdown to render before password field\nview:\n%s", view)
} }
if !strings.Contains(view, "│") { if strings.Contains(view, "│") {
t.Fatalf("expected group dropdown inside the unified frame\nview:\n%s", view) t.Fatalf("expected compact group dropdown without default list border\nview:\n%s", view)
} }
} }

View File

@ -16,8 +16,6 @@ func (m *tuiModel) renderServerDashboard() string {
if height <= 0 { if height <= 0 {
height = 40 height = 40
} }
sizeClass := classifyTerminal(width, height)
width = max(1, width-1)
header := m.renderDashboardHeader(width) header := m.renderDashboardHeader(width)
notification := m.renderDashboardNotification(width) notification := m.renderDashboardNotification(width)
@ -31,7 +29,7 @@ func (m *tuiModel) renderServerDashboard() string {
} }
var body string var body string
switch sizeClass { switch classifyTerminal(width, height) {
case sizeWide: case sizeWide:
leftWidth := width * 62 / 100 leftWidth := width * 62 / 100
rightWidth := width - leftWidth - 1 rightWidth := width - leftWidth - 1

View File

@ -589,6 +589,14 @@ func (fm *formModel) View() string {
if fm.edit { if fm.edit {
title = "Edit Server: " + fm.server.Alias title = "Edit Server: " + fm.server.Alias
} }
footer := renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "/", Action: "pick list"},
{Key: "Enter", Action: "select"},
{Key: "Esc", Action: "back"},
}, fm.width)
if fm.showAuthList || fm.showGroupList { if fm.showAuthList || fm.showGroupList {
var dropdown list.Model var dropdown list.Model
fieldIndex := 8 fieldIndex := 8
@ -598,18 +606,10 @@ func (fm *formModel) View() string {
} else { } else {
dropdown = fm.groupList dropdown = fm.groupList
} }
return renderScreenShell(screenShell{ return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" +
breadcrumb: title + " / Picker", fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" +
status: "Choose a value", fitLine(renderDropdown(dropdown), fm.width) + "\n" +
width: fm.width, renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)
height: fm.height,
body: func(width, height int) string {
lines := []string{fm.inputs[fieldIndex].View(), ""}
lines = append(lines, splitBlock(renderDropdown(dropdown))...)
return renderPaddedPanel(width, height, lines)
},
footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
})
} }
status := fm.formStatusLine() status := fm.formStatusLine()
@ -620,10 +620,14 @@ func (fm *formModel) View() string {
if fm.focusIdx == len(fm.inputs)+2 { if fm.focusIdx == len(fm.inputs)+2 {
saveBtn = selectedStyle.Render("> [ Save ]") saveBtn = selectedStyle.Render("> [ Save ]")
} }
actions := testBtn + " " + saveBtn actions := fitLine(testBtn+" "+saveBtn, fm.width)
body := func(width, height int) string { reserved := 1 + displayLineCount(footer) + 1
richLayout := width >= 90 && height >= 20 if status != "" {
reserved++
}
fieldRows := max(4, fm.height-reserved)
richLayout := fm.width >= 90 && fm.height >= 24
allFields := make([]string, 0, len(fm.inputs)+5) allFields := make([]string, 0, len(fm.inputs)+5)
focusRows := make([]int, len(fm.inputs)+1) focusRows := make([]int, len(fm.inputs)+1)
for i := range fm.inputs { for i := range fm.inputs {
@ -639,49 +643,37 @@ func (fm *formModel) View() string {
fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25) fm.inputs[i].Placeholder = truncateCells(strings.Join(fm.groups, ", "), 25)
} }
focusRows[i] = len(allFields) focusRows[i] = len(allFields)
allFields = append(allFields, fm.inputs[i].View()) allFields = append(allFields, fitLine(fm.inputs[i].View(), fm.width))
} }
focusRows[len(fm.inputs)] = len(allFields) focusRows[len(fm.inputs)] = len(allFields)
allFields = append(allFields, fm.password.View()) allFields = append(allFields, fitLine(fm.password.View(), fm.width))
focusField := len(allFields) - 1 focusField := len(allFields) - 1
if fm.focusIdx <= len(fm.inputs) { if fm.focusIdx <= len(fm.inputs) {
focusField = focusRows[fm.focusIdx] focusField = focusRows[fm.focusIdx]
} }
actionRows := 1
if richLayout {
actionRows = 2
}
fieldRows := max(1, height-2-actionRows)
start, end := visibleServerRange(len(allFields), focusField, fieldRows) start, end := visibleServerRange(len(allFields), focusField, fieldRows)
visible := append([]string(nil), allFields[start:end]...) visible := append([]string(nil), allFields[start:end]...)
if start > 0 && len(visible) > 0 { if start > 0 && len(visible) > 0 {
visible[0] = "↑ more fields · " + visible[0] visible[0] = fitLine("↑ more fields · "+visible[0], fm.width)
} }
if end < len(allFields) && len(visible) > 0 { if end < len(allFields) && len(visible) > 0 {
visible[len(visible)-1] += " · more ↓" visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width)
}
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
lines = append(lines, visible...)
if status != "" {
lines = append(lines, fitLine(status, fm.width))
} }
if richLayout { if richLayout {
visible = append(visible, sectionStyle.Copy().MarginTop(0).Render("Actions")) lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
} }
visible = append(visible, actions) lines = append(lines, actions)
return renderPaddedPanel(width, height, visible) lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
} }
return renderScreenShell(screenShell{ return strings.Join(lines, "\n")
breadcrumb: title,
status: "Server profile",
notification: status,
width: fm.width,
height: fm.height,
body: body,
footer: []helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "/", Action: "pick list"},
{Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
} }
func (fm *formModel) formStatusLine() string { func (fm *formModel) formStatusLine() string {
@ -709,7 +701,7 @@ func (fm *formModel) formStatusLine() string {
func renderDropdown(l list.Model) string { func renderDropdown(l list.Model) string {
var b strings.Builder var b strings.Builder
b.WriteString(dashboardSection(l.Title)) b.WriteString(sectionStyle.Render(l.Title))
b.WriteString("\n") b.WriteString("\n")
for i, item := range l.Items() { for i, item := range l.Items() {
group, ok := item.(groupItem) group, ok := item.(groupItem)

View File

@ -79,90 +79,52 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
} }
func (m *forwardScreenModel) View() string { func (m *forwardScreenModel) View() string {
notification := "" footer := renderHelp([]helpItem{
if m.err != nil {
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
}
body := func(width, height int) string {
switch classifyShellContent(width) {
case sizeWide:
leftWidth := width * 70 / 100
rightWidth := width - leftWidth - 1
return joinPanelColumns(
renderPaddedPanel(leftWidth, height, m.forwardListLines(leftWidth-4, height-2, false)), leftWidth,
renderPaddedPanel(rightWidth, height, m.forwardDetailLines(rightWidth-4, false)), rightWidth,
)
case sizeMedium:
detailHeight := min(7, max(4, height/3))
listHeight := max(3, height-detailHeight-1)
listPanel := renderPaddedPanel(width, listHeight, m.forwardListLines(width-4, listHeight-2, false))
detailPanel := renderPaddedPanel(width, detailHeight, m.forwardDetailLines(width-4, true))
return listPanel + "\n" + detailPanel
default:
return renderPaddedPanel(width, height, m.forwardListLines(width-4, height-2, true))
}
}
return renderScreenShell(screenShell{
breadcrumb: "Port Forwards / " + m.serverAlias,
status: fmt.Sprintf("%d rules", len(m.list)),
notification: notification,
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Ctrl+A (a)", Action: "add"}, {Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"}, {Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"}, {Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, }, m.width)
}) lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine("Port Forwards — "+m.serverAlias, m.width))}
if m.err != nil {
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)), m.width))
} }
func (m *forwardScreenModel) forwardListLines(width, capacity int, compact bool) []string { footerRows := displayLineCount(footer)
detailRows := 0
if len(m.list) > 0 && m.height-footerRows >= 7 {
detailRows = 3
}
rowCapacity := max(1, m.height-len(lines)-footerRows-detailRows-1)
if len(m.list) == 0 { if len(m.list) == 0 {
return []string{helpStyle.Copy().MarginLeft(0).Render("No port forwards configured. Ctrl+A adds one.")} lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
} } else {
lines := []string{m.renderForwardRow(nil, false, width, compact)} lines = append(lines, m.renderForwardRow(nil, false))
rowCapacity := max(1, capacity-1) rowCapacity--
showRange := len(m.list) > rowCapacity
if showRange {
rowCapacity = max(1, rowCapacity-1)
}
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity) start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ { for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected, width, compact)) lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
} }
if showRange { if end < len(m.list) || start > 0 {
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list)))) lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
return lines
}
func (m *forwardScreenModel) forwardDetailLines(width int, compact bool) []string {
lines := []string{dashboardSection("Selected rule")}
if m.selected < 0 || m.selected >= len(m.list) {
return append(lines, "", dashboardHelp("No rule selected."))
} }
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
forward := m.list[m.selected] forward := m.list[m.selected]
name := forward.Name lines = append(lines,
if name == "" { sectionStyle.Copy().MarginTop(0).Render("Selected"),
name = forward.ForwardListen() fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
)
} }
if compact {
lines = append(lines, name+" · "+string(forward.Type))
} else {
lines = append(lines, "", name, string(forward.Type))
} }
lines = append(lines, wrapCells(forward.ForwardHumanExplanation(m.serverAlias), max(1, width))...) lines = append(lines, strings.Split(footer, "\n")...)
if !compact { if len(lines) > m.height && m.height > 0 {
lines = append(lines, "") lines = lines[:m.height]
} }
lines = append(lines, dashboardHelp("ssh "+strings.Join(forward.ForwardSSHArgs(), " "))) return strings.Join(lines, "\n")
return lines
} }
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool, width int, compact bool) string { func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON" marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
if forward != nil { if forward != nil {
if selected { if selected {
@ -180,30 +142,30 @@ func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected b
enabled = "no" enabled = "no"
} }
} }
wide := m.width >= 70
typeWidth, enabledWidth := 8, 3 typeWidth, enabledWidth := 8, 3
if !compact && width >= 58 { if wide {
flexible := max(3, width-typeWidth-enabledWidth-7) nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
nameWidth := max(1, flexible*30/100) listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
listenWidth := max(1, flexible*32/100) targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
targetWidth := max(1, flexible-nameWidth-listenWidth) line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil { if forward == nil {
return listHeaderStyle.Render(fitLine(line, width)) return listHeaderStyle.Render(fitLine(line, m.width))
} }
if selected { if selected {
return selectedRowStyle.Render(fitLine(line, width)) return selectedRowStyle.Render(fitLine(line, m.width))
} }
return fitLine(line, width) return fitLine(line, m.width)
} }
nameWidth := max(1, width-typeWidth-enabledWidth-5) nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth) line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil { if forward == nil {
return listHeaderStyle.Render(fitLine(line, width)) return listHeaderStyle.Render(fitLine(line, m.width))
} }
if selected { if selected {
return selectedRowStyle.Render(fitLine(line, width)) return selectedRowStyle.Render(fitLine(line, m.width))
} }
return fitLine(line, width) return fitLine(line, m.width)
} }
// --- Forward form screen model --- // --- Forward form screen model ---
@ -633,15 +595,12 @@ func (fm *forwardFormModel) View() string {
if fm.editMode { if fm.editMode {
title = "Edit Port Forward" title = "Edit Port Forward"
} }
notification := "" lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
if fm.err != nil { lines = append(lines,
notification = errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) fitLine(fm.nameInput.View(), fm.width),
} else if fm.saved { fitLine(fm.descInput.View(), fm.width),
notification = successStyle.Render("✓ Saved.") )
}
body := func(width, height int) string {
contentWidth := max(1, width-4)
lines := []string{fm.nameInput.View(), fm.descInput.View()}
typeParts := make([]string, len(forwardTypes)) typeParts := make([]string, len(forwardTypes))
for i, forwardType := range forwardTypes { for i, forwardType := range forwardTypes {
selected := "○" selected := "○"
@ -654,48 +613,58 @@ func (fm *forwardFormModel) View() string {
} }
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label) typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
} }
lines = append(lines, "Type "+strings.Join(typeParts, " ")) lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
if width >= 100 { if fm.width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(forwardTypes[fm.typeIdx].description)) lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
} }
visible := fm.visibleFields() visible := fm.visibleFields()
for _, idx := range visible { for _, idx := range visible {
lines = append(lines, fm.inputs[idx].View()) lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
} }
if strings.TrimSpace(fm.inputs[0].Value()) == "0.0.0.0" {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render("⚠ This port will be accessible from the network.")) if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
}
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
fwd := &model.Forward{
Type: fm.currentType,
LocalAddr: fm.inputs[0].Value(),
LocalPort: 0,
RemoteAddr: fm.inputs[2].Value(),
RemotePort: 0,
} }
if width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
fwd := &model.Forward{Type: fm.currentType, LocalAddr: fm.inputs[0].Value(), RemoteAddr: fm.inputs[2].Value()}
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort) fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort) fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
preview := "Preview ssh " + strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes" preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
lines = append(lines, wrapCells(preview, contentWidth)...) lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
} }
total := 2 + 3 + len(visible) + 1 total := 2 + 3 + len(visible) + 1
button := " [ Save ]" button := " [ Save ]"
if fm.focusIdx == total-1 { if fm.focusIdx == total-1 {
button = selectedStyle.Render("> [ Save ]") button = selectedStyle.Render("> [ Save ]")
} }
lines = append(lines, "", button) if fm.err != nil {
return renderPaddedPanel(width, height, lines) lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
} }
return renderScreenShell(screenShell{ if fm.saved {
breadcrumb: "Port Forwards / " + title, lines = append(lines, successStyle.Render("✓ Saved."))
status: string(fm.currentType), }
notification: notification, lines = append(lines, button)
width: fm.width, footer := renderHelp([]helpItem{
height: fm.height,
body: body,
footer: []helpItem{
{Key: "Tab/↓", Action: "next"}, {Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"}, {Key: "↑", Action: "prev"},
{Key: "1/2/3", Action: "select type"}, {Key: "1/2/3", Action: "select type"},
{Key: "Enter", Action: "save"}, {Key: "Enter", Action: "save"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, }, fm.width)
}) lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > fm.height && fm.height > 0 {
lines = lines[:fm.height]
}
return strings.Join(lines, "\n")
} }
// forwardEditSignal is sent when user wants to edit a forward // forwardEditSignal is sent when user wants to edit a forward

View File

@ -3,6 +3,7 @@ package tui
import ( import (
"fmt" "fmt"
"io" "io"
"strings"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
@ -13,7 +14,6 @@ import (
type helpScreenModel struct { type helpScreenModel struct {
list list.Model list list.Model
width int width int
height int
} }
func newHelpScreenModel(w, h int) *helpScreenModel { func newHelpScreenModel(w, h int) *helpScreenModel {
@ -30,7 +30,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"}, helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"}, helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
helpScreenItem{key: "?", action: "This quick help", section: "Other"}, helpScreenItem{key: "?", action: "This quick help", section: "Other"},
helpScreenItem{key: "Ctrl+H", action: "Full documentation", section: "Other"}, helpScreenItem{key: "F1", action: "Full documentation", section: "Other"},
helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"}, helpScreenItem{key: "Ctrl+Q", action: "Quit", section: "Other"},
} }
@ -40,7 +40,7 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
l.SetFilteringEnabled(false) l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle l.Styles.Title = titleStyle
return &helpScreenModel{list: l, width: w, height: h} return &helpScreenModel{list: l, width: w}
} }
type helpScreenItem struct { type helpScreenItem struct {
@ -86,7 +86,6 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height
m.list.SetSize(msg.Width, msg.Height-4) m.list.SetSize(msg.Width, msg.Height-4)
return m, nil return m, nil
} }
@ -96,39 +95,10 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *helpScreenModel) View() string { func (m *helpScreenModel) View() string {
items := m.list.Items() return m.list.View()
body := func(width, height int) string {
innerRows := max(1, height-2)
start, end := visibleServerRange(len(items), m.list.Index(), innerRows)
lines := make([]string, 0, innerRows)
for index := start; index < end; index++ {
item, ok := items[index].(helpScreenItem)
if !ok {
continue
}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+padCells(item.key, 12)+" "+item.action)
}
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Quick Help",
status: "Keyboard reference",
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "↑/↓", Action: "move"},
{Key: "Ctrl+H", Action: "full help"},
{Key: "Esc", Action: "back"},
},
})
} }
// --- Full help (Ctrl+H) --- // --- Full help (F1) ---
type fullHelpModel struct { type fullHelpModel struct {
width int width int
@ -179,6 +149,11 @@ func (m *fullHelpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *fullHelpModel) View() string { func (m *fullHelpModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("sshkeeper — Full Help"))
b.WriteString("\n\n")
sections := []struct { sections := []struct {
title string title string
rows [][2]string rows [][2]string
@ -199,7 +174,7 @@ func (m *fullHelpModel) View() string {
{"Enter", "Select / Confirm / Open"}, {"Enter", "Select / Confirm / Open"},
{"Esc", "Back / Cancel / Close"}, {"Esc", "Back / Cancel / Close"},
{"?", "Quick help (hotkeys)"}, {"?", "Quick help (hotkeys)"},
{"Ctrl+H", "Full documentation"}, {"F1", "Full documentation"},
{"Ctrl+Q", "Quit"}, {"Ctrl+Q", "Quit"},
}}, }},
{"Server list", [][2]string{ {"Server list", [][2]string{
@ -249,37 +224,40 @@ func (m *fullHelpModel) View() string {
}}, }},
} }
var lines []string
for _, sec := range sections { for _, sec := range sections {
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render(sec.title)) b.WriteString(sectionStyle.Render(sec.title))
b.WriteString("\n")
for _, row := range sec.rows { for _, row := range sec.rows {
if row[0] == "" { if row[0] == "" {
lines = append(lines, " "+row[1]) b.WriteString(fmt.Sprintf(" %s\n", row[1]))
} else { } else {
lines = append(lines, fmt.Sprintf(" %-16s %s", row[0], row[1])) b.WriteString(fmt.Sprintf(" %-16s %s\n", row[0], row[1]))
} }
} }
lines = append(lines, "") b.WriteString("\n")
} }
body := func(width, height int) string { b.WriteString(helpStyle.Render(" ↑/↓ scroll — q/Esc/Enter close"))
capacity := max(1, height-2)
start := min(m.offset, max(0, len(lines)-capacity)) // Simple scroll
end := min(len(lines), start+capacity) lines := strings.Split(b.String(), "\n")
return renderPaddedPanel(width, height, lines[start:end]) maxLines := m.height - 1
if maxLines < 5 {
maxLines = 5
} }
return renderScreenShell(screenShell{ start := m.offset
breadcrumb: "Full Help", if start > len(lines)-maxLines {
status: fmt.Sprintf("line %d/%d", min(m.offset+1, len(lines)), len(lines)), start = len(lines) - maxLines
width: m.width, }
height: m.height, if start < 0 {
body: body, start = 0
footer: []helpItem{ }
{Key: "↑/↓", Action: "scroll"}, end := start + maxLines
{Key: "Ctrl+H", Action: "full help"}, if end > len(lines) {
{Key: "Esc/Enter", Action: "close"}, end = len(lines)
}, }
})
return strings.Join(lines[start:end], "\n")
} }
// --- Action menu --- // --- Action menu ---
@ -287,7 +265,6 @@ func (m *fullHelpModel) View() string {
type actionMenuItem struct { type actionMenuItem struct {
label string label string
action string action string
description string
} }
func (i actionMenuItem) Title() string { return i.label } func (i actionMenuItem) Title() string { return i.label }
@ -302,20 +279,20 @@ type actionMenuModel struct {
func newActionMenuModel(w, h int) *actionMenuModel { func newActionMenuModel(w, h int) *actionMenuModel {
items := []list.Item{ items := []list.Item{
actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."}, actionMenuItem{label: "Connect", action: "connect"},
actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."}, actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."}, actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."}, actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"},
actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."}, actionMenuItem{label: "Manage port forwards", action: "forwards"},
actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."}, actionMenuItem{label: "Manage tunnels", action: "tunnels"},
actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."}, actionMenuItem{label: "Manage route", action: "route"},
actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."}, actionMenuItem{label: "Test connection", action: "test"},
actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."}, actionMenuItem{label: "Edit", action: "edit"},
actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."}, actionMenuItem{label: "Delete", action: "delete"},
actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."}, actionMenuItem{label: "Import", action: "import"},
actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."}, actionMenuItem{label: "Export", action: "export"},
actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."}, actionMenuItem{label: "Vault: lock", action: "vault_lock"},
actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."}, actionMenuItem{label: "Vault: change password", action: "vault_change_pw"},
} }
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2) l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
@ -347,44 +324,10 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
} }
func (m *actionMenuModel) View() string { func (m *actionMenuModel) View() string {
body := func(width, height int) string { footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
listLines := m.actionLines(max(1, height-2)) lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
if classifyShellContent(width) == sizeWide { capacity := max(1, m.height-displayLineCount(footer)-1)
leftWidth := width * 48 / 100
rightWidth := width - leftWidth - 1
selected, _ := m.list.SelectedItem().(actionMenuItem)
detail := []string{dashboardSection("Selected action"), "", selected.label, ""}
detail = append(detail, wrapCells(selected.description, max(1, rightWidth-4))...)
return joinPanelColumns(
renderPaddedPanel(leftWidth, height, listLines), leftWidth,
renderPaddedPanel(rightWidth, height, detail), rightWidth,
)
}
if classifyShellContent(width) == sizeMedium {
if selected, ok := m.list.SelectedItem().(actionMenuItem); ok && len(listLines) < height-4 {
listLines = append(listLines, "", dashboardSection("Selected"), selected.description)
}
}
return renderPaddedPanel(width, height, listLines)
}
return renderScreenShell(screenShell{
breadcrumb: "Actions",
status: fmt.Sprintf("%d actions", len(m.list.Items())),
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "↑/↓", Action: "move"},
{Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
}
func (m *actionMenuModel) actionLines(capacity int) []string {
start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity) start, end := visibleServerRange(len(m.list.Items()), m.list.Index(), capacity)
lines := make([]string, 0, capacity)
for index := start; index < end; index++ { for index := start; index < end; index++ {
item, ok := m.list.Items()[index].(actionMenuItem) item, ok := m.list.Items()[index].(actionMenuItem)
if !ok { if !ok {
@ -394,7 +337,11 @@ func (m *actionMenuModel) actionLines(capacity int) []string {
if index == m.list.Index() { if index == m.list.Index() {
marker = "> " marker = "> "
} }
lines = append(lines, marker+item.label) lines = append(lines, fitLine(marker+item.label, m.width))
} }
return lines lines = append(lines, strings.Split(footer, "\n")...)
if len(lines) > m.height && m.height > 0 {
lines = lines[:m.height]
}
return strings.Join(lines, "\n")
} }

View File

@ -1,7 +1,6 @@
package tui package tui
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
@ -47,7 +46,6 @@ func TestDashboardFitsSupportedTerminalSizes(t *testing.T) {
m := New(servers) m := New(servers)
m.width, m.height = size.width, size.height m.width, m.height = size.width, size.height
assertViewFits(t, m.View(), size.width, size.height) assertViewFits(t, m.View(), size.width, size.height)
assertRightMargin(t, m.View(), size.width)
for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} { for _, want := range []string{"sshkeeper", "Servers", "Vault", "Enter", "Ctrl+Q"} {
if !strings.Contains(m.View(), want) { if !strings.Contains(m.View(), want) {
t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View()) t.Fatalf("dashboard at %dx%d missing %q:\n%s", size.width, size.height, want, m.View())
@ -92,7 +90,6 @@ func TestServerFormFitsSupportedTerminalSizes(t *testing.T) {
fm.updateFocus() fm.updateFocus()
view := fm.View() view := fm.View()
assertViewFits(t, view, size.width, size.height) assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} { for _, want := range []string{"Server", "Port *", "not-a-port", "Port must be", "Save", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -110,9 +107,7 @@ func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
fm.inputs[1].SetValue("15432") fm.inputs[1].SetValue("15432")
fm.inputs[2].SetValue("database.internal.example") fm.inputs[2].SetValue("database.internal.example")
fm.inputs[3].SetValue("5432") fm.inputs[3].SetValue("5432")
view := fm.View() assertViewFits(t, fm.View(), size.width, size.height)
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
} }
} }
@ -125,7 +120,6 @@ func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
} }
view := fm.View() view := fm.View()
assertViewFits(t, view, size.width, size.height) assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} { for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -139,7 +133,6 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
menu := newActionMenuModel(size.width, size.height) menu := newActionMenuModel(size.width, size.height)
view := menu.View() view := menu.View()
assertViewFits(t, view, size.width, size.height) assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} { for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -161,7 +154,6 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
}) })
view := m.View() view := m.View()
assertViewFits(t, view, size.width, size.height) assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} { for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -170,143 +162,6 @@ func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
} }
} }
func TestConfirmationKeepsActionsVisibleWithLongContent(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New(nil)
m.width, m.height = size.width, size.height
m.beginConfirm(confirmState{
title: "Delete port forward?",
target: strings.Repeat("非常に長い-очень-длинный-🔐 ", 20),
consequence: strings.Repeat("Active connections can be interrupted. ", 20),
verb: "Delete",
parent: screenForwardList,
})
view := m.View()
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"[ Cancel ]", "[ Delete ]"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d clipped %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestHelpScreensUseUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
for name, view := range map[string]string{
"quick": newHelpScreenModel(size.width, size.height).View(),
"full": newFullHelpModel(size.width, size.height).View(),
} {
t.Run(name+itoa(size.width), func(t *testing.T) {
assertUnifiedScreen(t, view, size.width, size.height)
if strings.Contains(view, "F1") || !strings.Contains(view, "Ctrl+H") {
t.Fatalf("help exposes the wrong global binding:\n%s", view)
}
})
}
}
}
func TestManagerScreensUseUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New([]*model.Server{{Alias: "prod", Host: "prod.example", Port: 22, User: "ops"}})
m.width, m.height = size.width, size.height
template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"}
m.setTemplates([]*model.CommandTemplate{template})
m.setTags([]string{"production"})
m.pendingTemplate = template
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
screens := []struct {
name string
screen screen
}{
{"search", screenSearch},
{"tags", screenTags},
{"tag-input", screenTagInput},
{"templates", screenTemplates},
{"template-picker", screenTemplatePicker},
{"template-mode", screenTemplateMode},
{"background-results", screenBackgroundResults},
}
for _, tt := range screens {
m.screen = tt.screen
t.Run(tt.name+itoa(size.width), func(t *testing.T) {
assertUnifiedScreen(t, m.View(), size.width, size.height)
})
}
tunnelScreen := newTunnelScreenModel(size.width, size.height)
assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height)
}
}
func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
covered := map[screen]string{
screenList: "dashboard",
screenForm: "server form",
screenSearch: "manager matrix",
screenTags: "manager matrix",
screenTagInput: "manager matrix",
screenTemplates: "manager matrix",
screenTemplateForm: "template form",
screenTemplatePicker: "manager matrix",
screenTemplateMode: "manager matrix",
screenBackgroundResults: "manager matrix",
screenHelp: "help matrix",
screenActionMenu: "action matrix",
screenForwardList: "forward matrix",
screenForwardForm: "forward form matrix",
screenTunnelManager: "manager matrix",
screenConfirm: "confirmation matrix",
screenFullHelp: "help matrix",
}
for value := screenList; value <= screenFullHelp; value++ {
if _, ok := covered[value]; !ok {
t.Fatalf("screen %d is missing from the layout matrix", value)
}
}
}
func TestShellBreakpointsUseTerminalWidth(t *testing.T) {
for _, tt := range []struct {
contentWidth int
want terminalSizeClass
}{{68, sizeNarrow}, {69, sizeMedium}, {98, sizeMedium}, {99, sizeWide}} {
if got := classifyShellContent(tt.contentWidth); got != tt.want {
t.Fatalf("content width %d classified as %v, want %v", tt.contentWidth, got, tt.want)
}
}
}
func TestTunnelErrorUsesUnifiedShellRows(t *testing.T) {
tunnelModel := newTunnelScreenModel(60, 16)
tunnelModel.tunnels = []*model.TunnelState{{Name: "prod tunnel", ServerAlias: "prod", LastError: "connection lost\nretry failed"}}
tunnelModel.rebuildList()
view := tunnelModel.View()
assertUnifiedScreen(t, view, 60, 16)
if !strings.Contains(view, "connection lost") || !strings.Contains(view, "retry failed") {
t.Fatalf("tunnel error was lost:\n%s", view)
}
}
func TestTemplateViewportKeepsSelectedDescribedItemVisible(t *testing.T) {
m := New(nil)
m.width, m.height = 60, 16
templates := make([]*model.CommandTemplate, 20)
for index := range templates {
templates[index] = &model.CommandTemplate{Name: fmt.Sprintf("template-%02d", index), Command: "echo ok", Description: "description"}
}
m.setTemplates(templates)
m.templateList.Select(len(templates) - 1)
m.screen = screenTemplates
view := m.View()
assertUnifiedScreen(t, view, 60, 16)
if !strings.Contains(view, "> template-19") {
t.Fatalf("selected template is outside viewport:\n%s", view)
}
}
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) { func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newTemplateFormModel(nil, size.width, size.height) form := newTemplateFormModel(nil, size.width, size.height)
@ -314,7 +169,6 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
form.inputs[1].SetValue("printf 'a very long command that remains editable'") form.inputs[1].SetValue("printf 'a very long command that remains editable'")
view := form.View() view := form.View()
assertViewFits(t, view, size.width, size.height) assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Template", "Name *", "Save", "Esc"} { for _, want := range []string{"Template", "Name *", "Save", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("template form at %dx%d missing %q:\n%s", size.width, size.height, want, view)
@ -323,21 +177,6 @@ func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
} }
} }
func TestServerFormDropdownUsesUnifiedShell(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newFormModel(size.width, size.height)
form.focusIdx = 5
form.showAuthList = true
view := form.View()
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Select auth method", "password", "agent", "Enter", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("dropdown at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func assertViewFits(t *testing.T, view string, width, height int) { func assertViewFits(t *testing.T, view string, width, height int) {
t.Helper() t.Helper()
lines := strings.Split(strings.TrimRight(view, "\n"), "\n") lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
@ -351,37 +190,6 @@ func assertViewFits(t *testing.T, view string, width, height int) {
} }
} }
func assertUnifiedScreen(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(view, "\n")
if len(lines) != height {
t.Fatalf("unified screen has %d lines, want %d:\n%s", len(lines), height, view)
}
if !strings.HasPrefix(ansi.Strip(lines[0]), "sshkeeper / ") {
t.Fatalf("unified screen has no breadcrumb header: %q", ansi.Strip(lines[0]))
}
if !strings.Contains(ansi.Strip(view), "┌") || !strings.Contains(ansi.Strip(view), "┘") {
t.Fatalf("unified screen has no framed content:\n%s", view)
}
if strings.TrimSpace(ansi.Strip(lines[height-1])) == "" {
t.Fatalf("unified screen footer is not on last row:\n%s", view)
}
for index, line := range lines {
if got := ansi.StringWidth(line); got > width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
}
}
}
func assertRightMargin(t *testing.T, view string, width int) {
t.Helper()
for index, line := range strings.Split(view, "\n") {
if got := ansi.StringWidth(line); got > width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d terminal=%d", index+1, got, width)
}
}
}
type errText string type errText string
func (e errText) Error() string { return string(e) } func (e errText) Error() string { return string(e) }

View File

@ -1,133 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type screenShell struct {
breadcrumb string
status string
notification string
width int
height int
body func(width, height int) string
footer []helpItem
}
func renderScreenShell(shell screenShell) string {
width, height := shell.width, shell.height
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
canvasWidth := max(1, width-1)
headerLeft := "sshkeeper"
if shell.breadcrumb != "" {
headerLeft += " / " + shell.breadcrumb
}
header := headerLeft
if shell.status != "" {
if gap := canvasWidth - lipgloss.Width(headerLeft) - lipgloss.Width(shell.status); gap > 0 {
header = headerLeft + strings.Repeat(" ", gap) + shell.status
} else {
header = headerLeft + " " + shell.status
}
}
footer := renderHelp(shell.footer, canvasWidth)
footerLines := splitBlock(footer)
if len(footerLines) == 0 {
footerLines = []string{""}
}
fixedRows := 2 + len(footerLines)
notificationLines := []string(nil)
if shell.notification != "" {
notificationLines = []string{fitLine(shell.notification, canvasWidth)}
fixedRows++
}
bodyHeight := max(1, height-fixedRows)
body := ""
if shell.body != nil {
body = shell.body(canvasWidth, bodyHeight)
}
bodyLines := fitBlock(body, canvasWidth, bodyHeight)
lines := make([]string, 0, height)
lines = append(lines,
titleStyle.Copy().MarginLeft(0).Render(fitLine(header, canvasWidth)),
helpStyle.Copy().MarginLeft(0).Render(strings.Repeat("─", canvasWidth)),
)
lines = append(lines, notificationLines...)
lines = append(lines, bodyLines...)
for _, line := range footerLines {
lines = append(lines, fitLine(line, canvasWidth))
}
if len(lines) > height {
lines = lines[:height]
}
for len(lines) < height {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
func renderPaddedPanel(width, height int, lines []string) string {
if width < 4 || height < 2 {
return ""
}
contentWidth := width - 4
padded := make([]string, 0, len(lines))
for _, line := range lines {
padded = append(padded, " "+padCells(line, contentWidth)+" ")
}
return renderPanel(width, height, padded)
}
func fitBlock(block string, width, height int) []string {
lines := splitBlock(block)
if len(lines) > height {
lines = lines[:height]
}
for index := range lines {
lines[index] = fitLine(lines[index], width)
}
for len(lines) < height {
lines = append(lines, strings.Repeat(" ", width))
}
return lines
}
func splitBlock(block string) []string {
if block == "" {
return nil
}
return strings.Split(strings.TrimRight(block, "\n"), "\n")
}
func classifyShellContent(contentWidth int) terminalSizeClass {
terminalWidth := contentWidth + 1
if terminalWidth >= 100 {
return sizeWide
}
if terminalWidth >= 70 {
return sizeMedium
}
return sizeNarrow
}
func shellStatus(vaultUnlocked bool, detail string) string {
vault := "Vault locked"
if vaultUnlocked {
vault = "Vault unlocked"
}
if detail == "" {
return vault
}
return fmt.Sprintf("%s · %s", vault, detail)
}

View File

@ -1,62 +0,0 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestScreenShellFitsAndAnchorsFooter(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
t.Run(itoa(size.width)+"x"+itoa(size.height), func(t *testing.T) {
view := renderScreenShell(screenShell{
breadcrumb: "Actions / production-数据库",
status: "Vault unlocked",
width: size.width,
height: size.height,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{"Actions", "> Connect", " Manage port forwards"})
},
footer: []helpItem{{Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
})
lines := strings.Split(view, "\n")
if len(lines) != size.height {
t.Fatalf("shell has %d lines, want %d:\n%s", len(lines), size.height, view)
}
if !strings.Contains(ansi.Strip(lines[0]), "sshkeeper / Actions") {
t.Fatalf("missing breadcrumb header: %q", ansi.Strip(lines[0]))
}
if !strings.HasPrefix(ansi.Strip(lines[2]), "┌") || !strings.HasSuffix(ansi.Strip(lines[size.height-2]), "┘") {
t.Fatalf("body panel does not fill shell:\n%s", view)
}
if !strings.Contains(ansi.Strip(lines[size.height-1]), "Ctrl+H") {
t.Fatalf("footer is not anchored to last row: %q", ansi.Strip(lines[size.height-1]))
}
for index, line := range lines {
if got := ansi.StringWidth(line); got > size.width-1 {
t.Fatalf("line %d uses unsafe last terminal column: width=%d, terminal=%d", index+1, got, size.width)
}
}
})
}
}
func TestScreenShellShowsNotificationWithoutMovingFooter(t *testing.T) {
view := renderScreenShell(screenShell{
breadcrumb: "Port Forwards / prod",
status: "2 rules",
notification: "Forward saved",
width: 60,
height: 16,
body: func(width, height int) string {
return renderPaddedPanel(width, height, []string{"Local PostgreSQL"})
},
footer: []helpItem{{Key: "Esc", Action: "back"}},
})
lines := strings.Split(view, "\n")
if len(lines) != 16 || !strings.Contains(view, "Forward saved") || !strings.Contains(ansi.Strip(lines[15]), "Esc") {
t.Fatalf("notification broke shell geometry:\n%s", view)
}
}

View File

@ -45,15 +45,15 @@ func TestNotificationSurvivesRepeatedView(t *testing.T) {
} }
} }
func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) { func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
m := New(nil) m := New(nil)
m.screen = screenForwardList m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24) m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
m = updated.(*tuiModel) m = updated.(*tuiModel)
if m.screen != screenFullHelp || m.fullHelp == nil { if m.screen != screenFullHelp || m.fullHelp == nil {
t.Fatalf("Ctrl+H did not open full help from forward list: screen=%v", m.screen) t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen)
} }
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(*tuiModel) m = updated.(*tuiModel)
@ -62,27 +62,6 @@ func TestCtrlHFullHelpReturnsToOriginatingScreen(t *testing.T) {
} }
} }
func TestF1NoLongerOpensFullHelp(t *testing.T) {
m := New(nil)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
m = updated.(*tuiModel)
if m.screen == screenFullHelp || m.fullHelp != nil {
t.Fatalf("F1 still opens full help: screen=%v", m.screen)
}
}
func TestBackspaceStillEditsSearchAfterCtrlHBinding(t *testing.T) {
m := New(nil)
m.screen = screenSearch
m.searchInput.SetValue("prod")
m.searchInput.Focus()
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
m = updated.(*tuiModel)
if m.screen != screenSearch || m.searchInput.Value() != "pro" {
t.Fatalf("Backspace did not edit search: screen=%v value=%q", m.screen, m.searchInput.Value())
}
}
func TestContextHelpReturnsToOriginatingManager(t *testing.T) { func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
m := New(nil) m := New(nil)
m.screen = screenForwardList m.screen = screenForwardList

View File

@ -151,40 +151,31 @@ func (tf *templateFormModel) save() tea.Cmd {
} }
func (tf *templateFormModel) View() string { func (tf *templateFormModel) View() string {
var b strings.Builder
title := "Add Template" title := "Add Template"
if tf.edit { if tf.edit {
title = "Edit Template" title = "Edit Template"
} }
notification := "" b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
if tf.err != nil { b.WriteString("\n\n")
notification = errorStyle.Render(tf.err.Error())
} else if tf.saved {
notification = successStyle.Render("✓ Saved.")
}
return renderScreenShell(screenShell{
breadcrumb: "Command Templates / " + title,
status: "Template editor",
notification: notification,
width: tf.width,
height: tf.height,
body: func(width, height int) string {
lines := make([]string, 0, len(tf.inputs)+3)
for i := range tf.inputs { for i := range tf.inputs {
lines = append(lines, tf.inputs[i].View()) b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
b.WriteString("\n")
} }
button := " [ Save ]" button := " [ Save ]"
if tf.focusIdx == len(tf.inputs) { if tf.focusIdx == len(tf.inputs) {
button = selectedStyle.Render("> [ Save ]") button = selectedStyle.Render("> [ Save ]")
} }
lines = append(lines, "", button) b.WriteString("\n" + button + "\n\n")
return renderPaddedPanel(width, height, lines) if tf.err != nil {
}, b.WriteString(errorStyle.Render(tf.err.Error()))
footer: []helpItem{ b.WriteString("\n")
}
b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"}, {Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"}, {Key: "↑", Action: "prev"},
{Key: "Enter", Action: "select"}, {Key: "Enter", Action: "select"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, }, tf.width))
}) return b.String()
} }

View File

@ -94,44 +94,19 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
} }
func (m *tunnelScreenModel) View() string { func (m *tunnelScreenModel) View() string {
notification := "" var b strings.Builder
b.WriteString(m.list.View())
b.WriteString("\n\n")
if m.err != nil { if m.err != nil {
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err)) b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
b.WriteString("\n\n")
} }
body := func(width, height int) string { b.WriteString(renderHelp([]helpItem{
if len(m.tunnels) == 0 {
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
}
capacity := max(1, height-2)
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3))
lines := make([]string, 0, capacity)
for index := start; index < end; index++ {
item := tunnelItem{state: m.tunnels[index]}
marker := " "
if index == m.list.Index() {
marker = "> "
}
lines = append(lines, marker+item.Title())
for _, description := range strings.Split(item.Description(), "\n") {
lines = append(lines, " "+strings.TrimSpace(description))
}
}
return renderPaddedPanel(width, height, lines)
}
return renderScreenShell(screenShell{
breadcrumb: "Tunnel Manager",
status: fmt.Sprintf("%d running", len(m.tunnels)),
notification: notification,
width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"}, {Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"}, {Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, }, m.width))
}) return b.String()
} }
type tunnelsLoadedMsg struct { type tunnelsLoadedMsg struct {

View File

@ -4,8 +4,7 @@ set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
APP=sshkeeper APP=sshkeeper
# --match 'v*' ignores the rolling `nightly` tag; see build.sh for the details. VERSION=${VERSION:-${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}}
VERSION=${VERSION:-${1:-$(git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo "dev")}}
LDFLAGS="-s -w -X main.version=${VERSION}" LDFLAGS="-s -w -X main.version=${VERSION}"
DIST_DIR="dist" DIST_DIR="dist"
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)} SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)}
@ -28,16 +27,6 @@ package_docs() {
normalize_package() { normalize_package() {
local package_dir="$1" local package_dir="$1"
local binary="$2"
# Permissions must not depend on the builder's umask. Without this, a host
# with umask 002 packages 664/775 while one with umask 022 packages
# 644/755, and the archives differ even though every file inside is
# byte-identical.
find "${package_dir}" -type d -exec chmod 755 {} +
find "${package_dir}" -type f -exec chmod 644 {} +
chmod 755 "${package_dir}/${binary}"
find "${package_dir}" -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} + find "${package_dir}" -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} +
} }
@ -53,7 +42,7 @@ build_tarball() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" . GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" .
package_docs "${package_dir}" package_docs "${package_dir}"
normalize_package "${package_dir}" "${APP}" normalize_package "${package_dir}"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH}" -cf - -C "${DIST_DIR}" "$(basename "${package_dir}")" | gzip -n > "${archive}" tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH}" -cf - -C "${DIST_DIR}" "$(basename "${package_dir}")" | gzip -n > "${archive}"
rm -rf "${package_dir}" rm -rf "${package_dir}"
} }
@ -70,15 +59,8 @@ build_zip() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}.exe" . GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}.exe" .
package_docs "${package_dir}" package_docs "${package_dir}"
normalize_package "${package_dir}" "${APP}.exe" normalize_package "${package_dir}"
# LC_ALL=C: `sort` is locale-sensitive, and it decides the entry order here. (cd "${DIST_DIR}" && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
# A ru_RU.UTF-8 host orders docs/ before LICENSE where a C locale does the
# reverse, producing a different archive from identical files.
#
# TZ=UTC: zip records DOS local time with no zone, so the same build in
# +08:00 and in UTC would embed different timestamps. tar needs neither —
# it sorts internally and stores Unix epochs.
(cd "${DIST_DIR}" && export LC_ALL=C TZ=UTC && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
rm -rf "${package_dir}" rm -rf "${package_dir}"
} }