detached 11ffa7d

feat(protocol): select an attached tab in place

Add the SelectTab client message so the client picker can switch the
live attachment to another tab of the same session without
reconnecting. The daemon moves only the requesting attachment's view
and refuses an unknown tab without repairing the view.

Bumps protocol.Version 58 -> 59; this is the plan's single bump, so
later layout changes in this plan fold into 59.
commit
11ffa7d3869a6541396c757091e306e6007e336d
author
brice · · 2026-09-22 20:07 UTC
parent
ee2521c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +375 -115
M internal/adapters/sessionwire/client_test.go +1 -0
@@ -34,6 +34,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
3434 {name: "reset", message: protocol.OutputResetRequest{}},
3535 {name: "UI fence", message: protocol.UIFence{ActionID: 7}},
3636 {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}},
37+ {name: "select tab", message: protocol.SelectTab{TabID: "tab-2"}},
3738 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},
3839 {name: "attention", message: protocol.RouteAttentionSubscription{}},
3940 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}},
@@ -34,6 +34,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
34 {name: "reset", message: protocol.OutputResetRequest{}},34 {name: "reset", message: protocol.OutputResetRequest{}},
35 {name: "UI fence", message: protocol.UIFence{ActionID: 7}},35 {name: "UI fence", message: protocol.UIFence{ActionID: 7}},
36 {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}},36 {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}},
37+ {name: "select tab", message: protocol.SelectTab{TabID: "tab-2"}},
37 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},38 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},
38 {name: "attention", message: protocol.RouteAttentionSubscription{}},39 {name: "attention", message: protocol.RouteAttentionSubscription{}},
39 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}},40 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}},
M internal/adapters/sessionwire/proto_convert_misc.go +18 -0
@@ -325,6 +325,24 @@ func uiFenceFromWire(message *wire.UIFence) (protocol.UIFence, error) {
325325 return protocol.UIFence{ActionID: message.GetActionId()}, nil
326326 }
327327
328+func selectTabToWire(message protocol.SelectTab) (*wire.SelectTab, error) {
329+ if err := message.Validate(); err != nil {
330+ return nil, err
331+ }
332+ return &wire.SelectTab{TabId: string(message.TabID)}, nil
333+}
334+
335+func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) {
336+ if message == nil {
337+ return protocol.SelectTab{}, errProtoConvertRange
338+ }
339+ selected := protocol.SelectTab{TabID: domain.TabStableID(message.GetTabId())}
340+ if err := selected.Validate(); err != nil {
341+ return protocol.SelectTab{}, err
342+ }
343+ return selected, nil
344+}
345+
328346 func uiReceiptToWire(message protocol.UIReceipt) (*wire.UIReceipt, error) {
329347 if err := message.Validate(); err != nil {
330348 return nil, err
@@ -325,6 +325,24 @@ func uiFenceFromWire(message *wire.UIFence) (protocol.UIFence, error) {
325 return protocol.UIFence{ActionID: message.GetActionId()}, nil325 return protocol.UIFence{ActionID: message.GetActionId()}, nil
326 }326 }
327 327
328+func selectTabToWire(message protocol.SelectTab) (*wire.SelectTab, error) {
329+ if err := message.Validate(); err != nil {
330+ return nil, err
331+ }
332+ return &wire.SelectTab{TabId: string(message.TabID)}, nil
333+}
334+
335+func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) {
336+ if message == nil {
337+ return protocol.SelectTab{}, errProtoConvertRange
338+ }
339+ selected := protocol.SelectTab{TabID: domain.TabStableID(message.GetTabId())}
340+ if err := selected.Validate(); err != nil {
341+ return protocol.SelectTab{}, err
342+ }
343+ return selected, nil
344+}
345+
328 func uiReceiptToWire(message protocol.UIReceipt) (*wire.UIReceipt, error) {346 func uiReceiptToWire(message protocol.UIReceipt) (*wire.UIReceipt, error) {
329 if err := message.Validate(); err != nil {347 if err := message.Validate(); err != nil {
330 return nil, err348 return nil, err
M internal/adapters/sessionwire/proto_convert_test.go +42 -0
@@ -50,6 +50,7 @@ func TestProtoClientRoundTrips(t *testing.T) {
5050 protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"},
5151 protocol.OutputResetRequest{},
5252 protocol.UIFence{ActionID: 7},
53+ protocol.SelectTab{TabID: "tab-2"},
5354 protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1},
5455 protocol.RouteAttentionSubscription{},
5556 protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}},
@@ -250,6 +251,47 @@ func TestProtoScanNegatives(t *testing.T) {
250251 require.NoError(t, proto.Unmarshal(duplicate, envelope))
251252 }
252253
254+// TestProtoSelectTabWire pins the SelectTab payload byte for byte and refuses
255+// a malformed tab identity, a truncated prefix, and trailing garbage.
256+func TestProtoSelectTabWire(t *testing.T) {
257+ envelope, err := encodeProtoClient(protocol.SelectTab{TabID: "t1"})
258+ require.NoError(t, err)
259+ raw, err := proto.Marshal(envelope)
260+ require.NoError(t, err)
261+ // field 31 (length-delimited) -> SelectTab{field 1 = "t1"}.
262+ require.Equal(t, []byte{0xFA, 0x01, 0x04, 0x0A, 0x02, 't', '1'}, raw)
263+
264+ tests := []struct {
265+ name string
266+ raw []byte
267+ wantErr bool
268+ }{
269+ {name: "exact", raw: raw},
270+ {name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true},
271+ {name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true},
272+ }
273+ for _, tt := range tests {
274+ t.Run(tt.name, func(t *testing.T) {
275+ err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw)
276+ if tt.wantErr {
277+ require.Error(t, err)
278+ return
279+ }
280+ require.NoError(t, err)
281+ decoded := &wire.ClientEnvelope{}
282+ require.NoError(t, proto.Unmarshal(tt.raw, decoded))
283+ got, err := decodeProtoClient(decoded)
284+ require.NoError(t, err)
285+ require.Equal(t, protocol.SelectTab{TabID: "t1"}, got)
286+ })
287+ }
288+
289+ _, err = encodeProtoClient(protocol.SelectTab{})
290+ require.Error(t, err, "an empty tab identity is never encoded")
291+ _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: &wire.SelectTab{}}})
292+ require.Error(t, err, "an empty tab identity is never decoded")
293+}
294+
253295 func protoMessageName(message any) string {
254296 return protoShortName(message)
255297 }
@@ -50,6 +50,7 @@ func TestProtoClientRoundTrips(t *testing.T) {
50 protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"},50 protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"},
51 protocol.OutputResetRequest{},51 protocol.OutputResetRequest{},
52 protocol.UIFence{ActionID: 7},52 protocol.UIFence{ActionID: 7},
53+ protocol.SelectTab{TabID: "tab-2"},
53 protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1},54 protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1},
54 protocol.RouteAttentionSubscription{},55 protocol.RouteAttentionSubscription{},
55 protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}},56 protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}},
@@ -250,6 +251,47 @@ func TestProtoScanNegatives(t *testing.T) {
250 require.NoError(t, proto.Unmarshal(duplicate, envelope))251 require.NoError(t, proto.Unmarshal(duplicate, envelope))
251 }252 }
252 253
254+// TestProtoSelectTabWire pins the SelectTab payload byte for byte and refuses
255+// a malformed tab identity, a truncated prefix, and trailing garbage.
256+func TestProtoSelectTabWire(t *testing.T) {
257+ envelope, err := encodeProtoClient(protocol.SelectTab{TabID: "t1"})
258+ require.NoError(t, err)
259+ raw, err := proto.Marshal(envelope)
260+ require.NoError(t, err)
261+ // field 31 (length-delimited) -> SelectTab{field 1 = "t1"}.
262+ require.Equal(t, []byte{0xFA, 0x01, 0x04, 0x0A, 0x02, 't', '1'}, raw)
263+
264+ tests := []struct {
265+ name string
266+ raw []byte
267+ wantErr bool
268+ }{
269+ {name: "exact", raw: raw},
270+ {name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true},
271+ {name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true},
272+ }
273+ for _, tt := range tests {
274+ t.Run(tt.name, func(t *testing.T) {
275+ err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw)
276+ if tt.wantErr {
277+ require.Error(t, err)
278+ return
279+ }
280+ require.NoError(t, err)
281+ decoded := &wire.ClientEnvelope{}
282+ require.NoError(t, proto.Unmarshal(tt.raw, decoded))
283+ got, err := decodeProtoClient(decoded)
284+ require.NoError(t, err)
285+ require.Equal(t, protocol.SelectTab{TabID: "t1"}, got)
286+ })
287+ }
288+
289+ _, err = encodeProtoClient(protocol.SelectTab{})
290+ require.Error(t, err, "an empty tab identity is never encoded")
291+ _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: &wire.SelectTab{}}})
292+ require.Error(t, err, "an empty tab identity is never decoded")
293+}
294+
253 func protoMessageName(message any) string {295 func protoMessageName(message any) string {
254 return protoShortName(message)296 return protoShortName(message)
255 }297 }
M internal/adapters/sessionwire/proto_envelope.go +13 -0
@@ -148,6 +148,17 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
148148 return nil, ErrInvalidMessage
149149 }
150150 return encodeProtoClient(*m)
151+ case protocol.SelectTab:
152+ converted, err := selectTabToWire(m)
153+ if err != nil {
154+ return nil, err
155+ }
156+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: converted}}, nil
157+ case *protocol.SelectTab:
158+ if m == nil {
159+ return nil, ErrInvalidMessage
160+ }
161+ return encodeProtoClient(*m)
151162 case protocol.UIFence:
152163 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil
153164 case *protocol.UIFence:
@@ -652,6 +663,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
652663 return protocol.OutputResetRequest{}, nil
653664 case *wire.ClientEnvelope_UiFence:
654665 return uiFenceFromWire(payload.UiFence)
666+ case *wire.ClientEnvelope_SelectTab:
667+ return selectTabFromWire(payload.SelectTab)
655668 case *wire.ClientEnvelope_RemotePreviewRequest:
656669 return remotePreviewRequestFromWire(payload.RemotePreviewRequest)
657670 case *wire.ClientEnvelope_RouteAttentionSubscription:
@@ -148,6 +148,17 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
148 return nil, ErrInvalidMessage148 return nil, ErrInvalidMessage
149 }149 }
150 return encodeProtoClient(*m)150 return encodeProtoClient(*m)
151+ case protocol.SelectTab:
152+ converted, err := selectTabToWire(m)
153+ if err != nil {
154+ return nil, err
155+ }
156+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_SelectTab{SelectTab: converted}}, nil
157+ case *protocol.SelectTab:
158+ if m == nil {
159+ return nil, ErrInvalidMessage
160+ }
161+ return encodeProtoClient(*m)
151 case protocol.UIFence:162 case protocol.UIFence:
152 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil163 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil
153 case *protocol.UIFence:164 case *protocol.UIFence:
@@ -652,6 +663,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
652 return protocol.OutputResetRequest{}, nil663 return protocol.OutputResetRequest{}, nil
653 case *wire.ClientEnvelope_UiFence:664 case *wire.ClientEnvelope_UiFence:
654 return uiFenceFromWire(payload.UiFence)665 return uiFenceFromWire(payload.UiFence)
666+ case *wire.ClientEnvelope_SelectTab:
667+ return selectTabFromWire(payload.SelectTab)
655 case *wire.ClientEnvelope_RemotePreviewRequest:668 case *wire.ClientEnvelope_RemotePreviewRequest:
656 return remotePreviewRequestFromWire(payload.RemotePreviewRequest)669 return remotePreviewRequestFromWire(payload.RemotePreviewRequest)
657 case *wire.ClientEnvelope_RouteAttentionSubscription:670 case *wire.ClientEnvelope_RouteAttentionSubscription:
M internal/adapters/sessionwire/server_test.go +1 -0
@@ -34,6 +34,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
3434 {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},
3535 {name: "command", want: protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}},
3636 {name: "reset", want: protocol.OutputResetRequest{}},
37+ {name: "select tab", want: protocol.SelectTab{TabID: "tab-2"}},
3738 {name: "preview", want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},
3839 {name: "attention", want: protocol.RouteAttentionSubscription{}},
3940 {name: "same peer", want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}},
@@ -34,6 +34,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
34 {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},34 {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},
35 {name: "command", want: protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}},35 {name: "command", want: protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}},
36 {name: "reset", want: protocol.OutputResetRequest{}},36 {name: "reset", want: protocol.OutputResetRequest{}},
37+ {name: "select tab", want: protocol.SelectTab{TabID: "tab-2"}},
37 {name: "preview", want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},38 {name: "preview", want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},
38 {name: "attention", want: protocol.RouteAttentionSubscription{}},39 {name: "attention", want: protocol.RouteAttentionSubscription{}},
39 {name: "same peer", want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}},40 {name: "same peer", want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}},
M internal/protocol/messages.go +1 -0
@@ -19,6 +19,7 @@ func (ImagePush) clientMessage() {}
1919 func (ClientNotice) clientMessage() {}
2020 func (CommandRequest) clientMessage() {}
2121 func (OutputResetRequest) clientMessage() {}
22+func (SelectTab) clientMessage() {}
2223 func (RemotePreviewRequest) clientMessage() {}
2324 func (RouteAttentionSubscription) clientMessage() {}
2425 func (SamePeerSwitchRequest) clientMessage() {}
@@ -19,6 +19,7 @@ func (ImagePush) clientMessage() {}
19 func (ClientNotice) clientMessage() {}19 func (ClientNotice) clientMessage() {}
20 func (CommandRequest) clientMessage() {}20 func (CommandRequest) clientMessage() {}
21 func (OutputResetRequest) clientMessage() {}21 func (OutputResetRequest) clientMessage() {}
22+func (SelectTab) clientMessage() {}
22 func (RemotePreviewRequest) clientMessage() {}23 func (RemotePreviewRequest) clientMessage() {}
23 func (RouteAttentionSubscription) clientMessage() {}24 func (RouteAttentionSubscription) clientMessage() {}
24 func (SamePeerSwitchRequest) clientMessage() {}25 func (SamePeerSwitchRequest) clientMessage() {}
M internal/protocol/session.go +14 -1
@@ -16,7 +16,7 @@ import (
1616 // daemonmux physical preamble conversation (multiplex.proto), whose
1717 // negotiated ceilings and accepted daemon binding ride the same wire
1818 // version as the session and broker conversations.
19-const Version uint16 = 58
19+const Version uint16 = 59
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
@@ -344,6 +344,19 @@ type SessionInfo struct {
344344 type Sessions struct{ Sessions []SessionInfo }
345345 type OutputResetRequest struct{}
346346
347+// SelectTab asks the daemon to show one exact tab of the attached session on
348+// this attachment's own view. It is the client picker's in-place tab switch:
349+// the attachment and its stream stay live, and a tab that no longer exists is
350+// refused without changing the view.
351+type SelectTab struct {
352+ TabID domain.TabStableID
353+}
354+
355+// Validate requires a well-formed stable tab identity.
356+func (m SelectTab) Validate() error {
357+ return domain.ValidateTabStableID(m.TabID)
358+}
359+
347360 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
348361 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
349362 }
@@ -16,7 +16,7 @@ import (
16 // daemonmux physical preamble conversation (multiplex.proto), whose16 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire17 // negotiated ceilings and accepted daemon binding ride the same wire
18 // version as the session and broker conversations.18 // version as the session and broker conversations.
19-const Version uint16 = 5819+const Version uint16 = 59
20 20
21 // HandshakeTimeout bounds every transport handshake from connect through the21 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local22 // first committed publication. It excludes the preceding client-local
@@ -344,6 +344,19 @@ type SessionInfo struct {
344 type Sessions struct{ Sessions []SessionInfo }344 type Sessions struct{ Sessions []SessionInfo }
345 type OutputResetRequest struct{}345 type OutputResetRequest struct{}
346 346
347+// SelectTab asks the daemon to show one exact tab of the attached session on
348+// this attachment's own view. It is the client picker's in-place tab switch:
349+// the attachment and its stream stay live, and a tab that no longer exists is
350+// refused without changing the view.
351+type SelectTab struct {
352+ TabID domain.TabStableID
353+}
354+
355+// Validate requires a well-formed stable tab identity.
356+func (m SelectTab) Validate() error {
357+ return domain.ValidateTabStableID(m.TabID)
358+}
359+
347 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {360 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
348 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned361 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
349 }362 }
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(58), Version)
13+ require.Equal(t, uint16(59), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(58), Version)13+ require.Equal(t, uint16(59), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/envelope.pb.go +87 -66 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +3 -3
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 58 {
19- t.Fatalf("protocol.Version = %d, want 58", protocol.Version)
18+ if protocol.Version != 59 {
19+ t.Fatalf("protocol.Version = %d, want 59", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
4040 "ui_fence": true, "navigation_inventory_request": true,
4141 "navigation_inventory_publication": true, "navigation_inventory_failure": true,
4242 "picker_close": true, "picker_selection": true,
43- "picker_preview_request": true,
43+ "picker_preview_request": true, "select_tab": true,
4444 }
4545 serverSemantic := map[string]bool{
4646 "attachment_suspended": true, "attachment_activated": true,
@@ -15,8 +15,8 @@ import (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 58 {18+ if protocol.Version != 59 {
19- t.Fatalf("protocol.Version = %d, want 58", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 59", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
40 "ui_fence": true, "navigation_inventory_request": true,40 "ui_fence": true, "navigation_inventory_request": true,
41 "navigation_inventory_publication": true, "navigation_inventory_failure": true,41 "navigation_inventory_publication": true, "navigation_inventory_failure": true,
42 "picker_close": true, "picker_selection": true,42 "picker_close": true, "picker_selection": true,
43- "picker_preview_request": true,43+ "picker_preview_request": true, "select_tab": true,
44 }44 }
45 serverSemantic := map[string]bool{45 serverSemantic := map[string]bool{
46 "attachment_suspended": true, "attachment_activated": true,46 "attachment_suspended": true, "attachment_activated": true,
M internal/protocol/wire/schema/envelope.proto +1 -0
@@ -96,6 +96,7 @@ message ClientEnvelope {
9696 PickerPreviewRequest picker_preview_request = 27;
9797 SuspendAttachment suspend_attachment = 29;
9898 ActivateAttachment activate_attachment = 30;
99+ SelectTab select_tab = 31;
99100 }
100101 }
101102
@@ -96,6 +96,7 @@ message ClientEnvelope {
96 PickerPreviewRequest picker_preview_request = 27;96 PickerPreviewRequest picker_preview_request = 27;
97 SuspendAttachment suspend_attachment = 29;97 SuspendAttachment suspend_attachment = 29;
98 ActivateAttachment activate_attachment = 30;98 ActivateAttachment activate_attachment = 30;
99+ SelectTab select_tab = 31;
99 }100 }
100 }101 }
101 102
M internal/protocol/wire/schema/terminal.proto +5 -0
@@ -86,6 +86,11 @@ message ClientNotice {
8686 // OutputResetRequest restarts the output chain. Empty marker.
8787 message OutputResetRequest {}
8888
89+// SelectTab shows one exact tab of the attached session on this attachment.
90+message SelectTab {
91+ string tab_id = 1;
92+}
93+
8994 // UIFence orders UI operations by action.
9095 message UIFence {
9196 uint64 action_id = 1;
@@ -86,6 +86,11 @@ message ClientNotice {
86 // OutputResetRequest restarts the output chain. Empty marker.86 // OutputResetRequest restarts the output chain. Empty marker.
87 message OutputResetRequest {}87 message OutputResetRequest {}
88 88
89+// SelectTab shows one exact tab of the attached session on this attachment.
90+message SelectTab {
91+ string tab_id = 1;
92+}
93+
89 // UIFence orders UI operations by action.94 // UIFence orders UI operations by action.
90 message UIFence {95 message UIFence {
91 uint64 action_id = 1;96 uint64 action_id = 1;
M internal/protocol/wire/terminal.pb.go +92 -44 generated file: collapsed
A internal/usecase/daemon/attachment_select_tab.go +40 -0
@@ -0,0 +1,40 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/protocol"
6+)
7+
8+// selectTabForAttachment shows one exact tab of the attached session on this
9+// attachment's own view. It is the client picker's in-place tab switch: the
10+// attachment stays live and only its view moves. A tab the session no longer
11+// holds is refused without touching the view, so the picker never lands on a
12+// repaired neighbour it did not choose.
13+func (d *Daemon) selectTabForAttachment(effect *attachmentEffect, request protocol.SelectTab) {
14+ if request.Validate() != nil || effect == nil || !effect.current() || effect.sess == nil || effect.ac == nil {
15+ return
16+ }
17+ sess, ac := effect.sess, effect.ac
18+ var selected *tab
19+ _ = sess.runMutation(func() error {
20+ sess.mu.Lock()
21+ for _, candidate := range sess.tabs {
22+ if candidate == nil || domain.TabStableID(candidate.stableID) != request.TabID {
23+ continue
24+ }
25+ if sess.selectAttachmentTabLocked(ac, request.TabID) {
26+ selected = candidate
27+ }
28+ break
29+ }
30+ sess.mu.Unlock()
31+ if selected != nil {
32+ d.activateTabAfterResizeForLease(sess, selected, false, ac, nil)
33+ }
34+ return nil
35+ })
36+ if selected == nil {
37+ return
38+ }
39+ d.invalidateRender(sess, ac, true, "attachment_select_tab.go")
40+}
@@ -0,0 +1,40 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/protocol"
6+)
7+
8+// selectTabForAttachment shows one exact tab of the attached session on this
9+// attachment's own view. It is the client picker's in-place tab switch: the
10+// attachment stays live and only its view moves. A tab the session no longer
11+// holds is refused without touching the view, so the picker never lands on a
12+// repaired neighbour it did not choose.
13+func (d *Daemon) selectTabForAttachment(effect *attachmentEffect, request protocol.SelectTab) {
14+ if request.Validate() != nil || effect == nil || !effect.current() || effect.sess == nil || effect.ac == nil {
15+ return
16+ }
17+ sess, ac := effect.sess, effect.ac
18+ var selected *tab
19+ _ = sess.runMutation(func() error {
20+ sess.mu.Lock()
21+ for _, candidate := range sess.tabs {
22+ if candidate == nil || domain.TabStableID(candidate.stableID) != request.TabID {
23+ continue
24+ }
25+ if sess.selectAttachmentTabLocked(ac, request.TabID) {
26+ selected = candidate
27+ }
28+ break
29+ }
30+ sess.mu.Unlock()
31+ if selected != nil {
32+ d.activateTabAfterResizeForLease(sess, selected, false, ac, nil)
33+ }
34+ return nil
35+ })
36+ if selected == nil {
37+ return
38+ }
39+ d.invalidateRender(sess, ac, true, "attachment_select_tab.go")
40+}
A internal/usecase/daemon/attachment_select_tab_test.go +54 -0
@@ -0,0 +1,54 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// TestSelectTabMovesOnlyThisAttachmentView proves the picker's in-place tab
13+// switch: an exact tab selects it on the requesting attachment, a peer keeps
14+// its own view, and an unknown or malformed tab leaves the view untouched.
15+func TestSelectTabMovesOnlyThisAttachmentView(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ request func(second domain.TabStableID) protocol.SelectTab
19+ moves bool
20+ }{
21+ {name: "exact tab", request: func(second domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{TabID: second} }, moves: true},
22+ {name: "unknown tab", request: func(domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{TabID: "t_missing"} }},
23+ {name: "malformed tab", request: func(domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{} }},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ d, sess, ac, _, releases := newManualTabSession(t, 2)
28+ for _, release := range releases {
29+ defer release()
30+ }
31+ peer := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}})
32+ rc := d.attachCoordinator(sess, nil, ac, true)
33+ d.attachCoordinator(sess, nil, peer, true)
34+ token := sess.captureAttachmentCapability(ac, ac.transport())
35+ token.lease = rc.attachmentLease(ac)
36+ ac.installTestAttachmentCapability(token)
37+
38+ first := domain.TabStableID(sess.tabs[0].stableID)
39+ second := domain.TabStableID(sess.tabs[1].stableID)
40+ require.True(t, sess.selectAttachmentTab(ac, first))
41+ require.True(t, sess.selectAttachmentTab(peer, first))
42+ before := ac.viewSnapshot()
43+
44+ require.False(t, d.handleAttachmentClientMessage(token, tt.request(second)))
45+
46+ if tt.moves {
47+ require.Equal(t, second, ac.viewSnapshot().tabID)
48+ } else {
49+ require.Equal(t, before, ac.viewSnapshot(), "a refused selection never repairs the view")
50+ }
51+ require.Equal(t, first, peer.viewSnapshot().tabID, "the peer attachment keeps its own view")
52+ })
53+ }
54+}
@@ -0,0 +1,54 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// TestSelectTabMovesOnlyThisAttachmentView proves the picker's in-place tab
13+// switch: an exact tab selects it on the requesting attachment, a peer keeps
14+// its own view, and an unknown or malformed tab leaves the view untouched.
15+func TestSelectTabMovesOnlyThisAttachmentView(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ request func(second domain.TabStableID) protocol.SelectTab
19+ moves bool
20+ }{
21+ {name: "exact tab", request: func(second domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{TabID: second} }, moves: true},
22+ {name: "unknown tab", request: func(domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{TabID: "t_missing"} }},
23+ {name: "malformed tab", request: func(domain.TabStableID) protocol.SelectTab { return protocol.SelectTab{} }},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ d, sess, ac, _, releases := newManualTabSession(t, 2)
28+ for _, release := range releases {
29+ defer release()
30+ }
31+ peer := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}})
32+ rc := d.attachCoordinator(sess, nil, ac, true)
33+ d.attachCoordinator(sess, nil, peer, true)
34+ token := sess.captureAttachmentCapability(ac, ac.transport())
35+ token.lease = rc.attachmentLease(ac)
36+ ac.installTestAttachmentCapability(token)
37+
38+ first := domain.TabStableID(sess.tabs[0].stableID)
39+ second := domain.TabStableID(sess.tabs[1].stableID)
40+ require.True(t, sess.selectAttachmentTab(ac, first))
41+ require.True(t, sess.selectAttachmentTab(peer, first))
42+ before := ac.viewSnapshot()
43+
44+ require.False(t, d.handleAttachmentClientMessage(token, tt.request(second)))
45+
46+ if tt.moves {
47+ require.Equal(t, second, ac.viewSnapshot().tabID)
48+ } else {
49+ require.Equal(t, before, ac.viewSnapshot(), "a refused selection never repairs the view")
50+ }
51+ require.Equal(t, first, peer.viewSnapshot().tabID, "the peer attachment keeps its own view")
52+ })
53+ }
54+}
M internal/usecase/daemon/client_frame_routing.go +2 -0
@@ -167,6 +167,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
167167 d.resetOutput(effect)
168168 case protocol.SamePeerSwitchRequest:
169169 d.switchSamePeerForAttachment(effect, message)
170+ case protocol.SelectTab:
171+ d.selectTabForAttachment(effect, message)
170172 case protocol.RecentRouteSnapshot:
171173 replayIdentity := effect.ac.setRouteSnapshot(message)
172174 d.pokeAttentionTicker()
@@ -167,6 +167,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
167 d.resetOutput(effect)167 d.resetOutput(effect)
168 case protocol.SamePeerSwitchRequest:168 case protocol.SamePeerSwitchRequest:
169 d.switchSamePeerForAttachment(effect, message)169 d.switchSamePeerForAttachment(effect, message)
170+ case protocol.SelectTab:
171+ d.selectTabForAttachment(effect, message)
170 case protocol.RecentRouteSnapshot:172 case protocol.RecentRouteSnapshot:
171 replayIdentity := effect.ac.setRouteSnapshot(message)173 replayIdentity := effect.ac.setRouteSnapshot(message)
172 d.pokeAttentionTicker()174 d.pokeAttentionTicker()

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