detached f44b572

fix(picker): name SSH failures and show picker notices top-right

commit
f44b5728ed07b9fa58a659644c2a91b1c417f1a4
author
brice · · 2026-09-23 19:53 UTC
parent
9cfdc13
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +161 -13
A internal/adapters/sshstdio/classify.go +29 -0
@@ -0,0 +1,29 @@
1+package sshstdio
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// ClassifyStderr maps captured ssh client stderr to a typed remote failure
10+// kind so the user sees why a host is unreachable instead of a generic
11+// connection failure. Unrecognized text is RemoteFailureNone; the raw text is
12+// never surfaced.
13+func ClassifyStderr(stderr string) domain.RemoteFailureKind {
14+ text := strings.ToLower(stderr)
15+ switch {
16+ case strings.Contains(text, "host key verification failed"),
17+ strings.Contains(text, "remote host identification has changed"):
18+ return domain.RemoteFailureTrust
19+ case strings.Contains(text, "permission denied"),
20+ strings.Contains(text, "unable to authenticate"),
21+ strings.Contains(text, "authentication required"),
22+ strings.Contains(text, "too many authentication failures"):
23+ return domain.RemoteFailureAuthentication
24+ case strings.Contains(text, "timed out"):
25+ return domain.RemoteFailureTimeout
26+ default:
27+ return domain.RemoteFailureNone
28+ }
29+}
@@ -0,0 +1,29 @@
1+package sshstdio
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// ClassifyStderr maps captured ssh client stderr to a typed remote failure
10+// kind so the user sees why a host is unreachable instead of a generic
11+// connection failure. Unrecognized text is RemoteFailureNone; the raw text is
12+// never surfaced.
13+func ClassifyStderr(stderr string) domain.RemoteFailureKind {
14+ text := strings.ToLower(stderr)
15+ switch {
16+ case strings.Contains(text, "host key verification failed"),
17+ strings.Contains(text, "remote host identification has changed"):
18+ return domain.RemoteFailureTrust
19+ case strings.Contains(text, "permission denied"),
20+ strings.Contains(text, "unable to authenticate"),
21+ strings.Contains(text, "authentication required"),
22+ strings.Contains(text, "too many authentication failures"):
23+ return domain.RemoteFailureAuthentication
24+ case strings.Contains(text, "timed out"):
25+ return domain.RemoteFailureTimeout
26+ default:
27+ return domain.RemoteFailureNone
28+ }
29+}
A internal/adapters/sshstdio/classify_test.go +31 -0
@@ -0,0 +1,31 @@
1+package sshstdio
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func TestClassifyStderr(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ stderr string
15+ want domain.RemoteFailureKind
16+ }{
17+ {name: "openssh key refused", stderr: "user@example.test: Permission denied (publickey).", want: domain.RemoteFailureAuthentication},
18+ {name: "go ssh no methods", stderr: "ssh: handshake failed: ssh: unable to authenticate, attempted methods [none password], no supported methods remain", want: domain.RemoteFailureAuthentication},
19+ {name: "browser login required", stderr: "SSH authentication required.\nPlease do the SSO login in your browser.", want: domain.RemoteFailureAuthentication},
20+ {name: "unknown host key", stderr: "Host key verification failed.", want: domain.RemoteFailureTrust},
21+ {name: "changed host key", stderr: "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", want: domain.RemoteFailureTrust},
22+ {name: "timeout", stderr: "ssh: connect to host example.test port 22: Connection timed out", want: domain.RemoteFailureTimeout},
23+ {name: "refused stays generic", stderr: "connect: connection refused", want: domain.RemoteFailureNone},
24+ {name: "empty", stderr: "", want: domain.RemoteFailureNone},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ require.Equal(t, tt.want, ClassifyStderr(tt.stderr))
29+ })
30+ }
31+}
@@ -0,0 +1,31 @@
1+package sshstdio
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func TestClassifyStderr(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ stderr string
15+ want domain.RemoteFailureKind
16+ }{
17+ {name: "openssh key refused", stderr: "user@example.test: Permission denied (publickey).", want: domain.RemoteFailureAuthentication},
18+ {name: "go ssh no methods", stderr: "ssh: handshake failed: ssh: unable to authenticate, attempted methods [none password], no supported methods remain", want: domain.RemoteFailureAuthentication},
19+ {name: "browser login required", stderr: "SSH authentication required.\nPlease do the SSO login in your browser.", want: domain.RemoteFailureAuthentication},
20+ {name: "unknown host key", stderr: "Host key verification failed.", want: domain.RemoteFailureTrust},
21+ {name: "changed host key", stderr: "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", want: domain.RemoteFailureTrust},
22+ {name: "timeout", stderr: "ssh: connect to host example.test port 22: Connection timed out", want: domain.RemoteFailureTimeout},
23+ {name: "refused stays generic", stderr: "connect: connection refused", want: domain.RemoteFailureNone},
24+ {name: "empty", stderr: "", want: domain.RemoteFailureNone},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ require.Equal(t, tt.want, ClassifyStderr(tt.stderr))
29+ })
30+ }
31+}
M internal/app/broker_offline_mux.go +16 -2
@@ -19,6 +19,7 @@ import (
1919 "github.com/bnema/vev/internal/adapters/ipc"
2020 "github.com/bnema/vev/internal/adapters/quic"
2121 "github.com/bnema/vev/internal/adapters/sshstdio"
22+ "github.com/bnema/vev/internal/domain"
2223 "github.com/bnema/vev/internal/ports"
2324 "github.com/bnema/vev/internal/protocol/wire"
2425 )
@@ -354,7 +355,7 @@ func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, mode por
354355 if err != nil {
355356 reapBootstrap()
356357 logBrokerBootstrapStderr(log, stderr)
357- return nil, brokerQUICUnavailable("read bootstrap readiness", err)
358+ return nil, brokerQUICUnavailable("read bootstrap readiness", classifyBootstrapFailure(stderr, err))
358359 }
359360 // The bootstrap channel served its purpose: the detached remote proxy owns
360361 // the carriage lifetime. Reap the short-lived local child without killing
@@ -363,7 +364,7 @@ func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, mode por
363364 if waitErr := cmd.Wait(); waitErr != nil && !errors.Is(waitErr, exec.ErrWaitDelay) {
364365 reaped = true
365366 logBrokerBootstrapStderr(log, stderr)
366- return nil, brokerQUICUnavailable("reap bootstrap", waitErr)
367+ return nil, brokerQUICUnavailable("reap bootstrap", classifyBootstrapFailure(stderr, waitErr))
367368 }
368369 reaped = true
369370
@@ -421,6 +422,19 @@ func logBrokerBootstrapStderr(log *slog.Logger, sink *sshstdio.DiagnosticSink) {
421422 log.Warn("broker_mux_bootstrap_stderr", "diagnostic", diagnostic)
422423 }
423424
425+// classifyBootstrapFailure wraps err in a typed remote failure when the
426+// captured ssh stderr names a recognizable cause (authentication, host key,
427+// timeout), so the picker explains the failure. The raw text stays in the log.
428+func classifyBootstrapFailure(sink *sshstdio.DiagnosticSink, err error) error {
429+ if sink == nil {
430+ return err
431+ }
432+ if kind := sshstdio.ClassifyStderr(sink.String()); kind != domain.RemoteFailureNone {
433+ return domain.RemoteFailure{Kind: kind, Err: err}
434+ }
435+ return err
436+}
437+
424438 // reapBootstrapProcess kills one bootstrap child and starts a bounded join of
425439 // its wait. The join is bounded by brokerMuxSetupTimeout so a helper that
426440 // leaves a grandchild holding the captured stderr pipe cannot pin cleanup, and
@@ -19,6 +19,7 @@ import (
19 "github.com/bnema/vev/internal/adapters/ipc"19 "github.com/bnema/vev/internal/adapters/ipc"
20 "github.com/bnema/vev/internal/adapters/quic"20 "github.com/bnema/vev/internal/adapters/quic"
21 "github.com/bnema/vev/internal/adapters/sshstdio"21 "github.com/bnema/vev/internal/adapters/sshstdio"
22+ "github.com/bnema/vev/internal/domain"
22 "github.com/bnema/vev/internal/ports"23 "github.com/bnema/vev/internal/ports"
23 "github.com/bnema/vev/internal/protocol/wire"24 "github.com/bnema/vev/internal/protocol/wire"
24 )25 )
@@ -354,7 +355,7 @@ func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, mode por
354 if err != nil {355 if err != nil {
355 reapBootstrap()356 reapBootstrap()
356 logBrokerBootstrapStderr(log, stderr)357 logBrokerBootstrapStderr(log, stderr)
357- return nil, brokerQUICUnavailable("read bootstrap readiness", err)358+ return nil, brokerQUICUnavailable("read bootstrap readiness", classifyBootstrapFailure(stderr, err))
358 }359 }
359 // The bootstrap channel served its purpose: the detached remote proxy owns360 // The bootstrap channel served its purpose: the detached remote proxy owns
360 // the carriage lifetime. Reap the short-lived local child without killing361 // the carriage lifetime. Reap the short-lived local child without killing
@@ -363,7 +364,7 @@ func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, mode por
363 if waitErr := cmd.Wait(); waitErr != nil && !errors.Is(waitErr, exec.ErrWaitDelay) {364 if waitErr := cmd.Wait(); waitErr != nil && !errors.Is(waitErr, exec.ErrWaitDelay) {
364 reaped = true365 reaped = true
365 logBrokerBootstrapStderr(log, stderr)366 logBrokerBootstrapStderr(log, stderr)
366- return nil, brokerQUICUnavailable("reap bootstrap", waitErr)367+ return nil, brokerQUICUnavailable("reap bootstrap", classifyBootstrapFailure(stderr, waitErr))
367 }368 }
368 reaped = true369 reaped = true
369 370
@@ -421,6 +422,19 @@ func logBrokerBootstrapStderr(log *slog.Logger, sink *sshstdio.DiagnosticSink) {
421 log.Warn("broker_mux_bootstrap_stderr", "diagnostic", diagnostic)422 log.Warn("broker_mux_bootstrap_stderr", "diagnostic", diagnostic)
422 }423 }
423 424
425+// classifyBootstrapFailure wraps err in a typed remote failure when the
426+// captured ssh stderr names a recognizable cause (authentication, host key,
427+// timeout), so the picker explains the failure. The raw text stays in the log.
428+func classifyBootstrapFailure(sink *sshstdio.DiagnosticSink, err error) error {
429+ if sink == nil {
430+ return err
431+ }
432+ if kind := sshstdio.ClassifyStderr(sink.String()); kind != domain.RemoteFailureNone {
433+ return domain.RemoteFailure{Kind: kind, Err: err}
434+ }
435+ return err
436+}
437+
424 // reapBootstrapProcess kills one bootstrap child and starts a bounded join of438 // reapBootstrapProcess kills one bootstrap child and starts a bounded join of
425 // its wait. The join is bounded by brokerMuxSetupTimeout so a helper that439 // its wait. The join is bounded by brokerMuxSetupTimeout so a helper that
426 // leaves a grandchild holding the captured stderr pipe cannot pin cleanup, and440 // leaves a grandchild holding the captured stderr pipe cannot pin cleanup, and
M internal/usecase/broker/registry.go +6 -1
@@ -1125,7 +1125,12 @@ func (r *Registry) apply(result probeResult) {
11251125 func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) {
11261126 if result.err != nil {
11271127 kind := domain.RemoteFailureTransport
1128- if errors.Is(result.err, context.DeadlineExceeded) {
1128+ var typed domain.RemoteFailure
1129+ switch {
1130+ case errors.As(result.err, &typed) && typed.Kind != domain.RemoteFailureNone:
1131+ // The dialer classified the failure (for example SSH authentication).
1132+ kind = typed.Kind
1133+ case errors.Is(result.err, context.DeadlineExceeded):
11291134 kind = domain.RemoteFailureTimeout
11301135 }
11311136 return kind, availabilityFor(kind), true
@@ -1125,7 +1125,12 @@ func (r *Registry) apply(result probeResult) {
1125 func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) {1125 func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) {
1126 if result.err != nil {1126 if result.err != nil {
1127 kind := domain.RemoteFailureTransport1127 kind := domain.RemoteFailureTransport
1128- if errors.Is(result.err, context.DeadlineExceeded) {1128+ var typed domain.RemoteFailure
1129+ switch {
1130+ case errors.As(result.err, &typed) && typed.Kind != domain.RemoteFailureNone:
1131+ // The dialer classified the failure (for example SSH authentication).
1132+ kind = typed.Kind
1133+ case errors.Is(result.err, context.DeadlineExceeded):
1129 kind = domain.RemoteFailureTimeout1134 kind = domain.RemoteFailureTimeout
1130 }1135 }
1131 return kind, availabilityFor(kind), true1136 return kind, availabilityFor(kind), true
A internal/usecase/broker/registry_outcome_test.go +34 -0
@@ -0,0 +1,34 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+)
13+
14+func TestObservationOutcomeKeepsTypedDialFailure(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ err error
18+ kind domain.RemoteFailureKind
19+ want domain.RemoteAvailability
20+ }{
21+ {name: "plain error is transport", err: errors.New("offline"), kind: domain.RemoteFailureTransport, want: domain.RemoteAvailabilityUnreachable},
22+ {name: "deadline is timeout", err: context.DeadlineExceeded, kind: domain.RemoteFailureTimeout, want: domain.RemoteAvailabilityUnreachable},
23+ {name: "wrapped authentication", err: fmt.Errorf("dial: %w", domain.RemoteFailure{Kind: domain.RemoteFailureAuthentication, Err: errors.New("exit 255")}), kind: domain.RemoteFailureAuthentication, want: domain.RemoteAvailabilityAuthFailed},
24+ {name: "wrapped trust", err: fmt.Errorf("dial: %w", domain.RemoteFailure{Kind: domain.RemoteFailureTrust}), kind: domain.RemoteFailureTrust, want: domain.RemoteAvailabilityUnreachable},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ kind, availability, failed := observationOutcome(probeResult{err: tt.err})
29+ require.True(t, failed)
30+ require.Equal(t, tt.kind, kind)
31+ require.Equal(t, tt.want, availability)
32+ })
33+ }
34+}
@@ -0,0 +1,34 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+)
13+
14+func TestObservationOutcomeKeepsTypedDialFailure(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ err error
18+ kind domain.RemoteFailureKind
19+ want domain.RemoteAvailability
20+ }{
21+ {name: "plain error is transport", err: errors.New("offline"), kind: domain.RemoteFailureTransport, want: domain.RemoteAvailabilityUnreachable},
22+ {name: "deadline is timeout", err: context.DeadlineExceeded, kind: domain.RemoteFailureTimeout, want: domain.RemoteAvailabilityUnreachable},
23+ {name: "wrapped authentication", err: fmt.Errorf("dial: %w", domain.RemoteFailure{Kind: domain.RemoteFailureAuthentication, Err: errors.New("exit 255")}), kind: domain.RemoteFailureAuthentication, want: domain.RemoteAvailabilityAuthFailed},
24+ {name: "wrapped trust", err: fmt.Errorf("dial: %w", domain.RemoteFailure{Kind: domain.RemoteFailureTrust}), kind: domain.RemoteFailureTrust, want: domain.RemoteAvailabilityUnreachable},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ kind, availability, failed := observationOutcome(probeResult{err: tt.err})
29+ require.True(t, failed)
30+ require.Equal(t, tt.kind, kind)
31+ require.Equal(t, tt.want, availability)
32+ })
33+ }
34+}
M internal/usecase/client/picker_controller.go +3 -3
@@ -491,7 +491,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte {
491491 return nil
492492 }
493493 var buffer bytes.Buffer
494- if _, err := drawClientToast(&buffer, size, newest.Message); err != nil {
494+ if _, err := drawClientToast(&buffer, size, newest.Message, domain.AnchorTopRight); err != nil {
495495 return nil
496496 }
497497 return buffer.Bytes()
@@ -724,7 +724,7 @@ func (p *pickerController) offerDiagnosticsLocked() {
724724 p.notices.Show(p.clock.Now(), ui.Toast{
725725 ID: "picker-host:" + failure.key,
726726 Message: failure.message,
727- Anchor: domain.AnchorCenter,
727+ Anchor: domain.AnchorTopRight,
728728 Duration: pickerNoticeLifetime,
729729 })
730730 }
@@ -740,7 +740,7 @@ func (p *pickerController) offerNotice(id, message string) {
740740 return
741741 }
742742 p.mu.Lock()
743- p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorCenter, Duration: pickerNoticeLifetime})
743+ p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime})
744744 p.mu.Unlock()
745745 }
746746
@@ -491,7 +491,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte {
491 return nil491 return nil
492 }492 }
493 var buffer bytes.Buffer493 var buffer bytes.Buffer
494- if _, err := drawClientToast(&buffer, size, newest.Message); err != nil {494+ if _, err := drawClientToast(&buffer, size, newest.Message, domain.AnchorTopRight); err != nil {
495 return nil495 return nil
496 }496 }
497 return buffer.Bytes()497 return buffer.Bytes()
@@ -724,7 +724,7 @@ func (p *pickerController) offerDiagnosticsLocked() {
724 p.notices.Show(p.clock.Now(), ui.Toast{724 p.notices.Show(p.clock.Now(), ui.Toast{
725 ID: "picker-host:" + failure.key,725 ID: "picker-host:" + failure.key,
726 Message: failure.message,726 Message: failure.message,
727- Anchor: domain.AnchorCenter,727+ Anchor: domain.AnchorTopRight,
728 Duration: pickerNoticeLifetime,728 Duration: pickerNoticeLifetime,
729 })729 })
730 }730 }
@@ -740,7 +740,7 @@ func (p *pickerController) offerNotice(id, message string) {
740 return740 return
741 }741 }
742 p.mu.Lock()742 p.mu.Lock()
743- p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorCenter, Duration: pickerNoticeLifetime})743+ p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime})
744 p.mu.Unlock()744 p.mu.Unlock()
745 }745 }
746 746
M internal/usecase/client/reconnect_toast.go +4 -6
@@ -13,18 +13,16 @@ import (
1313 // drawClientToast draws a client-local toast without changing terminal state.
1414 // The attach main loop owns both this write and the later daemon-frame
1515 // reconciliation; input pumps must only publish a request for it.
16-func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Rect, error) {
17- bounds := reconnectToastBoundsFor(size, message)
16+// The anchor places it: transitions stay centered, notices sit top-right so
17+// they never cover the picker list.
18+func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor) (domain.Rect, error) {
19+ bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})
1820 if bounds.Width <= 0 || bounds.Height <= 0 {
1921 return domain.Rect{}, nil
2022 }
2123 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
2224 }
2325
24-func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {
25- return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter})
26-}
27-
2826 func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
2927 if bounds.Width <= 0 || bounds.Height <= 0 {
3028 return nil
@@ -13,18 +13,16 @@ import (
13 // drawClientToast draws a client-local toast without changing terminal state.13 // drawClientToast draws a client-local toast without changing terminal state.
14 // The attach main loop owns both this write and the later daemon-frame14 // The attach main loop owns both this write and the later daemon-frame
15 // reconciliation; input pumps must only publish a request for it.15 // reconciliation; input pumps must only publish a request for it.
16-func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Rect, error) {16+// The anchor places it: transitions stay centered, notices sit top-right so
17- bounds := reconnectToastBoundsFor(size, message)17+// they never cover the picker list.
18+func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor) (domain.Rect, error) {
19+ bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})
18 if bounds.Width <= 0 || bounds.Height <= 0 {20 if bounds.Width <= 0 || bounds.Height <= 0 {
19 return domain.Rect{}, nil21 return domain.Rect{}, nil
20 }22 }
21 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))23 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
22 }24 }
23 25
24-func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {
25- return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter})
26-}
27-
28 func reconnectToastLinesFor(bounds domain.Rect, message string) []string {26 func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
29 if bounds.Width <= 0 || bounds.Height <= 0 {27 if bounds.Width <= 0 || bounds.Height <= 0 {
30 return nil28 return nil
A internal/usecase/client/reconnect_toast_test.go +37 -0
@@ -0,0 +1,37 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestDrawClientToastPlacement(t *testing.T) {
13+ size := domain.Size{Cols: 100, Rows: 30}
14+ tests := []struct {
15+ name string
16+ anchor domain.Anchor
17+ check func(t *testing.T, bounds domain.Rect)
18+ }{
19+ {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) {
20+ require.Less(t, bounds.Y, 3)
21+ require.Greater(t, bounds.X+bounds.Width, size.Cols-3)
22+ }},
23+ {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) {
24+ require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y)
25+ require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X)
26+ }},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ var out bytes.Buffer
31+ bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor)
32+ require.NoError(t, err)
33+ require.NotZero(t, out.Len())
34+ tt.check(t, bounds)
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestDrawClientToastPlacement(t *testing.T) {
13+ size := domain.Size{Cols: 100, Rows: 30}
14+ tests := []struct {
15+ name string
16+ anchor domain.Anchor
17+ check func(t *testing.T, bounds domain.Rect)
18+ }{
19+ {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) {
20+ require.Less(t, bounds.Y, 3)
21+ require.Greater(t, bounds.X+bounds.Width, size.Cols-3)
22+ }},
23+ {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) {
24+ require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y)
25+ require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X)
26+ }},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ var out bytes.Buffer
31+ bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor)
32+ require.NoError(t, err)
33+ require.NotZero(t, out.Len())
34+ tt.check(t, bounds)
35+ })
36+ }
37+}
M internal/usecase/client/transition_ui.go +1 -1
@@ -12,7 +12,7 @@ var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴'
1212 // RenderTransitionNotice draws one connecting frame for the terminal owner.
1313 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {
1414 var out bytes.Buffer
15- if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message)); err != nil {
15+ if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter); err != nil {
1616 return nil
1717 }
1818 return out.Bytes()
@@ -12,7 +12,7 @@ var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴'
12 // RenderTransitionNotice draws one connecting frame for the terminal owner.12 // RenderTransitionNotice draws one connecting frame for the terminal owner.
13 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {13 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {
14 var out bytes.Buffer14 var out bytes.Buffer
15- if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message)); err != nil {15+ if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter); err != nil {
16 return nil16 return nil
17 }17 }
18 return out.Bytes()18 return out.Bytes()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close