feat(client): add session transition feedback
- commit
- e40f2b0c6399e12fa178c818ceb6f1c589a99ec9
- author
- brice · · 2026-08-25 13:34 UTC
- parent
- 47e144b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +339 -26
M internal/usecase/client/handshake.go +60 -26
| @@ -83,53 +83,87 @@ func watchHandshakeTransport(ctx context.Context, transport ports.Transport) fun | |||
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | 85 | func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error { | |
| 86 | + | return boundedHandshakeOperationWithTransition(ctx, transport, operation, nil) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func boundedHandshakeOperationWithTransition(ctx context.Context, transport ports.Transport, operation func() error, transition *transitionUI) error { | |
| 86 | 90 | if err := ctx.Err(); err != nil { | |
| 87 | 91 | _ = transport.Close() | |
| 88 | 92 | return err | |
| 89 | 93 | } | |
| 90 | 94 | completed := make(chan error, 1) | |
| 91 | 95 | go func() { completed <- operation() }() | |
| 92 | - | select { | |
| 93 | - | case err := <-completed: | |
| 94 | - | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 96 | + | for { | |
| 97 | + | select { | |
| 98 | + | case err := <-completed: | |
| 99 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 100 | + | _ = transport.Close() | |
| 101 | + | return ctxErr | |
| 102 | + | } | |
| 103 | + | return err | |
| 104 | + | case <-transition.tickC(): | |
| 105 | + | if err := transition.advance(); err != nil { | |
| 106 | + | _ = transport.Close() | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | case <-ctx.Done(): | |
| 95 | 110 | _ = transport.Close() | |
| 96 | - | return ctxErr | |
| 111 | + | // The result channel is buffered, so the operation can publish its | |
| 112 | + | // completion after Close without keeping this cancellation path stuck. | |
| 113 | + | return ctx.Err() | |
| 97 | 114 | } | |
| 98 | - | return err | |
| 99 | - | case <-ctx.Done(): | |
| 100 | - | _ = transport.Close() | |
| 101 | - | // The result channel is buffered, so the operation can publish its | |
| 102 | - | // completion after Close without keeping this cancellation path stuck. | |
| 103 | - | return ctx.Err() | |
| 104 | 115 | } | |
| 105 | 116 | } | |
| 106 | 117 | ||
| 107 | 118 | func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) { | |
| 119 | + | return boundedDialWithTransition(ctx, dialer, nil) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func boundedDialWithTransition(ctx context.Context, dialer ports.Dialer, transition *transitionUI) (ports.Transport, error) { | |
| 108 | 123 | // The dial context only bounds startup. A successful carriage outlives the | |
| 109 | 124 | // handshake context; canceling that context after Welcome must not kill SSH. | |
| 110 | 125 | dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 111 | - | result := make(chan struct { | |
| 126 | + | type dialResult struct { | |
| 112 | 127 | transport ports.Transport | |
| 113 | 128 | err error | |
| 114 | - | }, 1) | |
| 129 | + | } | |
| 130 | + | result := make(chan dialResult, 1) | |
| 115 | 131 | go func() { | |
| 116 | 132 | transport, err := dialer.Dial(dialCtx) | |
| 117 | - | if err != nil { | |
| 118 | - | cancel() | |
| 119 | - | } else if ctx.Err() != nil && transport != nil { | |
| 120 | - | _ = transport.Close() | |
| 121 | - | } | |
| 122 | - | result <- struct { | |
| 123 | - | transport ports.Transport | |
| 124 | - | err error | |
| 125 | - | }{transport: transport, err: err} | |
| 133 | + | result <- dialResult{transport: transport, err: err} | |
| 126 | 134 | }() | |
| 127 | - | select { | |
| 128 | - | case result := <-result: | |
| 129 | - | return result.transport, result.err | |
| 130 | - | case <-ctx.Done(): | |
| 135 | + | abandon := func() { | |
| 131 | 136 | cancel() | |
| 132 | - | return nil, ctx.Err() | |
| 137 | + | go func() { | |
| 138 | + | late := <-result | |
| 139 | + | if late.transport != nil { | |
| 140 | + | _ = late.transport.Close() | |
| 141 | + | } | |
| 142 | + | }() | |
| 143 | + | } | |
| 144 | + | for { | |
| 145 | + | select { | |
| 146 | + | case result := <-result: | |
| 147 | + | if err := ctx.Err(); err != nil { | |
| 148 | + | cancel() | |
| 149 | + | if result.transport != nil { | |
| 150 | + | _ = result.transport.Close() | |
| 151 | + | } | |
| 152 | + | return nil, err | |
| 153 | + | } | |
| 154 | + | if result.err != nil { | |
| 155 | + | cancel() | |
| 156 | + | } | |
| 157 | + | return result.transport, result.err | |
| 158 | + | case <-transition.tickC(): | |
| 159 | + | if err := transition.advance(); err != nil { | |
| 160 | + | abandon() | |
| 161 | + | return nil, err | |
| 162 | + | } | |
| 163 | + | case <-ctx.Done(): | |
| 164 | + | abandon() | |
| 165 | + | return nil, ctx.Err() | |
| 166 | + | } | |
| 133 | 167 | } | |
| 134 | 168 | } | |
| 135 | 169 | ||
| @@ -83,53 +83,87 @@ func watchHandshakeTransport(ctx context.Context, transport ports.Transport) fun | |||||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error { | 85 | func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error { | ||
| 86 | + | return boundedHandshakeOperationWithTransition(ctx, transport, operation, nil) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func boundedHandshakeOperationWithTransition(ctx context.Context, transport ports.Transport, operation func() error, transition *transitionUI) error { | |||
| 86 | if err := ctx.Err(); err != nil { | 90 | if err := ctx.Err(); err != nil { | ||
| 87 | _ = transport.Close() | 91 | _ = transport.Close() | ||
| 88 | return err | 92 | return err | ||
| 89 | } | 93 | } | ||
| 90 | completed := make(chan error, 1) | 94 | completed := make(chan error, 1) | ||
| 91 | go func() { completed <- operation() }() | 95 | go func() { completed <- operation() }() | ||
| 92 | - | select { | 96 | + | for { |
| 93 | - | case err := <-completed: | 97 | + | select { |
| 94 | - | if ctxErr := ctx.Err(); ctxErr != nil { | 98 | + | case err := <-completed: |
| 99 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 100 | + | _ = transport.Close() | |||
| 101 | + | return ctxErr | |||
| 102 | + | } | |||
| 103 | + | return err | |||
| 104 | + | case <-transition.tickC(): | |||
| 105 | + | if err := transition.advance(); err != nil { | |||
| 106 | + | _ = transport.Close() | |||
| 107 | + | return err | |||
| 108 | + | } | |||
| 109 | + | case <-ctx.Done(): | |||
| 95 | _ = transport.Close() | 110 | _ = transport.Close() | ||
| 96 | - | return ctxErr | 111 | + | // The result channel is buffered, so the operation can publish its |
| 112 | + | // completion after Close without keeping this cancellation path stuck. | |||
| 113 | + | return ctx.Err() | |||
| 97 | } | 114 | } | ||
| 98 | - | return err | |||
| 99 | - | case <-ctx.Done(): | |||
| 100 | - | _ = transport.Close() | |||
| 101 | - | // The result channel is buffered, so the operation can publish its | |||
| 102 | - | // completion after Close without keeping this cancellation path stuck. | |||
| 103 | - | return ctx.Err() | |||
| 104 | } | 115 | } | ||
| 105 | } | 116 | } | ||
| 106 | 117 | ||||
| 107 | func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) { | 118 | func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) { | ||
| 119 | + | return boundedDialWithTransition(ctx, dialer, nil) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func boundedDialWithTransition(ctx context.Context, dialer ports.Dialer, transition *transitionUI) (ports.Transport, error) { | |||
| 108 | // The dial context only bounds startup. A successful carriage outlives the | 123 | // The dial context only bounds startup. A successful carriage outlives the | ||
| 109 | // handshake context; canceling that context after Welcome must not kill SSH. | 124 | // handshake context; canceling that context after Welcome must not kill SSH. | ||
| 110 | dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | 125 | dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | ||
| 111 | - | result := make(chan struct { | 126 | + | type dialResult struct { |
| 112 | transport ports.Transport | 127 | transport ports.Transport | ||
| 113 | err error | 128 | err error | ||
| 114 | - | }, 1) | 129 | + | } |
| 130 | + | result := make(chan dialResult, 1) | |||
| 115 | go func() { | 131 | go func() { | ||
| 116 | transport, err := dialer.Dial(dialCtx) | 132 | transport, err := dialer.Dial(dialCtx) | ||
| 117 | - | if err != nil { | 133 | + | result <- dialResult{transport: transport, err: err} |
| 118 | - | cancel() | |||
| 119 | - | } else if ctx.Err() != nil && transport != nil { | |||
| 120 | - | _ = transport.Close() | |||
| 121 | - | } | |||
| 122 | - | result <- struct { | |||
| 123 | - | transport ports.Transport | |||
| 124 | - | err error | |||
| 125 | - | }{transport: transport, err: err} | |||
| 126 | }() | 134 | }() | ||
| 127 | - | select { | 135 | + | abandon := func() { |
| 128 | - | case result := <-result: | |||
| 129 | - | return result.transport, result.err | |||
| 130 | - | case <-ctx.Done(): | |||
| 131 | cancel() | 136 | cancel() | ||
| 132 | - | return nil, ctx.Err() | 137 | + | go func() { |
| 138 | + | late := <-result | |||
| 139 | + | if late.transport != nil { | |||
| 140 | + | _ = late.transport.Close() | |||
| 141 | + | } | |||
| 142 | + | }() | |||
| 143 | + | } | |||
| 144 | + | for { | |||
| 145 | + | select { | |||
| 146 | + | case result := <-result: | |||
| 147 | + | if err := ctx.Err(); err != nil { | |||
| 148 | + | cancel() | |||
| 149 | + | if result.transport != nil { | |||
| 150 | + | _ = result.transport.Close() | |||
| 151 | + | } | |||
| 152 | + | return nil, err | |||
| 153 | + | } | |||
| 154 | + | if result.err != nil { | |||
| 155 | + | cancel() | |||
| 156 | + | } | |||
| 157 | + | return result.transport, result.err | |||
| 158 | + | case <-transition.tickC(): | |||
| 159 | + | if err := transition.advance(); err != nil { | |||
| 160 | + | abandon() | |||
| 161 | + | return nil, err | |||
| 162 | + | } | |||
| 163 | + | case <-ctx.Done(): | |||
| 164 | + | abandon() | |||
| 165 | + | return nil, ctx.Err() | |||
| 166 | + | } | |||
| 133 | } | 167 | } | ||
| 134 | } | 168 | } | ||
| 135 | 169 | ||||
A internal/usecase/client/handshake_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type failedContextDialer struct { | |
| 14 | + | ctx context.Context | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (d *failedContextDialer) Dial(ctx context.Context) (ports.Transport, error) { | |
| 18 | + | d.ctx = ctx | |
| 19 | + | return nil, errors.New("dial failed") | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) { | |
| 23 | + | dialer := &failedContextDialer{} | |
| 24 | + | _, err := boundedDial(context.Background(), dialer) | |
| 25 | + | require.ErrorContains(t, err, "dial failed") | |
| 26 | + | require.NotNil(t, dialer.ctx) | |
| 27 | + | require.ErrorIs(t, dialer.ctx.Err(), context.Canceled) | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type failedContextDialer struct { | |||
| 14 | + | ctx context.Context | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (d *failedContextDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 18 | + | d.ctx = ctx | |||
| 19 | + | return nil, errors.New("dial failed") | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) { | |||
| 23 | + | dialer := &failedContextDialer{} | |||
| 24 | + | _, err := boundedDial(context.Background(), dialer) | |||
| 25 | + | require.ErrorContains(t, err, "dial failed") | |||
| 26 | + | require.NotNil(t, dialer.ctx) | |||
| 27 | + | require.ErrorIs(t, dialer.ctx.Err(), context.Canceled) | |||
| 28 | + | } | |||
A internal/usecase/client/transition_ui.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | transitionSpinnerDelay = 120 * time.Millisecond | |
| 14 | + | transitionSpinnerInterval = 80 * time.Millisecond | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} | |
| 18 | + | ||
| 19 | + | func transitionMessage(target ports.AttachTarget) string { | |
| 20 | + | origin := target.Endpoint | |
| 21 | + | if target.RemoteTarget != nil && target.RemoteTarget.DisplayOrigin != "" { | |
| 22 | + | origin = target.RemoteTarget.DisplayOrigin | |
| 23 | + | } | |
| 24 | + | label := target.Session | |
| 25 | + | if origin != "" { | |
| 26 | + | label = domain.RemoteSessionDisplay(target.Session, origin) | |
| 27 | + | } | |
| 28 | + | verb := "Switching to " | |
| 29 | + | if target.RemoteTarget != nil && target.RemoteTarget.Stopped { | |
| 30 | + | verb = "Starting " | |
| 31 | + | } | |
| 32 | + | return verb + label + "…" | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) { | |
| 36 | + | spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)] | |
| 37 | + | return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // transitionUI owns the client-local handoff presentation. Every method is | |
| 41 | + | // called by the Runner or active attach loop, preserving the terminal's single | |
| 42 | + | // writer rule while network operations expose timer/resize events. | |
| 43 | + | type transitionUI struct { | |
| 44 | + | term ports.Terminal | |
| 45 | + | clock ports.Clock | |
| 46 | + | rawEntered *bool | |
| 47 | + | timer ports.Timer | |
| 48 | + | active bool | |
| 49 | + | showing bool | |
| 50 | + | frame int | |
| 51 | + | message string | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func newTransitionUI(term ports.Terminal, clock ports.Clock, rawEntered *bool) *transitionUI { | |
| 55 | + | return &transitionUI{term: term, clock: clock, rawEntered: rawEntered} | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (u *transitionUI) start(target ports.AttachTarget) { | |
| 59 | + | if u == nil { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | u.stop() | |
| 63 | + | u.active = true | |
| 64 | + | u.frame = 0 | |
| 65 | + | u.message = transitionMessage(target) | |
| 66 | + | if u.clock != nil { | |
| 67 | + | u.timer = u.clock.NewTimer(transitionSpinnerDelay) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (u *transitionUI) tickC() <-chan time.Time { | |
| 72 | + | if u == nil || !u.active || u.timer == nil { | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | return u.timer.C() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (u *transitionUI) advance() error { | |
| 79 | + | if u == nil || !u.active { | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | size, err := u.term.Size() | |
| 83 | + | if err != nil { | |
| 84 | + | return fmt.Errorf("reading terminal size for transition toast: %w", err) | |
| 85 | + | } | |
| 86 | + | if err := u.redraw(size); err != nil { | |
| 87 | + | return err | |
| 88 | + | } | |
| 89 | + | u.frame = (u.frame + 1) % len(transitionSpinnerFrames) | |
| 90 | + | if u.timer != nil { | |
| 91 | + | u.timer.Reset(transitionSpinnerInterval) | |
| 92 | + | } | |
| 93 | + | return nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (u *transitionUI) redraw(size domain.Size) error { | |
| 97 | + | if u == nil || !u.active || u.rawEntered == nil || !*u.rawEntered { | |
| 98 | + | return nil | |
| 99 | + | } | |
| 100 | + | bounds, err := drawTransitionToast(u.term.Out(), size, u.frame, u.message) | |
| 101 | + | if err != nil { | |
| 102 | + | return fmt.Errorf("drawing transition toast: %w", err) | |
| 103 | + | } | |
| 104 | + | if bounds.Width == 0 || bounds.Height == 0 { | |
| 105 | + | return nil | |
| 106 | + | } | |
| 107 | + | if err := u.term.Flush(); err != nil { | |
| 108 | + | return fmt.Errorf("flushing transition toast: %w", err) | |
| 109 | + | } | |
| 110 | + | u.showing = true | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (u *transitionUI) stop() { | |
| 115 | + | if u == nil { | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | if u.timer != nil { | |
| 119 | + | u.timer.Stop() | |
| 120 | + | u.timer = nil | |
| 121 | + | } | |
| 122 | + | u.active = false | |
| 123 | + | u.showing = false | |
| 124 | + | u.frame = 0 | |
| 125 | + | u.message = "" | |
| 126 | + | } | |
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | transitionSpinnerDelay = 120 * time.Millisecond | |||
| 14 | + | transitionSpinnerInterval = 80 * time.Millisecond | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} | |||
| 18 | + | ||||
| 19 | + | func transitionMessage(target ports.AttachTarget) string { | |||
| 20 | + | origin := target.Endpoint | |||
| 21 | + | if target.RemoteTarget != nil && target.RemoteTarget.DisplayOrigin != "" { | |||
| 22 | + | origin = target.RemoteTarget.DisplayOrigin | |||
| 23 | + | } | |||
| 24 | + | label := target.Session | |||
| 25 | + | if origin != "" { | |||
| 26 | + | label = domain.RemoteSessionDisplay(target.Session, origin) | |||
| 27 | + | } | |||
| 28 | + | verb := "Switching to " | |||
| 29 | + | if target.RemoteTarget != nil && target.RemoteTarget.Stopped { | |||
| 30 | + | verb = "Starting " | |||
| 31 | + | } | |||
| 32 | + | return verb + label + "…" | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) { | |||
| 36 | + | spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)] | |||
| 37 | + | return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message)) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // transitionUI owns the client-local handoff presentation. Every method is | |||
| 41 | + | // called by the Runner or active attach loop, preserving the terminal's single | |||
| 42 | + | // writer rule while network operations expose timer/resize events. | |||
| 43 | + | type transitionUI struct { | |||
| 44 | + | term ports.Terminal | |||
| 45 | + | clock ports.Clock | |||
| 46 | + | rawEntered *bool | |||
| 47 | + | timer ports.Timer | |||
| 48 | + | active bool | |||
| 49 | + | showing bool | |||
| 50 | + | frame int | |||
| 51 | + | message string | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func newTransitionUI(term ports.Terminal, clock ports.Clock, rawEntered *bool) *transitionUI { | |||
| 55 | + | return &transitionUI{term: term, clock: clock, rawEntered: rawEntered} | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (u *transitionUI) start(target ports.AttachTarget) { | |||
| 59 | + | if u == nil { | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | u.stop() | |||
| 63 | + | u.active = true | |||
| 64 | + | u.frame = 0 | |||
| 65 | + | u.message = transitionMessage(target) | |||
| 66 | + | if u.clock != nil { | |||
| 67 | + | u.timer = u.clock.NewTimer(transitionSpinnerDelay) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (u *transitionUI) tickC() <-chan time.Time { | |||
| 72 | + | if u == nil || !u.active || u.timer == nil { | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | return u.timer.C() | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (u *transitionUI) advance() error { | |||
| 79 | + | if u == nil || !u.active { | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | size, err := u.term.Size() | |||
| 83 | + | if err != nil { | |||
| 84 | + | return fmt.Errorf("reading terminal size for transition toast: %w", err) | |||
| 85 | + | } | |||
| 86 | + | if err := u.redraw(size); err != nil { | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | u.frame = (u.frame + 1) % len(transitionSpinnerFrames) | |||
| 90 | + | if u.timer != nil { | |||
| 91 | + | u.timer.Reset(transitionSpinnerInterval) | |||
| 92 | + | } | |||
| 93 | + | return nil | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (u *transitionUI) redraw(size domain.Size) error { | |||
| 97 | + | if u == nil || !u.active || u.rawEntered == nil || !*u.rawEntered { | |||
| 98 | + | return nil | |||
| 99 | + | } | |||
| 100 | + | bounds, err := drawTransitionToast(u.term.Out(), size, u.frame, u.message) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return fmt.Errorf("drawing transition toast: %w", err) | |||
| 103 | + | } | |||
| 104 | + | if bounds.Width == 0 || bounds.Height == 0 { | |||
| 105 | + | return nil | |||
| 106 | + | } | |||
| 107 | + | if err := u.term.Flush(); err != nil { | |||
| 108 | + | return fmt.Errorf("flushing transition toast: %w", err) | |||
| 109 | + | } | |||
| 110 | + | u.showing = true | |||
| 111 | + | return nil | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (u *transitionUI) stop() { | |||
| 115 | + | if u == nil { | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | if u.timer != nil { | |||
| 119 | + | u.timer.Stop() | |||
| 120 | + | u.timer = nil | |||
| 121 | + | } | |||
| 122 | + | u.active = false | |||
| 123 | + | u.showing = false | |||
| 124 | + | u.frame = 0 | |||
| 125 | + | u.message = "" | |||
| 126 | + | } | |||
A internal/usecase/client/transition_ui_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "io" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) { | |
| 17 | + | live := ports.AttachTarget{Endpoint: "remote", Session: "work"} | |
| 18 | + | stoppedTarget := domain.RemoteSessionTarget{ | |
| 19 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1}, | |
| 20 | + | SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"), | |
| 21 | + | } | |
| 22 | + | stopped := ports.AttachTarget{Endpoint: "remote", Session: "work", RemoteTarget: &stoppedTarget} | |
| 23 | + | ||
| 24 | + | require.Equal(t, "Switching to work@remote…", transitionMessage(live)) | |
| 25 | + | require.Equal(t, "Starting work@remote…", transitionMessage(stopped)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestDrawTransitionToastUsesAnimatedFrames(t *testing.T) { | |
| 29 | + | var first, second bytes.Buffer | |
| 30 | + | _, err := drawTransitionToast(&first, domain.Size{Cols: 80, Rows: 24}, 0, "Switching to work@remote…") | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | _, err = drawTransitionToast(&second, domain.Size{Cols: 80, Rows: 24}, 1, "Switching to work@remote…") | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | ||
| 35 | + | require.Contains(t, first.String(), string(transitionSpinnerFrames[0])) | |
| 36 | + | require.Contains(t, second.String(), string(transitionSpinnerFrames[1])) | |
| 37 | + | require.Contains(t, first.String(), "Switching to work@remote…") | |
| 38 | + | require.NotEqual(t, first.String(), second.String()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type transitionTestTimer struct { | |
| 42 | + | ch chan time.Time | |
| 43 | + | resets []time.Duration | |
| 44 | + | stopped bool | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (t *transitionTestTimer) C() <-chan time.Time { return t.ch } | |
| 48 | + | func (t *transitionTestTimer) Reset(delay time.Duration) bool { | |
| 49 | + | t.resets = append(t.resets, delay) | |
| 50 | + | return true | |
| 51 | + | } | |
| 52 | + | func (t *transitionTestTimer) Stop() bool { | |
| 53 | + | t.stopped = true | |
| 54 | + | return true | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type transitionTestClock struct { | |
| 58 | + | timer *transitionTestTimer | |
| 59 | + | delays []time.Duration | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (c *transitionTestClock) Now() time.Time { return time.Time{} } | |
| 63 | + | func (c *transitionTestClock) NewTimer(delay time.Duration) ports.Timer { | |
| 64 | + | c.delays = append(c.delays, delay) | |
| 65 | + | return c.timer | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | type transitionTestTerminal struct { | |
| 69 | + | out bytes.Buffer | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (*transitionTestTerminal) EnterRaw() (func() error, error) { | |
| 73 | + | return func() error { return nil }, nil | |
| 74 | + | } | |
| 75 | + | func (*transitionTestTerminal) Size() (domain.Size, error) { | |
| 76 | + | return domain.Size{Cols: 80, Rows: 24}, nil | |
| 77 | + | } | |
| 78 | + | func (*transitionTestTerminal) ResizeEvents() <-chan domain.Size { return nil } | |
| 79 | + | func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") } | |
| 80 | + | func (t *transitionTestTerminal) Out() io.Writer { return &t.out } | |
| 81 | + | func (*transitionTestTerminal) Flush() error { return nil } | |
| 82 | + | ||
| 83 | + | func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) { | |
| 84 | + | for _, tt := range []struct { | |
| 85 | + | name string | |
| 86 | + | raw bool | |
| 87 | + | }{ | |
| 88 | + | {name: "raw", raw: true}, | |
| 89 | + | {name: "not raw", raw: false}, | |
| 90 | + | } { | |
| 91 | + | t.Run(tt.name, func(t *testing.T) { | |
| 92 | + | timer := &transitionTestTimer{ch: make(chan time.Time, 1)} | |
| 93 | + | clock := &transitionTestClock{timer: timer} | |
| 94 | + | term := &transitionTestTerminal{} | |
| 95 | + | raw := tt.raw | |
| 96 | + | ui := newTransitionUI(term, clock, &raw) | |
| 97 | + | ui.start(ports.AttachTarget{Endpoint: "remote", Session: "work"}) | |
| 98 | + | ||
| 99 | + | require.Equal(t, []time.Duration{transitionSpinnerDelay}, clock.delays) | |
| 100 | + | require.NotNil(t, ui.tickC()) | |
| 101 | + | require.Empty(t, term.out.String(), "the delay prevents flashes on fast switches") | |
| 102 | + | require.NoError(t, ui.advance()) | |
| 103 | + | require.Equal(t, []time.Duration{transitionSpinnerInterval}, timer.resets) | |
| 104 | + | if !tt.raw { | |
| 105 | + | require.Empty(t, term.out.String()) | |
| 106 | + | require.False(t, ui.showing) | |
| 107 | + | ui.stop() | |
| 108 | + | require.True(t, timer.stopped) | |
| 109 | + | require.Nil(t, ui.tickC()) | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | require.Contains(t, term.out.String(), "Switching to work@remote…") | |
| 114 | + | require.True(t, ui.showing) | |
| 115 | + | beforeResize := term.out.Len() | |
| 116 | + | require.NoError(t, ui.redraw(domain.Size{Cols: 100, Rows: 30})) | |
| 117 | + | require.Greater(t, term.out.Len(), beforeResize) | |
| 118 | + | require.True(t, strings.Contains(term.out.String(), "Switching to work@remote…")) | |
| 119 | + | ||
| 120 | + | ui.stop() | |
| 121 | + | require.True(t, timer.stopped) | |
| 122 | + | require.Nil(t, ui.tickC()) | |
| 123 | + | }) | |
| 124 | + | } | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "io" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) { | |||
| 17 | + | live := ports.AttachTarget{Endpoint: "remote", Session: "work"} | |||
| 18 | + | stoppedTarget := domain.RemoteSessionTarget{ | |||
| 19 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1}, | |||
| 20 | + | SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"), | |||
| 21 | + | } | |||
| 22 | + | stopped := ports.AttachTarget{Endpoint: "remote", Session: "work", RemoteTarget: &stoppedTarget} | |||
| 23 | + | ||||
| 24 | + | require.Equal(t, "Switching to work@remote…", transitionMessage(live)) | |||
| 25 | + | require.Equal(t, "Starting work@remote…", transitionMessage(stopped)) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestDrawTransitionToastUsesAnimatedFrames(t *testing.T) { | |||
| 29 | + | var first, second bytes.Buffer | |||
| 30 | + | _, err := drawTransitionToast(&first, domain.Size{Cols: 80, Rows: 24}, 0, "Switching to work@remote…") | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | _, err = drawTransitionToast(&second, domain.Size{Cols: 80, Rows: 24}, 1, "Switching to work@remote…") | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | ||||
| 35 | + | require.Contains(t, first.String(), string(transitionSpinnerFrames[0])) | |||
| 36 | + | require.Contains(t, second.String(), string(transitionSpinnerFrames[1])) | |||
| 37 | + | require.Contains(t, first.String(), "Switching to work@remote…") | |||
| 38 | + | require.NotEqual(t, first.String(), second.String()) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type transitionTestTimer struct { | |||
| 42 | + | ch chan time.Time | |||
| 43 | + | resets []time.Duration | |||
| 44 | + | stopped bool | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (t *transitionTestTimer) C() <-chan time.Time { return t.ch } | |||
| 48 | + | func (t *transitionTestTimer) Reset(delay time.Duration) bool { | |||
| 49 | + | t.resets = append(t.resets, delay) | |||
| 50 | + | return true | |||
| 51 | + | } | |||
| 52 | + | func (t *transitionTestTimer) Stop() bool { | |||
| 53 | + | t.stopped = true | |||
| 54 | + | return true | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | type transitionTestClock struct { | |||
| 58 | + | timer *transitionTestTimer | |||
| 59 | + | delays []time.Duration | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (c *transitionTestClock) Now() time.Time { return time.Time{} } | |||
| 63 | + | func (c *transitionTestClock) NewTimer(delay time.Duration) ports.Timer { | |||
| 64 | + | c.delays = append(c.delays, delay) | |||
| 65 | + | return c.timer | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | type transitionTestTerminal struct { | |||
| 69 | + | out bytes.Buffer | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (*transitionTestTerminal) EnterRaw() (func() error, error) { | |||
| 73 | + | return func() error { return nil }, nil | |||
| 74 | + | } | |||
| 75 | + | func (*transitionTestTerminal) Size() (domain.Size, error) { | |||
| 76 | + | return domain.Size{Cols: 80, Rows: 24}, nil | |||
| 77 | + | } | |||
| 78 | + | func (*transitionTestTerminal) ResizeEvents() <-chan domain.Size { return nil } | |||
| 79 | + | func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") } | |||
| 80 | + | func (t *transitionTestTerminal) Out() io.Writer { return &t.out } | |||
| 81 | + | func (*transitionTestTerminal) Flush() error { return nil } | |||
| 82 | + | ||||
| 83 | + | func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) { | |||
| 84 | + | for _, tt := range []struct { | |||
| 85 | + | name string | |||
| 86 | + | raw bool | |||
| 87 | + | }{ | |||
| 88 | + | {name: "raw", raw: true}, | |||
| 89 | + | {name: "not raw", raw: false}, | |||
| 90 | + | } { | |||
| 91 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 92 | + | timer := &transitionTestTimer{ch: make(chan time.Time, 1)} | |||
| 93 | + | clock := &transitionTestClock{timer: timer} | |||
| 94 | + | term := &transitionTestTerminal{} | |||
| 95 | + | raw := tt.raw | |||
| 96 | + | ui := newTransitionUI(term, clock, &raw) | |||
| 97 | + | ui.start(ports.AttachTarget{Endpoint: "remote", Session: "work"}) | |||
| 98 | + | ||||
| 99 | + | require.Equal(t, []time.Duration{transitionSpinnerDelay}, clock.delays) | |||
| 100 | + | require.NotNil(t, ui.tickC()) | |||
| 101 | + | require.Empty(t, term.out.String(), "the delay prevents flashes on fast switches") | |||
| 102 | + | require.NoError(t, ui.advance()) | |||
| 103 | + | require.Equal(t, []time.Duration{transitionSpinnerInterval}, timer.resets) | |||
| 104 | + | if !tt.raw { | |||
| 105 | + | require.Empty(t, term.out.String()) | |||
| 106 | + | require.False(t, ui.showing) | |||
| 107 | + | ui.stop() | |||
| 108 | + | require.True(t, timer.stopped) | |||
| 109 | + | require.Nil(t, ui.tickC()) | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | require.Contains(t, term.out.String(), "Switching to work@remote…") | |||
| 114 | + | require.True(t, ui.showing) | |||
| 115 | + | beforeResize := term.out.Len() | |||
| 116 | + | require.NoError(t, ui.redraw(domain.Size{Cols: 100, Rows: 30})) | |||
| 117 | + | require.Greater(t, term.out.Len(), beforeResize) | |||
| 118 | + | require.True(t, strings.Contains(term.out.String(), "Switching to work@remote…")) | |||
| 119 | + | ||||
| 120 | + | ui.stop() | |||
| 121 | + | require.True(t, timer.stopped) | |||
| 122 | + | require.Nil(t, ui.tickC()) | |||
| 123 | + | }) | |||
| 124 | + | } | |||
| 125 | + | } | |||