detached d67cf9f

fix(client): show degraded-link and clipboard-paste toasts

commit
d67cf9f935affda8f23d283b9b529ab386c20963
author
brice · · 2026-07-21 07:29 UTC
parent
1f71d39
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +288 -5
M internal/usecase/client/client.go +57 -2
@@ -650,6 +650,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
650650 defer revokeInputClaim()
651651
652652 sendCh := make(chan ports.Frame, sendQueueDepth)
653+ // Client-local clipboard failures are published from stdinPump and rendered
654+ // only here, the attach loop's sole terminal writer.
655+ clientToasts := make(chan string, 1)
653656 ackQueue := newCumulativeAckQueue()
654657 sendErrCh := make(chan error, 1)
655658 senderStarted := false
@@ -762,7 +765,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
762765 stdinDone := make(chan struct{})
763766 go func() {
764767 defer close(stdinDone)
765- (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
768+ (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, clientToasts: clientToasts}).run()
766769 }()
767770 // Scanner cancellation can leave an undecided marker suffix (including a
768771 // standalone Escape) that it must hand back to the lifecycle-owned reader.
@@ -780,6 +783,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
780783 recvCh := make(chan recvResult, 1)
781784 go runRecv(loopCtx, transport, recvCh, log)
782785
786+ var transientToast clientToastUI
787+ awaitingToastResync := false
783788 for {
784789 select {
785790 case <-loopCtx.Done():
@@ -791,6 +796,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
791796 default:
792797 return welcomedResult(nil)
793798 }
799+ case message := <-clientToasts:
800+ size, serr := term.Size()
801+ if serr != nil {
802+ log.Warn("reading terminal size for client toast", "err", serr)
803+ continue
804+ }
805+ if !transientToast.draw(term, size, message) || awaitingToastResync {
806+ continue
807+ }
808+ // MsgResize is the existing reset-producing primitive. Sending the
809+ // current size preserves wire compatibility and causes the daemon to
810+ // publish an authoritative full frame; until then incremental frames
811+ // are acknowledged but not written over this local overlay.
812+ select {
813+ case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}:
814+ awaitingToastResync = true
815+ case <-loopCtx.Done():
816+ return welcomedResult(nil)
817+ }
794818 case ev, ok := <-linkEvents:
795819 if !ok {
796820 linkEvents = nil
@@ -802,7 +826,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
802826 }
803827 if ev.State == ports.LinkStateDegraded {
804828 log.Warn("UDP link degraded")
805- reconnect.clear()
829+ reconnect.drawStage(reconnectStageDegraded)
806830 continue
807831 }
808832 reconnect.drawStage(stageForLinkState(ev.State))
@@ -841,12 +865,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
841865 if derr != nil {
842866 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
843867 }
868+ // A local toast obscures terminal cells unknown to an incremental
869+ // daemon diff. A same-size MsgResize above produces this reset
870+ // frame (BaseStateNum=0); acknowledge any intervening state without
871+ // writing it, then apply only the authoritative full frame.
872+ if awaitingToastResync && !isClientToastResyncFrame(o) {
873+ if o.NewStateNum != 0 {
874+ ackQueue.offer(o.NewStateNum)
875+ }
876+ continue
877+ }
844878 if _, werr := term.Out().Write(o.Data); werr != nil {
845879 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
846880 }
847881 if ferr := term.Flush(); ferr != nil {
848882 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
849883 }
884+ if awaitingToastResync {
885+ transientToast.reconcile()
886+ awaitingToastResync = false
887+ }
850888 // The terminal boundary is after a successful flush and before ACK.
851889 if observer != nil {
852890 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
@@ -882,6 +920,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
882920
883921 // recvResult carries one framed message (or a read error) from the receive
884922 // pump to the main loop.
923+// isClientToastResyncFrame identifies the authoritative reset emitted by the
924+// daemon's existing resize path. State-less side effects also have base zero,
925+// so they must not dismiss an overlay.
926+func isClientToastResyncFrame(o ports.Output) bool {
927+ return o.BaseStateNum == 0 && o.NewStateNum != 0
928+}
929+
885930 type recvResult struct {
886931 frame ports.Frame
887932 err error
@@ -1304,6 +1349,7 @@ type stdinPump struct {
13041349 logger *slog.Logger
13051350 paletteEvents chan<- paletteGenerationEvent
13061351 activeGeneration *atomic.Uint64
1352+ clientToasts chan<- string
13071353 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
13081354 afterInputTake func() // test synchronization hook
13091355 afterInputDelivered func() // test synchronization hook
@@ -1366,6 +1412,15 @@ func (p *stdinPump) run() {
13661412 coalescer: coalescer,
13671413 reader: p.clipboard,
13681414 log: p.logger,
1415+ showToast: func(message string) {
1416+ if p.clientToasts == nil {
1417+ return
1418+ }
1419+ select {
1420+ case p.clientToasts <- message:
1421+ case <-p.ctx.Done():
1422+ }
1423+ },
13691424 sendImage: func(mime string, data []byte) {
13701425 inputSeq++
13711426 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
@@ -650,6 +650,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
650 defer revokeInputClaim()650 defer revokeInputClaim()
651 651
652 sendCh := make(chan ports.Frame, sendQueueDepth)652 sendCh := make(chan ports.Frame, sendQueueDepth)
653+ // Client-local clipboard failures are published from stdinPump and rendered
654+ // only here, the attach loop's sole terminal writer.
655+ clientToasts := make(chan string, 1)
653 ackQueue := newCumulativeAckQueue()656 ackQueue := newCumulativeAckQueue()
654 sendErrCh := make(chan error, 1)657 sendErrCh := make(chan error, 1)
655 senderStarted := false658 senderStarted := false
@@ -762,7 +765,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
762 stdinDone := make(chan struct{})765 stdinDone := make(chan struct{})
763 go func() {766 go func() {
764 defer close(stdinDone)767 defer close(stdinDone)
765- (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()768+ (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, clientToasts: clientToasts}).run()
766 }()769 }()
767 // Scanner cancellation can leave an undecided marker suffix (including a770 // Scanner cancellation can leave an undecided marker suffix (including a
768 // standalone Escape) that it must hand back to the lifecycle-owned reader.771 // standalone Escape) that it must hand back to the lifecycle-owned reader.
@@ -780,6 +783,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
780 recvCh := make(chan recvResult, 1)783 recvCh := make(chan recvResult, 1)
781 go runRecv(loopCtx, transport, recvCh, log)784 go runRecv(loopCtx, transport, recvCh, log)
782 785
786+ var transientToast clientToastUI
787+ awaitingToastResync := false
783 for {788 for {
784 select {789 select {
785 case <-loopCtx.Done():790 case <-loopCtx.Done():
@@ -791,6 +796,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
791 default:796 default:
792 return welcomedResult(nil)797 return welcomedResult(nil)
793 }798 }
799+ case message := <-clientToasts:
800+ size, serr := term.Size()
801+ if serr != nil {
802+ log.Warn("reading terminal size for client toast", "err", serr)
803+ continue
804+ }
805+ if !transientToast.draw(term, size, message) || awaitingToastResync {
806+ continue
807+ }
808+ // MsgResize is the existing reset-producing primitive. Sending the
809+ // current size preserves wire compatibility and causes the daemon to
810+ // publish an authoritative full frame; until then incremental frames
811+ // are acknowledged but not written over this local overlay.
812+ select {
813+ case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}:
814+ awaitingToastResync = true
815+ case <-loopCtx.Done():
816+ return welcomedResult(nil)
817+ }
794 case ev, ok := <-linkEvents:818 case ev, ok := <-linkEvents:
795 if !ok {819 if !ok {
796 linkEvents = nil820 linkEvents = nil
@@ -802,7 +826,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
802 }826 }
803 if ev.State == ports.LinkStateDegraded {827 if ev.State == ports.LinkStateDegraded {
804 log.Warn("UDP link degraded")828 log.Warn("UDP link degraded")
805- reconnect.clear()829+ reconnect.drawStage(reconnectStageDegraded)
806 continue830 continue
807 }831 }
808 reconnect.drawStage(stageForLinkState(ev.State))832 reconnect.drawStage(stageForLinkState(ev.State))
@@ -841,12 +865,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
841 if derr != nil {865 if derr != nil {
842 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))866 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
843 }867 }
868+ // A local toast obscures terminal cells unknown to an incremental
869+ // daemon diff. A same-size MsgResize above produces this reset
870+ // frame (BaseStateNum=0); acknowledge any intervening state without
871+ // writing it, then apply only the authoritative full frame.
872+ if awaitingToastResync && !isClientToastResyncFrame(o) {
873+ if o.NewStateNum != 0 {
874+ ackQueue.offer(o.NewStateNum)
875+ }
876+ continue
877+ }
844 if _, werr := term.Out().Write(o.Data); werr != nil {878 if _, werr := term.Out().Write(o.Data); werr != nil {
845 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))879 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
846 }880 }
847 if ferr := term.Flush(); ferr != nil {881 if ferr := term.Flush(); ferr != nil {
848 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))882 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
849 }883 }
884+ if awaitingToastResync {
885+ transientToast.reconcile()
886+ awaitingToastResync = false
887+ }
850 // The terminal boundary is after a successful flush and before ACK.888 // The terminal boundary is after a successful flush and before ACK.
851 if observer != nil {889 if observer != nil {
852 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))890 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
@@ -882,6 +920,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
882 920
883 // recvResult carries one framed message (or a read error) from the receive921 // recvResult carries one framed message (or a read error) from the receive
884 // pump to the main loop.922 // pump to the main loop.
923+// isClientToastResyncFrame identifies the authoritative reset emitted by the
924+// daemon's existing resize path. State-less side effects also have base zero,
925+// so they must not dismiss an overlay.
926+func isClientToastResyncFrame(o ports.Output) bool {
927+ return o.BaseStateNum == 0 && o.NewStateNum != 0
928+}
929+
885 type recvResult struct {930 type recvResult struct {
886 frame ports.Frame931 frame ports.Frame
887 err error932 err error
@@ -1304,6 +1349,7 @@ type stdinPump struct {
1304 logger *slog.Logger1349 logger *slog.Logger
1305 paletteEvents chan<- paletteGenerationEvent1350 paletteEvents chan<- paletteGenerationEvent
1306 activeGeneration *atomic.Uint641351 activeGeneration *atomic.Uint64
1352+ clientToasts chan<- string
1307 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook1353 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
1308 afterInputTake func() // test synchronization hook1354 afterInputTake func() // test synchronization hook
1309 afterInputDelivered func() // test synchronization hook1355 afterInputDelivered func() // test synchronization hook
@@ -1366,6 +1412,15 @@ func (p *stdinPump) run() {
1366 coalescer: coalescer,1412 coalescer: coalescer,
1367 reader: p.clipboard,1413 reader: p.clipboard,
1368 log: p.logger,1414 log: p.logger,
1415+ showToast: func(message string) {
1416+ if p.clientToasts == nil {
1417+ return
1418+ }
1419+ select {
1420+ case p.clientToasts <- message:
1421+ case <-p.ctx.Done():
1422+ }
1423+ },
1369 sendImage: func(mime string, data []byte) {1424 sendImage: func(mime string, data []byte) {
1370 inputSeq++1425 inputSeq++
1371 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})1426 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
M internal/usecase/client/clipboard_intercept.go +7 -0
@@ -29,6 +29,7 @@ type clipboardIntercept struct {
2929 reader ports.ClipboardReader
3030 log *slog.Logger
3131 sendImage func(mime string, data []byte)
32+ showToast func(string)
3233 next func([]byte)
3334 }
3435
@@ -99,12 +100,18 @@ func (c *clipboardIntercept) handleCtrlV() {
99100 if err != nil {
100101 if !errors.Is(err, ports.ErrNoClipboardImage) {
101102 c.log.Warn("clipboard image read failed", "err", err)
103+ if c.showToast != nil {
104+ c.showToast("image paste failed; sent Ctrl+V")
105+ }
102106 }
103107 c.next([]byte{ctrlV})
104108 return
105109 }
106110 if len(data) > maxClipboardImagePush {
107111 c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
112+ if c.showToast != nil {
113+ c.showToast("image too large to paste")
114+ }
108115 c.next([]byte{ctrlV})
109116 return
110117 }
@@ -29,6 +29,7 @@ type clipboardIntercept struct {
29 reader ports.ClipboardReader29 reader ports.ClipboardReader
30 log *slog.Logger30 log *slog.Logger
31 sendImage func(mime string, data []byte)31 sendImage func(mime string, data []byte)
32+ showToast func(string)
32 next func([]byte)33 next func([]byte)
33 }34 }
34 35
@@ -99,12 +100,18 @@ func (c *clipboardIntercept) handleCtrlV() {
99 if err != nil {100 if err != nil {
100 if !errors.Is(err, ports.ErrNoClipboardImage) {101 if !errors.Is(err, ports.ErrNoClipboardImage) {
101 c.log.Warn("clipboard image read failed", "err", err)102 c.log.Warn("clipboard image read failed", "err", err)
103+ if c.showToast != nil {
104+ c.showToast("image paste failed; sent Ctrl+V")
105+ }
102 }106 }
103 c.next([]byte{ctrlV})107 c.next([]byte{ctrlV})
104 return108 return
105 }109 }
106 if len(data) > maxClipboardImagePush {110 if len(data) > maxClipboardImagePush {
107 c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)111 c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
112+ if c.showToast != nil {
113+ c.showToast("image too large to paste")
114+ }
108 c.next([]byte{ctrlV})115 c.next([]byte{ctrlV})
109 return116 return
110 }117 }
M internal/usecase/client/clipboard_intercept_internal_test.go +25 -0
@@ -79,6 +79,31 @@ func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
7979 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
8080 }
8181
82+func TestClipboardInterceptFailureToastsAndForwardsCtrlV(t *testing.T) {
83+ tests := []struct {
84+ name string
85+ reader ports.ClipboardReader
86+ want string
87+ }{
88+ {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: "image paste failed; sent Ctrl+V"},
89+ {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: "image too large to paste"},
90+ }
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ ci, pc, _, col, images := newTestClipboardIntercept(tt.reader)
94+ defer pc.Close()
95+ var toasts []string
96+ ci.showToast = func(message string) { toasts = append(toasts, message) }
97+
98+ ci.Scan([]byte{ctrlV})
99+
100+ require.Equal(t, []string{tt.want}, toasts)
101+ require.Empty(t, *images)
102+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
103+ })
104+ }
105+}
106+
82107 func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
83108 huge := make([]byte, maxClipboardImagePush+1)
84109 reader := &ciFakeReader{mime: "image/png", data: huge}
@@ -79,6 +79,31 @@ func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
79 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())79 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
80 }80 }
81 81
82+func TestClipboardInterceptFailureToastsAndForwardsCtrlV(t *testing.T) {
83+ tests := []struct {
84+ name string
85+ reader ports.ClipboardReader
86+ want string
87+ }{
88+ {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: "image paste failed; sent Ctrl+V"},
89+ {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: "image too large to paste"},
90+ }
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ ci, pc, _, col, images := newTestClipboardIntercept(tt.reader)
94+ defer pc.Close()
95+ var toasts []string
96+ ci.showToast = func(message string) { toasts = append(toasts, message) }
97+
98+ ci.Scan([]byte{ctrlV})
99+
100+ require.Equal(t, []string{tt.want}, toasts)
101+ require.Empty(t, *images)
102+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
103+ })
104+ }
105+}
106+
82 func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {107 func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
83 huge := make([]byte, maxClipboardImagePush+1)108 huge := make([]byte, maxClipboardImagePush+1)
84 reader := &ciFakeReader{mime: "image/png", data: huge}109 reader := &ciFakeReader{mime: "image/png", data: huge}
M internal/usecase/client/clipboard_intercept_test.go +92 -1
@@ -3,6 +3,7 @@ package client_test
33 import (
44 "bytes"
55 "context"
6+ "errors"
67 "io"
78 "testing"
89 "time"
@@ -30,7 +31,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
3031 defer input.unblock()
3132
3233 tm := portsmocks.NewMockTerminal(t)
33- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
34+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
3435 tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
3536 tm.EXPECT().In().Return(input).Maybe()
3637 tm.EXPECT().Out().Return(&out).Maybe()
@@ -44,6 +45,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
4445 tr := portsmocks.NewMockTransport(t)
4546 tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
4647 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
48+ tr.EXPECT().Send(isType(ports.MsgResize)).Return(nil).Maybe()
4749 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
4850 in, err := ports.UnmarshalInput(f.Payload)
4951 require.NoError(t, err)
@@ -137,6 +139,95 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
137139 require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
138140 }
139141
142+type clipboardToastLifecycleTransport struct {
143+ recv chan ports.Frame
144+ sends chan ports.Frame
145+}
146+
147+func (t *clipboardToastLifecycleTransport) Send(f ports.Frame) error {
148+ t.sends <- f
149+ return nil
150+}
151+
152+func (t *clipboardToastLifecycleTransport) Recv() (ports.Frame, error) { return <-t.recv, nil }
153+func (*clipboardToastLifecycleTransport) Close() error { return nil }
154+
155+type clipboardToastLifecycleDialer struct{ transport ports.Transport }
156+
157+func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, error) {
158+ return d.transport, nil
159+}
160+
161+func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) {
162+ var out bytes.Buffer
163+ resizeEvents := make(chan domain.Size)
164+ input := newOneShotBlockingReader([]byte{0x16})
165+ defer input.unblock()
166+ term := portsmocks.NewMockTerminal(t)
167+ term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
168+ term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once()
169+ term.EXPECT().In().Return(input).Maybe()
170+ term.EXPECT().Out().Return(&out).Maybe()
171+ term.EXPECT().Flush().Return(nil).Maybe()
172+ term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(resizeEvents)).Maybe()
173+
174+ clipboard := portsmocks.NewMockClipboardReader(t)
175+ clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once()
176+ transport := &clipboardToastLifecycleTransport{recv: make(chan ports.Frame, 8), sends: make(chan ports.Frame, 16)}
177+ transport.recv <- frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
178+
179+ result := make(chan error, 1)
180+ go func() {
181+ result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true})
182+ }()
183+
184+ for {
185+ select {
186+ case sent := <-transport.sends:
187+ if sent.Type != ports.MsgResize {
188+ continue
189+ }
190+ goto resized
191+ case <-time.After(time.Second):
192+ t.Fatal("client did not request a daemon reset after drawing clipboard toast")
193+ }
194+ }
195+
196+resized:
197+ require.Contains(t, out.String(), "image paste failed; sent Ctrl+V")
198+ transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")}))
199+ for {
200+ select {
201+ case sent := <-transport.sends:
202+ if sent.Type != ports.MsgAck {
203+ continue
204+ }
205+ ack, err := ports.UnmarshalAck(sent.Payload)
206+ require.NoError(t, err)
207+ if ack.AckedStateNum == 2 {
208+ goto incrementalAcknowledged
209+ }
210+ case <-time.After(time.Second):
211+ t.Fatal("client did not ACK discarded incremental output")
212+ }
213+ }
214+
215+incrementalAcknowledged:
216+ require.NotContains(t, out.String(), "incremental", "incremental output must not overwrite a local toast before reset")
217+ transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("authoritative reset")}))
218+ transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
219+
220+ select {
221+ case err := <-result:
222+ require.NoError(t, err)
223+ case <-time.After(time.Second):
224+ t.Fatal("client did not finish after reset frame")
225+ }
226+ got := out.String()
227+ require.NotContains(t, got, "incremental", "incremental output must not overwrite a local toast before reset")
228+ require.Contains(t, got, "authoritative reset")
229+}
230+
140231 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
141232 huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
142233 clip := portsmocks.NewMockClipboardReader(t)
@@ -3,6 +3,7 @@ package client_test
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "errors"
6 "io"7 "io"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -30,7 +31,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
30 defer input.unblock()31 defer input.unblock()
31 32
32 tm := portsmocks.NewMockTerminal(t)33 tm := portsmocks.NewMockTerminal(t)
33- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()34+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
34 tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()35 tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
35 tm.EXPECT().In().Return(input).Maybe()36 tm.EXPECT().In().Return(input).Maybe()
36 tm.EXPECT().Out().Return(&out).Maybe()37 tm.EXPECT().Out().Return(&out).Maybe()
@@ -44,6 +45,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
44 tr := portsmocks.NewMockTransport(t)45 tr := portsmocks.NewMockTransport(t)
45 tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()46 tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
46 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()47 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
48+ tr.EXPECT().Send(isType(ports.MsgResize)).Return(nil).Maybe()
47 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {49 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
48 in, err := ports.UnmarshalInput(f.Payload)50 in, err := ports.UnmarshalInput(f.Payload)
49 require.NoError(t, err)51 require.NoError(t, err)
@@ -137,6 +139,95 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
137 require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")139 require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
138 }140 }
139 141
142+type clipboardToastLifecycleTransport struct {
143+ recv chan ports.Frame
144+ sends chan ports.Frame
145+}
146+
147+func (t *clipboardToastLifecycleTransport) Send(f ports.Frame) error {
148+ t.sends <- f
149+ return nil
150+}
151+
152+func (t *clipboardToastLifecycleTransport) Recv() (ports.Frame, error) { return <-t.recv, nil }
153+func (*clipboardToastLifecycleTransport) Close() error { return nil }
154+
155+type clipboardToastLifecycleDialer struct{ transport ports.Transport }
156+
157+func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, error) {
158+ return d.transport, nil
159+}
160+
161+func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) {
162+ var out bytes.Buffer
163+ resizeEvents := make(chan domain.Size)
164+ input := newOneShotBlockingReader([]byte{0x16})
165+ defer input.unblock()
166+ term := portsmocks.NewMockTerminal(t)
167+ term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
168+ term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once()
169+ term.EXPECT().In().Return(input).Maybe()
170+ term.EXPECT().Out().Return(&out).Maybe()
171+ term.EXPECT().Flush().Return(nil).Maybe()
172+ term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(resizeEvents)).Maybe()
173+
174+ clipboard := portsmocks.NewMockClipboardReader(t)
175+ clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once()
176+ transport := &clipboardToastLifecycleTransport{recv: make(chan ports.Frame, 8), sends: make(chan ports.Frame, 16)}
177+ transport.recv <- frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
178+
179+ result := make(chan error, 1)
180+ go func() {
181+ result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true})
182+ }()
183+
184+ for {
185+ select {
186+ case sent := <-transport.sends:
187+ if sent.Type != ports.MsgResize {
188+ continue
189+ }
190+ goto resized
191+ case <-time.After(time.Second):
192+ t.Fatal("client did not request a daemon reset after drawing clipboard toast")
193+ }
194+ }
195+
196+resized:
197+ require.Contains(t, out.String(), "image paste failed; sent Ctrl+V")
198+ transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")}))
199+ for {
200+ select {
201+ case sent := <-transport.sends:
202+ if sent.Type != ports.MsgAck {
203+ continue
204+ }
205+ ack, err := ports.UnmarshalAck(sent.Payload)
206+ require.NoError(t, err)
207+ if ack.AckedStateNum == 2 {
208+ goto incrementalAcknowledged
209+ }
210+ case <-time.After(time.Second):
211+ t.Fatal("client did not ACK discarded incremental output")
212+ }
213+ }
214+
215+incrementalAcknowledged:
216+ require.NotContains(t, out.String(), "incremental", "incremental output must not overwrite a local toast before reset")
217+ transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("authoritative reset")}))
218+ transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
219+
220+ select {
221+ case err := <-result:
222+ require.NoError(t, err)
223+ case <-time.After(time.Second):
224+ t.Fatal("client did not finish after reset frame")
225+ }
226+ got := out.String()
227+ require.NotContains(t, got, "incremental", "incremental output must not overwrite a local toast before reset")
228+ require.Contains(t, got, "authoritative reset")
229+}
230+
140 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {231 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
141 huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap232 huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
142 clip := portsmocks.NewMockClipboardReader(t)233 clip := portsmocks.NewMockClipboardReader(t)
M internal/usecase/client/reconnect_toast.go +41 -1
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/internal/usecase/ui"
1011 "github.com/bnema/vev/pkg/renderer"
1112 )
@@ -33,7 +34,13 @@ func drawReconnectToast(out io.Writer, size domain.Size) error {
3334 }
3435
3536 func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
36- message := reconnectStageMessage(stage)
37+ return drawClientToast(out, size, reconnectStageMessage(stage))
38+}
39+
40+// drawClientToast draws a client-local toast without changing terminal state.
41+// The attach main loop owns both this write and the later daemon-frame
42+// reconciliation; input pumps must only publish a request for it.
43+func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Rect, error) {
3744 bounds := reconnectToastBoundsFor(size, message)
3845 if bounds.Width <= 0 || bounds.Height <= 0 {
3946 return domain.Rect{}, nil
@@ -41,6 +48,39 @@ func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectSta
4148 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
4249 }
4350
51+// clientToastUI owns a transient client-local overlay. It is intentionally
52+// reconciled by a daemon reset frame rather than erased locally: an
53+// incremental daemon diff cannot restore cells hidden by the overlay.
54+type clientToastUI struct {
55+ showing bool
56+ rect domain.Rect
57+}
58+
59+func (u *clientToastUI) draw(term ports.Terminal, size domain.Size, message string) bool {
60+ changed := u.showing
61+ if u.showing {
62+ _ = clearReconnectToast(term.Out(), u.rect)
63+ u.showing = false
64+ u.rect = domain.Rect{}
65+ }
66+ rect, _ := drawClientToast(term.Out(), size, message)
67+ if rect.Width <= 0 || rect.Height <= 0 {
68+ _ = term.Flush()
69+ return changed
70+ }
71+ u.rect = rect
72+ u.showing = true
73+ _ = term.Flush()
74+ return true
75+}
76+
77+// reconcile marks the overlay gone after the daemon has painted a full reset
78+// frame over it. It deliberately emits no terminal bytes.
79+func (u *clientToastUI) reconcile() {
80+ u.showing = false
81+ u.rect = domain.Rect{}
82+}
83+
4484 func clearReconnectToast(out io.Writer, bounds domain.Rect) error {
4585 if bounds.Width <= 0 || bounds.Height <= 0 {
4686 return nil
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/ui"10 "github.com/bnema/vev/internal/usecase/ui"
10 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
11 )12 )
@@ -33,7 +34,13 @@ func drawReconnectToast(out io.Writer, size domain.Size) error {
33 }34 }
34 35
35 func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {36 func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
36- message := reconnectStageMessage(stage)37+ return drawClientToast(out, size, reconnectStageMessage(stage))
38+}
39+
40+// drawClientToast draws a client-local toast without changing terminal state.
41+// The attach main loop owns both this write and the later daemon-frame
42+// reconciliation; input pumps must only publish a request for it.
43+func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Rect, error) {
37 bounds := reconnectToastBoundsFor(size, message)44 bounds := reconnectToastBoundsFor(size, message)
38 if bounds.Width <= 0 || bounds.Height <= 0 {45 if bounds.Width <= 0 || bounds.Height <= 0 {
39 return domain.Rect{}, nil46 return domain.Rect{}, nil
@@ -41,6 +48,39 @@ func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectSta
41 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))48 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
42 }49 }
43 50
51+// clientToastUI owns a transient client-local overlay. It is intentionally
52+// reconciled by a daemon reset frame rather than erased locally: an
53+// incremental daemon diff cannot restore cells hidden by the overlay.
54+type clientToastUI struct {
55+ showing bool
56+ rect domain.Rect
57+}
58+
59+func (u *clientToastUI) draw(term ports.Terminal, size domain.Size, message string) bool {
60+ changed := u.showing
61+ if u.showing {
62+ _ = clearReconnectToast(term.Out(), u.rect)
63+ u.showing = false
64+ u.rect = domain.Rect{}
65+ }
66+ rect, _ := drawClientToast(term.Out(), size, message)
67+ if rect.Width <= 0 || rect.Height <= 0 {
68+ _ = term.Flush()
69+ return changed
70+ }
71+ u.rect = rect
72+ u.showing = true
73+ _ = term.Flush()
74+ return true
75+}
76+
77+// reconcile marks the overlay gone after the daemon has painted a full reset
78+// frame over it. It deliberately emits no terminal bytes.
79+func (u *clientToastUI) reconcile() {
80+ u.showing = false
81+ u.rect = domain.Rect{}
82+}
83+
44 func clearReconnectToast(out io.Writer, bounds domain.Rect) error {84 func clearReconnectToast(out io.Writer, bounds domain.Rect) error {
45 if bounds.Width <= 0 || bounds.Height <= 0 {85 if bounds.Width <= 0 || bounds.Height <= 0 {
46 return nil86 return nil
M internal/usecase/client/reconnect_toast_test.go +66 -1
@@ -282,7 +282,7 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c
282282 }
283283 }
284284
285-func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
285+func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) {
286286 out := newReconnectToastOutputRecorder()
287287 term := newReconnectToastTerminalHarnessWithOutput(t, out)
288288 defer term.closeInput()
@@ -301,11 +301,19 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
301301 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
302302 cleared := <-out.completed
303303 assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
304+ degraded := <-out.completed
305+ require.Contains(t, degraded, reconnectStageMessage(reconnectStageDegraded))
304306
305307 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
308+ cleared = <-out.completed
309+ assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageDegraded)))
306310 probed = <-out.completed
307311 require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
308312
313+ tr.events <- ports.LinkEvent{State: ports.LinkStateConnected}
314+ cleared = <-out.completed
315+ assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
316+
309317 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
310318 result := <-resultCh
311319 require.NoError(t, result.err)
@@ -335,6 +343,41 @@ func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {
335343 require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)
336344 }
337345
346+func TestClientToastReconciliationWaitsForDaemonResetFrame(t *testing.T) {
347+ tests := []struct {
348+ name string
349+ out ports.Output
350+ want bool
351+ }{
352+ {name: "incremental frame", out: ports.Output{BaseStateNum: 7, NewStateNum: 8}, want: false},
353+ {name: "state-less side effect", out: ports.Output{}, want: false},
354+ {name: "reset frame", out: ports.Output{BaseStateNum: 0, NewStateNum: 9}, want: true},
355+ }
356+ for _, tt := range tests {
357+ t.Run(tt.name, func(t *testing.T) {
358+ require.Equal(t, tt.want, isClientToastResyncFrame(tt.out))
359+ })
360+ }
361+}
362+
363+func TestClientToastDrawsWithoutInputPumpTerminalWrites(t *testing.T) {
364+ out := newReconnectToastOutputRecorder()
365+ term := newReconnectToastTerminalHarnessWithOutput(t, out)
366+ defer term.closeInput()
367+ toast := clientToastUI{}
368+
369+ require.True(t, toast.draw(term.term, term.size, "image paste failed; sent Ctrl+V"))
370+ drawn := <-out.completed
371+ require.Contains(t, drawn, "image paste failed; sent Ctrl+V")
372+ toast.reconcile()
373+
374+ select {
375+ case got := <-out.completed:
376+ t.Fatalf("reconcile wrote terminal bytes: %q", got)
377+ default:
378+ }
379+}
380+
338381 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
339382 var out bytes.Buffer
340383 size := domain.Size{Cols: 80, Rows: 24}
@@ -359,6 +402,28 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) {
359402 require.Contains(t, strings.Join(lines, "\n"), "…")
360403 }
361404
405+func TestReconnectToastStageBytesUnchangedAfterClientHelperExtraction(t *testing.T) {
406+ for _, stage := range []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying} {
407+ t.Run(reconnectStageMessage(stage), func(t *testing.T) {
408+ var got, want bytes.Buffer
409+ gotBounds, gotErr := drawReconnectToastStage(&got, domain.Size{Cols: 80, Rows: 24}, stage)
410+ wantBounds, wantErr := legacyReconnectToastStage(&want, domain.Size{Cols: 80, Rows: 24}, stage)
411+ require.Equal(t, wantErr, gotErr)
412+ require.Equal(t, wantBounds, gotBounds)
413+ require.Equal(t, want.String(), got.String())
414+ })
415+ }
416+}
417+
418+func legacyReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
419+ message := reconnectStageMessage(stage)
420+ bounds := reconnectToastBoundsFor(size, message)
421+ if bounds.Width <= 0 || bounds.Height <= 0 {
422+ return domain.Rect{}, nil
423+ }
424+ return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
425+}
426+
362427 func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) {
363428 size := domain.Size{Cols: 80, Rows: 24}
364429 tests := []struct {
@@ -282,7 +282,7 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c
282 }282 }
283 }283 }
284 284
285-func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {285+func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) {
286 out := newReconnectToastOutputRecorder()286 out := newReconnectToastOutputRecorder()
287 term := newReconnectToastTerminalHarnessWithOutput(t, out)287 term := newReconnectToastTerminalHarnessWithOutput(t, out)
288 defer term.closeInput()288 defer term.closeInput()
@@ -301,11 +301,19 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
301 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}301 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
302 cleared := <-out.completed302 cleared := <-out.completed
303 assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))303 assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
304+ degraded := <-out.completed
305+ require.Contains(t, degraded, reconnectStageMessage(reconnectStageDegraded))
304 306
305 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}307 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
308+ cleared = <-out.completed
309+ assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageDegraded)))
306 probed = <-out.completed310 probed = <-out.completed
307 require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))311 require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
308 312
313+ tr.events <- ports.LinkEvent{State: ports.LinkStateConnected}
314+ cleared = <-out.completed
315+ assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
316+
309 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}317 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
310 result := <-resultCh318 result := <-resultCh
311 require.NoError(t, result.err)319 require.NoError(t, result.err)
@@ -335,6 +343,41 @@ func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {
335 require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)343 require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)
336 }344 }
337 345
346+func TestClientToastReconciliationWaitsForDaemonResetFrame(t *testing.T) {
347+ tests := []struct {
348+ name string
349+ out ports.Output
350+ want bool
351+ }{
352+ {name: "incremental frame", out: ports.Output{BaseStateNum: 7, NewStateNum: 8}, want: false},
353+ {name: "state-less side effect", out: ports.Output{}, want: false},
354+ {name: "reset frame", out: ports.Output{BaseStateNum: 0, NewStateNum: 9}, want: true},
355+ }
356+ for _, tt := range tests {
357+ t.Run(tt.name, func(t *testing.T) {
358+ require.Equal(t, tt.want, isClientToastResyncFrame(tt.out))
359+ })
360+ }
361+}
362+
363+func TestClientToastDrawsWithoutInputPumpTerminalWrites(t *testing.T) {
364+ out := newReconnectToastOutputRecorder()
365+ term := newReconnectToastTerminalHarnessWithOutput(t, out)
366+ defer term.closeInput()
367+ toast := clientToastUI{}
368+
369+ require.True(t, toast.draw(term.term, term.size, "image paste failed; sent Ctrl+V"))
370+ drawn := <-out.completed
371+ require.Contains(t, drawn, "image paste failed; sent Ctrl+V")
372+ toast.reconcile()
373+
374+ select {
375+ case got := <-out.completed:
376+ t.Fatalf("reconcile wrote terminal bytes: %q", got)
377+ default:
378+ }
379+}
380+
338 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {381 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
339 var out bytes.Buffer382 var out bytes.Buffer
340 size := domain.Size{Cols: 80, Rows: 24}383 size := domain.Size{Cols: 80, Rows: 24}
@@ -359,6 +402,28 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) {
359 require.Contains(t, strings.Join(lines, "\n"), "…")402 require.Contains(t, strings.Join(lines, "\n"), "…")
360 }403 }
361 404
405+func TestReconnectToastStageBytesUnchangedAfterClientHelperExtraction(t *testing.T) {
406+ for _, stage := range []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying} {
407+ t.Run(reconnectStageMessage(stage), func(t *testing.T) {
408+ var got, want bytes.Buffer
409+ gotBounds, gotErr := drawReconnectToastStage(&got, domain.Size{Cols: 80, Rows: 24}, stage)
410+ wantBounds, wantErr := legacyReconnectToastStage(&want, domain.Size{Cols: 80, Rows: 24}, stage)
411+ require.Equal(t, wantErr, gotErr)
412+ require.Equal(t, wantBounds, gotBounds)
413+ require.Equal(t, want.String(), got.String())
414+ })
415+ }
416+}
417+
418+func legacyReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
419+ message := reconnectStageMessage(stage)
420+ bounds := reconnectToastBoundsFor(size, message)
421+ if bounds.Width <= 0 || bounds.Height <= 0 {
422+ return domain.Rect{}, nil
423+ }
424+ return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
425+}
426+
362 func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) {427 func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) {
363 size := domain.Size{Cols: 80, Rows: 24}428 size := domain.Size{Cols: 80, Rows: 24}
364 tests := []struct {429 tests := []struct {

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