detached 0ae031a

fix(remote): preserve picker proxy handoffs

commit
0ae031af0325095428ad709eb74bc37c36d11021
author
brice · · 2026-07-31 20:40 UTC
parent
c2951b3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +206 -22
M internal/domain/remote_session.go +9 -2
@@ -1,6 +1,9 @@
11 package domain
22
3-import "encoding/base64"
3+import (
4+ "encoding/base64"
5+ "strings"
6+)
47
58 // RemoteSessionKey identifies a discovered remote session independently from
69 // its display label.
@@ -27,5 +30,9 @@ func (k RemoteSessionKey) ID() SessionID {
2730
2831 // Display returns the presentation label for this remote session.
2932 func (k RemoteSessionKey) Display() string {
30- return k.Name + "@" + k.Host
33+ host := k.Host
34+ if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 {
35+ host = host[i+1:]
36+ }
37+ return k.Name + "@" + host
3138 }
@@ -1,6 +1,9 @@
1 package domain1 package domain
2 2
3-import "encoding/base64"3+import (
4+ "encoding/base64"
5+ "strings"
6+)
4 7
5 // RemoteSessionKey identifies a discovered remote session independently from8 // RemoteSessionKey identifies a discovered remote session independently from
6 // its display label.9 // its display label.
@@ -27,5 +30,9 @@ func (k RemoteSessionKey) ID() SessionID {
27 30
28 // Display returns the presentation label for this remote session.31 // Display returns the presentation label for this remote session.
29 func (k RemoteSessionKey) Display() string {32 func (k RemoteSessionKey) Display() string {
30- return k.Name + "@" + k.Host33+ host := k.Host
34+ if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 {
35+ host = host[i+1:]
36+ }
37+ return k.Name + "@" + host
31 }38 }
M internal/domain/remote_session_test.go +20 -6
@@ -89,12 +89,26 @@ func TestRemoteSessionKeyValidate(t *testing.T) {
8989 }
9090
9191 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
92- key := RemoteSessionKey{Host: "arch", Name: "hello"}
93-
94- if got, want := key.Display(), "hello@arch"; got != want {
95- t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want)
92+ tests := []struct {
93+ host string
94+ want string
95+ }{
96+ {host: "arch", want: "hello@arch"},
97+ {host: "test@arch", want: "hello@arch"},
98+ {host: "test@arch:2222", want: "hello@arch:2222"},
99+ {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},
100+ {host: "test@", want: "hello@test@"},
101+ {host: "@arch", want: "hello@@arch"},
96102 }
97- if key.Host != "arch" || key.Name != "hello" {
98- t.Fatalf("Display mutated routing key: %#v", key)
103+ for _, test := range tests {
104+ t.Run(test.host, func(t *testing.T) {
105+ key := RemoteSessionKey{Host: test.host, Name: "hello"}
106+ if got := key.Display(); got != test.want {
107+ t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)
108+ }
109+ if key.Host != test.host || key.Name != "hello" {
110+ t.Fatalf("Display mutated routing key: %#v", key)
111+ }
112+ })
99113 }
100114 }
@@ -89,12 +89,26 @@ func TestRemoteSessionKeyValidate(t *testing.T) {
89 }89 }
90 90
91 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {91 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
92- key := RemoteSessionKey{Host: "arch", Name: "hello"}92+ tests := []struct {
93-93+ host string
94- if got, want := key.Display(), "hello@arch"; got != want {94+ want string
95- t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want)95+ }{
96+ {host: "arch", want: "hello@arch"},
97+ {host: "test@arch", want: "hello@arch"},
98+ {host: "test@arch:2222", want: "hello@arch:2222"},
99+ {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},
100+ {host: "test@", want: "hello@test@"},
101+ {host: "@arch", want: "hello@@arch"},
96 }102 }
97- if key.Host != "arch" || key.Name != "hello" {103+ for _, test := range tests {
98- t.Fatalf("Display mutated routing key: %#v", key)104+ t.Run(test.host, func(t *testing.T) {
105+ key := RemoteSessionKey{Host: test.host, Name: "hello"}
106+ if got := key.Display(); got != test.want {
107+ t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)
108+ }
109+ if key.Host != test.host || key.Name != "hello" {
110+ t.Fatalf("Display mutated routing key: %#v", key)
111+ }
112+ })
99 }113 }
100 }114 }
M internal/usecase/daemon/client_frame_routing.go +15 -6
@@ -20,28 +20,37 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
2020 if !ac.currentTransportIs(tr) {
2121 return
2222 }
23- sess := ac.currentSession()
23+ sess := ac.currentAttachmentSession()
2424 if sess == nil {
2525 return
2626 }
2727 f, err := tr.Recv()
2828 if err != nil {
29- token := sess.attachmentToken(ac, tr)
29+ if !ac.currentTransportIs(tr) {
30+ return
31+ }
32+ sess = ac.currentAttachmentSession()
33+ if sess == nil {
34+ return
35+ }
36+ token := attachmentToken(sess, ac, tr)
3037 if token.role == attachmentSnatched {
3138 d.parkOrDropSnatchedAttachment(token)
32- } else {
33- d.clientGone(sess, ac, tr, false)
39+ } else if proxy, ok := sess.(*proxySession); ok {
40+ d.detachProxyOnSendError(proxy, ac, tr)
41+ } else if local, ok := localSession(sess); ok {
42+ d.clientGone(local, ac, tr, false)
3443 }
3544 return
3645 }
3746 if !ac.currentTransportIs(tr) {
3847 return
3948 }
40- sess = ac.currentSession()
49+ sess = ac.currentAttachmentSession()
4150 if sess == nil {
4251 return
4352 }
44- token := sess.attachmentToken(ac, tr)
53+ token := attachmentToken(sess, ac, tr)
4554 switch token.role {
4655 case attachmentActive:
4756 if d.handleActiveClientFrame(token, f) {
@@ -20,28 +20,37 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
20 if !ac.currentTransportIs(tr) {20 if !ac.currentTransportIs(tr) {
21 return21 return
22 }22 }
23- sess := ac.currentSession()23+ sess := ac.currentAttachmentSession()
24 if sess == nil {24 if sess == nil {
25 return25 return
26 }26 }
27 f, err := tr.Recv()27 f, err := tr.Recv()
28 if err != nil {28 if err != nil {
29- token := sess.attachmentToken(ac, tr)29+ if !ac.currentTransportIs(tr) {
30+ return
31+ }
32+ sess = ac.currentAttachmentSession()
33+ if sess == nil {
34+ return
35+ }
36+ token := attachmentToken(sess, ac, tr)
30 if token.role == attachmentSnatched {37 if token.role == attachmentSnatched {
31 d.parkOrDropSnatchedAttachment(token)38 d.parkOrDropSnatchedAttachment(token)
32- } else {39+ } else if proxy, ok := sess.(*proxySession); ok {
33- d.clientGone(sess, ac, tr, false)40+ d.detachProxyOnSendError(proxy, ac, tr)
41+ } else if local, ok := localSession(sess); ok {
42+ d.clientGone(local, ac, tr, false)
34 }43 }
35 return44 return
36 }45 }
37 if !ac.currentTransportIs(tr) {46 if !ac.currentTransportIs(tr) {
38 return47 return
39 }48 }
40- sess = ac.currentSession()49+ sess = ac.currentAttachmentSession()
41 if sess == nil {50 if sess == nil {
42 return51 return
43 }52 }
44- token := sess.attachmentToken(ac, tr)53+ token := attachmentToken(sess, ac, tr)
45 switch token.role {54 switch token.role {
46 case attachmentActive:55 case attachmentActive:
47 if d.handleActiveClientFrame(token, f) {56 if d.handleActiveClientFrame(token, f) {
M internal/usecase/daemon/picker.go +18 -8
@@ -578,20 +578,24 @@ type sessionHandoffGuard struct {
578578 // and revalidates the exact initiating token before changing target focus or
579579 // attachment ownership.
580580 func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {
581+ return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
582+}
583+
584+func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
581585 if token.sess == nil || token.ac == nil || token.role != attachmentActive {
582586 return nil
583587 }
584588 d.mu.Lock()
585- targetSess := d.sessions[target.Session]
589+ targetSess, ok := localSession(d.sessions[target.Session])
586590 d.mu.Unlock()
587- if targetSess == nil || targetSess == token.sess {
591+ if !ok || targetSess == token.sess {
588592 return nil
589593 }
590594
591595 transition, err := d.transitionAttachment(attachmentTransitionRequest{
592596 source: token.sess, target: targetSess, next: token.ac,
593597 expectedRole: attachmentActive, targetRole: attachmentActive,
594- expectedTransport: token.transport, sourceToken: &token, action: "jump-attention",
598+ expectedTransport: token.transport, sourceToken: &token, action: action,
595599 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
596600 })
597601 if err != nil {
@@ -602,12 +606,15 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic
602606 }
603607 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
604608 }
605- if target, ok := localSession(targetSess); ok {
606- d.touchMRU(target)
607- }
608- if source, ok := localSession(token.sess); ok {
609- token.ac.recordPreviousSession(source)
609+ if guard.closePicker {
610+ fresh, admitted := token.ac.beginRoleEffect(transition.published)
611+ if admitted {
612+ d.closePicker(token.ac)
613+ fresh.End()
614+ }
610615 }
616+ d.touchMRU(targetSess)
617+ token.ac.recordPreviousSession(token.sess)
611618 d.deferAttachmentTransitionCleanups(transition)
612619 d.firstPaintForTransition(transition.published)
613620 return nil
@@ -633,6 +640,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
633640 if target.RemoteKey != nil {
634641 return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)
635642 }
643+ if token.sess.isProxy() && !target.Stopped {
644+ return d.switchActiveTargetForRoleGuarded(token, target, guard, action)
645+ }
636646 sess, ok := localSession(token.sess)
637647 if !ok {
638648 return errAttachmentTransition
@@ -578,20 +578,24 @@ type sessionHandoffGuard struct {
578 // and revalidates the exact initiating token before changing target focus or578 // and revalidates the exact initiating token before changing target focus or
579 // attachment ownership.579 // attachment ownership.
580 func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {580 func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {
581+ return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
582+}
583+
584+func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
581 if token.sess == nil || token.ac == nil || token.role != attachmentActive {585 if token.sess == nil || token.ac == nil || token.role != attachmentActive {
582 return nil586 return nil
583 }587 }
584 d.mu.Lock()588 d.mu.Lock()
585- targetSess := d.sessions[target.Session]589+ targetSess, ok := localSession(d.sessions[target.Session])
586 d.mu.Unlock()590 d.mu.Unlock()
587- if targetSess == nil || targetSess == token.sess {591+ if !ok || targetSess == token.sess {
588 return nil592 return nil
589 }593 }
590 594
591 transition, err := d.transitionAttachment(attachmentTransitionRequest{595 transition, err := d.transitionAttachment(attachmentTransitionRequest{
592 source: token.sess, target: targetSess, next: token.ac,596 source: token.sess, target: targetSess, next: token.ac,
593 expectedRole: attachmentActive, targetRole: attachmentActive,597 expectedRole: attachmentActive, targetRole: attachmentActive,
594- expectedTransport: token.transport, sourceToken: &token, action: "jump-attention",598+ expectedTransport: token.transport, sourceToken: &token, action: action,
595 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,599 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
596 })600 })
597 if err != nil {601 if err != nil {
@@ -602,12 +606,15 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic
602 }606 }
603 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)607 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
604 }608 }
605- if target, ok := localSession(targetSess); ok {609+ if guard.closePicker {
606- d.touchMRU(target)610+ fresh, admitted := token.ac.beginRoleEffect(transition.published)
607- }611+ if admitted {
608- if source, ok := localSession(token.sess); ok {612+ d.closePicker(token.ac)
609- token.ac.recordPreviousSession(source)613+ fresh.End()
614+ }
610 }615 }
616+ d.touchMRU(targetSess)
617+ token.ac.recordPreviousSession(token.sess)
611 d.deferAttachmentTransitionCleanups(transition)618 d.deferAttachmentTransitionCleanups(transition)
612 d.firstPaintForTransition(transition.published)619 d.firstPaintForTransition(transition.published)
613 return nil620 return nil
@@ -633,6 +640,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
633 if target.RemoteKey != nil {640 if target.RemoteKey != nil {
634 return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)641 return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)
635 }642 }
643+ if token.sess.isProxy() && !target.Stopped {
644+ return d.switchActiveTargetForRoleGuarded(token, target, guard, action)
645+ }
636 sess, ok := localSession(token.sess)646 sess, ok := localSession(token.sess)
637 if !ok {647 if !ok {
638 return errAttachmentTransition648 return errAttachmentTransition
M internal/usecase/daemon/proxy_input_test.go +143 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "errors"
6+ "io"
67 "log/slog"
78 "sync"
89 "sync/atomic"
@@ -681,3 +682,145 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
681682 require.Equal(t, key, proxy.key)
682683 t.Cleanup(func() { stopProxy(t, proxy) })
683684 }
685+
686+func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
687+ d, proxy, ac, _, handler := newProxyInputHarness(t)
688+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
689+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
690+ publishTiledPaneOwners(local, tb)
691+ d.mu.Lock()
692+ require.True(t, d.registerSessionLocked(local))
693+ d.mu.Unlock()
694+
695+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
696+ d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})
697+ target, selectable := model.Selected()
698+ require.True(t, selectable)
699+ require.Equal(t, local.id, target.Session)
700+ require.Equal(t, local.name, target.Name)
701+ require.False(t, target.Stopped)
702+
703+ require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
704+ require.Same(t, local, ac.currentSession())
705+ require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
706+}
707+
708+func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
709+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
710+ d.attachCoordinator(local, nil, ac, true)
711+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
712+ remote := newProxyTestTransport()
713+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
714+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
715+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
716+ d.remoteTransportMode = ports.RemoteTransportUDP
717+
718+ localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
719+ require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
720+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
721+ require.True(t, ok)
722+ t.Cleanup(func() { stopProxy(t, proxy) })
723+
724+ proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
725+ require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
726+ require.Same(t, local, ac.currentSession())
727+
728+ model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
729+ d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
730+ target, selectable := model.Selected()
731+ require.True(t, selectable)
732+ require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")
733+ localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
734+ require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
735+ require.Same(t, proxy, ac.currentAttachmentSession())
736+ require.False(t, ac.overlays.pickerActive())
737+}
738+
739+func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) {
740+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
741+ client := newProxyTestTransport()
742+ ac.replaceTransport(client)
743+ d.attachCoordinator(local, nil, ac, true)
744+
745+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
746+ remote := newProxyTestTransport()
747+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
748+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
749+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
750+ d.remoteTransportMode = ports.RemoteTransportUDP
751+
752+ loopDone := make(chan struct{})
753+ go func() {
754+ d.runConnLoop(ac)
755+ close(loopDone)
756+ }()
757+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
758+
759+ token := roleEffectForTest(t, local.attachmentToken(ac, client))
760+ require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
761+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
762+ require.True(t, ok)
763+ t.Cleanup(func() { stopProxy(t, proxy) })
764+
765+ client.recv <- proxyRecv{err: io.EOF}
766+ awaitTestCompletion(t, loopDone, "connection loop did not return after receive failure")
767+ require.Nil(t, ac.currentAttachmentSession(), "receive failure cleaned the stale local role instead of the current proxy role")
768+}
769+
770+func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
771+ d, _, ac, _, handler := newProxyInputHarness(t)
772+ client := ac.transport().(*proxyTestTransport)
773+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
774+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
775+ publishTiledPaneOwners(local, tb)
776+ d.mu.Lock()
777+ require.True(t, d.registerSessionLocked(local))
778+ d.mu.Unlock()
779+
780+ loopDone := make(chan struct{})
781+ go func() {
782+ d.runConnLoop(ac)
783+ close(loopDone)
784+ }()
785+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
786+
787+ require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
788+ require.Same(t, local, ac.currentSession())
789+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
790+ awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
791+ require.Nil(t, ac.currentAttachmentSession())
792+}
793+
794+func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
795+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
796+ client := newProxyTestTransport()
797+ ac.replaceTransport(client)
798+ d.attachCoordinator(local, nil, ac, true)
799+
800+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
801+ remote := newProxyTestTransport()
802+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
803+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
804+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
805+ d.remoteTransportMode = ports.RemoteTransportUDP
806+
807+ loopDone := make(chan struct{})
808+ go func() {
809+ d.runConnLoop(ac)
810+ close(loopDone)
811+ }()
812+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
813+
814+ token := local.attachmentToken(ac, client)
815+ effect, admitted := ac.beginRoleEffect(token)
816+ require.True(t, admitted)
817+ token.effect = effect
818+ require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
819+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
820+ require.True(t, ok)
821+ t.Cleanup(func() { stopProxy(t, proxy) })
822+
823+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
824+ awaitTestCompletion(t, loopDone, "connection loop did not handle input after the proxy handoff")
825+ require.Nil(t, ac.currentAttachmentSession(), "connection loop abandoned the client after the proxy handoff")
826+}
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "io"
6 "log/slog"7 "log/slog"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
@@ -681,3 +682,145 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
681 require.Equal(t, key, proxy.key)682 require.Equal(t, key, proxy.key)
682 t.Cleanup(func() { stopProxy(t, proxy) })683 t.Cleanup(func() { stopProxy(t, proxy) })
683 }684 }
685+
686+func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
687+ d, proxy, ac, _, handler := newProxyInputHarness(t)
688+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
689+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
690+ publishTiledPaneOwners(local, tb)
691+ d.mu.Lock()
692+ require.True(t, d.registerSessionLocked(local))
693+ d.mu.Unlock()
694+
695+ model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
696+ d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})
697+ target, selectable := model.Selected()
698+ require.True(t, selectable)
699+ require.Equal(t, local.id, target.Session)
700+ require.Equal(t, local.name, target.Name)
701+ require.False(t, target.Stopped)
702+
703+ require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
704+ require.Same(t, local, ac.currentSession())
705+ require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
706+}
707+
708+func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
709+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
710+ d.attachCoordinator(local, nil, ac, true)
711+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
712+ remote := newProxyTestTransport()
713+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
714+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
715+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
716+ d.remoteTransportMode = ports.RemoteTransportUDP
717+
718+ localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
719+ require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
720+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
721+ require.True(t, ok)
722+ t.Cleanup(func() { stopProxy(t, proxy) })
723+
724+ proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
725+ require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
726+ require.Same(t, local, ac.currentSession())
727+
728+ model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
729+ d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
730+ target, selectable := model.Selected()
731+ require.True(t, selectable)
732+ require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")
733+ localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
734+ require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
735+ require.Same(t, proxy, ac.currentAttachmentSession())
736+ require.False(t, ac.overlays.pickerActive())
737+}
738+
739+func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) {
740+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
741+ client := newProxyTestTransport()
742+ ac.replaceTransport(client)
743+ d.attachCoordinator(local, nil, ac, true)
744+
745+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
746+ remote := newProxyTestTransport()
747+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
748+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
749+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
750+ d.remoteTransportMode = ports.RemoteTransportUDP
751+
752+ loopDone := make(chan struct{})
753+ go func() {
754+ d.runConnLoop(ac)
755+ close(loopDone)
756+ }()
757+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
758+
759+ token := roleEffectForTest(t, local.attachmentToken(ac, client))
760+ require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
761+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
762+ require.True(t, ok)
763+ t.Cleanup(func() { stopProxy(t, proxy) })
764+
765+ client.recv <- proxyRecv{err: io.EOF}
766+ awaitTestCompletion(t, loopDone, "connection loop did not return after receive failure")
767+ require.Nil(t, ac.currentAttachmentSession(), "receive failure cleaned the stale local role instead of the current proxy role")
768+}
769+
770+func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
771+ d, _, ac, _, handler := newProxyInputHarness(t)
772+ client := ac.transport().(*proxyTestTransport)
773+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
774+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
775+ publishTiledPaneOwners(local, tb)
776+ d.mu.Lock()
777+ require.True(t, d.registerSessionLocked(local))
778+ d.mu.Unlock()
779+
780+ loopDone := make(chan struct{})
781+ go func() {
782+ d.runConnLoop(ac)
783+ close(loopDone)
784+ }()
785+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
786+
787+ require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
788+ require.Same(t, local, ac.currentSession())
789+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
790+ awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
791+ require.Nil(t, ac.currentAttachmentSession())
792+}
793+
794+func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
795+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
796+ client := newProxyTestTransport()
797+ ac.replaceTransport(client)
798+ d.attachCoordinator(local, nil, ac, true)
799+
800+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
801+ remote := newProxyTestTransport()
802+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
803+ remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
804+ d.remoteDialerFactory = newProxyConstructionFactory(remote)
805+ d.remoteTransportMode = ports.RemoteTransportUDP
806+
807+ loopDone := make(chan struct{})
808+ go func() {
809+ d.runConnLoop(ac)
810+ close(loopDone)
811+ }()
812+ require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
813+
814+ token := local.attachmentToken(ac, client)
815+ effect, admitted := ac.beginRoleEffect(token)
816+ require.True(t, admitted)
817+ token.effect = effect
818+ require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
819+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
820+ require.True(t, ok)
821+ t.Cleanup(func() { stopProxy(t, proxy) })
822+
823+ client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
824+ awaitTestCompletion(t, loopDone, "connection loop did not handle input after the proxy handoff")
825+ require.Nil(t, ac.currentAttachmentSession(), "connection loop abandoned the client after the proxy handoff")
826+}
M internal/usecase/daemon/remote_picker.go +1 -0
@@ -151,6 +151,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker
151151 view.Name += " [expired]"
152152 }
153153 view.TargetName = ""
154+ view.ExpectedCreatedAt = nil
154155 view.RemoteKey = &key
155156 view.RemoteAvailability = picker.RemoteFresh
156157 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
@@ -151,6 +151,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker
151 view.Name += " [expired]"151 view.Name += " [expired]"
152 }152 }
153 view.TargetName = ""153 view.TargetName = ""
154+ view.ExpectedCreatedAt = nil
154 view.RemoteKey = &key155 view.RemoteKey = &key
155 view.RemoteAvailability = picker.RemoteFresh156 view.RemoteAvailability = picker.RemoteFresh
156 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))157 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))

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