Compare commits
14 Commits
codex/tui-
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
a19b3deb24 | |
|
|
59b57a4970 | |
|
|
878f7b4472 | |
|
|
0a02f0fc60 | |
|
|
19ffc4ba5e | |
|
|
cc83802244 | |
|
|
44156a11af | |
|
|
84070d2721 | |
|
|
5e83300ea8 | |
|
|
18d7a7c07a | |
|
|
d8ae2d4236 | |
|
|
762d14bb9b | |
|
|
4ba2bfce34 | |
|
|
7b5919b6c1 |
|
|
@ -0,0 +1,71 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
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
|
||||||
11
README.md
|
|
@ -111,11 +111,16 @@ 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 (F1)
|
### Full Help (Ctrl+H)
|
||||||
|
|
||||||
Press `F1` on any screen for full documentation including routes, port
|
Press `Ctrl+H` 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 |
|
||||||
|
|
@ -138,7 +143,7 @@ forwarding, tunnels, and vault.
|
||||||
| 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) |
|
||||||
| F1 | Full documentation |
|
| Ctrl+H | 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
|
||||||
|
|
|
||||||
5
build.sh
|
|
@ -4,7 +4,10 @@ set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
APP=sshkeeper
|
APP=sshkeeper
|
||||||
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
# --match 'v*' keeps the rolling `nightly` tag from hijacking the version: a
|
||||||
|
# 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}..."
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,22 @@ 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 {
|
||||||
|
|
@ -110,3 +124,70 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ Auth: agent Group: - Status: ?
|
||||||
| `Ctrl+F` | Поиск |
|
| `Ctrl+F` | Поиск |
|
||||||
| `Ins` | Выбрать/снять выбор |
|
| `Ins` | Выбрать/снять выбор |
|
||||||
| `?` | Краткая справка по клавишам |
|
| `?` | Краткая справка по клавишам |
|
||||||
| `F1` | Полная справка по приложению |
|
| `Ctrl+H` | Полная справка по приложению |
|
||||||
| `Ctrl+Q` | Выход |
|
| `Ctrl+Q` | Выход |
|
||||||
|
|
||||||
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
|
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
|
||||||
|
|
@ -179,7 +179,7 @@ Auth: agent Group: - Status: ?
|
||||||
### Быстрая справка по клавишам
|
### Быстрая справка по клавишам
|
||||||
|
|
||||||
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
|
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`
|
||||||
остаётся обычным вводом. `F1` открывает полную справку также из форм.
|
остаётся обычным вводом. `Ctrl+H` открывает полную справку также из форм.
|
||||||
|
|
||||||
```
|
```
|
||||||
sshkeeper — Quick Help
|
sshkeeper — Quick Help
|
||||||
|
|
@ -211,14 +211,20 @@ sshkeeper — Quick Help
|
||||||
|
|
||||||
Other
|
Other
|
||||||
? This quick help
|
? This quick help
|
||||||
F1 Full documentation
|
Ctrl+H Full documentation
|
||||||
|
|
||||||
Esc / Enter / ? / q — close
|
Esc / Enter / ? / q — close
|
||||||
```
|
```
|
||||||
|
|
||||||
### Полная справка по приложению
|
### Полная справка по приложению
|
||||||
|
|
||||||
Нажмите `F1` на любом экране. Это полная документация по sshkeeper:
|
Нажмите `Ctrl+H` на любом экране. Это полная документация по sshkeeper:
|
||||||
|
|
||||||
|
> **Про терминалы.** `Ctrl+H` — это управляющий символ BS (0x08), а Backspace в
|
||||||
|
> xterm и большинстве современных эмуляторов шлёт DEL (0x7F), поэтому справка и
|
||||||
|
> редактирование текста не конфликтуют. Если ваш терминал настроен отправлять BS
|
||||||
|
> по Backspace, различить их невозможно: Backspace начнёт открывать справку.
|
||||||
|
> В этом случае переключите терминал на DEL (в xterm — `backarrowKey: false`).
|
||||||
|
|
||||||
```
|
```
|
||||||
sshkeeper — Full Help
|
sshkeeper — Full Help
|
||||||
|
|
@ -238,7 +244,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)
|
||||||
F1 Full documentation
|
Ctrl+H Full documentation
|
||||||
Ctrl+Q Quit
|
Ctrl+Q Quit
|
||||||
|
|
||||||
Server list
|
Server list
|
||||||
|
|
@ -743,7 +749,7 @@ sshkeeper connect secure
|
||||||
| `Ctrl+X` | Меню действий |
|
| `Ctrl+X` | Меню действий |
|
||||||
| `Ins` | Выбрать/снять выбор |
|
| `Ins` | Выбрать/снять выбор |
|
||||||
| `?` | Краткая справка по клавишам |
|
| `?` | Краткая справка по клавишам |
|
||||||
| `F1` | Полная справка по приложению |
|
| `Ctrl+H` | Полная справка по приложению |
|
||||||
| `Ctrl+Q` | Выход |
|
| `Ctrl+Q` | Выход |
|
||||||
|
|
||||||
### Меню действий (Ctrl+X)
|
### Меню действий (Ctrl+X)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,41 @@
|
||||||
# Release Packaging
|
# Release Packaging
|
||||||
|
|
||||||
This document describes the manual release flow for sshkeeper.
|
Releases are published by GitHub Actions. Pushing a `v*` tag is the whole
|
||||||
|
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:
|
Use a semantic version tag. Pushing it is what triggers `release.yml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status --short
|
git status --short
|
||||||
|
|
@ -12,8 +43,14 @@ git tag -a v0.2.0 -m "sshkeeper v0.2.0"
|
||||||
git push origin v0.2.0
|
git push origin v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The release script uses `git describe --tags --always --dirty` by default. You
|
The remaining sections describe the manual equivalent, which is still the way
|
||||||
can also pass the version explicitly:
|
to test packaging locally or to recover if Actions is unavailable.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -87,9 +124,40 @@ 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
|
||||||
|
|
||||||
Upload these files to the release:
|
`release.yml` does this automatically on tag push. To publish by hand, upload:
|
||||||
|
|
||||||
- all five platform archives
|
- all five platform archives
|
||||||
- `checksums.txt`
|
- `checksums.txt`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
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 (70–99) stacks them, narrow (60–69) 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.
|
||||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 20 KiB |
|
|
@ -0,0 +1,141 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Unified sshkeeper TUI Shell
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Bring every sshkeeper TUI screen to the same visual and behavioral standard as
|
||||||
|
the approved server dashboard. Every screen must have a stable header, bounded
|
||||||
|
framed content, and a contextual footer anchored to the bottom of the terminal.
|
||||||
|
|
||||||
|
## User-approved direction
|
||||||
|
|
||||||
|
The existing server dashboard is the visual reference. Do not introduce a new
|
||||||
|
visual language or browser mockups. Apply its hierarchy, border treatment,
|
||||||
|
selection markers, spacing, colors, and responsive degradation to all child
|
||||||
|
screens.
|
||||||
|
|
||||||
|
`Ctrl+H` replaces `F1` as the global full-help binding. `?` remains contextual
|
||||||
|
quick help outside text editors. Remove `F1` from runtime help, footers, README,
|
||||||
|
and the user guide.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
The dashboard owns a height-aware renderer with a header, panels, and footer.
|
||||||
|
Most child screens still render independent free-form strings or a default
|
||||||
|
Bubbles list. They therefore do not share inner-width budgeting, borders,
|
||||||
|
viewport height, or bottom-footer placement. The correction is a shared shell,
|
||||||
|
not per-screen blank-line padding.
|
||||||
|
|
||||||
|
## Shared screen shell
|
||||||
|
|
||||||
|
Every full-screen state uses this vertical contract:
|
||||||
|
|
||||||
|
1. Header: `sshkeeper / <breadcrumb>` on the left and truthful vault/context
|
||||||
|
status on the right.
|
||||||
|
2. Separator: one display-cell-bounded horizontal line.
|
||||||
|
3. Content: one or two bordered panels filling all available rows.
|
||||||
|
4. Footer: only the current screen's primary shortcuts, wrapped by display
|
||||||
|
width and anchored to the last terminal row.
|
||||||
|
|
||||||
|
The shell computes content height as terminal height minus header, separator,
|
||||||
|
notification, and wrapped-footer rows. A panel owns a one-cell border and at
|
||||||
|
least one-cell inner horizontal padding. No content row may consume the
|
||||||
|
terminal's last column directly.
|
||||||
|
|
||||||
|
Errors, success messages, pending state, and partial success appear in a
|
||||||
|
dedicated notification row below the separator. Rendering remains pure.
|
||||||
|
|
||||||
|
## Screen families
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
Actions use a framed selectable list. At 100 columns and wider, a second panel
|
||||||
|
describes the selected action and its target. At 70-99 columns, the description
|
||||||
|
appears below the list when height permits. At 60-69 columns, only the framed
|
||||||
|
list remains. The footer is always at the bottom.
|
||||||
|
|
||||||
|
### Port forwards
|
||||||
|
|
||||||
|
At 100 columns and wider, forwards use a framed table and a framed selected-rule
|
||||||
|
panel. At 70-99 columns, the selected-rule panel is stacked below the table. At
|
||||||
|
60-69 columns, the table contains Name, Type, and On; details stay available
|
||||||
|
through the selected-rule panel only when vertical space permits.
|
||||||
|
|
||||||
|
All column widths are derived from the panel's inner width. The table must
|
||||||
|
leave an inner right margin, so an 80-column terminal never renders a row at 80
|
||||||
|
display cells. Long names, endpoints, explanations, and SSH arguments truncate
|
||||||
|
by display cells with an ellipsis.
|
||||||
|
|
||||||
|
The forward form uses the shared shell, a framed form panel, radio markers for
|
||||||
|
type, and an action row. Focused fields and validation remain visible.
|
||||||
|
|
||||||
|
### Managers and pickers
|
||||||
|
|
||||||
|
Tags, command templates, template picker/mode/results, and tunnel manager use a
|
||||||
|
framed list or result panel. Selection uses `>` as well as color. Empty and
|
||||||
|
error states remain inside the panel. Lists viewport around the selected item
|
||||||
|
and never rely on the default Bubbles frame or footer.
|
||||||
|
|
||||||
|
### Forms and text entry
|
||||||
|
|
||||||
|
Server, template, tag, search, and forward editors use a framed form panel.
|
||||||
|
Their title moves into the common breadcrumb. Required markers, validation,
|
||||||
|
dirty confirmation, and input ownership do not change. The focused control,
|
||||||
|
status/error, and action row remain in the panel's visible window.
|
||||||
|
|
||||||
|
### Confirmations
|
||||||
|
|
||||||
|
Confirmations use the same application header and a centered or width-bounded
|
||||||
|
framed dialog panel. Exact target, consequence, Cancel-first action row, and
|
||||||
|
footer remain visible at 60x16. Long Unicode text wraps by display cells.
|
||||||
|
|
||||||
|
### Help
|
||||||
|
|
||||||
|
`Ctrl+H` opens full help from every state except a confirmation overlay. `?`
|
||||||
|
opens contextual quick help only when a text editor does not own printable
|
||||||
|
input. `F1` has no documented or runtime binding.
|
||||||
|
|
||||||
|
Bubble Tea v1 distinguishes `KeyCtrlH` (ASCII BS) from `KeyBackspace` (DEL).
|
||||||
|
Automated and PTY checks must prove that xterm Backspace edits text while
|
||||||
|
`Ctrl+H` opens help. Terminals configured to emit BS for Backspace cannot
|
||||||
|
distinguish the two; the supported runtime check uses xterm's default DEL
|
||||||
|
Backspace mapping.
|
||||||
|
|
||||||
|
Both help screens use the shared shell and a framed, scrolling body. Their
|
||||||
|
footer stays at the bottom and states how to close/scroll.
|
||||||
|
|
||||||
|
## Responsive contract
|
||||||
|
|
||||||
|
- Supported floor: `60x16`.
|
||||||
|
- Wide: `width >= 100`, two panels where useful.
|
||||||
|
- Medium: `70 <= width < 100`, stacked panels.
|
||||||
|
- Narrow: `60 <= width < 70`, compact single panel.
|
||||||
|
- Below the floor: render only the minimum-size message.
|
||||||
|
|
||||||
|
Every screen family is tested at 120x40, 80x24, and 60x16 with long ASCII,
|
||||||
|
Cyrillic, CJK, combining, and emoji content. Assertions measure ANSI-aware
|
||||||
|
display cells and exact maximum height.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Automated coverage must inventory every `screen` enum value and prove that its
|
||||||
|
normal representative state:
|
||||||
|
|
||||||
|
- fits terminal width and height;
|
||||||
|
- contains a top header and at least one border;
|
||||||
|
- keeps the contextual footer on the final rendered row(s);
|
||||||
|
- retains a non-color selection/focus marker where applicable;
|
||||||
|
- does not expose `F1` and does expose `Ctrl+H` help where applicable.
|
||||||
|
|
||||||
|
Runtime verification uses the freshly built binary in an isolated XDG profile.
|
||||||
|
Capture and inspect dashboard, actions, forwards, forward form, tunnel manager,
|
||||||
|
server form, template/tag managers, confirmation, and help at all three sizes.
|
||||||
|
|
||||||
|
## Delivery boundary
|
||||||
|
|
||||||
|
Commit and push each implementation stage. Finish with a fresh binary under
|
||||||
|
`bin/sshkeeper` and report its absolute path and checksum. Do not publish a
|
||||||
|
release or update release metadata until the user explicitly approves the
|
||||||
|
binary.
|
||||||
|
|
@ -680,7 +680,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.err = nil
|
m.err = nil
|
||||||
m.success = ""
|
m.success = ""
|
||||||
}
|
}
|
||||||
if msg.Type == tea.KeyF1 && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
|
if msg.Type == tea.KeyCtrlH && 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,8 +1213,7 @@ func (m *tuiModel) View() string {
|
||||||
b.WriteString(m.viewServerList())
|
b.WriteString(m.viewServerList())
|
||||||
|
|
||||||
case screenSearch:
|
case screenSearch:
|
||||||
b.WriteString("Search: " + m.searchInput.View() + "\n")
|
b.WriteString(m.viewSearch())
|
||||||
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())
|
||||||
|
|
@ -1274,13 +1273,6 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1549,22 +1541,14 @@ func (m *tuiModel) viewConfirm() string {
|
||||||
if m.confirm == nil {
|
if m.confirm == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
width := m.width
|
body := func(width, height int) string {
|
||||||
if width <= 0 {
|
innerWidth := max(1, width-4)
|
||||||
width = 80
|
innerHeight := max(1, height-2)
|
||||||
}
|
message := wrapCells(m.confirm.target, 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 != "" {
|
if m.confirm.consequence != "" {
|
||||||
lines = append(lines, "")
|
message = append(message, "")
|
||||||
for _, line := range wrapCells(m.confirm.consequence, innerWidth) {
|
message = append(message, 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 {
|
||||||
|
|
@ -1572,18 +1556,37 @@ 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 {
|
||||||
lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "")
|
action = m.confirm.verb + " in progress…"
|
||||||
} else {
|
|
||||||
lines = append(lines, fitLine(" "+cancel+" "+accept, width), "")
|
|
||||||
}
|
}
|
||||||
footer := renderHelp([]helpItem{
|
messageRows := max(0, innerHeight-2)
|
||||||
|
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) {
|
||||||
|
|
@ -1771,6 +1774,36 @@ 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"))
|
||||||
|
|
@ -1891,37 +1924,34 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTags() string {
|
func (m *tuiModel) viewTags() string {
|
||||||
var b strings.Builder
|
return renderScreenShell(screenShell{
|
||||||
b.WriteString(titleStyle.Render("Tags"))
|
breadcrumb: "Tags",
|
||||||
b.WriteString("\n\n")
|
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d tags", len(m.tags))),
|
||||||
|
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 {
|
||||||
b.WriteString(helpStyle.Render(" No tags yet. Press Ctrl+A to add one to the selected servers."))
|
return renderPaddedPanel(width, height, []string{dashboardHelp("No tags yet. Ctrl+A adds one to the selected servers.")})
|
||||||
b.WriteString("\n")
|
}
|
||||||
} else {
|
capacity := max(1, height-2)
|
||||||
for i, item := range m.tagList.Items() {
|
start, end := visibleServerRange(len(m.tagList.Items()), m.tagList.Index(), capacity)
|
||||||
tag, ok := item.(groupItem)
|
lines := make([]string, 0, capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
tag, ok := m.tagList.Items()[index].(groupItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
marker := " "
|
marker := " "
|
||||||
style := normalStyle
|
if index == m.tagList.Index() {
|
||||||
if i == m.tagList.Index() {
|
|
||||||
marker = "> "
|
marker = "> "
|
||||||
style = selectedRowStyle
|
|
||||||
}
|
}
|
||||||
b.WriteString(style.Render(marker + tag.name))
|
lines = append(lines, marker+tag.name)
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
}
|
return renderPaddedPanel(width, height, lines)
|
||||||
b.WriteString("\n")
|
},
|
||||||
b.WriteString(renderHelp([]helpItem{
|
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"}},
|
||||||
{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 {
|
||||||
|
|
@ -1929,74 +1959,83 @@ func (m *tuiModel) viewTagInput() string {
|
||||||
if m.tagMode == "rename" {
|
if m.tagMode == "rename" {
|
||||||
title = "Rename Tag"
|
title = "Rename Tag"
|
||||||
}
|
}
|
||||||
return titleStyle.Render(title) + "\n\n" + m.tagInput.View() + "\n\n" + renderHelp([]helpItem{{Key: "Enter", Action: "save"}, {Key: "Esc", Action: "cancel"}}, m.width)
|
return renderScreenShell(screenShell{
|
||||||
|
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 {
|
||||||
var b strings.Builder
|
return renderScreenShell(screenShell{
|
||||||
b.WriteString(titleStyle.Render("Command Templates"))
|
breadcrumb: "Command Templates",
|
||||||
b.WriteString("\n\n")
|
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d templates", len(m.templates))),
|
||||||
|
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 {
|
||||||
b.WriteString(helpStyle.Render(" No command templates yet. Press Ctrl+A to add one."))
|
return renderPaddedPanel(width, height, []string{dashboardHelp("No command templates yet. Ctrl+A adds one.")})
|
||||||
b.WriteString("\n")
|
}
|
||||||
} else {
|
capacity := max(1, height-2)
|
||||||
for i, item := range m.templateList.Items() {
|
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
|
||||||
tpl, ok := item.(templateItem)
|
lines := make([]string, 0, capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
tpl, ok := m.templateList.Items()[index].(templateItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
marker := " "
|
marker := " "
|
||||||
style := normalStyle
|
if index == m.templateList.Index() {
|
||||||
if i == m.templateList.Index() {
|
|
||||||
marker = "> "
|
marker = "> "
|
||||||
style = selectedRowStyle
|
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)
|
line := marker + tpl.template.Name + " " + tpl.template.Command
|
||||||
b.WriteString(style.Render(line))
|
if tpl.template.Description != "" && classifyShellContent(width) != sizeNarrow {
|
||||||
b.WriteString("\n")
|
line += " — " + tpl.template.Description
|
||||||
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")
|
},
|
||||||
b.WriteString(renderHelp([]helpItem{
|
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"}},
|
||||||
{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 {
|
||||||
var b strings.Builder
|
targets := strings.Join(serverAliases(m.targetServers()), ", ")
|
||||||
b.WriteString(titleStyle.Render("Run Template"))
|
return renderScreenShell(screenShell{
|
||||||
b.WriteString("\n")
|
breadcrumb: "Run Template",
|
||||||
b.WriteString(helpStyle.Render(fmt.Sprintf("Targets: %s", strings.Join(serverAliases(m.targetServers()), ", "))))
|
status: "Targets: " + targets,
|
||||||
b.WriteString("\n\n")
|
width: m.width,
|
||||||
|
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 {
|
||||||
b.WriteString(helpStyle.Render(" No command templates. Press Esc, then Ctrl+P to add one."))
|
lines = append(lines, dashboardHelp("No command templates. Press Esc, then Ctrl+P to add one."))
|
||||||
} else {
|
} else {
|
||||||
for i, item := range m.templateList.Items() {
|
capacity := max(1, height-len(lines)-2)
|
||||||
tpl, ok := item.(templateItem)
|
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
tpl, ok := m.templateList.Items()[index].(templateItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
marker := " "
|
marker := " "
|
||||||
style := normalStyle
|
if index == m.templateList.Index() {
|
||||||
if i == m.templateList.Index() {
|
|
||||||
marker = "> "
|
marker = "> "
|
||||||
style = selectedRowStyle
|
|
||||||
}
|
}
|
||||||
b.WriteString(style.Render(fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)))
|
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
return renderPaddedPanel(width, height, lines)
|
||||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "choose"}, {Key: "Esc", Action: "back"}}, m.width))
|
},
|
||||||
return b.String()
|
footer: []helpItem{{Key: "Enter", Action: "choose"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTemplateMode() string {
|
func (m *tuiModel) viewTemplateMode() string {
|
||||||
|
|
@ -2006,30 +2045,44 @@ func (m *tuiModel) viewTemplateMode() string {
|
||||||
name = m.pendingTemplate.Name
|
name = m.pendingTemplate.Name
|
||||||
command = m.pendingTemplate.Command
|
command = m.pendingTemplate.Command
|
||||||
}
|
}
|
||||||
return titleStyle.Render("Run Mode") + "\n\n" +
|
targets := strings.Join(serverAliases(m.targetServers()), ", ")
|
||||||
fmt.Sprintf("Template: %s\nCommand: %s\nTargets: %s\n\n", name, command, strings.Join(serverAliases(m.targetServers()), ", ")) +
|
return renderScreenShell(screenShell{
|
||||||
renderHelp([]helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Esc", Action: "back"}}, m.width)
|
breadcrumb: "Run Template / Mode",
|
||||||
|
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 {
|
||||||
var b strings.Builder
|
return renderScreenShell(screenShell{
|
||||||
b.WriteString(titleStyle.Render("Background Results"))
|
breadcrumb: "Background Results",
|
||||||
b.WriteString("\n\n")
|
status: fmt.Sprintf("%d results", len(m.bgResults)),
|
||||||
|
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
|
||||||
}
|
}
|
||||||
b.WriteString(sectionStyle.Render(result.Alias + " " + status))
|
lines = append(lines, dashboardSection(result.Alias+" "+status))
|
||||||
b.WriteString("\n")
|
|
||||||
if result.Output != "" {
|
if result.Output != "" {
|
||||||
b.WriteString(result.Output)
|
for _, line := range strings.Split(result.Output, "\n") {
|
||||||
b.WriteString("\n")
|
lines = append(lines, strings.ReplaceAll(line, "\t", " "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
lines = append(lines, "")
|
||||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter/Esc", Action: "back"}}, m.width))
|
}
|
||||||
return b.String()
|
return renderPaddedPanel(width, height, lines)
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Enter/Esc", Action: "back"}, {Key: "Ctrl+H", Action: "help"}},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) selectedServers() []*model.Server {
|
func (m *tuiModel) selectedServers() []*model.Server {
|
||||||
|
|
@ -2134,7 +2187,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 - 2
|
width := m.width - 3
|
||||||
if width <= 0 {
|
if width <= 0 {
|
||||||
width = 80
|
width = 80
|
||||||
}
|
}
|
||||||
|
|
@ -2163,7 +2216,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: "F1", Action: "help"},
|
helpItem{Key: "Ctrl+H", Action: "help"},
|
||||||
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
|
||||||
|
|
@ -342,8 +342,8 @@ func TestAuthMethodListViewShowsAllOptions(t *testing.T) {
|
||||||
if between := view[authPos:listPos]; strings.Contains(between, "Identity File") {
|
if between := view[authPos:listPos]; strings.Contains(between, "Identity File") {
|
||||||
t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view)
|
t.Fatalf("expected auth method list to render directly under auth field\nview:\n%s", view)
|
||||||
}
|
}
|
||||||
if strings.Contains(view, "│") {
|
if !strings.Contains(view, "│") {
|
||||||
t.Fatalf("expected compact auth method dropdown without default list border\nview:\n%s", view)
|
t.Fatalf("expected auth method dropdown inside the unified frame\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 compact group dropdown without default list border\nview:\n%s", view)
|
t.Fatalf("expected group dropdown inside the unified frame\nview:\n%s", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ 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)
|
||||||
|
|
@ -29,7 +31,7 @@ func (m *tuiModel) renderServerDashboard() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body string
|
var body string
|
||||||
switch classifyTerminal(width, height) {
|
switch sizeClass {
|
||||||
case sizeWide:
|
case sizeWide:
|
||||||
leftWidth := width * 62 / 100
|
leftWidth := width * 62 / 100
|
||||||
rightWidth := width - leftWidth - 1
|
rightWidth := width - leftWidth - 1
|
||||||
|
|
|
||||||
|
|
@ -589,14 +589,6 @@ 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
|
||||||
|
|
@ -606,10 +598,18 @@ func (fm *formModel) View() string {
|
||||||
} else {
|
} else {
|
||||||
dropdown = fm.groupList
|
dropdown = fm.groupList
|
||||||
}
|
}
|
||||||
return titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width)) + "\n" +
|
return renderScreenShell(screenShell{
|
||||||
fitLine(fm.inputs[fieldIndex].View(), fm.width) + "\n" +
|
breadcrumb: title + " / Picker",
|
||||||
fitLine(renderDropdown(dropdown), fm.width) + "\n" +
|
status: "Choose a value",
|
||||||
renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)
|
width: 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,14 +620,10 @@ 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 := fitLine(testBtn+" "+saveBtn, fm.width)
|
actions := testBtn + " " + saveBtn
|
||||||
|
|
||||||
reserved := 1 + displayLineCount(footer) + 1
|
body := func(width, height int) string {
|
||||||
if status != "" {
|
richLayout := width >= 90 && height >= 20
|
||||||
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 {
|
||||||
|
|
@ -643,37 +639,49 @@ 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, fitLine(fm.inputs[i].View(), fm.width))
|
allFields = append(allFields, fm.inputs[i].View())
|
||||||
}
|
}
|
||||||
focusRows[len(fm.inputs)] = len(allFields)
|
focusRows[len(fm.inputs)] = len(allFields)
|
||||||
allFields = append(allFields, fitLine(fm.password.View(), fm.width))
|
allFields = append(allFields, fm.password.View())
|
||||||
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] = fitLine("↑ more fields · "+visible[0], fm.width)
|
visible[0] = "↑ more fields · " + visible[0]
|
||||||
}
|
}
|
||||||
if end < len(allFields) && len(visible) > 0 {
|
if end < len(allFields) && len(visible) > 0 {
|
||||||
visible[len(visible)-1] = fitLine(visible[len(visible)-1]+" · more ↓", fm.width)
|
visible[len(visible)-1] += " · more ↓"
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render("Actions"))
|
visible = append(visible, sectionStyle.Copy().MarginTop(0).Render("Actions"))
|
||||||
}
|
}
|
||||||
lines = append(lines, actions)
|
visible = append(visible, actions)
|
||||||
lines = append(lines, strings.Split(footer, "\n")...)
|
return renderPaddedPanel(width, height, visible)
|
||||||
if len(lines) > fm.height && fm.height > 0 {
|
|
||||||
lines = lines[:fm.height]
|
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return renderScreenShell(screenShell{
|
||||||
|
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 {
|
||||||
|
|
@ -701,7 +709,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(sectionStyle.Render(l.Title))
|
b.WriteString(dashboardSection(l.Title))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
for i, item := range l.Items() {
|
for i, item := range l.Items() {
|
||||||
group, ok := item.(groupItem)
|
group, ok := item.(groupItem)
|
||||||
|
|
|
||||||
|
|
@ -79,52 +79,90 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) View() string {
|
func (m *forwardScreenModel) View() string {
|
||||||
footer := renderHelp([]helpItem{
|
notification := ""
|
||||||
|
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
footerRows := displayLineCount(footer)
|
func (m *forwardScreenModel) forwardListLines(width, capacity int, compact bool) []string {
|
||||||
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 {
|
||||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
|
return []string{helpStyle.Copy().MarginLeft(0).Render("No port forwards configured. Ctrl+A adds one.")}
|
||||||
} else {
|
}
|
||||||
lines = append(lines, m.renderForwardRow(nil, false))
|
lines := []string{m.renderForwardRow(nil, false, width, compact)}
|
||||||
rowCapacity--
|
rowCapacity := max(1, capacity-1)
|
||||||
|
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))
|
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected, width, compact))
|
||||||
}
|
}
|
||||||
if end < len(m.list) || start > 0 {
|
if showRange {
|
||||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
|
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
|
||||||
}
|
}
|
||||||
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
|
return lines
|
||||||
forward := m.list[m.selected]
|
|
||||||
lines = append(lines,
|
|
||||||
sectionStyle.Copy().MarginTop(0).Render("Selected"),
|
|
||||||
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
|
|
||||||
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines = append(lines, strings.Split(footer, "\n")...)
|
|
||||||
if len(lines) > m.height && m.height > 0 {
|
|
||||||
lines = lines[:m.height]
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string {
|
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."))
|
||||||
|
}
|
||||||
|
forward := m.list[m.selected]
|
||||||
|
name := forward.Name
|
||||||
|
if name == "" {
|
||||||
|
name = forward.ForwardListen()
|
||||||
|
}
|
||||||
|
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))...)
|
||||||
|
if !compact {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
lines = append(lines, dashboardHelp("ssh "+strings.Join(forward.ForwardSSHArgs(), " ")))
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool, width int, compact 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 {
|
||||||
|
|
@ -142,30 +180,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 wide {
|
if !compact && width >= 58 {
|
||||||
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100)
|
flexible := max(3, width-typeWidth-enabledWidth-7)
|
||||||
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2)
|
nameWidth := max(1, flexible*30/100)
|
||||||
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5
|
listenWidth := max(1, flexible*32/100)
|
||||||
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
|
targetWidth := max(1, flexible-nameWidth-listenWidth)
|
||||||
|
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, m.width))
|
return listHeaderStyle.Render(fitLine(line, width))
|
||||||
}
|
}
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle.Render(fitLine(line, m.width))
|
return selectedRowStyle.Render(fitLine(line, width))
|
||||||
}
|
}
|
||||||
return fitLine(line, m.width)
|
return fitLine(line, width)
|
||||||
}
|
}
|
||||||
nameWidth := max(12, m.width-typeWidth-enabledWidth-4)
|
nameWidth := max(1, width-typeWidth-enabledWidth-5)
|
||||||
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
|
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
|
||||||
if forward == nil {
|
if forward == nil {
|
||||||
return listHeaderStyle.Render(fitLine(line, m.width))
|
return listHeaderStyle.Render(fitLine(line, width))
|
||||||
}
|
}
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle.Render(fitLine(line, m.width))
|
return selectedRowStyle.Render(fitLine(line, width))
|
||||||
}
|
}
|
||||||
return fitLine(line, m.width)
|
return fitLine(line, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Forward form screen model ---
|
// --- Forward form screen model ---
|
||||||
|
|
@ -595,12 +633,15 @@ func (fm *forwardFormModel) View() string {
|
||||||
if fm.editMode {
|
if fm.editMode {
|
||||||
title = "Edit Port Forward"
|
title = "Edit Port Forward"
|
||||||
}
|
}
|
||||||
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))}
|
notification := ""
|
||||||
lines = append(lines,
|
if fm.err != nil {
|
||||||
fitLine(fm.nameInput.View(), fm.width),
|
notification = errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
|
||||||
fitLine(fm.descInput.View(), fm.width),
|
} else if fm.saved {
|
||||||
)
|
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 := "○"
|
||||||
|
|
@ -613,58 +654,48 @@ 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, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
|
lines = append(lines, "Type "+strings.Join(typeParts, " "))
|
||||||
if fm.width >= 100 {
|
if width >= 100 {
|
||||||
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(forwardTypes[fm.typeIdx].description))
|
||||||
}
|
}
|
||||||
|
|
||||||
visible := fm.visibleFields()
|
visible := fm.visibleFields()
|
||||||
for _, idx := range visible {
|
for _, idx := range visible {
|
||||||
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
|
lines = append(lines, fm.inputs[idx].View())
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(fm.inputs[0].Value()) == "0.0.0.0" {
|
||||||
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render("⚠ This port will be accessible from the network."))
|
||||||
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 := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
preview := "Preview ssh " + strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
||||||
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
|
lines = append(lines, wrapCells(preview, contentWidth)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ]")
|
||||||
}
|
}
|
||||||
if fm.err != nil {
|
lines = append(lines, "", button)
|
||||||
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width))
|
return renderPaddedPanel(width, height, lines)
|
||||||
}
|
}
|
||||||
if fm.saved {
|
return renderScreenShell(screenShell{
|
||||||
lines = append(lines, successStyle.Render("✓ Saved."))
|
breadcrumb: "Port Forwards / " + title,
|
||||||
}
|
status: string(fm.currentType),
|
||||||
lines = append(lines, button)
|
notification: notification,
|
||||||
footer := renderHelp([]helpItem{
|
width: fm.width,
|
||||||
|
height: fm.height,
|
||||||
|
body: body,
|
||||||
|
footer: []helpItem{
|
||||||
{Key: "Tab/↓", Action: "next"},
|
{Key: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "1/2/3", Action: "select type"},
|
{Key: "1/2/3", Action: "select type"},
|
||||||
{Key: "Enter", Action: "save"},
|
{Key: "Enter", Action: "save"},
|
||||||
|
{Key: "Ctrl+H", Action: "help"},
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
}, fm.width)
|
},
|
||||||
lines = append(lines, strings.Split(footer, "\n")...)
|
})
|
||||||
if len(lines) > fm.height && fm.height > 0 {
|
|
||||||
lines = lines[:fm.height]
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// forwardEditSignal is sent when user wants to edit a forward
|
// forwardEditSignal is sent when user wants to edit a forward
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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"
|
||||||
|
|
@ -14,6 +13,7 @@ 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: "F1", action: "Full documentation", section: "Other"},
|
helpScreenItem{key: "Ctrl+H", 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}
|
return &helpScreenModel{list: l, width: w, height: h}
|
||||||
}
|
}
|
||||||
|
|
||||||
type helpScreenItem struct {
|
type helpScreenItem struct {
|
||||||
|
|
@ -86,6 +86,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -95,10 +96,39 @@ func (m *helpScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *helpScreenModel) View() string {
|
func (m *helpScreenModel) View() string {
|
||||||
return m.list.View()
|
items := m.list.Items()
|
||||||
|
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 (F1) ---
|
// --- Full help (Ctrl+H) ---
|
||||||
|
|
||||||
type fullHelpModel struct {
|
type fullHelpModel struct {
|
||||||
width int
|
width int
|
||||||
|
|
@ -149,11 +179,6 @@ 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
|
||||||
|
|
@ -174,7 +199,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)"},
|
||||||
{"F1", "Full documentation"},
|
{"Ctrl+H", "Full documentation"},
|
||||||
{"Ctrl+Q", "Quit"},
|
{"Ctrl+Q", "Quit"},
|
||||||
}},
|
}},
|
||||||
{"Server list", [][2]string{
|
{"Server list", [][2]string{
|
||||||
|
|
@ -224,40 +249,37 @@ func (m *fullHelpModel) View() string {
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
for _, sec := range sections {
|
for _, sec := range sections {
|
||||||
b.WriteString(sectionStyle.Render(sec.title))
|
lines = append(lines, sectionStyle.Copy().MarginTop(0).Render(sec.title))
|
||||||
b.WriteString("\n")
|
|
||||||
for _, row := range sec.rows {
|
for _, row := range sec.rows {
|
||||||
if row[0] == "" {
|
if row[0] == "" {
|
||||||
b.WriteString(fmt.Sprintf(" %s\n", row[1]))
|
lines = append(lines, " "+row[1])
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(fmt.Sprintf(" %-16s %s\n", row[0], row[1]))
|
lines = append(lines, fmt.Sprintf(" %-16s %s", row[0], row[1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
lines = append(lines, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(helpStyle.Render(" ↑/↓ scroll — q/Esc/Enter close"))
|
body := func(width, height int) string {
|
||||||
|
capacity := max(1, height-2)
|
||||||
// Simple scroll
|
start := min(m.offset, max(0, len(lines)-capacity))
|
||||||
lines := strings.Split(b.String(), "\n")
|
end := min(len(lines), start+capacity)
|
||||||
maxLines := m.height - 1
|
return renderPaddedPanel(width, height, lines[start:end])
|
||||||
if maxLines < 5 {
|
|
||||||
maxLines = 5
|
|
||||||
}
|
}
|
||||||
start := m.offset
|
return renderScreenShell(screenShell{
|
||||||
if start > len(lines)-maxLines {
|
breadcrumb: "Full Help",
|
||||||
start = len(lines) - maxLines
|
status: fmt.Sprintf("line %d/%d", min(m.offset+1, len(lines)), len(lines)),
|
||||||
}
|
width: m.width,
|
||||||
if start < 0 {
|
height: m.height,
|
||||||
start = 0
|
body: body,
|
||||||
}
|
footer: []helpItem{
|
||||||
end := start + maxLines
|
{Key: "↑/↓", Action: "scroll"},
|
||||||
if end > len(lines) {
|
{Key: "Ctrl+H", Action: "full help"},
|
||||||
end = len(lines)
|
{Key: "Esc/Enter", Action: "close"},
|
||||||
}
|
},
|
||||||
|
})
|
||||||
return strings.Join(lines[start:end], "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Action menu ---
|
// --- Action menu ---
|
||||||
|
|
@ -265,6 +287,7 @@ 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 }
|
||||||
|
|
@ -279,20 +302,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"},
|
actionMenuItem{label: "Connect", action: "connect", description: "Open an interactive SSH session."},
|
||||||
actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
|
actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."},
|
||||||
actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
|
actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."},
|
||||||
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"},
|
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."},
|
||||||
actionMenuItem{label: "Manage port forwards", action: "forwards"},
|
actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."},
|
||||||
actionMenuItem{label: "Manage tunnels", action: "tunnels"},
|
actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."},
|
||||||
actionMenuItem{label: "Manage route", action: "route"},
|
actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."},
|
||||||
actionMenuItem{label: "Test connection", action: "test"},
|
actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."},
|
||||||
actionMenuItem{label: "Edit", action: "edit"},
|
actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."},
|
||||||
actionMenuItem{label: "Delete", action: "delete"},
|
actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."},
|
||||||
actionMenuItem{label: "Import", action: "import"},
|
actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."},
|
||||||
actionMenuItem{label: "Export", action: "export"},
|
actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."},
|
||||||
actionMenuItem{label: "Vault: lock", action: "vault_lock"},
|
actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."},
|
||||||
actionMenuItem{label: "Vault: change password", action: "vault_change_pw"},
|
actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."},
|
||||||
}
|
}
|
||||||
|
|
||||||
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
|
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
|
||||||
|
|
@ -324,10 +347,44 @@ func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *actionMenuModel) View() string {
|
func (m *actionMenuModel) View() string {
|
||||||
footer := renderHelp([]helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "back"}}, m.width)
|
body := func(width, height int) string {
|
||||||
lines := []string{titleStyle.Copy().MarginLeft(0).Render("Actions")}
|
listLines := m.actionLines(max(1, height-2))
|
||||||
capacity := max(1, m.height-displayLineCount(footer)-1)
|
if classifyShellContent(width) == sizeWide {
|
||||||
|
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 {
|
||||||
|
|
@ -337,11 +394,7 @@ func (m *actionMenuModel) View() string {
|
||||||
if index == m.list.Index() {
|
if index == m.list.Index() {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
lines = append(lines, fitLine(marker+item.label, m.width))
|
lines = append(lines, marker+item.label)
|
||||||
}
|
}
|
||||||
lines = append(lines, strings.Split(footer, "\n")...)
|
return lines
|
||||||
if len(lines) > m.height && m.height > 0 {
|
|
||||||
lines = lines[:m.height]
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ 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())
|
||||||
|
|
@ -90,6 +92,7 @@ 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)
|
||||||
|
|
@ -107,7 +110,9 @@ 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")
|
||||||
assertViewFits(t, fm.View(), size.width, size.height)
|
view := fm.View()
|
||||||
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
assertUnifiedScreen(t, view, size.width, size.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +125,7 @@ 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)
|
||||||
|
|
@ -133,6 +139,7 @@ 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)
|
||||||
|
|
@ -154,6 +161,7 @@ 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)
|
||||||
|
|
@ -162,6 +170,143 @@ 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)
|
||||||
|
|
@ -169,6 +314,7 @@ 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)
|
||||||
|
|
@ -177,6 +323,21 @@ 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")
|
||||||
|
|
@ -190,6 +351,37 @@ 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) }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScreenShellFitsAndAnchorsFooter(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
t.Run(itoa(size.width)+"x"+itoa(size.height), func(t *testing.T) {
|
||||||
|
view := renderScreenShell(screenShell{
|
||||||
|
breadcrumb: "Actions / production-数据库",
|
||||||
|
status: "Vault unlocked",
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
return renderPaddedPanel(width, height, []string{"Actions", "> Connect", " Manage port forwards"})
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Enter", Action: "select"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := strings.Split(view, "\n")
|
||||||
|
if len(lines) != size.height {
|
||||||
|
t.Fatalf("shell has %d lines, want %d:\n%s", len(lines), size.height, view)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ansi.Strip(lines[0]), "sshkeeper / Actions") {
|
||||||
|
t.Fatalf("missing breadcrumb header: %q", ansi.Strip(lines[0]))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ansi.Strip(lines[2]), "┌") || !strings.HasSuffix(ansi.Strip(lines[size.height-2]), "┘") {
|
||||||
|
t.Fatalf("body panel does not fill shell:\n%s", view)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ansi.Strip(lines[size.height-1]), "Ctrl+H") {
|
||||||
|
t.Fatalf("footer is not anchored to last row: %q", ansi.Strip(lines[size.height-1]))
|
||||||
|
}
|
||||||
|
for index, line := range lines {
|
||||||
|
if got := ansi.StringWidth(line); got > size.width-1 {
|
||||||
|
t.Fatalf("line %d uses unsafe last terminal column: width=%d, terminal=%d", index+1, got, size.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenShellShowsNotificationWithoutMovingFooter(t *testing.T) {
|
||||||
|
view := renderScreenShell(screenShell{
|
||||||
|
breadcrumb: "Port Forwards / prod",
|
||||||
|
status: "2 rules",
|
||||||
|
notification: "Forward saved",
|
||||||
|
width: 60,
|
||||||
|
height: 16,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
return renderPaddedPanel(width, height, []string{"Local PostgreSQL"})
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Esc", Action: "back"}},
|
||||||
|
})
|
||||||
|
lines := strings.Split(view, "\n")
|
||||||
|
if len(lines) != 16 || !strings.Contains(view, "Forward saved") || !strings.Contains(ansi.Strip(lines[15]), "Esc") {
|
||||||
|
t.Fatalf("notification broke shell geometry:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,15 +45,15 @@ func TestNotificationSurvivesRepeatedView(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
func TestCtrlHFullHelpReturnsToOriginatingScreen(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.KeyF1})
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH})
|
||||||
m = updated.(*tuiModel)
|
m = updated.(*tuiModel)
|
||||||
if m.screen != screenFullHelp || m.fullHelp == nil {
|
if m.screen != screenFullHelp || m.fullHelp == nil {
|
||||||
t.Fatalf("F1 did not open full help from forward list: screen=%v", m.screen)
|
t.Fatalf("Ctrl+H 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,6 +62,27 @@ func TestFullHelpReturnsToOriginatingScreen(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestF1NoLongerOpensFullHelp(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen == screenFullHelp || m.fullHelp != nil {
|
||||||
|
t.Fatalf("F1 still opens full help: screen=%v", m.screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackspaceStillEditsSearchAfterCtrlHBinding(t *testing.T) {
|
||||||
|
m := New(nil)
|
||||||
|
m.screen = screenSearch
|
||||||
|
m.searchInput.SetValue("prod")
|
||||||
|
m.searchInput.Focus()
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenSearch || m.searchInput.Value() != "pro" {
|
||||||
|
t.Fatalf("Backspace did not edit search: screen=%v value=%q", m.screen, m.searchInput.Value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
|
func TestContextHelpReturnsToOriginatingManager(t *testing.T) {
|
||||||
m := New(nil)
|
m := New(nil)
|
||||||
m.screen = screenForwardList
|
m.screen = screenForwardList
|
||||||
|
|
|
||||||
|
|
@ -151,31 +151,40 @@ 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"
|
||||||
}
|
}
|
||||||
b.WriteString(titleStyle.Copy().MarginLeft(0).Render(fitLine(title, tf.width)))
|
notification := ""
|
||||||
b.WriteString("\n\n")
|
if tf.err != nil {
|
||||||
|
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 {
|
||||||
b.WriteString(fitLine(tf.inputs[i].View(), tf.width))
|
lines = append(lines, tf.inputs[i].View())
|
||||||
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 ]")
|
||||||
}
|
}
|
||||||
b.WriteString("\n" + button + "\n\n")
|
lines = append(lines, "", button)
|
||||||
if tf.err != nil {
|
return renderPaddedPanel(width, height, lines)
|
||||||
b.WriteString(errorStyle.Render(tf.err.Error()))
|
},
|
||||||
b.WriteString("\n")
|
footer: []helpItem{
|
||||||
}
|
|
||||||
b.WriteString(renderHelp([]helpItem{
|
|
||||||
{Key: "Tab/↓", Action: "next"},
|
{Key: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "Enter", Action: "select"},
|
{Key: "Enter", Action: "select"},
|
||||||
|
{Key: "Ctrl+H", Action: "help"},
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
}, tf.width))
|
},
|
||||||
return b.String()
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,19 +94,44 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tunnelScreenModel) View() string {
|
func (m *tunnelScreenModel) View() string {
|
||||||
var b strings.Builder
|
notification := ""
|
||||||
b.WriteString(m.list.View())
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
||||||
b.WriteString("\n\n")
|
|
||||||
}
|
}
|
||||||
b.WriteString(renderHelp([]helpItem{
|
body := func(width, height int) string {
|
||||||
|
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 {
|
||||||
|
|
|
||||||
26
release.sh
|
|
@ -4,7 +4,8 @@ set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
APP=sshkeeper
|
APP=sshkeeper
|
||||||
VERSION=${VERSION:-${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}}
|
# --match 'v*' ignores the rolling `nightly` tag; see build.sh for the details.
|
||||||
|
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)}
|
||||||
|
|
@ -27,6 +28,16 @@ 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}" {} +
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +53,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}"
|
normalize_package "${package_dir}" "${APP}"
|
||||||
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}"
|
||||||
}
|
}
|
||||||
|
|
@ -59,8 +70,15 @@ 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}"
|
normalize_package "${package_dir}" "${APP}.exe"
|
||||||
(cd "${DIST_DIR}" && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
|
# LC_ALL=C: `sort` is locale-sensitive, and it decides the entry order here.
|
||||||
|
# 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}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||