detached ad25a70

Merge pull request #51 from bnema/fix/reconnect-toast-clear-bounds

fix(client): clear drawn reconnect toast bounds
commit
ad25a70bdfde9bc0075dec2626f0f9b369e0c816
author
bnema · · 2026-07-09 15:48 UTC
committer
GitHub · · 2026-07-09 15:48 UTC
merge of
71c1f46 + 622d29b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +131 -12
M internal/usecase/client/client.go +5 -5
@@ -160,17 +160,17 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
160160 attemptName := name
161161 backoff := defaultReconnectBackoff.initial
162162 showingStatus := false
163- var reconnectToastSize domain.Size
163+ var reconnectToastRect domain.Rect
164164 statusStage := reconnectStageOfflineRetrying
165165 redrawRemoteStatus := func(size domain.Size) {
166166 if !rawEntered || !remote {
167167 return
168168 }
169169 if showingStatus {
170- _ = clearReconnectToast(term.Out(), reconnectToastSize)
170+ _ = clearReconnectToast(term.Out(), reconnectToastRect)
171171 }
172- _ = drawReconnectToastStage(term.Out(), size, statusStage)
173- reconnectToastSize = size
172+ drawnRect, _ := drawReconnectToastStage(term.Out(), size, statusStage)
173+ reconnectToastRect = drawnRect
174174 _ = term.Flush()
175175 showingStatus = true
176176 }
@@ -207,7 +207,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
207207 return
208208 }
209209 if remote {
210- _ = clearReconnectToast(term.Out(), reconnectToastSize)
210+ _ = clearReconnectToast(term.Out(), reconnectToastRect)
211211 } else {
212212 _, _ = term.Out().Write([]byte(statusClear))
213213 }
@@ -160,17 +160,17 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
160 attemptName := name160 attemptName := name
161 backoff := defaultReconnectBackoff.initial161 backoff := defaultReconnectBackoff.initial
162 showingStatus := false162 showingStatus := false
163- var reconnectToastSize domain.Size163+ var reconnectToastRect domain.Rect
164 statusStage := reconnectStageOfflineRetrying164 statusStage := reconnectStageOfflineRetrying
165 redrawRemoteStatus := func(size domain.Size) {165 redrawRemoteStatus := func(size domain.Size) {
166 if !rawEntered || !remote {166 if !rawEntered || !remote {
167 return167 return
168 }168 }
169 if showingStatus {169 if showingStatus {
170- _ = clearReconnectToast(term.Out(), reconnectToastSize)170+ _ = clearReconnectToast(term.Out(), reconnectToastRect)
171 }171 }
172- _ = drawReconnectToastStage(term.Out(), size, statusStage)172+ drawnRect, _ := drawReconnectToastStage(term.Out(), size, statusStage)
173- reconnectToastSize = size173+ reconnectToastRect = drawnRect
174 _ = term.Flush()174 _ = term.Flush()
175 showingStatus = true175 showingStatus = true
176 }176 }
@@ -207,7 +207,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
207 return207 return
208 }208 }
209 if remote {209 if remote {
210- _ = clearReconnectToast(term.Out(), reconnectToastSize)210+ _ = clearReconnectToast(term.Out(), reconnectToastRect)
211 } else {211 } else {
212 _, _ = term.Out().Write([]byte(statusClear))212 _, _ = term.Out().Write([]byte(statusClear))
213 }213 }
M internal/usecase/client/reconnect_toast.go +6 -6
@@ -28,20 +28,20 @@ func reconnectStageMessage(stage reconnectStage) string {
2828 }
2929
3030 func drawReconnectToast(out io.Writer, size domain.Size) error {
31- return drawReconnectToastStage(out, size, reconnectStageOfflineRetrying)
31+ _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying)
32+ return err
3233 }
3334
34-func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) error {
35+func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
3536 message := reconnectStageMessage(stage)
3637 bounds := reconnectToastBoundsFor(size, message)
3738 if bounds.Width <= 0 || bounds.Height <= 0 {
38- return nil
39+ return domain.Rect{}, nil
3940 }
40- return writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
41+ return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
4142 }
4243
43-func clearReconnectToast(out io.Writer, size domain.Size) error {
44- bounds := reconnectToastBounds(size)
44+func clearReconnectToast(out io.Writer, bounds domain.Rect) error {
4545 if bounds.Width <= 0 || bounds.Height <= 0 {
4646 return nil
4747 }
@@ -28,20 +28,20 @@ func reconnectStageMessage(stage reconnectStage) string {
28 }28 }
29 29
30 func drawReconnectToast(out io.Writer, size domain.Size) error {30 func drawReconnectToast(out io.Writer, size domain.Size) error {
31- return drawReconnectToastStage(out, size, reconnectStageOfflineRetrying)31+ _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying)
32+ return err
32 }33 }
33 34
34-func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) error {35+func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
35 message := reconnectStageMessage(stage)36 message := reconnectStageMessage(stage)
36 bounds := reconnectToastBoundsFor(size, message)37 bounds := reconnectToastBoundsFor(size, message)
37 if bounds.Width <= 0 || bounds.Height <= 0 {38 if bounds.Width <= 0 || bounds.Height <= 0 {
38- return nil39+ return domain.Rect{}, nil
39 }40 }
40- return writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))41+ return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
41 }42 }
42 43
43-func clearReconnectToast(out io.Writer, size domain.Size) error {44+func clearReconnectToast(out io.Writer, bounds domain.Rect) error {
44- bounds := reconnectToastBounds(size)
45 if bounds.Width <= 0 || bounds.Height <= 0 {45 if bounds.Width <= 0 || bounds.Height <= 0 {
46 return nil46 return nil
47 }47 }
M internal/usecase/client/reconnect_toast_test.go +120 -1
@@ -49,6 +49,26 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
4949 return h
5050 }
5151
52+func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *reconnectToastTerminalHarness {
53+ t.Helper()
54+ in, inWriter := io.Pipe()
55+ h := &reconnectToastTerminalHarness{
56+ term: portsmocks.NewMockTerminal(t),
57+ in: in,
58+ inWriter: inWriter,
59+ size: domain.Size{Cols: 80, Rows: 24},
60+ resizeCh: make(chan domain.Size),
61+ }
62+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
63+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
64+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
65+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
66+ h.term.EXPECT().In().Return(h.in).Maybe()
67+ h.term.EXPECT().Out().Return(out).Maybe()
68+ h.term.EXPECT().Flush().Return(nil).Maybe()
69+ return h
70+}
71+
5272 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
5373
5474 func reconnectToastWelcome(token uint64) ports.Frame {
@@ -222,8 +242,8 @@ func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
222242 require.Contains(t, out.String(), reconnectToastMessage)
223243 require.Contains(t, out.String(), "\x1b[0m")
224244
225- require.NoError(t, clearReconnectToast(&out, size))
226245 bounds := reconnectToastBounds(size)
246+ require.NoError(t, clearReconnectToast(&out, bounds))
227247 require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width))
228248 }
229249
@@ -237,6 +257,61 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) {
237257 require.Contains(t, strings.Join(lines, "\n"), "…")
238258 }
239259
260+func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) {
261+ size := domain.Size{Cols: 80, Rows: 24}
262+ tests := []struct {
263+ name string
264+ stages []reconnectStage
265+ }{
266+ {
267+ name: "degraded to probing to ssh to offline",
268+ stages: []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying},
269+ },
270+ {
271+ name: "widest ssh to offline",
272+ stages: []reconnectStage{reconnectStageSSH, reconnectStageOfflineRetrying},
273+ },
274+ }
275+
276+ for _, tt := range tests {
277+ t.Run(tt.name, func(t *testing.T) {
278+ var out bytes.Buffer
279+ previousBounds, err := drawReconnectToastStage(&out, size, tt.stages[0])
280+ require.NoError(t, err)
281+
282+ for _, stage := range tt.stages[1:] {
283+ out.Reset()
284+ require.NoError(t, clearReconnectToast(&out, previousBounds))
285+ assertReconnectToastClearCoversBounds(t, out.String(), previousBounds)
286+
287+ out.Reset()
288+ previousBounds, err = drawReconnectToastStage(&out, size, stage)
289+ require.NoError(t, err)
290+ }
291+ })
292+ }
293+}
294+
295+func TestReconnectToastClearAfterWidestStageErasesFullBorder(t *testing.T) {
296+ size := domain.Size{Cols: 80, Rows: 24}
297+ var out bytes.Buffer
298+ bounds, err := drawReconnectToastStage(&out, size, reconnectStageSSH)
299+ require.NoError(t, err)
300+ require.Greater(t, bounds.Width, reconnectToastBounds(size).Width)
301+
302+ out.Reset()
303+ require.NoError(t, clearReconnectToast(&out, bounds))
304+ assertReconnectToastClearCoversBounds(t, out.String(), bounds)
305+}
306+
307+func assertReconnectToastClearCoversBounds(t *testing.T, output string, bounds domain.Rect) {
308+ t.Helper()
309+ blank := strings.Repeat(" ", bounds.Width)
310+ for row := range bounds.Height {
311+ require.Contains(t, output, fmt.Sprintf("\x1b[%d;%dH\x1b[0m%s", bounds.Y+row+1, bounds.X+1, blank))
312+ }
313+}
314+
240315 func displayWidth(s string) int {
241316 width := 0
242317 for _, r := range s {
@@ -270,6 +345,50 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
270345 require.True(t, <-done)
271346 }
272347
348+type reconnectToastFailOnceWriter struct {
349+ bytes.Buffer
350+ needle string
351+ failed bool
352+}
353+
354+func (w *reconnectToastFailOnceWriter) Write(p []byte) (int, error) {
355+ n, _ := w.Buffer.Write(p)
356+ if !w.failed && strings.Contains(string(p), w.needle) {
357+ w.failed = true
358+ return n, errors.New("reconnect toast write failed")
359+ }
360+ return n, nil
361+}
362+
363+func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
364+ oldSleep := reconnectSleep
365+ oldSleepWithResize := reconnectSleepWithResize
366+ ctx, cancel := context.WithCancel(context.Background())
367+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false }
368+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
369+ cancel()
370+ return false
371+ }
372+ defer func() {
373+ reconnectSleep = oldSleep
374+ reconnectSleepWithResize = oldSleepWithResize
375+ }()
376+
377+ out := &reconnectToastFailOnceWriter{needle: "reconnecting through SSH"}
378+ term := newReconnectToastTerminalHarnessWithOutput(t, out)
379+ defer term.closeInput()
380+ tr := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
381+ {frame: reconnectToastWelcome(44)},
382+ {err: io.EOF},
383+ }}
384+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
385+
386+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
387+ require.ErrorIs(t, err, context.Canceled)
388+ require.True(t, out.failed)
389+ assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
390+}
391+
273392 func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
274393 linkDead := errors.New("remote link dead")
275394 wrappedLinkDead := errors.Join(
@@ -49,6 +49,26 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
49 return h49 return h
50 }50 }
51 51
52+func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *reconnectToastTerminalHarness {
53+ t.Helper()
54+ in, inWriter := io.Pipe()
55+ h := &reconnectToastTerminalHarness{
56+ term: portsmocks.NewMockTerminal(t),
57+ in: in,
58+ inWriter: inWriter,
59+ size: domain.Size{Cols: 80, Rows: 24},
60+ resizeCh: make(chan domain.Size),
61+ }
62+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
63+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
64+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
65+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
66+ h.term.EXPECT().In().Return(h.in).Maybe()
67+ h.term.EXPECT().Out().Return(out).Maybe()
68+ h.term.EXPECT().Flush().Return(nil).Maybe()
69+ return h
70+}
71+
52 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }72 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
53 73
54 func reconnectToastWelcome(token uint64) ports.Frame {74 func reconnectToastWelcome(token uint64) ports.Frame {
@@ -222,8 +242,8 @@ func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
222 require.Contains(t, out.String(), reconnectToastMessage)242 require.Contains(t, out.String(), reconnectToastMessage)
223 require.Contains(t, out.String(), "\x1b[0m")243 require.Contains(t, out.String(), "\x1b[0m")
224 244
225- require.NoError(t, clearReconnectToast(&out, size))
226 bounds := reconnectToastBounds(size)245 bounds := reconnectToastBounds(size)
246+ require.NoError(t, clearReconnectToast(&out, bounds))
227 require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width))247 require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width))
228 }248 }
229 249
@@ -237,6 +257,61 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) {
237 require.Contains(t, strings.Join(lines, "\n"), "…")257 require.Contains(t, strings.Join(lines, "\n"), "…")
238 }258 }
239 259
260+func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) {
261+ size := domain.Size{Cols: 80, Rows: 24}
262+ tests := []struct {
263+ name string
264+ stages []reconnectStage
265+ }{
266+ {
267+ name: "degraded to probing to ssh to offline",
268+ stages: []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying},
269+ },
270+ {
271+ name: "widest ssh to offline",
272+ stages: []reconnectStage{reconnectStageSSH, reconnectStageOfflineRetrying},
273+ },
274+ }
275+
276+ for _, tt := range tests {
277+ t.Run(tt.name, func(t *testing.T) {
278+ var out bytes.Buffer
279+ previousBounds, err := drawReconnectToastStage(&out, size, tt.stages[0])
280+ require.NoError(t, err)
281+
282+ for _, stage := range tt.stages[1:] {
283+ out.Reset()
284+ require.NoError(t, clearReconnectToast(&out, previousBounds))
285+ assertReconnectToastClearCoversBounds(t, out.String(), previousBounds)
286+
287+ out.Reset()
288+ previousBounds, err = drawReconnectToastStage(&out, size, stage)
289+ require.NoError(t, err)
290+ }
291+ })
292+ }
293+}
294+
295+func TestReconnectToastClearAfterWidestStageErasesFullBorder(t *testing.T) {
296+ size := domain.Size{Cols: 80, Rows: 24}
297+ var out bytes.Buffer
298+ bounds, err := drawReconnectToastStage(&out, size, reconnectStageSSH)
299+ require.NoError(t, err)
300+ require.Greater(t, bounds.Width, reconnectToastBounds(size).Width)
301+
302+ out.Reset()
303+ require.NoError(t, clearReconnectToast(&out, bounds))
304+ assertReconnectToastClearCoversBounds(t, out.String(), bounds)
305+}
306+
307+func assertReconnectToastClearCoversBounds(t *testing.T, output string, bounds domain.Rect) {
308+ t.Helper()
309+ blank := strings.Repeat(" ", bounds.Width)
310+ for row := range bounds.Height {
311+ require.Contains(t, output, fmt.Sprintf("\x1b[%d;%dH\x1b[0m%s", bounds.Y+row+1, bounds.X+1, blank))
312+ }
313+}
314+
240 func displayWidth(s string) int {315 func displayWidth(s string) int {
241 width := 0316 width := 0
242 for _, r := range s {317 for _, r := range s {
@@ -270,6 +345,50 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
270 require.True(t, <-done)345 require.True(t, <-done)
271 }346 }
272 347
348+type reconnectToastFailOnceWriter struct {
349+ bytes.Buffer
350+ needle string
351+ failed bool
352+}
353+
354+func (w *reconnectToastFailOnceWriter) Write(p []byte) (int, error) {
355+ n, _ := w.Buffer.Write(p)
356+ if !w.failed && strings.Contains(string(p), w.needle) {
357+ w.failed = true
358+ return n, errors.New("reconnect toast write failed")
359+ }
360+ return n, nil
361+}
362+
363+func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
364+ oldSleep := reconnectSleep
365+ oldSleepWithResize := reconnectSleepWithResize
366+ ctx, cancel := context.WithCancel(context.Background())
367+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false }
368+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
369+ cancel()
370+ return false
371+ }
372+ defer func() {
373+ reconnectSleep = oldSleep
374+ reconnectSleepWithResize = oldSleepWithResize
375+ }()
376+
377+ out := &reconnectToastFailOnceWriter{needle: "reconnecting through SSH"}
378+ term := newReconnectToastTerminalHarnessWithOutput(t, out)
379+ defer term.closeInput()
380+ tr := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
381+ {frame: reconnectToastWelcome(44)},
382+ {err: io.EOF},
383+ }}
384+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
385+
386+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
387+ require.ErrorIs(t, err, context.Canceled)
388+ require.True(t, out.failed)
389+ assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
390+}
391+
273 func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {392 func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
274 linkDead := errors.New("remote link dead")393 linkDead := errors.New("remote link dead")
275 wrappedLinkDead := errors.Join(394 wrappedLinkDead := errors.Join(

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