detached e40f2b0

feat(client): add session transition feedback

commit
e40f2b0c6399e12fa178c818ceb6f1c589a99ec9
author
brice · · 2026-08-25 13:34 UTC
parent
47e144b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
8383 }
8484
8585 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 {
8690 if err := ctx.Err(); err != nil {
8791 _ = transport.Close()
8892 return err
8993 }
9094 completed := make(chan error, 1)
9195 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():
95110 _ = 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()
97114 }
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()
104115 }
105116 }
106117
107118 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) {
108123 // The dial context only bounds startup. A successful carriage outlives the
109124 // handshake context; canceling that context after Welcome must not kill SSH.
110125 dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
111- result := make(chan struct {
126+ type dialResult struct {
112127 transport ports.Transport
113128 err error
114- }, 1)
129+ }
130+ result := make(chan dialResult, 1)
115131 go func() {
116132 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}
126134 }()
127- select {
128- case result := <-result:
129- return result.transport, result.err
130- case <-ctx.Done():
135+ abandon := func() {
131136 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+ }
133167 }
134168 }
135169
@@ -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 err92 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 ctxErr111+ // 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 the123 // 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.Transport127 transport ports.Transport
113 err error128 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+}

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