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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 19 | 19 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 20 | 20 | "github.com/bnema/vev/internal/adapters/quic" | |
| 21 | 21 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 22 | + | "github.com/bnema/vev/internal/domain" | |
| 22 | 23 | "github.com/bnema/vev/internal/ports" | |
| 23 | 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 | 355 | if err != nil { | |
| 355 | 356 | reapBootstrap() | |
| 356 | 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 | 360 | // The bootstrap channel served its purpose: the detached remote proxy owns | |
| 360 | 361 | // 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 | 364 | if waitErr := cmd.Wait(); waitErr != nil && !errors.Is(waitErr, exec.ErrWaitDelay) { | |
| 364 | 365 | reaped = true | |
| 365 | 366 | logBrokerBootstrapStderr(log, stderr) | |
| 366 | - | return nil, brokerQUICUnavailable("reap bootstrap", waitErr) | |
| 367 | + | return nil, brokerQUICUnavailable("reap bootstrap", classifyBootstrapFailure(stderr, waitErr)) | |
| 367 | 368 | } | |
| 368 | 369 | reaped = true | |
| 369 | 370 | ||
| @@ -421,6 +422,19 @@ func logBrokerBootstrapStderr(log *slog.Logger, sink *sshstdio.DiagnosticSink) { | |||
| 421 | 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 | 438 | // reapBootstrapProcess kills one bootstrap child and starts a bounded join of | |
| 425 | 439 | // its wait. The join is bounded by brokerMuxSetupTimeout so a helper that | |
| 426 | 440 | // 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 owns | 360 | // The bootstrap channel served its purpose: the detached remote proxy owns | ||
| 360 | // the carriage lifetime. Reap the short-lived local child without killing | 361 | // 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 = true | 365 | 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 = true | 369 | 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 of | 438 | // reapBootstrapProcess kills one bootstrap child and starts a bounded join of | ||
| 425 | // its wait. The join is bounded by brokerMuxSetupTimeout so a helper that | 439 | // its wait. The join is bounded by brokerMuxSetupTimeout so a helper that | ||
| 426 | // leaves a grandchild holding the captured stderr pipe cannot pin cleanup, and | 440 | // 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) { | |||
| 1125 | 1125 | func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) { | |
| 1126 | 1126 | if result.err != nil { | |
| 1127 | 1127 | 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 | 1134 | kind = domain.RemoteFailureTimeout | |
| 1130 | 1135 | } | |
| 1131 | 1136 | 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.RemoteFailureTransport | 1127 | 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.RemoteFailureTimeout | 1134 | kind = domain.RemoteFailureTimeout | ||
| 1130 | } | 1135 | } | ||
| 1131 | return kind, availabilityFor(kind), true | 1136 | 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 { | |||
| 491 | 491 | return nil | |
| 492 | 492 | } | |
| 493 | 493 | 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 | 495 | return nil | |
| 496 | 496 | } | |
| 497 | 497 | return buffer.Bytes() | |
| @@ -724,7 +724,7 @@ func (p *pickerController) offerDiagnosticsLocked() { | |||
| 724 | 724 | p.notices.Show(p.clock.Now(), ui.Toast{ | |
| 725 | 725 | ID: "picker-host:" + failure.key, | |
| 726 | 726 | Message: failure.message, | |
| 727 | - | Anchor: domain.AnchorCenter, | |
| 727 | + | Anchor: domain.AnchorTopRight, | |
| 728 | 728 | Duration: pickerNoticeLifetime, | |
| 729 | 729 | }) | |
| 730 | 730 | } | |
| @@ -740,7 +740,7 @@ func (p *pickerController) offerNotice(id, message string) { | |||
| 740 | 740 | return | |
| 741 | 741 | } | |
| 742 | 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 | 744 | p.mu.Unlock() | |
| 745 | 745 | } | |
| 746 | 746 | ||
| @@ -491,7 +491,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte { | |||||
| 491 | return nil | 491 | return nil | ||
| 492 | } | 492 | } | ||
| 493 | var buffer bytes.Buffer | 493 | 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 nil | 495 | 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 | return | 740 | 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 ( | |||
| 13 | 13 | // drawClientToast draws a client-local toast without changing terminal state. | |
| 14 | 14 | // The attach main loop owns both this write and the later daemon-frame | |
| 15 | 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) { | |
| 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}) | |
| 18 | 20 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 19 | 21 | return domain.Rect{}, nil | |
| 20 | 22 | } | |
| 21 | 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 | 26 | func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | |
| 29 | 27 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 30 | 28 | 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-frame | 14 | // 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{}, nil | 21 | 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 nil | 28 | 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{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴' | |||
| 12 | 12 | // RenderTransitionNotice draws one connecting frame for the terminal owner. | |
| 13 | 13 | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | |
| 14 | 14 | 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 | 16 | return nil | |
| 17 | 17 | } | |
| 18 | 18 | 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.Buffer | 14 | 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 nil | 16 | return nil | ||
| 17 | } | 17 | } | ||
| 18 | return out.Bytes() | 18 | return out.Bytes() | ||