detached 25fad49

fix(client): address reconnect toast review feedback

commit
25fad494b7a380cda29328a012f47f15f37ed28e
author
brice · · 2026-07-06 01:26 UTC
parent
8a56e91
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +58 -35
M internal/usecase/client/client.go +8 -8
@@ -209,9 +209,9 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
209209 }
210210 sleepWhileReconnecting := func(d time.Duration) bool {
211211 if remote && showingStatus {
212- return reconnectSleepWithResize(ctx, d, term.ResizeEvents(), redrawRemoteStatus)
212+ return reconnectSleepWithResize(ctx, clk, d, term.ResizeEvents(), redrawRemoteStatus)
213213 }
214- return reconnectSleep(ctx, d)
214+ return reconnectSleep(ctx, clk, d)
215215 }
216216
217217 for {
@@ -260,23 +260,23 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
260260 }
261261 }
262262
263-func sleepReconnect(ctx context.Context, d time.Duration) bool {
264- t := time.NewTimer(d)
263+func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
264+ t := clk.NewTimer(d)
265265 defer t.Stop()
266266 select {
267- case <-t.C:
267+ case <-t.C():
268268 return true
269269 case <-ctx.Done():
270270 return false
271271 }
272272 }
273273
274-func sleepReconnectWithResizeEvents(ctx context.Context, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size)) bool {
275- t := time.NewTimer(d)
274+func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size)) bool {
275+ t := clk.NewTimer(d)
276276 defer t.Stop()
277277 for {
278278 select {
279- case <-t.C:
279+ case <-t.C():
280280 return true
281281 case <-ctx.Done():
282282 return false
@@ -209,9 +209,9 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
209 }209 }
210 sleepWhileReconnecting := func(d time.Duration) bool {210 sleepWhileReconnecting := func(d time.Duration) bool {
211 if remote && showingStatus {211 if remote && showingStatus {
212- return reconnectSleepWithResize(ctx, d, term.ResizeEvents(), redrawRemoteStatus)212+ return reconnectSleepWithResize(ctx, clk, d, term.ResizeEvents(), redrawRemoteStatus)
213 }213 }
214- return reconnectSleep(ctx, d)214+ return reconnectSleep(ctx, clk, d)
215 }215 }
216 216
217 for {217 for {
@@ -260,23 +260,23 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
260 }260 }
261 }261 }
262 262
263-func sleepReconnect(ctx context.Context, d time.Duration) bool {263+func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
264- t := time.NewTimer(d)264+ t := clk.NewTimer(d)
265 defer t.Stop()265 defer t.Stop()
266 select {266 select {
267- case <-t.C:267+ case <-t.C():
268 return true268 return true
269 case <-ctx.Done():269 case <-ctx.Done():
270 return false270 return false
271 }271 }
272 }272 }
273 273
274-func sleepReconnectWithResizeEvents(ctx context.Context, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size)) bool {274+func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size)) bool {
275- t := time.NewTimer(d)275+ t := clk.NewTimer(d)
276 defer t.Stop()276 defer t.Stop()
277 for {277 for {
278 select {278 select {
279- case <-t.C:279+ case <-t.C():
280 return true280 return true
281 case <-ctx.Done():281 case <-ctx.Done():
282 return false282 return false
M internal/usecase/client/reconnect_toast.go +2 -2
@@ -68,10 +68,10 @@ func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) erro
6868 return err
6969 }
7070 for i, line := range lines {
71- if _, err := fmt.Fprintf(out, "\x1b[%d;%dH%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
71+ if _, err := fmt.Fprintf(out, "\x1b[%d;%dH\x1b[0m%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
7272 return err
7373 }
7474 }
75- _, err := io.WriteString(out, "\x1b[u")
75+ _, err := io.WriteString(out, "\x1b[0m\x1b[u")
7676 return err
7777 }
@@ -68,10 +68,10 @@ func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) erro
68 return err68 return err
69 }69 }
70 for i, line := range lines {70 for i, line := range lines {
71- if _, err := fmt.Fprintf(out, "\x1b[%d;%dH%s", bounds.Y+i+1, bounds.X+1, line); err != nil {71+ if _, err := fmt.Fprintf(out, "\x1b[%d;%dH\x1b[0m%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
72 return err72 return err
73 }73 }
74 }74 }
75- _, err := io.WriteString(out, "\x1b[u")75+ _, err := io.WriteString(out, "\x1b[0m\x1b[u")
76 return err76 return err
77 }77 }
M internal/usecase/client/reconnect_toast_test.go +48 -25
@@ -18,18 +18,28 @@ import (
1818 "github.com/bnema/vev/pkg/renderer"
1919 )
2020
21-type reconnectToastClock struct{}
21+type reconnectToastClock struct {
22+ timer *reconnectToastTimer
23+ created chan struct{}
24+}
25+
26+func newReconnectToastClock() *reconnectToastClock {
27+ return &reconnectToastClock{created: make(chan struct{})}
28+}
2229
23-func (reconnectToastClock) Now() time.Time { return time.Now() }
24-func (reconnectToastClock) NewTimer(d time.Duration) ports.Timer {
25- return &reconnectToastTimer{t: time.NewTimer(d)}
30+func (*reconnectToastClock) Now() time.Time { return time.Time{} }
31+func (c *reconnectToastClock) NewTimer(time.Duration) ports.Timer {
32+ c.timer = &reconnectToastTimer{ch: make(chan time.Time, 1)}
33+ close(c.created)
34+ return c.timer
2635 }
2736
28-type reconnectToastTimer struct{ t *time.Timer }
37+type reconnectToastTimer struct{ ch chan time.Time }
2938
30-func (t *reconnectToastTimer) C() <-chan time.Time { return t.t.C }
31-func (t *reconnectToastTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }
32-func (t *reconnectToastTimer) Stop() bool { return t.t.Stop() }
39+func (t *reconnectToastTimer) C() <-chan time.Time { return t.ch }
40+func (t *reconnectToastTimer) Reset(time.Duration) bool { return true }
41+func (t *reconnectToastTimer) Stop() bool { return true }
42+func (t *reconnectToastTimer) fire() { t.ch <- time.Time{} }
3343
3444 type reconnectToastDialer struct {
3545 trs []ports.Transport
@@ -115,6 +125,7 @@ func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
115125 require.NoError(t, drawReconnectToast(&out, size))
116126 require.Contains(t, out.String(), "┌")
117127 require.Contains(t, out.String(), reconnectToastMessage)
128+ require.Contains(t, out.String(), "\x1b[0m")
118129
119130 require.NoError(t, clearReconnectToast(&out, size))
120131 bounds := reconnectToastBounds(size)
@@ -140,23 +151,33 @@ func displayWidth(s string) int {
140151 }
141152
142153 func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
143- resizeCh := make(chan domain.Size, 1)
154+ clk := newReconnectToastClock()
155+ resizeCh := make(chan domain.Size, 2)
144156 resizeCh <- domain.Size{Cols: 100, Rows: 30}
145- var got domain.Size
146-
147- ok := sleepReconnectWithResizeEvents(context.Background(), time.Millisecond, resizeCh, func(size domain.Size) {
148- got = size
149- })
157+ resizeCh <- domain.Size{Cols: 120, Rows: 40}
158+ got := make(chan domain.Size, 2)
159+ done := make(chan bool, 1)
160+
161+ go func() {
162+ done <- sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(size domain.Size) {
163+ got <- size
164+ })
165+ }()
150166
151- require.True(t, ok)
152- require.Equal(t, domain.Size{Cols: 100, Rows: 30}, got)
167+ <-clk.created
168+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)
169+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)
170+ clk.timer.fire()
171+ require.True(t, <-done)
153172 }
154173
155174 func TestRemoteReconnectToastClearsOnSuccessfulReconnect(t *testing.T) {
156175 oldSleep := reconnectSleep
157176 oldSleepWithResize := reconnectSleepWithResize
158- reconnectSleep = func(context.Context, time.Duration) bool { return true }
159- reconnectSleepWithResize = func(context.Context, time.Duration, <-chan domain.Size, func(domain.Size)) bool { return true }
177+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
178+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
179+ return true
180+ }
160181 defer func() {
161182 reconnectSleep = oldSleep
162183 reconnectSleepWithResize = oldSleepWithResize
@@ -168,7 +189,7 @@ func TestRemoteReconnectToastClearsOnSuccessfulReconnect(t *testing.T) {
168189 tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonDetach)}}}
169190 dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}
170191
171- err := Run(context.Background(), dialer, term, reconnectToastClock{}, ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
192+ err := Run(context.Background(), dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
172193 require.NoError(t, err)
173194 out := term.out.String()
174195 require.Contains(t, out, reconnectToastMessage)
@@ -179,11 +200,11 @@ func TestRemoteReconnectToastClearsOnCancellation(t *testing.T) {
179200 oldSleep := reconnectSleep
180201 oldSleepWithResize := reconnectSleepWithResize
181202 ctx, cancel := context.WithCancel(context.Background())
182- reconnectSleep = func(context.Context, time.Duration) bool {
203+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool {
183204 cancel()
184205 return false
185206 }
186- reconnectSleepWithResize = func(context.Context, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
207+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
187208 cancel()
188209 return false
189210 }
@@ -197,7 +218,7 @@ func TestRemoteReconnectToastClearsOnCancellation(t *testing.T) {
197218 tr := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}
198219 dialer := &reconnectToastDialer{trs: []ports.Transport{tr}}
199220
200- err := Run(ctx, dialer, term, reconnectToastClock{}, ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
221+ err := Run(ctx, dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
201222 require.ErrorIs(t, err, context.Canceled)
202223 out := term.out.String()
203224 require.Contains(t, out, reconnectToastMessage)
@@ -207,8 +228,10 @@ func TestRemoteReconnectToastClearsOnCancellation(t *testing.T) {
207228 func TestRemoteReconnectToastClearsOnFinalExit(t *testing.T) {
208229 oldSleep := reconnectSleep
209230 oldSleepWithResize := reconnectSleepWithResize
210- reconnectSleep = func(context.Context, time.Duration) bool { return true }
211- reconnectSleepWithResize = func(context.Context, time.Duration, <-chan domain.Size, func(domain.Size)) bool { return true }
231+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
232+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
233+ return true
234+ }
212235 defer func() {
213236 reconnectSleep = oldSleep
214237 reconnectSleepWithResize = oldSleepWithResize
@@ -220,7 +243,7 @@ func TestRemoteReconnectToastClearsOnFinalExit(t *testing.T) {
220243 tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonSessionKilled)}}}
221244 dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}
222245
223- err := Run(context.Background(), dialer, term, reconnectToastClock{}, ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
246+ err := Run(context.Background(), dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
224247 var detached *DetachedError
225248 require.True(t, errors.As(err, &detached))
226249 out := term.out.String()
@@ -18,18 +18,28 @@ import (
18 "github.com/bnema/vev/pkg/renderer"18 "github.com/bnema/vev/pkg/renderer"
19 )19 )
20 20
21-type reconnectToastClock struct{}21+type reconnectToastClock struct {
22+ timer *reconnectToastTimer
23+ created chan struct{}
24+}
25+
26+func newReconnectToastClock() *reconnectToastClock {
27+ return &reconnectToastClock{created: make(chan struct{})}
28+}
22 29
23-func (reconnectToastClock) Now() time.Time { return time.Now() }30+func (*reconnectToastClock) Now() time.Time { return time.Time{} }
24-func (reconnectToastClock) NewTimer(d time.Duration) ports.Timer {31+func (c *reconnectToastClock) NewTimer(time.Duration) ports.Timer {
25- return &reconnectToastTimer{t: time.NewTimer(d)}32+ c.timer = &reconnectToastTimer{ch: make(chan time.Time, 1)}
33+ close(c.created)
34+ return c.timer
26 }35 }
27 36
28-type reconnectToastTimer struct{ t *time.Timer }37+type reconnectToastTimer struct{ ch chan time.Time }
29 38
30-func (t *reconnectToastTimer) C() <-chan time.Time { return t.t.C }39+func (t *reconnectToastTimer) C() <-chan time.Time { return t.ch }
31-func (t *reconnectToastTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }40+func (t *reconnectToastTimer) Reset(time.Duration) bool { return true }
32-func (t *reconnectToastTimer) Stop() bool { return t.t.Stop() }41+func (t *reconnectToastTimer) Stop() bool { return true }
42+func (t *reconnectToastTimer) fire() { t.ch <- time.Time{} }
33 43
34 type reconnectToastDialer struct {44 type reconnectToastDialer struct {
35 trs []ports.Transport45 trs []ports.Transport
@@ -115,6 +125,7 @@ func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
115 require.NoError(t, drawReconnectToast(&out, size))125 require.NoError(t, drawReconnectToast(&out, size))
116 require.Contains(t, out.String(), "┌")126 require.Contains(t, out.String(), "┌")
117 require.Contains(t, out.String(), reconnectToastMessage)127 require.Contains(t, out.String(), reconnectToastMessage)
128+ require.Contains(t, out.String(), "\x1b[0m")
118 129
119 require.NoError(t, clearReconnectToast(&out, size))130 require.NoError(t, clearReconnectToast(&out, size))
120 bounds := reconnectToastBounds(size)131 bounds := reconnectToastBounds(size)
@@ -140,23 +151,33 @@ func displayWidth(s string) int {
140 }151 }
141 152
142 func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {153 func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
143- resizeCh := make(chan domain.Size, 1)154+ clk := newReconnectToastClock()
155+ resizeCh := make(chan domain.Size, 2)
144 resizeCh <- domain.Size{Cols: 100, Rows: 30}156 resizeCh <- domain.Size{Cols: 100, Rows: 30}
145- var got domain.Size157+ resizeCh <- domain.Size{Cols: 120, Rows: 40}
146-158+ got := make(chan domain.Size, 2)
147- ok := sleepReconnectWithResizeEvents(context.Background(), time.Millisecond, resizeCh, func(size domain.Size) {159+ done := make(chan bool, 1)
148- got = size160+
149- })161+ go func() {
162+ done <- sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(size domain.Size) {
163+ got <- size
164+ })
165+ }()
150 166
151- require.True(t, ok)167+ <-clk.created
152- require.Equal(t, domain.Size{Cols: 100, Rows: 30}, got)168+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)
169+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)
170+ clk.timer.fire()
171+ require.True(t, <-done)
153 }172 }
154 173
155 func TestRemoteReconnectToastClearsOnSuccessfulReconnect(t *testing.T) {174 func TestRemoteReconnectToastClearsOnSuccessfulReconnect(t *testing.T) {
156 oldSleep := reconnectSleep175 oldSleep := reconnectSleep
157 oldSleepWithResize := reconnectSleepWithResize176 oldSleepWithResize := reconnectSleepWithResize
158- reconnectSleep = func(context.Context, time.Duration) bool { return true }177+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
159- reconnectSleepWithResize = func(context.Context, time.Duration, <-chan domain.Size, func(domain.Size)) bool { return true }178+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
179+ return true
180+ }
160 defer func() {181 defer func() {
161 reconnectSleep = oldSleep182 reconnectSleep = oldSleep
162 reconnectSleepWithResize = oldSleepWithResize183 reconnectSleepWithResize = oldSleepWithResize
@@ -168,7 +189,7 @@ func TestRemoteReconnectToastClearsOnSuccessfulReconnect(t *testing.T) {
168 tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonDetach)}}}189 tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonDetach)}}}
169 dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}190 dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}
170 191
171- err := Run(context.Background(), dialer, term, reconnectToastClock{}, ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))192+ err := Run(context.Background(), dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
172 require.NoError(t, err)193 require.NoError(t, err)
173 out := term.out.String()194 out := term.out.String()
174 require.Contains(t, out, reconnectToastMessage)195 require.Contains(t, out, reconnectToastMessage)
@@ -179,11 +200,11 @@ func TestRemoteReconnectToastClearsOnCancellation(t *testing.T) {
179 oldSleep := reconnectSleep200 oldSleep := reconnectSleep
180 oldSleepWithResize := reconnectSleepWithResize201 oldSleepWithResize := reconnectSleepWithResize
181 ctx, cancel := context.WithCancel(context.Background())202 ctx, cancel := context.WithCancel(context.Background())
182- reconnectSleep = func(context.Context, time.Duration) bool {203+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool {
183 cancel()204 cancel()
184 return false205 return false
185 }206 }
186- reconnectSleepWithResize = func(context.Context, time.Duration, <-chan domain.Size, func(domain.Size)) bool {207+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
187 cancel()208 cancel()
188 return false209 return false
189 }210 }
@@ -197,7 +218,7 @@ func TestRemoteReconnectToastClearsOnCancellation(t *testing.T) {
197 tr := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}218 tr := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}
198 dialer := &reconnectToastDialer{trs: []ports.Transport{tr}}219 dialer := &reconnectToastDialer{trs: []ports.Transport{tr}}
199 220
200- err := Run(ctx, dialer, term, reconnectToastClock{}, ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))221+ err := Run(ctx, dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
201 require.ErrorIs(t, err, context.Canceled)222 require.ErrorIs(t, err, context.Canceled)
202 out := term.out.String()223 out := term.out.String()
203 require.Contains(t, out, reconnectToastMessage)224 require.Contains(t, out, reconnectToastMessage)
@@ -207,8 +228,10 @@ func TestRemoteReconnectToastClearsOnCancellation(t *testing.T) {
207 func TestRemoteReconnectToastClearsOnFinalExit(t *testing.T) {228 func TestRemoteReconnectToastClearsOnFinalExit(t *testing.T) {
208 oldSleep := reconnectSleep229 oldSleep := reconnectSleep
209 oldSleepWithResize := reconnectSleepWithResize230 oldSleepWithResize := reconnectSleepWithResize
210- reconnectSleep = func(context.Context, time.Duration) bool { return true }231+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
211- reconnectSleepWithResize = func(context.Context, time.Duration, <-chan domain.Size, func(domain.Size)) bool { return true }232+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
233+ return true
234+ }
212 defer func() {235 defer func() {
213 reconnectSleep = oldSleep236 reconnectSleep = oldSleep
214 reconnectSleepWithResize = oldSleepWithResize237 reconnectSleepWithResize = oldSleepWithResize
@@ -220,7 +243,7 @@ func TestRemoteReconnectToastClearsOnFinalExit(t *testing.T) {
220 tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonSessionKilled)}}}243 tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonSessionKilled)}}}
221 dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}244 dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}
222 245
223- err := Run(context.Background(), dialer, term, reconnectToastClock{}, ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))246+ err := Run(context.Background(), dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
224 var detached *DetachedError247 var detached *DetachedError
225 require.True(t, errors.As(err, &detached))248 require.True(t, errors.As(err, &detached))
226 out := term.out.String()249 out := term.out.String()

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