detached 7dbc84a

fix(remote): complete proxy navigation fallbacks

commit
7dbc84a39d0b1a0600223bf8e392b6cdd8a12199
author
brice · · 2026-07-31 21:20 UTC
parent
d2f9957
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +38 -4
M internal/usecase/daemon/client_frame_routing.go +2 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "runtime"
56
67 "github.com/bnema/vev/internal/ports"
78 "github.com/bnema/vev/internal/usecase/command"
@@ -21,6 +22,7 @@ func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (
2122 }
2223 token := attachmentToken(sess, ac, tr)
2324 if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {
25+ runtime.Gosched()
2426 continue
2527 }
2628 return sess, token, true
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "runtime"
5 6
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
@@ -21,6 +22,7 @@ func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (
21 }22 }
22 token := attachmentToken(sess, ac, tr)23 token := attachmentToken(sess, ac, tr)
23 if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {24 if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {
25+ runtime.Gosched()
24 continue26 continue
25 }27 }
26 return sess, token, true28 return sess, token, true
M internal/usecase/daemon/picker.go +8 -1
@@ -610,7 +610,14 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
610610 d.mu.Lock()
611611 targetSess, ok := localSession(d.sessions[target.Session])
612612 d.mu.Unlock()
613- if !ok || targetSess == token.sess {
613+ if !ok {
614+ if !token.activeCurrent() {
615+ return nil
616+ }
617+ d.invalidateRender(token.sess, token.ac, true, "picker.go")
618+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
619+ }
620+ if targetSess == token.sess {
614621 return nil
615622 }
616623
@@ -610,7 +610,14 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
610 d.mu.Lock()610 d.mu.Lock()
611 targetSess, ok := localSession(d.sessions[target.Session])611 targetSess, ok := localSession(d.sessions[target.Session])
612 d.mu.Unlock()612 d.mu.Unlock()
613- if !ok || targetSess == token.sess {613+ if !ok {
614+ if !token.activeCurrent() {
615+ return nil
616+ }
617+ d.invalidateRender(token.sess, token.ac, true, "picker.go")
618+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
619+ }
620+ if targetSess == token.sess {
614 return nil621 return nil
615 }622 }
616 623
M internal/usecase/daemon/proxy_input_test.go +15 -1
@@ -708,6 +708,19 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
708708 localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
709709 require.NoError(t, d.backSessionForRole(localToken))
710710 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
711+
712+ proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
713+ require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
714+ require.Same(t, local, ac.currentSession())
715+ d.backSession(local, ac)
716+ require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
717+}
718+
719+func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
720+ d, proxy, ac, _, handler := newProxyInputHarness(t)
721+ err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
722+ require.Error(t, err)
723+ require.Same(t, proxy, ac.currentAttachmentSession())
711724 }
712725
713726 func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
@@ -862,7 +875,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
862875
863876 func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
864877 d, _, ac, _, handler := newProxyInputHarness(t)
865- client := ac.transport().(*proxyTestTransport)
878+ client, ok := ac.transport().(*proxyTestTransport)
879+ require.True(t, ok)
866880 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
867881 local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
868882 publishTiledPaneOwners(local, tb)
@@ -708,6 +708,19 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
708 localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))708 localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
709 require.NoError(t, d.backSessionForRole(localToken))709 require.NoError(t, d.backSessionForRole(localToken))
710 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")710 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
711+
712+ proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
713+ require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
714+ require.Same(t, local, ac.currentSession())
715+ d.backSession(local, ac)
716+ require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
717+}
718+
719+func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
720+ d, proxy, ac, _, handler := newProxyInputHarness(t)
721+ err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
722+ require.Error(t, err)
723+ require.Same(t, proxy, ac.currentAttachmentSession())
711 }724 }
712 725
713 func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {726 func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
@@ -862,7 +875,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
862 875
863 func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {876 func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
864 d, _, ac, _, handler := newProxyInputHarness(t)877 d, _, ac, _, handler := newProxyInputHarness(t)
865- client := ac.transport().(*proxyTestTransport)878+ client, ok := ac.transport().(*proxyTestTransport)
879+ require.True(t, ok)
866 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})880 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
867 local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}881 local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
868 publishTiledPaneOwners(local, tb)882 publishTiledPaneOwners(local, tb)
M internal/usecase/daemon/session_back.go +13 -2
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/usecase/picker"
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
47
58 // backSession toggles this attachment between its current session and the
69 // immediately preceding successfully activated session.
@@ -31,7 +34,15 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
3134 d.invalidateRender(current, ac, true, "session_back.go")
3235 return
3336 }
34- if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil {
37+ token := attachmentToken(current, ac, ac.transport())
38+ effect, admitted := ac.beginRoleEffect(token)
39+ if !admitted {
40+ d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
41+ return
42+ }
43+ token.effect = effect
44+ defer effect.End()
45+ if err := d.backSessionForRole(token); err != nil {
3546 d.reportError(current, err)
3647 }
3748 }
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/usecase/picker"3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
4 7
5 // backSession toggles this attachment between its current session and the8 // backSession toggles this attachment between its current session and the
6 // immediately preceding successfully activated session.9 // immediately preceding successfully activated session.
@@ -31,7 +34,15 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
31 d.invalidateRender(current, ac, true, "session_back.go")34 d.invalidateRender(current, ac, true, "session_back.go")
32 return35 return
33 }36 }
34- if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil {37+ token := attachmentToken(current, ac, ac.transport())
38+ effect, admitted := ac.beginRoleEffect(token)
39+ if !admitted {
40+ d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
41+ return
42+ }
43+ token.effect = effect
44+ defer effect.End()
45+ if err := d.backSessionForRole(token); err != nil {
35 d.reportError(current, err)46 d.reportError(current, err)
36 }47 }
37 }48 }

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