detached 9db7f4b

refactor(routes): unify session handoffs

commit
9db7f4b0f7c393f1d2025b0794aeff85a24cdda0
author
brice · · 2026-08-11 07:52 UTC
parent
a4eed8a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +224 -46
M internal/app/run.go +3 -3
@@ -857,7 +857,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
857857 factory := deps.remoteDialerFactory
858858 var remoteSelection *domain.RemoteSessionTarget
859859 var remoteEnvironmentPolicy ports.EnvironmentPolicy
860- remoteDisplayOrigin := remoteTarget
860+ remoteDisplayOrigin := domain.RemoteDisplayOrigin(remoteTarget)
861861 routeOrigin := ports.RouteOriginLocal
862862 routeOriginKey := "local"
863863 if remoteTarget != "" {
@@ -897,7 +897,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
897897 return nil, client.AttachRequest{}, err
898898 }
899899 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
900- displayOrigin := target.Endpoint
900+ displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint)
901901 if selection != nil {
902902 displayOrigin = selection.DisplayOrigin
903903 }
@@ -978,7 +978,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
978978 }
979979 handoffAttempts++
980980 remoteTarget = handoffErr.Target.Endpoint
981- remoteDisplayOrigin = remoteTarget
981+ remoteDisplayOrigin = domain.RemoteDisplayOrigin(remoteTarget)
982982 routeOrigin = ports.RouteOriginDiscovery
983983 routeOriginKey = remoteTarget
984984 name = handoffErr.Target.Session
@@ -857,7 +857,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
857 factory := deps.remoteDialerFactory857 factory := deps.remoteDialerFactory
858 var remoteSelection *domain.RemoteSessionTarget858 var remoteSelection *domain.RemoteSessionTarget
859 var remoteEnvironmentPolicy ports.EnvironmentPolicy859 var remoteEnvironmentPolicy ports.EnvironmentPolicy
860- remoteDisplayOrigin := remoteTarget860+ remoteDisplayOrigin := domain.RemoteDisplayOrigin(remoteTarget)
861 routeOrigin := ports.RouteOriginLocal861 routeOrigin := ports.RouteOriginLocal
862 routeOriginKey := "local"862 routeOriginKey := "local"
863 if remoteTarget != "" {863 if remoteTarget != "" {
@@ -897,7 +897,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
897 return nil, client.AttachRequest{}, err897 return nil, client.AttachRequest{}, err
898 }898 }
899 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)899 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
900- displayOrigin := target.Endpoint900+ displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint)
901 if selection != nil {901 if selection != nil {
902 displayOrigin = selection.DisplayOrigin902 displayOrigin = selection.DisplayOrigin
903 }903 }
@@ -978,7 +978,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
978 }978 }
979 handoffAttempts++979 handoffAttempts++
980 remoteTarget = handoffErr.Target.Endpoint980 remoteTarget = handoffErr.Target.Endpoint
981- remoteDisplayOrigin = remoteTarget981+ remoteDisplayOrigin = domain.RemoteDisplayOrigin(remoteTarget)
982 routeOrigin = ports.RouteOriginDiscovery982 routeOrigin = ports.RouteOriginDiscovery
983 routeOriginKey = remoteTarget983 routeOriginKey = remoteTarget
984 name = handoffErr.Target.Session984 name = handoffErr.Target.Session
M internal/domain/remote_session.go +12 -4
@@ -37,14 +37,22 @@ func (k RemoteSessionKey) ID() SessionID {
3737 return SessionID("remote:" + host + "." + name)
3838 }
3939
40+// RemoteDisplayOrigin returns the host-facing portion of a remote endpoint.
41+// Routing retains the full user@host target; presentation consistently omits
42+// the login prefix regardless of whether the route came from the CLI or a
43+// discovered picker entry.
44+func RemoteDisplayOrigin(endpoint string) string {
45+ if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 {
46+ return endpoint[i+1:]
47+ }
48+ return endpoint
49+}
50+
4051 // Display returns the presentation label for this remote session.
4152 func (k RemoteSessionKey) Display() string {
4253 origin := k.DisplayOrigin
4354 if origin == "" {
44- origin = k.Host
45- if i := strings.LastIndexByte(origin, '@'); i > 0 && i < len(origin)-1 {
46- origin = origin[i+1:]
47- }
55+ origin = RemoteDisplayOrigin(k.Host)
4856 }
4957 return k.Name + "@" + origin
5058 }
@@ -37,14 +37,22 @@ func (k RemoteSessionKey) ID() SessionID {
37 return SessionID("remote:" + host + "." + name)37 return SessionID("remote:" + host + "." + name)
38 }38 }
39 39
40+// RemoteDisplayOrigin returns the host-facing portion of a remote endpoint.
41+// Routing retains the full user@host target; presentation consistently omits
42+// the login prefix regardless of whether the route came from the CLI or a
43+// discovered picker entry.
44+func RemoteDisplayOrigin(endpoint string) string {
45+ if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 {
46+ return endpoint[i+1:]
47+ }
48+ return endpoint
49+}
50+
40 // Display returns the presentation label for this remote session.51 // Display returns the presentation label for this remote session.
41 func (k RemoteSessionKey) Display() string {52 func (k RemoteSessionKey) Display() string {
42 origin := k.DisplayOrigin53 origin := k.DisplayOrigin
43 if origin == "" {54 if origin == "" {
44- origin = k.Host55+ origin = RemoteDisplayOrigin(k.Host)
45- if i := strings.LastIndexByte(origin, '@'); i > 0 && i < len(origin)-1 {
46- origin = origin[i+1:]
47- }
48 }56 }
49 return k.Name + "@" + origin57 return k.Name + "@" + origin
50 }58 }
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 30
15+const ProtocolVersion uint16 = 31
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 3015+const ProtocolVersion uint16 = 31
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 30 {
7- t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion)
6+ if ProtocolVersion != 31 {
7+ t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion)
88 }
99 }
1010
@@ -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 != 30 {6+ if ProtocolVersion != 31 {
7- t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/navigation_test.go +1 -1
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
6262 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
6363 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
6464 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001e02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
6666 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
6767 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
6868 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001e02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/remote_preview_hardening_test.go +1 -0
@@ -200,6 +200,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
200200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
201201 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
202202 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
203+ 0x00,
203204 }
204205 if !bytes.Equal(got, want) {
205206 t.Fatalf("rich target bytes = %x, want %x", got, want)
@@ -200,6 +200,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
201 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,201 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
202 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,202 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
203+ 0x00,
203 }204 }
204 if !bytes.Equal(got, want) {205 if !bytes.Equal(got, want) {
205 t.Fatalf("rich target bytes = %x, want %x", got, want)206 t.Fatalf("rich target bytes = %x, want %x", got, want)
M internal/ports/remote_target_wire_test.go +2 -2
@@ -139,7 +139,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
139139 })
140140 },
141141 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
142- policyOffset: 1,
142+ policyOffset: 2,
143143 wantErr: ErrInvalidAttachTarget,
144144 },
145145 }
@@ -195,7 +195,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
195195 }
196196
197197 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
198- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
198+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0}
199199 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
200200 if !bytes.Equal(got, want) {
201201 t.Fatalf("target bytes = %x, want %x", got, want)
@@ -139,7 +139,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
139 })139 })
140 },140 },
141 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },141 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
142- policyOffset: 1,142+ policyOffset: 2,
143 wantErr: ErrInvalidAttachTarget,143 wantErr: ErrInvalidAttachTarget,
144 },144 },
145 }145 }
@@ -195,7 +195,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
195 }195 }
196 196
197 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {197 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
198- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}198+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0}
199 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})199 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
200 if !bytes.Equal(got, want) {200 if !bytes.Equal(got, want) {
201 t.Fatalf("target bytes = %x, want %x", got, want)201 t.Fatalf("target bytes = %x, want %x", got, want)
M internal/ports/wire.go +19 -4
@@ -293,6 +293,7 @@ type AttachTarget struct {
293293 Session string
294294 Intent uint8
295295 RemoteTarget *domain.RemoteSessionTarget
296+ ExactTarget *ExactSessionTarget
296297 EnvironmentPolicy EnvironmentPolicy
297298 }
298299
@@ -806,10 +807,14 @@ func ValidateAck(m Ack) error {
806807 return nil
807808 }
808809
809-// ValidateAttachTarget validates route fields and, when present, the exact
810-// picker identity carried by the handoff.
810+// ValidateAttachTarget validates a client-owned route handoff. An empty
811+// Endpoint selects another session on the currently connected daemon; a
812+// non-empty Endpoint selects a discovered remote daemon.
811813 func ValidateAttachTarget(m AttachTarget) error {
812- if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
814+ if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
815+ return ErrInvalidAttachTarget
816+ }
817+ if err := domain.ValidateSessionName(m.Session); err != nil {
813818 return ErrInvalidAttachTarget
814819 }
815820 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
@@ -821,6 +826,9 @@ func ValidateAttachTarget(m AttachTarget) error {
821826 if !validEnvironmentPolicy(m.EnvironmentPolicy) {
822827 return ErrInvalidAttachTarget
823828 }
829+ if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
830+ return ErrInvalidAttachTarget
831+ }
824832 if m.RemoteTarget == nil {
825833 return nil
826834 }
@@ -1498,6 +1506,7 @@ func MarshalAttachTarget(m AttachTarget) []byte {
14981506 w.putString(m.Session)
14991507 w.putUint8(m.Intent)
15001508 marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
1509+ marshalExactTargetSection(&w, m.ExactTarget)
15011510 return w.b
15021511 }
15031512
@@ -1506,7 +1515,7 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
15061515 // Preflight lengths and intent before getString can allocate either value.
15071516 probe := payloadReader{b: b}
15081517 endpointLen, err := probe.getUint16()
1509- if err != nil || endpointLen == 0 || int(endpointLen) > len(probe.b) {
1518+ if err != nil || int(endpointLen) > len(probe.b) {
15101519 return AttachTarget{}, ErrInvalidAttachTarget
15111520 }
15121521 probe.b = probe.b[endpointLen:]
@@ -1522,6 +1531,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
15221531 if err := skipRemoteTargetSection(&probe); err != nil {
15231532 return AttachTarget{}, ErrInvalidAttachTarget
15241533 }
1534+ if err := skipExactTargetSection(&probe); err != nil {
1535+ return AttachTarget{}, ErrInvalidAttachTarget
1536+ }
15251537 if err := probe.done(); err != nil {
15261538 return AttachTarget{}, ErrInvalidAttachTarget
15271539 }
@@ -1540,6 +1552,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
15401552 if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
15411553 return AttachTarget{}, err
15421554 }
1555+ if m.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {
1556+ return AttachTarget{}, err
1557+ }
15431558 if err := r.done(); err != nil {
15441559 return AttachTarget{}, err
15451560 }
@@ -293,6 +293,7 @@ type AttachTarget struct {
293 Session string293 Session string
294 Intent uint8294 Intent uint8
295 RemoteTarget *domain.RemoteSessionTarget295 RemoteTarget *domain.RemoteSessionTarget
296+ ExactTarget *ExactSessionTarget
296 EnvironmentPolicy EnvironmentPolicy297 EnvironmentPolicy EnvironmentPolicy
297 }298 }
298 299
@@ -806,10 +807,14 @@ func ValidateAck(m Ack) error {
806 return nil807 return nil
807 }808 }
808 809
809-// ValidateAttachTarget validates route fields and, when present, the exact810+// ValidateAttachTarget validates a client-owned route handoff. An empty
810-// picker identity carried by the handoff.811+// Endpoint selects another session on the currently connected daemon; a
812+// non-empty Endpoint selects a discovered remote daemon.
811 func ValidateAttachTarget(m AttachTarget) error {813 func ValidateAttachTarget(m AttachTarget) error {
812- if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {814+ if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
815+ return ErrInvalidAttachTarget
816+ }
817+ if err := domain.ValidateSessionName(m.Session); err != nil {
813 return ErrInvalidAttachTarget818 return ErrInvalidAttachTarget
814 }819 }
815 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {820 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
@@ -821,6 +826,9 @@ func ValidateAttachTarget(m AttachTarget) error {
821 if !validEnvironmentPolicy(m.EnvironmentPolicy) {826 if !validEnvironmentPolicy(m.EnvironmentPolicy) {
822 return ErrInvalidAttachTarget827 return ErrInvalidAttachTarget
823 }828 }
829+ if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
830+ return ErrInvalidAttachTarget
831+ }
824 if m.RemoteTarget == nil {832 if m.RemoteTarget == nil {
825 return nil833 return nil
826 }834 }
@@ -1498,6 +1506,7 @@ func MarshalAttachTarget(m AttachTarget) []byte {
1498 w.putString(m.Session)1506 w.putString(m.Session)
1499 w.putUint8(m.Intent)1507 w.putUint8(m.Intent)
1500 marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)1508 marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
1509+ marshalExactTargetSection(&w, m.ExactTarget)
1501 return w.b1510 return w.b
1502 }1511 }
1503 1512
@@ -1506,7 +1515,7 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1506 // Preflight lengths and intent before getString can allocate either value.1515 // Preflight lengths and intent before getString can allocate either value.
1507 probe := payloadReader{b: b}1516 probe := payloadReader{b: b}
1508 endpointLen, err := probe.getUint16()1517 endpointLen, err := probe.getUint16()
1509- if err != nil || endpointLen == 0 || int(endpointLen) > len(probe.b) {1518+ if err != nil || int(endpointLen) > len(probe.b) {
1510 return AttachTarget{}, ErrInvalidAttachTarget1519 return AttachTarget{}, ErrInvalidAttachTarget
1511 }1520 }
1512 probe.b = probe.b[endpointLen:]1521 probe.b = probe.b[endpointLen:]
@@ -1522,6 +1531,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1522 if err := skipRemoteTargetSection(&probe); err != nil {1531 if err := skipRemoteTargetSection(&probe); err != nil {
1523 return AttachTarget{}, ErrInvalidAttachTarget1532 return AttachTarget{}, ErrInvalidAttachTarget
1524 }1533 }
1534+ if err := skipExactTargetSection(&probe); err != nil {
1535+ return AttachTarget{}, ErrInvalidAttachTarget
1536+ }
1525 if err := probe.done(); err != nil {1537 if err := probe.done(); err != nil {
1526 return AttachTarget{}, ErrInvalidAttachTarget1538 return AttachTarget{}, ErrInvalidAttachTarget
1527 }1539 }
@@ -1540,6 +1552,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1540 if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {1552 if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
1541 return AttachTarget{}, err1553 return AttachTarget{}, err
1542 }1554 }
1555+ if m.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {
1556+ return AttachTarget{}, err
1557+ }
1543 if err := r.done(); err != nil {1558 if err := r.done(); err != nil {
1544 return AttachTarget{}, err1559 return AttachTarget{}, err
1545 }1560 }
M internal/ports/wire_final_test.go +20 -5
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 30 {
15- t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion)
14+ if ProtocolVersion != 31 {
15+ t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 30, 2}, make([]byte, 16+8)...)
29+ want := append([]byte{0, 31, 2}, make([]byte, 16+8)...)
3030 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
@@ -178,7 +178,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
178178
179179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
180180 msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}
181- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0}
181+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0}
182182 got := MarshalAttachTarget(msg)
183183 if !bytes.Equal(got, want) {
184184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -189,7 +189,22 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
189189 }
190190 assertAllPrefixesFail(t, got, UnmarshalAttachTarget)
191191 assertTrailingGarbageFails(t, got, UnmarshalAttachTarget)
192- for _, bad := range []AttachTarget{{Session: "work", Intent: IntentAttach}, {Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {
192+ local := AttachTarget{Session: "work", Intent: IntentAttach}
193+ localPayload := MarshalAttachTarget(local)
194+ if localPayload == nil {
195+ t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
196+ }
197+ wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0}
198+ if !bytes.Equal(localPayload, wantLocal) {
199+ t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
200+ }
201+ decodedLocal, err := UnmarshalAttachTarget(localPayload)
202+ if err != nil || decodedLocal != local {
203+ t.Fatalf("same-peer target = %+v, error %v, want %+v", decodedLocal, err, local)
204+ }
205+ assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget)
206+ assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget)
207+ for _, bad := range []AttachTarget{{Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {
193208 if got := MarshalAttachTarget(bad); got != nil {
194209 t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)
195210 }
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 30 {14+ if ProtocolVersion != 31 {
15- t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 30, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 31, 2}, make([]byte, 16+8)...)
30 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)30 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
@@ -178,7 +178,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
178 178
179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
180 msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}180 msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}
181- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0}181+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0}
182 got := MarshalAttachTarget(msg)182 got := MarshalAttachTarget(msg)
183 if !bytes.Equal(got, want) {183 if !bytes.Equal(got, want) {
184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -189,7 +189,22 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
189 }189 }
190 assertAllPrefixesFail(t, got, UnmarshalAttachTarget)190 assertAllPrefixesFail(t, got, UnmarshalAttachTarget)
191 assertTrailingGarbageFails(t, got, UnmarshalAttachTarget)191 assertTrailingGarbageFails(t, got, UnmarshalAttachTarget)
192- for _, bad := range []AttachTarget{{Session: "work", Intent: IntentAttach}, {Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {192+ local := AttachTarget{Session: "work", Intent: IntentAttach}
193+ localPayload := MarshalAttachTarget(local)
194+ if localPayload == nil {
195+ t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
196+ }
197+ wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0}
198+ if !bytes.Equal(localPayload, wantLocal) {
199+ t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
200+ }
201+ decodedLocal, err := UnmarshalAttachTarget(localPayload)
202+ if err != nil || decodedLocal != local {
203+ t.Fatalf("same-peer target = %+v, error %v, want %+v", decodedLocal, err, local)
204+ }
205+ assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget)
206+ assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget)
207+ for _, bad := range []AttachTarget{{Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {
193 if got := MarshalAttachTarget(bad); got != nil {208 if got := MarshalAttachTarget(bad); got != nil {
194 t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)209 t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)
195 }210 }
M internal/ports/wire_test.go +6 -6
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131131 }
132132 got := MarshalHello(msg)
133133 want := []byte{
134- 0x00, 0x1e, 0x00, // version, intent, first client ID byte
134+ 0x00, 0x1f, 0x00, // version, intent, first client ID byte
135135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361361 }
362362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(30), ProtocolVersion)
364+ require.Equal(t, uint16(31), ProtocolVersion)
365365 }
366366
367367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522522 name: "minimal",
523523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524524 want: []byte{
525- 0x00, 0x1e,
525+ 0x00, 0x1f,
526526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527527 0x00, // attached
528528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545545 JSON: true,
546546 },
547547 want: []byte{
548- 0x00, 0x1e,
548+ 0x00, 0x1f,
549549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550550 0x00, // attached
551551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974974 name: "attached",
975975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976976 want: []byte{
977- 0x00, 0x1e, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
977+ 0x00, 0x1f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984984 name: "control",
985985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986986 want: []byte{
987- 0x00, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
987+ 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990990 },
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131 }131 }
132 got := MarshalHello(msg)132 got := MarshalHello(msg)
133 want := []byte{133 want := []byte{
134- 0x00, 0x1e, 0x00, // version, intent, first client ID byte134+ 0x00, 0x1f, 0x00, // version, intent, first client ID byte
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361 }361 }
362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363 require.Equal(t, want, MarshalTheme(cleared))363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(30), ProtocolVersion)364+ require.Equal(t, uint16(31), ProtocolVersion)
365 }365 }
366 366
367 func TestResizeGoldenAndRoundTrip(t *testing.T) {367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522 name: "minimal",522 name: "minimal",
523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524 want: []byte{524 want: []byte{
525- 0x00, 0x1e,525+ 0x00, 0x1f,
526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527 0x00, // attached527 0x00, // attached
528 0x00, // self528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545 JSON: true,545 JSON: true,
546 },546 },
547 want: []byte{547 want: []byte{
548- 0x00, 0x1e,548+ 0x00, 0x1f,
549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550 0x00, // attached550 0x00, // attached
551 0x01, // self551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974 name: "attached",974 name: "attached",
975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976 want: []byte{976 want: []byte{
977- 0x00, 0x1e, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,977+ 0x00, 0x1f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984 name: "control",984 name: "control",
985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986 want: []byte{986 want: []byte{
987- 0x00, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,987+ 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988 0x00, 0x02, 'l', 's', 0x00, 0x00,988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990 },990 },
M internal/usecase/client/client.go +24 -8
@@ -634,9 +634,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
634634 }
635635 }
636636 if result.target != nil {
637- if r.attachHandoff == nil {
638- return &AttachTargetError{Target: *result.target}
639- }
640637 if result.target.RemoteTarget != nil || result.target.Endpoint != "" {
641638 if homeRoute == nil && attemptRequest.RemoteTarget == nil {
642639 routeRequest := attemptRequest
@@ -649,9 +646,28 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
649646 homeRoute = &attachRoute{dialer: dialer, request: routeRequest}
650647 }
651648 }
652- nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)
653- if handoffErr != nil {
654- return handoffErr
649+ var (
650+ nextDialer ports.Dialer
651+ nextRequest AttachRequest
652+ handoffErr error
653+ )
654+ if result.target.Endpoint == "" {
655+ if r.ledger == nil {
656+ return errors.New("vev: route ledger unavailable")
657+ }
658+ nextDialer = dialer
659+ nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target)
660+ } else {
661+ if r.attachHandoff == nil {
662+ return &AttachTargetError{Target: *result.target}
663+ }
664+ nextDialer, nextRequest, handoffErr = r.attachHandoff(*result.target)
665+ if handoffErr != nil {
666+ return handoffErr
667+ }
668+ }
669+ if result.target.ExactTarget != nil {
670+ nextRequest.ExactTarget = result.target.ExactTarget
655671 }
656672 if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned) {
657673 nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker
@@ -662,10 +678,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
662678 }
663679 nextRequest = cloneAttachRequest(nextRequest)
664680 if err := validateAttachRequest(nextRequest); err != nil {
665- return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)
681+ return fmt.Errorf("vev: invalid route handoff request: %w", err)
666682 }
667683 if nextDialer == nil {
668- return errors.New("vev: remote attach handoff returned nil dialer")
684+ return errors.New("vev: route handoff returned nil dialer")
669685 }
670686 dialer = nextDialer
671687 attemptRequest = nextRequest
@@ -634,9 +634,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
634 }634 }
635 }635 }
636 if result.target != nil {636 if result.target != nil {
637- if r.attachHandoff == nil {
638- return &AttachTargetError{Target: *result.target}
639- }
640 if result.target.RemoteTarget != nil || result.target.Endpoint != "" {637 if result.target.RemoteTarget != nil || result.target.Endpoint != "" {
641 if homeRoute == nil && attemptRequest.RemoteTarget == nil {638 if homeRoute == nil && attemptRequest.RemoteTarget == nil {
642 routeRequest := attemptRequest639 routeRequest := attemptRequest
@@ -649,9 +646,28 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
649 homeRoute = &attachRoute{dialer: dialer, request: routeRequest}646 homeRoute = &attachRoute{dialer: dialer, request: routeRequest}
650 }647 }
651 }648 }
652- nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)649+ var (
653- if handoffErr != nil {650+ nextDialer ports.Dialer
654- return handoffErr651+ nextRequest AttachRequest
652+ handoffErr error
653+ )
654+ if result.target.Endpoint == "" {
655+ if r.ledger == nil {
656+ return errors.New("vev: route ledger unavailable")
657+ }
658+ nextDialer = dialer
659+ nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target)
660+ } else {
661+ if r.attachHandoff == nil {
662+ return &AttachTargetError{Target: *result.target}
663+ }
664+ nextDialer, nextRequest, handoffErr = r.attachHandoff(*result.target)
665+ if handoffErr != nil {
666+ return handoffErr
667+ }
668+ }
669+ if result.target.ExactTarget != nil {
670+ nextRequest.ExactTarget = result.target.ExactTarget
655 }671 }
656 if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned) {672 if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned) {
657 nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker673 nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker
@@ -662,10 +678,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
662 }678 }
663 nextRequest = cloneAttachRequest(nextRequest)679 nextRequest = cloneAttachRequest(nextRequest)
664 if err := validateAttachRequest(nextRequest); err != nil {680 if err := validateAttachRequest(nextRequest); err != nil {
665- return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)681+ return fmt.Errorf("vev: invalid route handoff request: %w", err)
666 }682 }
667 if nextDialer == nil {683 if nextDialer == nil {
668- return errors.New("vev: remote attach handoff returned nil dialer")684+ return errors.New("vev: route handoff returned nil dialer")
669 }685 }
670 dialer = nextDialer686 dialer = nextDialer
671 attemptRequest = nextRequest687 attemptRequest = nextRequest
M internal/usecase/client/routes.go +36 -0
@@ -531,6 +531,42 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
531531 return snapshot
532532 }
533533
534+// samePeerHandoff restores a previously committed route on the daemon already
535+// connected to by the client. The daemon deliberately sends no tab state: the
536+// ledger is the sole owner of per-route view memory.
537+func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest {
538+ origin := normalizeRouteOrigin(active.Origin, active.Remote)
539+ originKey := normalizeRouteOriginKey(active.OriginKey, origin)
540+
541+ l.mu.RLock()
542+ defer l.mu.RUnlock()
543+ for _, entry := range l.entries {
544+ if entry.origin != origin || entry.originKey != originKey || entry.target.SessionName != target.Session {
545+ continue
546+ }
547+ if target.ExactTarget != nil && entry.target != *target.ExactTarget {
548+ continue
549+ }
550+ request := cloneAttachRequest(entry.request)
551+ request.Intent = target.Intent
552+ request.EnvironmentPolicy = target.EnvironmentPolicy
553+ request.ExactTarget = target.ExactTarget
554+ if target.ExactTarget == nil {
555+ request.PreferredTabID = ""
556+ }
557+ return request
558+ }
559+
560+ request := cloneAttachRequest(active)
561+ request.Intent = target.Intent
562+ request.SessionName = target.Session
563+ request.ExactTarget = target.ExactTarget
564+ request.PreferredTabID = ""
565+ request.RemoteTarget = nil
566+ request.EnvironmentPolicy = target.EnvironmentPolicy
567+ return request
568+}
569+
534570 func (l *routeLedger) activeRef() ports.RouteRef {
535571 l.mu.RLock()
536572 defer l.mu.RUnlock()
@@ -531,6 +531,42 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
531 return snapshot531 return snapshot
532 }532 }
533 533
534+// samePeerHandoff restores a previously committed route on the daemon already
535+// connected to by the client. The daemon deliberately sends no tab state: the
536+// ledger is the sole owner of per-route view memory.
537+func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest {
538+ origin := normalizeRouteOrigin(active.Origin, active.Remote)
539+ originKey := normalizeRouteOriginKey(active.OriginKey, origin)
540+
541+ l.mu.RLock()
542+ defer l.mu.RUnlock()
543+ for _, entry := range l.entries {
544+ if entry.origin != origin || entry.originKey != originKey || entry.target.SessionName != target.Session {
545+ continue
546+ }
547+ if target.ExactTarget != nil && entry.target != *target.ExactTarget {
548+ continue
549+ }
550+ request := cloneAttachRequest(entry.request)
551+ request.Intent = target.Intent
552+ request.EnvironmentPolicy = target.EnvironmentPolicy
553+ request.ExactTarget = target.ExactTarget
554+ if target.ExactTarget == nil {
555+ request.PreferredTabID = ""
556+ }
557+ return request
558+ }
559+
560+ request := cloneAttachRequest(active)
561+ request.Intent = target.Intent
562+ request.SessionName = target.Session
563+ request.ExactTarget = target.ExactTarget
564+ request.PreferredTabID = ""
565+ request.RemoteTarget = nil
566+ request.EnvironmentPolicy = target.EnvironmentPolicy
567+ return request
568+}
569+
534 func (l *routeLedger) activeRef() ports.RouteRef {570 func (l *routeLedger) activeRef() ports.RouteRef {
535 l.mu.RLock()571 l.mu.RLock()
536 defer l.mu.RUnlock()572 defer l.mu.RUnlock()
M internal/usecase/client/routes_test.go +39 -0
@@ -109,6 +109,45 @@ func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
109109 require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
110110 }
111111
112+func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
113+ ledger := newRouteLedger()
114+ first := routeTestCandidate(1, ports.RouteOriginLocal)
115+ first.originKey = "local"
116+ _, err := ledger.commit(first)
117+ require.NoError(t, err)
118+ require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
119+
120+ second := routeTestCandidate(2, ports.RouteOriginLocal)
121+ second.originKey = "local"
122+ _, err = ledger.commit(second)
123+ require.NoError(t, err)
124+
125+ recreated := first.target
126+ recreated.LifecycleID[0]++
127+ for _, tt := range []struct {
128+ name string
129+ target ports.ExactSessionTarget
130+ wantExactTarget *ports.ExactSessionTarget
131+ wantTab domain.TabStableID
132+ }{
133+ {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"},
134+ {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated},
135+ } {
136+ t.Run(tt.name, func(t *testing.T) {
137+ request := ledger.samePeerHandoff(second.request, ports.AttachTarget{
138+ Session: first.target.SessionName,
139+ Intent: ports.IntentAttach,
140+ ExactTarget: &tt.target,
141+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
142+ })
143+ require.Equal(t, first.target.SessionName, request.SessionName)
144+ require.Equal(t, tt.wantExactTarget, request.ExactTarget)
145+ require.Equal(t, tt.wantTab, request.PreferredTabID)
146+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, request.EnvironmentPolicy)
147+ })
148+ }
149+}
150+
112151 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
113152 ledger := newRouteLedger()
114153 first := routeTestCandidate(0, ports.RouteOriginRemote)
@@ -109,6 +109,45 @@ func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
109 require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)109 require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
110 }110 }
111 111
112+func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
113+ ledger := newRouteLedger()
114+ first := routeTestCandidate(1, ports.RouteOriginLocal)
115+ first.originKey = "local"
116+ _, err := ledger.commit(first)
117+ require.NoError(t, err)
118+ require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
119+
120+ second := routeTestCandidate(2, ports.RouteOriginLocal)
121+ second.originKey = "local"
122+ _, err = ledger.commit(second)
123+ require.NoError(t, err)
124+
125+ recreated := first.target
126+ recreated.LifecycleID[0]++
127+ for _, tt := range []struct {
128+ name string
129+ target ports.ExactSessionTarget
130+ wantExactTarget *ports.ExactSessionTarget
131+ wantTab domain.TabStableID
132+ }{
133+ {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"},
134+ {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated},
135+ } {
136+ t.Run(tt.name, func(t *testing.T) {
137+ request := ledger.samePeerHandoff(second.request, ports.AttachTarget{
138+ Session: first.target.SessionName,
139+ Intent: ports.IntentAttach,
140+ ExactTarget: &tt.target,
141+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
142+ })
143+ require.Equal(t, first.target.SessionName, request.SessionName)
144+ require.Equal(t, tt.wantExactTarget, request.ExactTarget)
145+ require.Equal(t, tt.wantTab, request.PreferredTabID)
146+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, request.EnvironmentPolicy)
147+ })
148+ }
149+}
150+
112 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {151 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
113 ledger := newRouteLedger()152 ledger := newRouteLedger()
114 first := routeTestCandidate(0, ports.RouteOriginRemote)153 first := routeTestCandidate(0, ports.RouteOriginRemote)
M internal/usecase/daemon/picker.go +57 -1
@@ -901,7 +901,63 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
901901 if !token.attachmentCurrent() {
902902 return errAttachmentTransition
903903 }
904- return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
904+ return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
905+}
906+
907+// sendLocalAttachTargetForAttachment makes local picker navigation follow the
908+// same client-owned handoff path as remote navigation. The client reconnects
909+// to this daemon and restores its per-route tab from the route ledger.
910+func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
911+ d.mu.Lock()
912+ var targetSess *session
913+ var sessionName string
914+ var exactTarget *ports.ExactSessionTarget
915+ var matches bool
916+ if target.Name != "" {
917+ var stopped stoppedSession
918+ var stoppedTarget bool
919+ targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)
920+ if stoppedTarget && matches {
921+ sessionName = stopped.name
922+ exactTarget = &ports.ExactSessionTarget{LifecycleID: stopped.incarnation, SessionName: sessionName}
923+ }
924+ } else {
925+ targetSess = d.sessions[target.Session]
926+ matches = targetSess != nil
927+ }
928+ if matches && targetSess != nil {
929+ targetSess.mu.Lock()
930+ sessionName = targetSess.name
931+ exactTarget = &ports.ExactSessionTarget{LifecycleID: targetSess.incarnation, SessionName: sessionName}
932+ targetSess.mu.Unlock()
933+ }
934+ d.mu.Unlock()
935+ if !matches || !token.attachmentCurrent() {
936+ return errAttachmentTransition
937+ }
938+ if target.TabID != "" {
939+ // An explicit tab row is a direct user choice, not route memory; retain
940+ // the daemon-side transition so it opens exactly that tab.
941+ return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
942+ }
943+
944+ payload := ports.MarshalAttachTarget(ports.AttachTarget{
945+ Session: sessionName,
946+ Intent: ports.IntentAttach,
947+ ExactTarget: exactTarget,
948+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
949+ })
950+ if payload == nil {
951+ return errAttachmentTransition
952+ }
953+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
954+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to local session", err)
955+ }
956+ if guard.closePicker {
957+ d.closePicker(token.ac)
958+ }
959+ d.clientGoneForAttachment(token, false)
960+ return nil
905961 }
906962
907963 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the
@@ -901,7 +901,63 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
901 if !token.attachmentCurrent() {901 if !token.attachmentCurrent() {
902 return errAttachmentTransition902 return errAttachmentTransition
903 }903 }
904- return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)904+ return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
905+}
906+
907+// sendLocalAttachTargetForAttachment makes local picker navigation follow the
908+// same client-owned handoff path as remote navigation. The client reconnects
909+// to this daemon and restores its per-route tab from the route ledger.
910+func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
911+ d.mu.Lock()
912+ var targetSess *session
913+ var sessionName string
914+ var exactTarget *ports.ExactSessionTarget
915+ var matches bool
916+ if target.Name != "" {
917+ var stopped stoppedSession
918+ var stoppedTarget bool
919+ targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)
920+ if stoppedTarget && matches {
921+ sessionName = stopped.name
922+ exactTarget = &ports.ExactSessionTarget{LifecycleID: stopped.incarnation, SessionName: sessionName}
923+ }
924+ } else {
925+ targetSess = d.sessions[target.Session]
926+ matches = targetSess != nil
927+ }
928+ if matches && targetSess != nil {
929+ targetSess.mu.Lock()
930+ sessionName = targetSess.name
931+ exactTarget = &ports.ExactSessionTarget{LifecycleID: targetSess.incarnation, SessionName: sessionName}
932+ targetSess.mu.Unlock()
933+ }
934+ d.mu.Unlock()
935+ if !matches || !token.attachmentCurrent() {
936+ return errAttachmentTransition
937+ }
938+ if target.TabID != "" {
939+ // An explicit tab row is a direct user choice, not route memory; retain
940+ // the daemon-side transition so it opens exactly that tab.
941+ return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
942+ }
943+
944+ payload := ports.MarshalAttachTarget(ports.AttachTarget{
945+ Session: sessionName,
946+ Intent: ports.IntentAttach,
947+ ExactTarget: exactTarget,
948+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
949+ })
950+ if payload == nil {
951+ return errAttachmentTransition
952+ }
953+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
954+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to local session", err)
955+ }
956+ if guard.closePicker {
957+ d.closePicker(token.ac)
958+ }
959+ d.clientGoneForAttachment(token, false)
960+ return nil
905 }961 }
906 962
907 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the963 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the
M internal/usecase/daemon/remote_picker.go +1 -9
@@ -6,7 +6,6 @@ import (
66 "errors"
77 "fmt"
88 "sort"
9- "strings"
109 "time"
1110
1211 "github.com/bnema/vev/internal/domain"
@@ -122,20 +121,13 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri
122121 }
123122 }
124123
125-func remoteSessionOrigin(host string) string {
126- if i := strings.LastIndexByte(host, '@'); i >= 0 && i+1 < len(host) {
127- return host[i+1:]
128- }
129- return host
130-}
131-
132124 func remoteSessionStateStopped(state string) bool {
133125 return state == "down" || state == "stopped"
134126 }
135127
136128 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
137129 key.LifecycleID = session.LifecycleID
138- key.DisplayOrigin = remoteSessionOrigin(key.Host)
130+ key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
139131 availability := remotePickerAvailability(status)
140132 stopped := remoteSessionStateStopped(session.State)
141133 broken := session.State == "broken"
@@ -6,7 +6,6 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "sort"8 "sort"
9- "strings"
10 "time"9 "time"
11 10
12 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
@@ -122,20 +121,13 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri
122 }121 }
123 }122 }
124 123
125-func remoteSessionOrigin(host string) string {
126- if i := strings.LastIndexByte(host, '@'); i >= 0 && i+1 < len(host) {
127- return host[i+1:]
128- }
129- return host
130-}
131-
132 func remoteSessionStateStopped(state string) bool {124 func remoteSessionStateStopped(state string) bool {
133 return state == "down" || state == "stopped"125 return state == "down" || state == "stopped"
134 }126 }
135 127
136 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {128 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
137 key.LifecycleID = session.LifecycleID129 key.LifecycleID = session.LifecycleID
138- key.DisplayOrigin = remoteSessionOrigin(key.Host)130+ key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
139 availability := remotePickerAvailability(status)131 availability := remotePickerAvailability(status)
140 stopped := remoteSessionStateStopped(session.State)132 stopped := remoteSessionStateStopped(session.State)
141 broken := session.State == "broken"133 broken := session.State == "broken"

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