fix(client): show degraded-link and clipboard-paste toasts
- commit
- d67cf9f935affda8f23d283b9b529ab386c20963
- author
- brice · · 2026-07-21 07:29 UTC
- parent
- 1f71d39
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 650 | 650 | defer revokeInputClaim() | |
| 651 | 651 | ||
| 652 | 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 | 656 | ackQueue := newCumulativeAckQueue() | |
| 654 | 657 | sendErrCh := make(chan error, 1) | |
| 655 | 658 | senderStarted := false | |
| @@ -762,7 +765,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 762 | 765 | stdinDone := make(chan struct{}) | |
| 763 | 766 | go func() { | |
| 764 | 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 | 770 | // Scanner cancellation can leave an undecided marker suffix (including a | |
| 768 | 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 | 783 | recvCh := make(chan recvResult, 1) | |
| 781 | 784 | go runRecv(loopCtx, transport, recvCh, log) | |
| 782 | 785 | ||
| 786 | + | var transientToast clientToastUI | |
| 787 | + | awaitingToastResync := false | |
| 783 | 788 | for { | |
| 784 | 789 | select { | |
| 785 | 790 | case <-loopCtx.Done(): | |
| @@ -791,6 +796,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 791 | 796 | default: | |
| 792 | 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 | 818 | case ev, ok := <-linkEvents: | |
| 795 | 819 | if !ok { | |
| 796 | 820 | linkEvents = nil | |
| @@ -802,7 +826,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 802 | 826 | } | |
| 803 | 827 | if ev.State == ports.LinkStateDegraded { | |
| 804 | 828 | log.Warn("UDP link degraded") | |
| 805 | - | reconnect.clear() | |
| 829 | + | reconnect.drawStage(reconnectStageDegraded) | |
| 806 | 830 | continue | |
| 807 | 831 | } | |
| 808 | 832 | reconnect.drawStage(stageForLinkState(ev.State)) | |
| @@ -841,12 +865,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 841 | 865 | if derr != nil { | |
| 842 | 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 | 878 | if _, werr := term.Out().Write(o.Data); werr != nil { | |
| 845 | 879 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | |
| 846 | 880 | } | |
| 847 | 881 | if ferr := term.Flush(); ferr != nil { | |
| 848 | 882 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | |
| 849 | 883 | } | |
| 884 | + | if awaitingToastResync { | |
| 885 | + | transientToast.reconcile() | |
| 886 | + | awaitingToastResync = false | |
| 887 | + | } | |
| 850 | 888 | // The terminal boundary is after a successful flush and before ACK. | |
| 851 | 889 | if observer != nil { | |
| 852 | 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 | 921 | // recvResult carries one framed message (or a read error) from the receive | |
| 884 | 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 | 930 | type recvResult struct { | |
| 886 | 931 | frame ports.Frame | |
| 887 | 932 | err error | |
| @@ -1304,6 +1349,7 @@ type stdinPump struct { | |||
| 1304 | 1349 | logger *slog.Logger | |
| 1305 | 1350 | paletteEvents chan<- paletteGenerationEvent | |
| 1306 | 1351 | activeGeneration *atomic.Uint64 | |
| 1352 | + | clientToasts chan<- string | |
| 1307 | 1353 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | |
| 1308 | 1354 | afterInputTake func() // test synchronization hook | |
| 1309 | 1355 | afterInputDelivered func() // test synchronization hook | |
| @@ -1366,6 +1412,15 @@ func (p *stdinPump) run() { | |||
| 1366 | 1412 | coalescer: coalescer, | |
| 1367 | 1413 | reader: p.clipboard, | |
| 1368 | 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 | 1424 | sendImage: func(mime string, data []byte) { | |
| 1370 | 1425 | inputSeq++ | |
| 1371 | 1426 | 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 := false | 658 | 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 a | 770 | // 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 = nil | 820 | 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 | continue | 830 | 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 receive | 921 | // 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.Frame | 931 | frame ports.Frame | ||
| 887 | err error | 932 | err error | ||
| @@ -1304,6 +1349,7 @@ type stdinPump struct { | |||||
| 1304 | logger *slog.Logger | 1349 | logger *slog.Logger | ||
| 1305 | paletteEvents chan<- paletteGenerationEvent | 1350 | paletteEvents chan<- paletteGenerationEvent | ||
| 1306 | activeGeneration *atomic.Uint64 | 1351 | activeGeneration *atomic.Uint64 | ||
| 1352 | + | clientToasts chan<- string | |||
| 1307 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | 1353 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | ||
| 1308 | afterInputTake func() // test synchronization hook | 1354 | afterInputTake func() // test synchronization hook | ||
| 1309 | afterInputDelivered func() // test synchronization hook | 1355 | 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 { | |||
| 29 | 29 | reader ports.ClipboardReader | |
| 30 | 30 | log *slog.Logger | |
| 31 | 31 | sendImage func(mime string, data []byte) | |
| 32 | + | showToast func(string) | |
| 32 | 33 | next func([]byte) | |
| 33 | 34 | } | |
| 34 | 35 | ||
| @@ -99,12 +100,18 @@ func (c *clipboardIntercept) handleCtrlV() { | |||
| 99 | 100 | if err != nil { | |
| 100 | 101 | if !errors.Is(err, ports.ErrNoClipboardImage) { | |
| 101 | 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 | 107 | c.next([]byte{ctrlV}) | |
| 104 | 108 | return | |
| 105 | 109 | } | |
| 106 | 110 | if len(data) > maxClipboardImagePush { | |
| 107 | 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 | 115 | c.next([]byte{ctrlV}) | |
| 109 | 116 | return | |
| 110 | 117 | } | |
| @@ -29,6 +29,7 @@ type clipboardIntercept struct { | |||||
| 29 | reader ports.ClipboardReader | 29 | reader ports.ClipboardReader | ||
| 30 | log *slog.Logger | 30 | 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 | return | 108 | 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 | return | 116 | return | ||
| 110 | } | 117 | } | ||
M internal/usecase/client/clipboard_intercept_internal_test.go +25 -0
| @@ -79,6 +79,31 @@ func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) { | |||
| 79 | 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 | 107 | func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) { | |
| 83 | 108 | huge := make([]byte, maxClipboardImagePush+1) | |
| 84 | 109 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| @@ -30,7 +31,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||
| 30 | 31 | defer input.unblock() | |
| 31 | 32 | ||
| 32 | 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 | 35 | tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once() | |
| 35 | 36 | tm.EXPECT().In().Return(input).Maybe() | |
| 36 | 37 | tm.EXPECT().Out().Return(&out).Maybe() | |
| @@ -44,6 +45,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||
| 44 | 45 | tr := portsmocks.NewMockTransport(t) | |
| 45 | 46 | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 46 | 47 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 48 | + | tr.EXPECT().Send(isType(ports.MsgResize)).Return(nil).Maybe() | |
| 47 | 49 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 48 | 50 | in, err := ports.UnmarshalInput(f.Payload) | |
| 49 | 51 | require.NoError(t, err) | |
| @@ -137,6 +139,95 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) { | |||
| 137 | 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 | 231 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | |
| 141 | 232 | huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap | |
| 142 | 233 | 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 cap | 232 | 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 ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/ui" | |
| 10 | 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 | 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 | 44 | bounds := reconnectToastBoundsFor(size, message) | |
| 38 | 45 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 39 | 46 | return domain.Rect{}, nil | |
| @@ -41,6 +48,39 @@ func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectSta | |||
| 41 | 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 | 84 | func clearReconnectToast(out io.Writer, bounds domain.Rect) error { | |
| 45 | 85 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 46 | 86 | 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{}, nil | 46 | 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 nil | 86 | 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 | |||
| 282 | 282 | } | |
| 283 | 283 | } | |
| 284 | 284 | ||
| 285 | - | func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |
| 285 | + | func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) { | |
| 286 | 286 | out := newReconnectToastOutputRecorder() | |
| 287 | 287 | term := newReconnectToastTerminalHarnessWithOutput(t, out) | |
| 288 | 288 | defer term.closeInput() | |
| @@ -301,11 +301,19 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |||
| 301 | 301 | tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded} | |
| 302 | 302 | cleared := <-out.completed | |
| 303 | 303 | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP))) | |
| 304 | + | degraded := <-out.completed | |
| 305 | + | require.Contains(t, degraded, reconnectStageMessage(reconnectStageDegraded)) | |
| 304 | 306 | ||
| 305 | 307 | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | |
| 308 | + | cleared = <-out.completed | |
| 309 | + | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageDegraded))) | |
| 306 | 310 | probed = <-out.completed | |
| 307 | 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 | 317 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | |
| 310 | 318 | result := <-resultCh | |
| 311 | 319 | require.NoError(t, result.err) | |
| @@ -335,6 +343,41 @@ func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { | |||
| 335 | 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 | 381 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |
| 339 | 382 | var out bytes.Buffer | |
| 340 | 383 | size := domain.Size{Cols: 80, Rows: 24} | |
| @@ -359,6 +402,28 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) { | |||
| 359 | 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 | 427 | func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) { | |
| 363 | 428 | size := domain.Size{Cols: 80, Rows: 24} | |
| 364 | 429 | 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.completed | 302 | 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.completed | 310 | 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 := <-resultCh | 318 | 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.Buffer | 382 | 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 { | ||