detached 29b1a92

feat(wire): route client notices and displacement reasons

commit
29b1a9254aa71268ed59834ef4f2681f66600b07
author
brice · · 2026-07-21 08:20 UTC
parent
ebf4652
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +296 -212
M internal/domain/notice.go +4 -0
@@ -32,7 +32,9 @@ const (
3232 NoticeInputDropped
3333 NoticeResizeFailed
3434 NoticeClipboard
35+ NoticeClipboardTooLarge
3536 NoticeAutoResume
37+ NoticeConnection
3638 )
3739
3840 var noticeSlugs = map[NoticeCode]string{
@@ -53,7 +55,9 @@ var noticeSlugs = map[NoticeCode]string{
5355 NoticeInputDropped: "input-dropped",
5456 NoticeResizeFailed: "resize-failed",
5557 NoticeClipboard: "clipboard",
58+ NoticeClipboardTooLarge: "clipboard-too-large",
5659 NoticeAutoResume: "auto-resume",
60+ NoticeConnection: "connection",
5761 }
5862
5963 func (c NoticeCode) String() string {
@@ -32,7 +32,9 @@ const (
32 NoticeInputDropped32 NoticeInputDropped
33 NoticeResizeFailed33 NoticeResizeFailed
34 NoticeClipboard34 NoticeClipboard
35+ NoticeClipboardTooLarge
35 NoticeAutoResume36 NoticeAutoResume
37+ NoticeConnection
36 )38 )
37 39
38 var noticeSlugs = map[NoticeCode]string{40 var noticeSlugs = map[NoticeCode]string{
@@ -53,7 +55,9 @@ var noticeSlugs = map[NoticeCode]string{
53 NoticeInputDropped: "input-dropped",55 NoticeInputDropped: "input-dropped",
54 NoticeResizeFailed: "resize-failed",56 NoticeResizeFailed: "resize-failed",
55 NoticeClipboard: "clipboard",57 NoticeClipboard: "clipboard",
58+ NoticeClipboardTooLarge: "clipboard-too-large",
56 NoticeAutoResume: "auto-resume",59 NoticeAutoResume: "auto-resume",
60+ NoticeConnection: "connection",
57 }61 }
58 62
59 func (c NoticeCode) String() string {63 func (c NoticeCode) String() string {
M internal/ports/frame.go +12 -11
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 16
4+const ProtocolVersion uint16 = 17
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -10,16 +10,17 @@ type MsgType uint8
1010 // through 15, server-originated messages from 16, leaving room for growth in
1111 // each band.
1212 const (
13- MsgHello MsgType = 1
14- MsgInput MsgType = 2
15- MsgResize MsgType = 3
16- MsgDetach MsgType = 4
17- MsgPing MsgType = 5
18- MsgList MsgType = 6
19- MsgKill MsgType = 7
20- MsgTheme MsgType = 8
21- MsgAck MsgType = 9
22- MsgImagePush MsgType = 10
13+ MsgHello MsgType = 1
14+ MsgInput MsgType = 2
15+ MsgResize MsgType = 3
16+ MsgDetach MsgType = 4
17+ MsgPing MsgType = 5
18+ MsgList MsgType = 6
19+ MsgKill MsgType = 7
20+ MsgTheme MsgType = 8
21+ MsgAck MsgType = 9
22+ MsgImagePush MsgType = 10
23+ MsgClientNotice MsgType = 11
2324
2425 MsgWelcome MsgType = 16
2526 MsgError MsgType = 17
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 164+const ProtocolVersion uint16 = 17
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
@@ -10,16 +10,17 @@ type MsgType uint8
10 // through 15, server-originated messages from 16, leaving room for growth in10 // through 15, server-originated messages from 16, leaving room for growth in
11 // each band.11 // each band.
12 const (12 const (
13- MsgHello MsgType = 113+ MsgHello MsgType = 1
14- MsgInput MsgType = 214+ MsgInput MsgType = 2
15- MsgResize MsgType = 315+ MsgResize MsgType = 3
16- MsgDetach MsgType = 416+ MsgDetach MsgType = 4
17- MsgPing MsgType = 517+ MsgPing MsgType = 5
18- MsgList MsgType = 618+ MsgList MsgType = 6
19- MsgKill MsgType = 719+ MsgKill MsgType = 7
20- MsgTheme MsgType = 820+ MsgTheme MsgType = 8
21- MsgAck MsgType = 921+ MsgAck MsgType = 9
22- MsgImagePush MsgType = 1022+ MsgImagePush MsgType = 10
23+ MsgClientNotice MsgType = 11
23 24
24 MsgWelcome MsgType = 1625 MsgWelcome MsgType = 16
25 MsgError MsgType = 1726 MsgError MsgType = 17
M internal/ports/frame_test.go +3 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 16 {
7- t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion)
6+ if ProtocolVersion != 17 {
7+ t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion)
88 }
99 }
1010
@@ -23,6 +23,7 @@ func TestMsgTypeUnique(t *testing.T) {
2323 {"MsgTheme", MsgTheme},
2424 {"MsgAck", MsgAck},
2525 {"MsgImagePush", MsgImagePush},
26+ {"MsgClientNotice", MsgClientNotice},
2627 {"MsgWelcome", MsgWelcome},
2728 {"MsgError", MsgError},
2829 {"MsgOutput", MsgOutput},
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 16 {6+ if ProtocolVersion != 17 {
7- t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -23,6 +23,7 @@ func TestMsgTypeUnique(t *testing.T) {
23 {"MsgTheme", MsgTheme},23 {"MsgTheme", MsgTheme},
24 {"MsgAck", MsgAck},24 {"MsgAck", MsgAck},
25 {"MsgImagePush", MsgImagePush},25 {"MsgImagePush", MsgImagePush},
26+ {"MsgClientNotice", MsgClientNotice},
26 {"MsgWelcome", MsgWelcome},27 {"MsgWelcome", MsgWelcome},
27 {"MsgError", MsgError},28 {"MsgError", MsgError},
28 {"MsgOutput", MsgOutput},29 {"MsgOutput", MsgOutput},
M internal/ports/wire.go +46 -0
@@ -53,6 +53,7 @@ const (
5353 ReasonDetach uint8 = 0
5454 ReasonSessionKilled uint8 = 1
5555 ReasonServerShutdown uint8 = 2
56+ ReasonReplaced uint8 = 3
5657 )
5758
5859 // Hello is sent by the client immediately after connecting.
@@ -108,6 +109,29 @@ type ImagePush struct {
108109 Data []byte
109110 }
110111
112+// ClientNotice reports a fixed client-side event for daemon-rendered user
113+// feedback. Action values are deliberately closed so a client cannot inject
114+// arbitrary display text into a shared session.
115+type ClientNotice struct {
116+ Action uint8
117+}
118+
119+const (
120+ ClientNoticeClipboardFallback uint8 = 1
121+ ClientNoticeClipboardTooLarge uint8 = 2
122+ ClientNoticeLinkDegraded uint8 = 3
123+ ClientNoticeLinkConnected uint8 = 4
124+)
125+
126+func validClientNoticeAction(action uint8) bool {
127+ switch action {
128+ case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
129+ return true
130+ default:
131+ return false
132+ }
133+}
134+
111135 // Detach asks the daemon to detach the current client without killing the
112136 // session.
113137 type Detach struct{}
@@ -458,6 +482,28 @@ func UnmarshalImagePush(b []byte) (ImagePush, error) {
458482 return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
459483 }
460484
485+// MarshalClientNotice encodes a fixed one-byte client notice action.
486+func MarshalClientNotice(m ClientNotice) []byte {
487+ return []byte{m.Action}
488+}
489+
490+// UnmarshalClientNotice decodes a fixed client notice action and rejects both
491+// unknown values and any trailing bytes.
492+func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
493+ r := payloadReader{b: b}
494+ action, err := r.getUint8()
495+ if err != nil {
496+ return ClientNotice{}, err
497+ }
498+ if !validClientNoticeAction(action) {
499+ return ClientNotice{}, errors.New("ports: unknown client notice action")
500+ }
501+ if err := r.done(); err != nil {
502+ return ClientNotice{}, err
503+ }
504+ return ClientNotice{Action: action}, nil
505+}
506+
461507 // MarshalResize encodes m into a Resize message payload.
462508 func MarshalResize(m Resize) []byte {
463509 w := payloadWriter{}
@@ -53,6 +53,7 @@ const (
53 ReasonDetach uint8 = 053 ReasonDetach uint8 = 0
54 ReasonSessionKilled uint8 = 154 ReasonSessionKilled uint8 = 1
55 ReasonServerShutdown uint8 = 255 ReasonServerShutdown uint8 = 2
56+ ReasonReplaced uint8 = 3
56 )57 )
57 58
58 // Hello is sent by the client immediately after connecting.59 // Hello is sent by the client immediately after connecting.
@@ -108,6 +109,29 @@ type ImagePush struct {
108 Data []byte109 Data []byte
109 }110 }
110 111
112+// ClientNotice reports a fixed client-side event for daemon-rendered user
113+// feedback. Action values are deliberately closed so a client cannot inject
114+// arbitrary display text into a shared session.
115+type ClientNotice struct {
116+ Action uint8
117+}
118+
119+const (
120+ ClientNoticeClipboardFallback uint8 = 1
121+ ClientNoticeClipboardTooLarge uint8 = 2
122+ ClientNoticeLinkDegraded uint8 = 3
123+ ClientNoticeLinkConnected uint8 = 4
124+)
125+
126+func validClientNoticeAction(action uint8) bool {
127+ switch action {
128+ case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
129+ return true
130+ default:
131+ return false
132+ }
133+}
134+
111 // Detach asks the daemon to detach the current client without killing the135 // Detach asks the daemon to detach the current client without killing the
112 // session.136 // session.
113 type Detach struct{}137 type Detach struct{}
@@ -458,6 +482,28 @@ func UnmarshalImagePush(b []byte) (ImagePush, error) {
458 return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil482 return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
459 }483 }
460 484
485+// MarshalClientNotice encodes a fixed one-byte client notice action.
486+func MarshalClientNotice(m ClientNotice) []byte {
487+ return []byte{m.Action}
488+}
489+
490+// UnmarshalClientNotice decodes a fixed client notice action and rejects both
491+// unknown values and any trailing bytes.
492+func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
493+ r := payloadReader{b: b}
494+ action, err := r.getUint8()
495+ if err != nil {
496+ return ClientNotice{}, err
497+ }
498+ if !validClientNoticeAction(action) {
499+ return ClientNotice{}, errors.New("ports: unknown client notice action")
500+ }
501+ if err := r.done(); err != nil {
502+ return ClientNotice{}, err
503+ }
504+ return ClientNotice{Action: action}, nil
505+}
506+
461 // MarshalResize encodes m into a Resize message payload.507 // MarshalResize encodes m into a Resize message payload.
462 func MarshalResize(m Resize) []byte {508 func MarshalResize(m Resize) []byte {
463 w := payloadWriter{}509 w := payloadWriter{}
M internal/ports/wire_test.go +32 -5
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121121 Env: []string{"A=B", "XY=123"},
122122 })
123123 want := []byte{
124- 0x00, 0x10, 0x00, // version, intent
124+ 0x00, 0x11, 0x00, // version, intent
125125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
335335 }
336336 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
337337 require.Equal(t, want, MarshalTheme(cleared))
338- require.Equal(t, uint16(16), ProtocolVersion)
338+ require.Equal(t, uint16(17), ProtocolVersion)
339339 }
340340
341341 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -502,8 +502,8 @@ func TestAckGoldenAndRoundTrip(t *testing.T) {
502502 }
503503
504504 func TestDetachedGoldenAndRoundTrip(t *testing.T) {
505- msg := Detached{Reason: ReasonSessionKilled}
506- want := []byte{0x01}
505+ msg := Detached{Reason: ReasonReplaced}
506+ want := []byte{0x03}
507507
508508 got := MarshalDetached(msg)
509509 if !bytes.Equal(got, want) {
@@ -521,6 +521,32 @@ func TestDetachedGoldenAndRoundTrip(t *testing.T) {
521521 assertTrailingGarbageFails(t, got, UnmarshalDetached)
522522 }
523523
524+func TestClientNoticeGoldenAndStrict(t *testing.T) {
525+ tests := []struct {
526+ name string
527+ msg ClientNotice
528+ want []byte
529+ }{
530+ {name: "clipboard fallback", msg: ClientNotice{Action: ClientNoticeClipboardFallback}, want: []byte{0x01}},
531+ {name: "clipboard too large", msg: ClientNotice{Action: ClientNoticeClipboardTooLarge}, want: []byte{0x02}},
532+ {name: "link degraded", msg: ClientNotice{Action: ClientNoticeLinkDegraded}, want: []byte{0x03}},
533+ {name: "link connected", msg: ClientNotice{Action: ClientNoticeLinkConnected}, want: []byte{0x04}},
534+ }
535+ for _, tt := range tests {
536+ t.Run(tt.name, func(t *testing.T) {
537+ got := MarshalClientNotice(tt.msg)
538+ require.Equal(t, tt.want, got)
539+ back, err := UnmarshalClientNotice(got)
540+ require.NoError(t, err)
541+ require.Equal(t, tt.msg, back)
542+ assertAllPrefixesFail(t, got, UnmarshalClientNotice)
543+ assertTrailingGarbageFails(t, got, UnmarshalClientNotice)
544+ })
545+ }
546+ _, err := UnmarshalClientNotice([]byte{0xff})
547+ require.Error(t, err, "unknown actions must not become display text")
548+}
549+
524550 func TestListGoldenAndRoundTrip(t *testing.T) {
525551 got := MarshalList(List{})
526552 if len(got) != 0 {
@@ -661,8 +687,9 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
661687 ReasonDetach: "ReasonDetach",
662688 ReasonSessionKilled: "ReasonSessionKilled",
663689 ReasonServerShutdown: "ReasonServerShutdown",
690+ ReasonReplaced: "ReasonReplaced",
664691 }
665- if len(vals) != 3 {
692+ if len(vals) != 4 {
666693 t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals))
667694 }
668695 })
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121 Env: []string{"A=B", "XY=123"},121 Env: []string{"A=B", "XY=123"},
122 })122 })
123 want := []byte{123 want := []byte{
124- 0x00, 0x10, 0x00, // version, intent124+ 0x00, 0x11, 0x00, // version, intent
125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
335 }335 }
336 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)336 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
337 require.Equal(t, want, MarshalTheme(cleared))337 require.Equal(t, want, MarshalTheme(cleared))
338- require.Equal(t, uint16(16), ProtocolVersion)338+ require.Equal(t, uint16(17), ProtocolVersion)
339 }339 }
340 340
341 func TestResizeGoldenAndRoundTrip(t *testing.T) {341 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -502,8 +502,8 @@ func TestAckGoldenAndRoundTrip(t *testing.T) {
502 }502 }
503 503
504 func TestDetachedGoldenAndRoundTrip(t *testing.T) {504 func TestDetachedGoldenAndRoundTrip(t *testing.T) {
505- msg := Detached{Reason: ReasonSessionKilled}505+ msg := Detached{Reason: ReasonReplaced}
506- want := []byte{0x01}506+ want := []byte{0x03}
507 507
508 got := MarshalDetached(msg)508 got := MarshalDetached(msg)
509 if !bytes.Equal(got, want) {509 if !bytes.Equal(got, want) {
@@ -521,6 +521,32 @@ func TestDetachedGoldenAndRoundTrip(t *testing.T) {
521 assertTrailingGarbageFails(t, got, UnmarshalDetached)521 assertTrailingGarbageFails(t, got, UnmarshalDetached)
522 }522 }
523 523
524+func TestClientNoticeGoldenAndStrict(t *testing.T) {
525+ tests := []struct {
526+ name string
527+ msg ClientNotice
528+ want []byte
529+ }{
530+ {name: "clipboard fallback", msg: ClientNotice{Action: ClientNoticeClipboardFallback}, want: []byte{0x01}},
531+ {name: "clipboard too large", msg: ClientNotice{Action: ClientNoticeClipboardTooLarge}, want: []byte{0x02}},
532+ {name: "link degraded", msg: ClientNotice{Action: ClientNoticeLinkDegraded}, want: []byte{0x03}},
533+ {name: "link connected", msg: ClientNotice{Action: ClientNoticeLinkConnected}, want: []byte{0x04}},
534+ }
535+ for _, tt := range tests {
536+ t.Run(tt.name, func(t *testing.T) {
537+ got := MarshalClientNotice(tt.msg)
538+ require.Equal(t, tt.want, got)
539+ back, err := UnmarshalClientNotice(got)
540+ require.NoError(t, err)
541+ require.Equal(t, tt.msg, back)
542+ assertAllPrefixesFail(t, got, UnmarshalClientNotice)
543+ assertTrailingGarbageFails(t, got, UnmarshalClientNotice)
544+ })
545+ }
546+ _, err := UnmarshalClientNotice([]byte{0xff})
547+ require.Error(t, err, "unknown actions must not become display text")
548+}
549+
524 func TestListGoldenAndRoundTrip(t *testing.T) {550 func TestListGoldenAndRoundTrip(t *testing.T) {
525 got := MarshalList(List{})551 got := MarshalList(List{})
526 if len(got) != 0 {552 if len(got) != 0 {
@@ -661,8 +687,9 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
661 ReasonDetach: "ReasonDetach",687 ReasonDetach: "ReasonDetach",
662 ReasonSessionKilled: "ReasonSessionKilled",688 ReasonSessionKilled: "ReasonSessionKilled",
663 ReasonServerShutdown: "ReasonServerShutdown",689 ReasonServerShutdown: "ReasonServerShutdown",
690+ ReasonReplaced: "ReasonReplaced",
664 }691 }
665- if len(vals) != 3 {692+ if len(vals) != 4 {
666 t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals))693 t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals))
667 }694 }
668 })695 })
M internal/usecase/client/client.go +15 -56
@@ -650,9 +650,6 @@ 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)
656653 ackQueue := newCumulativeAckQueue()
657654 sendErrCh := make(chan error, 1)
658655 senderStarted := false
@@ -765,7 +762,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
765762 stdinDone := make(chan struct{})
766763 go func() {
767764 defer close(stdinDone)
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()
765+ (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
769766 }()
770767 // Scanner cancellation can leave an undecided marker suffix (including a
771768 // standalone Escape) that it must hand back to the lifecycle-owned reader.
@@ -783,8 +780,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
783780 recvCh := make(chan recvResult, 1)
784781 go runRecv(loopCtx, transport, recvCh, log)
785782
786- var transientToast clientToastUI
787- awaitingToastResync := false
788783 for {
789784 select {
790785 case <-loopCtx.Done():
@@ -796,25 +791,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
796791 default:
797792 return welcomedResult(nil)
798793 }
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- }
818794 case ev, ok := <-linkEvents:
819795 if !ok {
820796 linkEvents = nil
@@ -822,11 +798,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
822798 }
823799 if ev.State == ports.LinkStateConnected {
824800 reconnect.clear()
801+ select {
802+ case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})}:
803+ case <-loopCtx.Done():
804+ return welcomedResult(nil)
805+ }
825806 continue
826807 }
827808 if ev.State == ports.LinkStateDegraded {
828809 log.Warn("UDP link degraded")
829- reconnect.drawStage(reconnectStageDegraded)
810+ select {
811+ case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})}:
812+ case <-loopCtx.Done():
813+ return welcomedResult(nil)
814+ }
830815 continue
831816 }
832817 reconnect.drawStage(stageForLinkState(ev.State))
@@ -865,26 +850,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
865850 if derr != nil {
866851 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
867852 }
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- }
878853 if _, werr := term.Out().Write(o.Data); werr != nil {
879854 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
880855 }
881856 if ferr := term.Flush(); ferr != nil {
882857 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
883858 }
884- if awaitingToastResync {
885- transientToast.reconcile()
886- awaitingToastResync = false
887- }
888859 // The terminal boundary is after a successful flush and before ACK.
889860 if observer != nil {
890861 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
@@ -920,13 +891,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
920891
921892 // recvResult carries one framed message (or a read error) from the receive
922893 // 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-
930894 type recvResult struct {
931895 frame ports.Frame
932896 err error
@@ -1349,7 +1313,6 @@ type stdinPump struct {
13491313 logger *slog.Logger
13501314 paletteEvents chan<- paletteGenerationEvent
13511315 activeGeneration *atomic.Uint64
1352- clientToasts chan<- string
13531316 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
13541317 afterInputTake func() // test synchronization hook
13551318 afterInputDelivered func() // test synchronization hook
@@ -1412,14 +1375,8 @@ func (p *stdinPump) run() {
14121375 coalescer: coalescer,
14131376 reader: p.clipboard,
14141377 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- }
1378+ sendNotice: func(action uint8) {
1379+ send(ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: action})})
14231380 },
14241381 sendImage: func(mime string, data []byte) {
14251382 inputSeq++
@@ -1617,6 +1574,8 @@ func detachedResult(reason uint8) error {
16171574 return &DetachedError{Reason: reason, Text: "session was killed"}
16181575 case ports.ReasonServerShutdown:
16191576 return &DetachedError{Reason: reason, Text: "daemon shut down"}
1577+ case ports.ReasonReplaced:
1578+ return &DetachedError{Reason: reason, Text: "session taken over by another client"}
16201579 default:
16211580 return &DetachedError{Reason: reason, Text: "detached by daemon"}
16221581 }
@@ -650,9 +650,6 @@ 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)
656 ackQueue := newCumulativeAckQueue()653 ackQueue := newCumulativeAckQueue()
657 sendErrCh := make(chan error, 1)654 sendErrCh := make(chan error, 1)
658 senderStarted := false655 senderStarted := false
@@ -765,7 +762,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
765 stdinDone := make(chan struct{})762 stdinDone := make(chan struct{})
766 go func() {763 go func() {
767 defer close(stdinDone)764 defer close(stdinDone)
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()765+ (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
769 }()766 }()
770 // Scanner cancellation can leave an undecided marker suffix (including a767 // Scanner cancellation can leave an undecided marker suffix (including a
771 // standalone Escape) that it must hand back to the lifecycle-owned reader.768 // standalone Escape) that it must hand back to the lifecycle-owned reader.
@@ -783,8 +780,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
783 recvCh := make(chan recvResult, 1)780 recvCh := make(chan recvResult, 1)
784 go runRecv(loopCtx, transport, recvCh, log)781 go runRecv(loopCtx, transport, recvCh, log)
785 782
786- var transientToast clientToastUI
787- awaitingToastResync := false
788 for {783 for {
789 select {784 select {
790 case <-loopCtx.Done():785 case <-loopCtx.Done():
@@ -796,25 +791,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
796 default:791 default:
797 return welcomedResult(nil)792 return welcomedResult(nil)
798 }793 }
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- }
818 case ev, ok := <-linkEvents:794 case ev, ok := <-linkEvents:
819 if !ok {795 if !ok {
820 linkEvents = nil796 linkEvents = nil
@@ -822,11 +798,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
822 }798 }
823 if ev.State == ports.LinkStateConnected {799 if ev.State == ports.LinkStateConnected {
824 reconnect.clear()800 reconnect.clear()
801+ select {
802+ case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})}:
803+ case <-loopCtx.Done():
804+ return welcomedResult(nil)
805+ }
825 continue806 continue
826 }807 }
827 if ev.State == ports.LinkStateDegraded {808 if ev.State == ports.LinkStateDegraded {
828 log.Warn("UDP link degraded")809 log.Warn("UDP link degraded")
829- reconnect.drawStage(reconnectStageDegraded)810+ select {
811+ case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})}:
812+ case <-loopCtx.Done():
813+ return welcomedResult(nil)
814+ }
830 continue815 continue
831 }816 }
832 reconnect.drawStage(stageForLinkState(ev.State))817 reconnect.drawStage(stageForLinkState(ev.State))
@@ -865,26 +850,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
865 if derr != nil {850 if derr != nil {
866 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))851 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
867 }852 }
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- }
878 if _, werr := term.Out().Write(o.Data); werr != nil {853 if _, werr := term.Out().Write(o.Data); werr != nil {
879 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))854 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
880 }855 }
881 if ferr := term.Flush(); ferr != nil {856 if ferr := term.Flush(); ferr != nil {
882 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))857 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
883 }858 }
884- if awaitingToastResync {
885- transientToast.reconcile()
886- awaitingToastResync = false
887- }
888 // The terminal boundary is after a successful flush and before ACK.859 // The terminal boundary is after a successful flush and before ACK.
889 if observer != nil {860 if observer != nil {
890 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))861 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
@@ -920,13 +891,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
920 891
921 // recvResult carries one framed message (or a read error) from the receive892 // recvResult carries one framed message (or a read error) from the receive
922 // pump to the main loop.893 // 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-
930 type recvResult struct {894 type recvResult struct {
931 frame ports.Frame895 frame ports.Frame
932 err error896 err error
@@ -1349,7 +1313,6 @@ type stdinPump struct {
1349 logger *slog.Logger1313 logger *slog.Logger
1350 paletteEvents chan<- paletteGenerationEvent1314 paletteEvents chan<- paletteGenerationEvent
1351 activeGeneration *atomic.Uint641315 activeGeneration *atomic.Uint64
1352- clientToasts chan<- string
1353 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook1316 afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
1354 afterInputTake func() // test synchronization hook1317 afterInputTake func() // test synchronization hook
1355 afterInputDelivered func() // test synchronization hook1318 afterInputDelivered func() // test synchronization hook
@@ -1412,14 +1375,8 @@ func (p *stdinPump) run() {
1412 coalescer: coalescer,1375 coalescer: coalescer,
1413 reader: p.clipboard,1376 reader: p.clipboard,
1414 log: p.logger,1377 log: p.logger,
1415- showToast: func(message string) {1378+ sendNotice: func(action uint8) {
1416- if p.clientToasts == nil {1379+ send(ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: action})})
1417- return
1418- }
1419- select {
1420- case p.clientToasts <- message:
1421- case <-p.ctx.Done():
1422- }
1423 },1380 },
1424 sendImage: func(mime string, data []byte) {1381 sendImage: func(mime string, data []byte) {
1425 inputSeq++1382 inputSeq++
@@ -1617,6 +1574,8 @@ func detachedResult(reason uint8) error {
1617 return &DetachedError{Reason: reason, Text: "session was killed"}1574 return &DetachedError{Reason: reason, Text: "session was killed"}
1618 case ports.ReasonServerShutdown:1575 case ports.ReasonServerShutdown:
1619 return &DetachedError{Reason: reason, Text: "daemon shut down"}1576 return &DetachedError{Reason: reason, Text: "daemon shut down"}
1577+ case ports.ReasonReplaced:
1578+ return &DetachedError{Reason: reason, Text: "session taken over by another client"}
1620 default:1579 default:
1621 return &DetachedError{Reason: reason, Text: "detached by daemon"}1580 return &DetachedError{Reason: reason, Text: "detached by daemon"}
1622 }1581 }
M internal/usecase/client/clipboard_intercept.go +10 -10
@@ -25,12 +25,12 @@ const maxClipboardImagePush = 1 << 20 // 1 MiB
2525 // text may legitimately contain it) — is forwarded to next unchanged, so it
2626 // still gets the coalescer's marker-splitting treatment.
2727 type clipboardIntercept struct {
28- coalescer *pasteCoalescer
29- reader ports.ClipboardReader
30- log *slog.Logger
31- sendImage func(mime string, data []byte)
32- showToast func(string)
33- next func([]byte)
28+ coalescer *pasteCoalescer
29+ reader ports.ClipboardReader
30+ log *slog.Logger
31+ sendImage func(mime string, data []byte)
32+ sendNotice func(action uint8)
33+ next func([]byte)
3434 }
3535
3636 // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
@@ -100,8 +100,8 @@ func (c *clipboardIntercept) handleCtrlV() {
100100 if err != nil {
101101 if !errors.Is(err, ports.ErrNoClipboardImage) {
102102 c.log.Warn("clipboard image read failed", "err", err)
103- if c.showToast != nil {
104- c.showToast("image paste failed; sent Ctrl+V")
103+ if c.sendNotice != nil {
104+ c.sendNotice(ports.ClientNoticeClipboardFallback)
105105 }
106106 }
107107 c.next([]byte{ctrlV})
@@ -109,8 +109,8 @@ func (c *clipboardIntercept) handleCtrlV() {
109109 }
110110 if len(data) > maxClipboardImagePush {
111111 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")
112+ if c.sendNotice != nil {
113+ c.sendNotice(ports.ClientNoticeClipboardTooLarge)
114114 }
115115 c.next([]byte{ctrlV})
116116 return
@@ -25,12 +25,12 @@ const maxClipboardImagePush = 1 << 20 // 1 MiB
25 // text may legitimately contain it) — is forwarded to next unchanged, so it25 // text may legitimately contain it) — is forwarded to next unchanged, so it
26 // still gets the coalescer's marker-splitting treatment.26 // still gets the coalescer's marker-splitting treatment.
27 type clipboardIntercept struct {27 type clipboardIntercept struct {
28- coalescer *pasteCoalescer28+ coalescer *pasteCoalescer
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+ sendNotice func(action uint8)
33- next func([]byte)33+ next func([]byte)
34 }34 }
35 35
36 // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it36 // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
@@ -100,8 +100,8 @@ func (c *clipboardIntercept) handleCtrlV() {
100 if err != nil {100 if err != nil {
101 if !errors.Is(err, ports.ErrNoClipboardImage) {101 if !errors.Is(err, ports.ErrNoClipboardImage) {
102 c.log.Warn("clipboard image read failed", "err", err)102 c.log.Warn("clipboard image read failed", "err", err)
103- if c.showToast != nil {103+ if c.sendNotice != nil {
104- c.showToast("image paste failed; sent Ctrl+V")104+ c.sendNotice(ports.ClientNoticeClipboardFallback)
105 }105 }
106 }106 }
107 c.next([]byte{ctrlV})107 c.next([]byte{ctrlV})
@@ -109,8 +109,8 @@ func (c *clipboardIntercept) handleCtrlV() {
109 }109 }
110 if len(data) > maxClipboardImagePush {110 if len(data) > maxClipboardImagePush {
111 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 {112+ if c.sendNotice != nil {
113- c.showToast("image too large to paste")113+ c.sendNotice(ports.ClientNoticeClipboardTooLarge)
114 }114 }
115 c.next([]byte{ctrlV})115 c.next([]byte{ctrlV})
116 return116 return
M internal/usecase/client/clipboard_intercept_internal_test.go +7 -7
@@ -79,25 +79,25 @@ func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
7979 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
8080 }
8181
82-func TestClipboardInterceptFailureToastsAndForwardsCtrlV(t *testing.T) {
82+func TestClipboardInterceptFailureNotifiesDaemonAndForwardsCtrlV(t *testing.T) {
8383 tests := []struct {
8484 name string
8585 reader ports.ClipboardReader
86- want string
86+ want uint8
8787 }{
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"},
88+ {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: ports.ClientNoticeClipboardFallback},
89+ {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: ports.ClientNoticeClipboardTooLarge},
9090 }
9191 for _, tt := range tests {
9292 t.Run(tt.name, func(t *testing.T) {
9393 ci, pc, _, col, images := newTestClipboardIntercept(tt.reader)
9494 defer pc.Close()
95- var toasts []string
96- ci.showToast = func(message string) { toasts = append(toasts, message) }
95+ var actions []uint8
96+ ci.sendNotice = func(action uint8) { actions = append(actions, action) }
9797
9898 ci.Scan([]byte{ctrlV})
9999
100- require.Equal(t, []string{tt.want}, toasts)
100+ require.Equal(t, []uint8{tt.want}, actions)
101101 require.Empty(t, *images)
102102 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
103103 })
@@ -79,25 +79,25 @@ 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) {82+func TestClipboardInterceptFailureNotifiesDaemonAndForwardsCtrlV(t *testing.T) {
83 tests := []struct {83 tests := []struct {
84 name string84 name string
85 reader ports.ClipboardReader85 reader ports.ClipboardReader
86- want string86+ want uint8
87 }{87 }{
88- {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: "image paste failed; sent Ctrl+V"},88+ {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: ports.ClientNoticeClipboardFallback},
89- {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: "image too large to paste"},89+ {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: ports.ClientNoticeClipboardTooLarge},
90 }90 }
91 for _, tt := range tests {91 for _, tt := range tests {
92 t.Run(tt.name, func(t *testing.T) {92 t.Run(tt.name, func(t *testing.T) {
93 ci, pc, _, col, images := newTestClipboardIntercept(tt.reader)93 ci, pc, _, col, images := newTestClipboardIntercept(tt.reader)
94 defer pc.Close()94 defer pc.Close()
95- var toasts []string95+ var actions []uint8
96- ci.showToast = func(message string) { toasts = append(toasts, message) }96+ ci.sendNotice = func(action uint8) { actions = append(actions, action) }
97 97
98 ci.Scan([]byte{ctrlV})98 ci.Scan([]byte{ctrlV})
99 99
100- require.Equal(t, []string{tt.want}, toasts)100+ require.Equal(t, []uint8{tt.want}, actions)
101 require.Empty(t, *images)101 require.Empty(t, *images)
102 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())102 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
103 })103 })
M internal/usecase/client/clipboard_intercept_test.go +13 -31
@@ -61,6 +61,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
6161 gotImage <- ip
6262 return nil
6363 }).Maybe()
64+ tr.EXPECT().Send(isType(ports.MsgClientNotice)).Return(nil).Maybe()
6465
6566 welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
6667 detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
@@ -158,7 +159,7 @@ func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, e
158159 return d.transport, nil
159160 }
160161
161-func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) {
162+func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) {
162163 var out bytes.Buffer
163164 resizeEvents := make(chan domain.Size)
164165 input := newOneShotBlockingReader([]byte{0x16})
@@ -181,51 +182,32 @@ func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) {
181182 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})
182183 }()
183184
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 {
185+ var gotNotice bool
186+ for !gotNotice {
200187 select {
201188 case sent := <-transport.sends:
202- if sent.Type != ports.MsgAck {
189+ if sent.Type != ports.MsgClientNotice {
203190 continue
204191 }
205- ack, err := ports.UnmarshalAck(sent.Payload)
192+ notice, err := ports.UnmarshalClientNotice(sent.Payload)
206193 require.NoError(t, err)
207- if ack.AckedStateNum == 2 {
208- goto incrementalAcknowledged
209- }
194+ require.Equal(t, ports.ClientNoticeClipboardFallback, notice.Action)
195+ gotNotice = true
210196 case <-time.After(time.Second):
211- t.Fatal("client did not ACK discarded incremental output")
197+ t.Fatal("client did not notify daemon of clipboard failure")
212198 }
213199 }
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")}))
200+ beforeOutput := out.String()
201+ transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")}))
218202 transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
219203
220204 select {
221205 case err := <-result:
222206 require.NoError(t, err)
223207 case <-time.After(time.Second):
224- t.Fatal("client did not finish after reset frame")
208+ t.Fatal("client did not finish")
225209 }
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")
210+ require.Equal(t, beforeOutput+"incremental", out.String(), "daemon output must remain verbatim with no local toast bytes")
229211 }
230212
231213 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
@@ -61,6 +61,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
61 gotImage <- ip61 gotImage <- ip
62 return nil62 return nil
63 }).Maybe()63 }).Maybe()
64+ tr.EXPECT().Send(isType(ports.MsgClientNotice)).Return(nil).Maybe()
64 65
65 welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))66 welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
66 detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))67 detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
@@ -158,7 +159,7 @@ func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, e
158 return d.transport, nil159 return d.transport, nil
159 }160 }
160 161
161-func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) {162+func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) {
162 var out bytes.Buffer163 var out bytes.Buffer
163 resizeEvents := make(chan domain.Size)164 resizeEvents := make(chan domain.Size)
164 input := newOneShotBlockingReader([]byte{0x16})165 input := newOneShotBlockingReader([]byte{0x16})
@@ -181,51 +182,32 @@ func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) {
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 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 }()
183 184
184- for {185+ var gotNotice bool
185- select {186+ for !gotNotice {
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 {187 select {
201 case sent := <-transport.sends:188 case sent := <-transport.sends:
202- if sent.Type != ports.MsgAck {189+ if sent.Type != ports.MsgClientNotice {
203 continue190 continue
204 }191 }
205- ack, err := ports.UnmarshalAck(sent.Payload)192+ notice, err := ports.UnmarshalClientNotice(sent.Payload)
206 require.NoError(t, err)193 require.NoError(t, err)
207- if ack.AckedStateNum == 2 {194+ require.Equal(t, ports.ClientNoticeClipboardFallback, notice.Action)
208- goto incrementalAcknowledged195+ gotNotice = true
209- }
210 case <-time.After(time.Second):196 case <-time.After(time.Second):
211- t.Fatal("client did not ACK discarded incremental output")197+ t.Fatal("client did not notify daemon of clipboard failure")
212 }198 }
213 }199 }
214-200+ beforeOutput := out.String()
215-incrementalAcknowledged:201+ transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")}))
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}))202 transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
219 203
220 select {204 select {
221 case err := <-result:205 case err := <-result:
222 require.NoError(t, err)206 require.NoError(t, err)
223 case <-time.After(time.Second):207 case <-time.After(time.Second):
224- t.Fatal("client did not finish after reset frame")208+ t.Fatal("client did not finish")
225 }209 }
226- got := out.String()210+ require.Equal(t, beforeOutput+"incremental", out.String(), "daemon output must remain verbatim with no local toast bytes")
227- require.NotContains(t, got, "incremental", "incremental output must not overwrite a local toast before reset")
228- require.Contains(t, got, "authoritative reset")
229 }211 }
230 212
231 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {213 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
M internal/usecase/client/reconnect_toast.go +0 -34
@@ -6,7 +6,6 @@ import (
66 "strings"
77
88 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/ports"
109 "github.com/bnema/vev/internal/usecase/ui"
1110 "github.com/bnema/vev/pkg/renderer"
1211 )
@@ -48,39 +47,6 @@ func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Re
4847 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
4948 }
5049
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-
8450 func clearReconnectToast(out io.Writer, bounds domain.Rect) error {
8551 if bounds.Width <= 0 || bounds.Height <= 0 {
8652 return nil
@@ -6,7 +6,6 @@ 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"
10 "github.com/bnema/vev/internal/usecase/ui"9 "github.com/bnema/vev/internal/usecase/ui"
11 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
12 )11 )
@@ -48,39 +47,6 @@ func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Re
48 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))47 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
49 }48 }
50 49
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-
84 func clearReconnectToast(out io.Writer, bounds domain.Rect) error {50 func clearReconnectToast(out io.Writer, bounds domain.Rect) error {
85 if bounds.Width <= 0 || bounds.Height <= 0 {51 if bounds.Width <= 0 || bounds.Height <= 0 {
86 return nil52 return nil
M internal/usecase/client/reconnect_toast_test.go +39 -52
@@ -141,6 +141,7 @@ func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnect
141141 }
142142
143143 type reconnectToastLinkTransport struct {
144+ mu sync.Mutex
144145 recvCh chan reconnectToastRecv
145146 sends []ports.Frame
146147 events chan ports.LinkEvent
@@ -156,10 +157,18 @@ func newReconnectToastLinkTransport() *reconnectToastLinkTransport {
156157 }
157158
158159 func (t *reconnectToastLinkTransport) Send(f ports.Frame) error {
160+ t.mu.Lock()
159161 t.sends = append(t.sends, f)
162+ t.mu.Unlock()
160163 return nil
161164 }
162165
166+func (t *reconnectToastLinkTransport) sentFrames() []ports.Frame {
167+ t.mu.Lock()
168+ defer t.mu.Unlock()
169+ return append([]ports.Frame(nil), t.sends...)
170+}
171+
163172 func (t *reconnectToastLinkTransport) Recv() (ports.Frame, error) {
164173 recv := <-t.recvCh
165174 return recv.frame, recv.err
@@ -282,7 +291,7 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c
282291 }
283292 }
284293
285-func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) {
294+func TestReconnectLinkEventsNotifyDaemonWithoutLocalTerminalWrites(t *testing.T) {
286295 out := newReconnectToastOutputRecorder()
287296 term := newReconnectToastTerminalHarnessWithOutput(t, out)
288297 defer term.closeInput()
@@ -294,25 +303,26 @@ func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) {
294303 attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms)
295304 go func() { resultCh <- attempt.run(context.Background()) }()
296305
297- tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
298- probed := <-out.completed
299- require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
300-
301306 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
302- cleared := <-out.completed
303- assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
304- degraded := <-out.completed
305- require.Contains(t, degraded, reconnectStageMessage(reconnectStageDegraded))
306-
307- tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
308- cleared = <-out.completed
309- assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageDegraded)))
310- probed = <-out.completed
311- require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
312-
313307 tr.events <- ports.LinkEvent{State: ports.LinkStateConnected}
314- cleared = <-out.completed
315- assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
308+ require.Eventually(t, func() bool {
309+ var actions []uint8
310+ for _, frame := range tr.sentFrames() {
311+ if frame.Type != ports.MsgClientNotice {
312+ continue
313+ }
314+ notice, err := ports.UnmarshalClientNotice(frame.Payload)
315+ if err == nil {
316+ actions = append(actions, notice.Action)
317+ }
318+ }
319+ return len(actions) == 2 && actions[0] == ports.ClientNoticeLinkDegraded && actions[1] == ports.ClientNoticeLinkConnected
320+ }, time.Second, time.Millisecond)
321+ select {
322+ case bytes := <-out.completed:
323+ t.Fatalf("link notices wrote client-local terminal bytes: %q", bytes)
324+ default:
325+ }
316326
317327 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
318328 result := <-resultCh
@@ -338,44 +348,21 @@ func TestAttachAttemptOfflineLinkEventReturnsReconnectableError(t *testing.T) {
338348 require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")
339349 }
340350
341-func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {
342- bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24})
343- require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)
344-}
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},
351+func TestDetachedResultReplacementAndCleanDetach(t *testing.T) {
352+ if err := detachedResult(ports.ReasonDetach); err != nil {
353+ t.Fatalf("clean detach = %v, want nil", err)
355354 }
356- for _, tt := range tests {
357- t.Run(tt.name, func(t *testing.T) {
358- require.Equal(t, tt.want, isClientToastResyncFrame(tt.out))
359- })
355+ err := detachedResult(ports.ReasonReplaced)
356+ var detached *DetachedError
357+ if !errors.As(err, &detached) {
358+ t.Fatalf("replacement error = %T, want *DetachedError", err)
360359 }
360+ require.Equal(t, "session taken over by another client", detached.Text)
361361 }
362362
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- }
363+func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {
364+ bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24})
365+ require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)
379366 }
380367
381368 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
@@ -141,6 +141,7 @@ func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnect
141 }141 }
142 142
143 type reconnectToastLinkTransport struct {143 type reconnectToastLinkTransport struct {
144+ mu sync.Mutex
144 recvCh chan reconnectToastRecv145 recvCh chan reconnectToastRecv
145 sends []ports.Frame146 sends []ports.Frame
146 events chan ports.LinkEvent147 events chan ports.LinkEvent
@@ -156,10 +157,18 @@ func newReconnectToastLinkTransport() *reconnectToastLinkTransport {
156 }157 }
157 158
158 func (t *reconnectToastLinkTransport) Send(f ports.Frame) error {159 func (t *reconnectToastLinkTransport) Send(f ports.Frame) error {
160+ t.mu.Lock()
159 t.sends = append(t.sends, f)161 t.sends = append(t.sends, f)
162+ t.mu.Unlock()
160 return nil163 return nil
161 }164 }
162 165
166+func (t *reconnectToastLinkTransport) sentFrames() []ports.Frame {
167+ t.mu.Lock()
168+ defer t.mu.Unlock()
169+ return append([]ports.Frame(nil), t.sends...)
170+}
171+
163 func (t *reconnectToastLinkTransport) Recv() (ports.Frame, error) {172 func (t *reconnectToastLinkTransport) Recv() (ports.Frame, error) {
164 recv := <-t.recvCh173 recv := <-t.recvCh
165 return recv.frame, recv.err174 return recv.frame, recv.err
@@ -282,7 +291,7 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c
282 }291 }
283 }292 }
284 293
285-func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) {294+func TestReconnectLinkEventsNotifyDaemonWithoutLocalTerminalWrites(t *testing.T) {
286 out := newReconnectToastOutputRecorder()295 out := newReconnectToastOutputRecorder()
287 term := newReconnectToastTerminalHarnessWithOutput(t, out)296 term := newReconnectToastTerminalHarnessWithOutput(t, out)
288 defer term.closeInput()297 defer term.closeInput()
@@ -294,25 +303,26 @@ func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) {
294 attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms)303 attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms)
295 go func() { resultCh <- attempt.run(context.Background()) }()304 go func() { resultCh <- attempt.run(context.Background()) }()
296 305
297- tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
298- probed := <-out.completed
299- require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
300-
301 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}306 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
302- cleared := <-out.completed
303- assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
304- degraded := <-out.completed
305- require.Contains(t, degraded, reconnectStageMessage(reconnectStageDegraded))
306-
307- tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
308- cleared = <-out.completed
309- assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageDegraded)))
310- probed = <-out.completed
311- require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
312-
313 tr.events <- ports.LinkEvent{State: ports.LinkStateConnected}307 tr.events <- ports.LinkEvent{State: ports.LinkStateConnected}
314- cleared = <-out.completed308+ require.Eventually(t, func() bool {
315- assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))309+ var actions []uint8
310+ for _, frame := range tr.sentFrames() {
311+ if frame.Type != ports.MsgClientNotice {
312+ continue
313+ }
314+ notice, err := ports.UnmarshalClientNotice(frame.Payload)
315+ if err == nil {
316+ actions = append(actions, notice.Action)
317+ }
318+ }
319+ return len(actions) == 2 && actions[0] == ports.ClientNoticeLinkDegraded && actions[1] == ports.ClientNoticeLinkConnected
320+ }, time.Second, time.Millisecond)
321+ select {
322+ case bytes := <-out.completed:
323+ t.Fatalf("link notices wrote client-local terminal bytes: %q", bytes)
324+ default:
325+ }
316 326
317 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}327 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
318 result := <-resultCh328 result := <-resultCh
@@ -338,44 +348,21 @@ func TestAttachAttemptOfflineLinkEventReturnsReconnectableError(t *testing.T) {
338 require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")348 require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")
339 }349 }
340 350
341-func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {351+func TestDetachedResultReplacementAndCleanDetach(t *testing.T) {
342- bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24})352+ if err := detachedResult(ports.ReasonDetach); err != nil {
343- require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)353+ t.Fatalf("clean detach = %v, want nil", err)
344-}
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 }354 }
356- for _, tt := range tests {355+ err := detachedResult(ports.ReasonReplaced)
357- t.Run(tt.name, func(t *testing.T) {356+ var detached *DetachedError
358- require.Equal(t, tt.want, isClientToastResyncFrame(tt.out))357+ if !errors.As(err, &detached) {
359- })358+ t.Fatalf("replacement error = %T, want *DetachedError", err)
360 }359 }
360+ require.Equal(t, "session taken over by another client", detached.Text)
361 }361 }
362 362
363-func TestClientToastDrawsWithoutInputPumpTerminalWrites(t *testing.T) {363+func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {
364- out := newReconnectToastOutputRecorder()364+ bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24})
365- term := newReconnectToastTerminalHarnessWithOutput(t, out)365+ require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)
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 }366 }
380 367
381 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {368 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
M internal/usecase/daemon/client.go +41 -1
@@ -575,7 +575,7 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc
575575 if !blockedRender {
576576 // A healthy idle client observes the required replacement notice. Its
577577 // watchdog still bounds a peer that stops draining after this check.
578- d.boundedSend(old, frameDetached(ports.ReasonDetach))
578+ d.boundedSend(old, frameDetached(ports.ReasonReplaced))
579579 }
580580 _ = old.closeCapturedTransport(old.revokeTransport(oldTransport))
581581 d.unregisterPreview(old)
@@ -665,6 +665,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
665665 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
666666 d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
667667 }
668+ case ports.MsgClientNotice:
669+ if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
670+ d.handleClientNotice(sess, ac, notice)
671+ } else {
672+ d.log.Warn("malformed client notice", "err", derr)
673+ }
668674 case ports.MsgDetach:
669675 d.clientGone(sess, ac, tr, true)
670676 return
@@ -754,6 +760,40 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
754760 d.requestTransactionalResize(sess, ac, sz, false)
755761 }
756762
763+// handleClientNotice maps the closed client-event enum to daemon-owned notice
764+// content. The connection loop has already bound sess and ac to this transport;
765+// verify that ownership is still current before changing presentation state.
766+func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
767+ sess.mu.Lock()
768+ current := sess.client == ac
769+ sess.mu.Unlock()
770+ if !current {
771+ return
772+ }
773+
774+ switch notice.Action {
775+ case ports.ClientNoticeClipboardFallback:
776+ d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
777+ case ports.ClientNoticeClipboardTooLarge:
778+ d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
779+ case ports.ClientNoticeLinkDegraded:
780+ d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
781+ case ports.ClientNoticeLinkConnected:
782+ d.dismissToast(ac, domain.NoticeConnection, sess.id)
783+ }
784+}
785+
786+func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) {
787+ n := d.notices.record(domain.Notification{
788+ Code: code,
789+ Severity: sev,
790+ Message: message,
791+ Time: d.clock.Now(),
792+ SessionID: sess.id,
793+ })
794+ d.showToast(ac, n)
795+}
796+
757797 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
758798 // returned value reports whether the synchronous request was accepted.
759799 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
@@ -575,7 +575,7 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc
575 if !blockedRender {575 if !blockedRender {
576 // A healthy idle client observes the required replacement notice. Its576 // A healthy idle client observes the required replacement notice. Its
577 // watchdog still bounds a peer that stops draining after this check.577 // watchdog still bounds a peer that stops draining after this check.
578- d.boundedSend(old, frameDetached(ports.ReasonDetach))578+ d.boundedSend(old, frameDetached(ports.ReasonReplaced))
579 }579 }
580 _ = old.closeCapturedTransport(old.revokeTransport(oldTransport))580 _ = old.closeCapturedTransport(old.revokeTransport(oldTransport))
581 d.unregisterPreview(old)581 d.unregisterPreview(old)
@@ -665,6 +665,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
665 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {665 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
666 d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)666 d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
667 }667 }
668+ case ports.MsgClientNotice:
669+ if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
670+ d.handleClientNotice(sess, ac, notice)
671+ } else {
672+ d.log.Warn("malformed client notice", "err", derr)
673+ }
668 case ports.MsgDetach:674 case ports.MsgDetach:
669 d.clientGone(sess, ac, tr, true)675 d.clientGone(sess, ac, tr, true)
670 return676 return
@@ -754,6 +760,40 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
754 d.requestTransactionalResize(sess, ac, sz, false)760 d.requestTransactionalResize(sess, ac, sz, false)
755 }761 }
756 762
763+// handleClientNotice maps the closed client-event enum to daemon-owned notice
764+// content. The connection loop has already bound sess and ac to this transport;
765+// verify that ownership is still current before changing presentation state.
766+func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
767+ sess.mu.Lock()
768+ current := sess.client == ac
769+ sess.mu.Unlock()
770+ if !current {
771+ return
772+ }
773+
774+ switch notice.Action {
775+ case ports.ClientNoticeClipboardFallback:
776+ d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
777+ case ports.ClientNoticeClipboardTooLarge:
778+ d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
779+ case ports.ClientNoticeLinkDegraded:
780+ d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
781+ case ports.ClientNoticeLinkConnected:
782+ d.dismissToast(ac, domain.NoticeConnection, sess.id)
783+ }
784+}
785+
786+func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) {
787+ n := d.notices.record(domain.Notification{
788+ Code: code,
789+ Severity: sev,
790+ Message: message,
791+ Time: d.clock.Now(),
792+ SessionID: sess.id,
793+ })
794+ d.showToast(ac, n)
795+}
796+
757 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The797 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
758 // returned value reports whether the synchronous request was accepted.798 // returned value reports whether the synchronous request was accepted.
759 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {799 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
M internal/usecase/daemon/notify.go +22 -0
@@ -269,6 +269,28 @@ func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.
269269 return -1
270270 }
271271
272+// dismissToast removes only the specified visible toast. In particular, a
273+// link-connected event must not clear clipboard or other notice state.
274+func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) {
275+ if ac == nil || ac.overlays == nil {
276+ return
277+ }
278+ rt := ac.overlays
279+ rt.noticeMu.Lock()
280+ i := rt.indexOfToastLocked(code, sid)
281+ if i < 0 {
282+ rt.noticeMu.Unlock()
283+ return
284+ }
285+ rt.noticeToasts[i].timer.stop()
286+ rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)
287+ if len(rt.noticeToasts) == 0 {
288+ rt.noticeOverflow = 0
289+ }
290+ rt.noticeMu.Unlock()
291+ d.repaintForNotice(ac)
292+}
293+
272294 // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the
273295 // timer goroutine re-acquires it and releases it before repainting, so the
274296 // callback never runs with noticeMu held.
@@ -269,6 +269,28 @@ func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.
269 return -1269 return -1
270 }270 }
271 271
272+// dismissToast removes only the specified visible toast. In particular, a
273+// link-connected event must not clear clipboard or other notice state.
274+func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) {
275+ if ac == nil || ac.overlays == nil {
276+ return
277+ }
278+ rt := ac.overlays
279+ rt.noticeMu.Lock()
280+ i := rt.indexOfToastLocked(code, sid)
281+ if i < 0 {
282+ rt.noticeMu.Unlock()
283+ return
284+ }
285+ rt.noticeToasts[i].timer.stop()
286+ rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)
287+ if len(rt.noticeToasts) == 0 {
288+ rt.noticeOverflow = 0
289+ }
290+ rt.noticeMu.Unlock()
291+ d.repaintForNotice(ac)
292+}
293+
272 // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the294 // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the
273 // timer goroutine re-acquires it and releases it before repainting, so the295 // timer goroutine re-acquires it and releases it before repainting, so the
274 // callback never runs with noticeMu held.296 // callback never runs with noticeMu held.
M internal/usecase/daemon/notify_test.go +49 -0
@@ -14,8 +14,57 @@ import (
1414
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1718 )
1819
20+func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
21+ d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
22+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})
23+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardTooLarge})
24+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})
25+
26+ toasts, _ := visibleToasts(ac)
27+ require.Len(t, toasts, 3)
28+ require.Equal(t, []domain.Notification{
29+ {Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", SessionID: sess.id, Count: 1},
30+ {Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", SessionID: sess.id, Count: 1},
31+ {Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", SessionID: sess.id, Count: 1},
32+ }, stripNoticeTimes(toasts))
33+
34+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})
35+ toasts, _ = visibleToasts(ac)
36+ require.Len(t, toasts, 2)
37+ for _, toast := range toasts {
38+ require.NotEqual(t, domain.NoticeConnection, toast.Code)
39+ }
40+}
41+
42+func TestMalformedClientNoticeIsIgnored(t *testing.T) {
43+ d, _, ac, _ := newNoticeFixture(t, &noticeClock{})
44+ tr := ac.tr.(*portsmocks.MockTransport)
45+ frames := []ports.Frame{
46+ {Type: ports.MsgClientNotice, Payload: []byte{0xff}},
47+ {Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
48+ }
49+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
50+ frame := frames[0]
51+ frames = frames[1:]
52+ return frame, nil
53+ }).Times(2)
54+ d.runConnLoop(ac)
55+ toasts, _ := visibleToasts(ac)
56+ require.Empty(t, toasts)
57+ require.Empty(t, d.notices.history())
58+}
59+
60+func stripNoticeTimes(notices []domain.Notification) []domain.Notification {
61+ out := append([]domain.Notification(nil), notices...)
62+ for i := range out {
63+ out[i].Time = time.Time{}
64+ }
65+ return out
66+}
67+
1968 func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {
2069 return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid}
2170 }
@@ -14,8 +14,57 @@ import (
14 14
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 )18 )
18 19
20+func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
21+ d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
22+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})
23+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardTooLarge})
24+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})
25+
26+ toasts, _ := visibleToasts(ac)
27+ require.Len(t, toasts, 3)
28+ require.Equal(t, []domain.Notification{
29+ {Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", SessionID: sess.id, Count: 1},
30+ {Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", SessionID: sess.id, Count: 1},
31+ {Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", SessionID: sess.id, Count: 1},
32+ }, stripNoticeTimes(toasts))
33+
34+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})
35+ toasts, _ = visibleToasts(ac)
36+ require.Len(t, toasts, 2)
37+ for _, toast := range toasts {
38+ require.NotEqual(t, domain.NoticeConnection, toast.Code)
39+ }
40+}
41+
42+func TestMalformedClientNoticeIsIgnored(t *testing.T) {
43+ d, _, ac, _ := newNoticeFixture(t, &noticeClock{})
44+ tr := ac.tr.(*portsmocks.MockTransport)
45+ frames := []ports.Frame{
46+ {Type: ports.MsgClientNotice, Payload: []byte{0xff}},
47+ {Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
48+ }
49+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
50+ frame := frames[0]
51+ frames = frames[1:]
52+ return frame, nil
53+ }).Times(2)
54+ d.runConnLoop(ac)
55+ toasts, _ := visibleToasts(ac)
56+ require.Empty(t, toasts)
57+ require.Empty(t, d.notices.history())
58+}
59+
60+func stripNoticeTimes(notices []domain.Notification) []domain.Notification {
61+ out := append([]domain.Notification(nil), notices...)
62+ for i := range out {
63+ out[i].Time = time.Time{}
64+ }
65+ return out
66+}
67+
19 func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {68 func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {
20 return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid}69 return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid}
21 }70 }
M internal/usecase/daemon/picker.go +1 -1
@@ -434,7 +434,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
434434 d.unregisterPreview(old)
435435 old.clearPreviousSession()
436436 old.setSession(nil)
437- d.notifyDetachedAsync(old, ports.ReasonDetach)
437+ d.notifyDetachedAsync(old, ports.ReasonReplaced)
438438 }
439439 if targetSess == from {
440440 d.activateTab(from, from.activeTab())
@@ -434,7 +434,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
434 d.unregisterPreview(old)434 d.unregisterPreview(old)
435 old.clearPreviousSession()435 old.clearPreviousSession()
436 old.setSession(nil)436 old.setSession(nil)
437- d.notifyDetachedAsync(old, ports.ReasonDetach)437+ d.notifyDetachedAsync(old, ports.ReasonReplaced)
438 }438 }
439 if targetSess == from {439 if targetSess == from {
440 d.activateTab(from, from.activeTab())440 d.activateTab(from, from.activeTab())
M internal/usecase/daemon/picker_test.go +1 -1
@@ -399,7 +399,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
399399 det := awaitFrame(t, sends2, ports.MsgDetached)
400400 dm, err := ports.UnmarshalDetached(det.Payload)
401401 require.NoError(t, err)
402- require.Equal(t, ports.ReasonDetach, dm.Reason)
402+ require.Equal(t, ports.ReasonReplaced, dm.Reason)
403403 awaitFrame(t, sends1, ports.MsgOutput)
404404 }
405405
@@ -399,7 +399,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
399 det := awaitFrame(t, sends2, ports.MsgDetached)399 det := awaitFrame(t, sends2, ports.MsgDetached)
400 dm, err := ports.UnmarshalDetached(det.Payload)400 dm, err := ports.UnmarshalDetached(det.Payload)
401 require.NoError(t, err)401 require.NoError(t, err)
402- require.Equal(t, ports.ReasonDetach, dm.Reason)402+ require.Equal(t, ports.ReasonReplaced, dm.Reason)
403 awaitFrame(t, sends1, ports.MsgOutput)403 awaitFrame(t, sends1, ports.MsgOutput)
404 }404 }
405 405
M internal/usecase/daemon/session_test.go +1 -1
@@ -306,7 +306,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
306306 // A is notified it was detached.
307307 dA := awaitFrame(t, sendsA, ports.MsgDetached)
308308 det, _ := ports.UnmarshalDetached(dA.Payload)
309- require.Equal(t, ports.ReasonDetach, det.Reason)
309+ require.Equal(t, ports.ReasonReplaced, det.Reason)
310310
311311 // B is now the current client.
312312 sess.mu.Lock()
@@ -306,7 +306,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
306 // A is notified it was detached.306 // A is notified it was detached.
307 dA := awaitFrame(t, sendsA, ports.MsgDetached)307 dA := awaitFrame(t, sendsA, ports.MsgDetached)
308 det, _ := ports.UnmarshalDetached(dA.Payload)308 det, _ := ports.UnmarshalDetached(dA.Payload)
309- require.Equal(t, ports.ReasonDetach, det.Reason)309+ require.Equal(t, ports.ReasonReplaced, det.Reason)
310 310
311 // B is now the current client.311 // B is now the current client.
312 sess.mu.Lock()312 sess.mu.Lock()

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