detached 74436e9

feat(client): remember selected tabs per session (#304)

* feat(client): remember selected tabs across session switches

* perf(client): avoid rescanning tab memory on output frames
commit
74436e95aaff02678b26eb0445b3ae1737532f7f
author
bnema · · 2026-09-30 06:23 UTC
committer
GitHub · · 2026-09-30 06:23 UTC
parent
80d04fe
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +249 -8
M docs/remote-resilience.md +1 -1
@@ -52,7 +52,7 @@ Then the session picker (`SSP`) shows local and remote sessions in one list. Rem
5252 - Selecting a session switches to it, local or remote. `BCK` goes back to the previous one; `JRS` jumps to a recent one.
5353 - A remote session opened from the picker uses the remote host's environment and saved working directory.
5454 - `CNS` asks where to create the new session when several daemons are available.
55-- Session history is per client and is not saved.
55+- Session history and the last selected tab in each session are per client and kept only while that client runs. Switching back restores that tab; choosing a specific tab overrides it. If the remembered tab was closed, vev falls back to the first tab.
5656 - Previews of remote sessions are cached in memory only, never written to disk.
5757
5858 After you leave a remote, vev keeps its connection warm so going back is instant. See [warm remote transports](configuration.md#warm-remote-transports).
@@ -52,7 +52,7 @@ Then the session picker (`SSP`) shows local and remote sessions in one list. Rem
52 - Selecting a session switches to it, local or remote. `BCK` goes back to the previous one; `JRS` jumps to a recent one.52 - Selecting a session switches to it, local or remote. `BCK` goes back to the previous one; `JRS` jumps to a recent one.
53 - A remote session opened from the picker uses the remote host's environment and saved working directory.53 - A remote session opened from the picker uses the remote host's environment and saved working directory.
54 - `CNS` asks where to create the new session when several daemons are available.54 - `CNS` asks where to create the new session when several daemons are available.
55-- Session history is per client and is not saved.55+- Session history and the last selected tab in each session are per client and kept only while that client runs. Switching back restores that tab; choosing a specific tab overrides it. If the remembered tab was closed, vev falls back to the first tab.
56 - Previews of remote sessions are cached in memory only, never written to disk.56 - Previews of remote sessions are cached in memory only, never written to disk.
57 57
58 After you leave a remote, vev keeps its connection warm so going back is instant. See [warm remote transports](configuration.md#warm-remote-transports).58 After you leave a remote, vev keeps its connection warm so going back is instant. See [warm remote transports](configuration.md#warm-remote-transports).
M internal/usecase/client/attachment_session.go +13 -7
@@ -96,6 +96,8 @@ type sessionAttachmentConfig struct {
9696 // Tab is the exact tab a picker tab row committed; its zero value
9797 // attaches at session level.
9898 Tab attachmentTab
99+ // Tabs retains this client's committed tab per authority and session.
100+ Tabs *sessionTabMemory
99101 }
100102
101103 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -219,7 +221,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
219221 return state, &event
220222 }
221223 state = next
222- attachmentNoteCommitted(fg, state.context)
224+ w.noteCommitted(fg, state.context)
223225 // The frame transaction is the pre-attach publication: it commits the first
224226 // session bytes with the Connecting presentation and, through the foreground
225227 // shape rule, a public generation of zero. Attached is published only after
@@ -284,7 +286,8 @@ func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground {
284286 return overlay
285287 }
286288
287-func attachmentNoteCommitted(fg AttachmentForeground, view protocol.ViewContext) {
289+func (w *sessionAttachmentWorker) noteCommitted(fg AttachmentForeground, view protocol.ViewContext) {
290+ w.cfg.Tabs.remember(requestAuthority(w.cfg.Request), view.Route.Target, view.TabID)
288291 if overlay := attachmentOverlay(fg); overlay != nil {
289292 overlay.noteCommitted(view.Route.Target, view.TabID)
290293 }
@@ -413,7 +416,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
413416 samePeerUI.uiSamePeerArrived()
414417 }
415418 }
416- attachmentNoteCommitted(fg, state.context)
419+ w.noteCommitted(fg, state.context)
417420 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
418421 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
419422 }
@@ -432,7 +435,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
432435 continue
433436 }
434437 state = next
435- attachmentNoteCommitted(fg, state.context)
438+ w.noteCommitted(fg, state.context)
436439 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil {
437440 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)}
438441 }
@@ -453,7 +456,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
453456 // An endpoint-empty offer on this very connection: confirm
454457 // it and the daemon moves this attachment in place.
455458 samePeerRequests++
456- request := protocol.SamePeerSwitchRequest{RequestID: samePeerRequests, Target: *typed.ExactTarget, PreferredTabID: typed.PreferredTabID}
459+ request := protocol.SamePeerSwitchRequest{
460+ RequestID: samePeerRequests, Target: *typed.ExactTarget,
461+ PreferredTabID: w.cfg.Tabs.preferred(requestAuthority(w.cfg.Request), *typed.ExactTarget, typed.PreferredTabID),
462+ }
457463 if request.Validate() != nil {
458464 continue
459465 }
@@ -773,8 +779,8 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
773779 // A stopped remote tab is restored through its exact selector.
774780 selector := *w.cfg.Tab.stopped
775781 hello.SessionTarget = &selector
776- case w.cfg.Tab.preferred != "":
777- hello.PreferredTabID = w.cfg.Tab.preferred
782+ default:
783+ hello.PreferredTabID = w.cfg.Tabs.preferred(requestAuthority(request), target, w.cfg.Tab.preferred)
778784 }
779785 case ports.BrokerAdmissionCreateNamed:
780786 hello.Intent = protocol.IntentNew
@@ -96,6 +96,8 @@ type sessionAttachmentConfig struct {
96 // Tab is the exact tab a picker tab row committed; its zero value96 // Tab is the exact tab a picker tab row committed; its zero value
97 // attaches at session level.97 // attaches at session level.
98 Tab attachmentTab98 Tab attachmentTab
99+ // Tabs retains this client's committed tab per authority and session.
100+ Tabs *sessionTabMemory
99 }101 }
100 102
101 // sessionAttachmentWorker implements AttachmentWorker for one broker logical103 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -219,7 +221,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
219 return state, &event221 return state, &event
220 }222 }
221 state = next223 state = next
222- attachmentNoteCommitted(fg, state.context)224+ w.noteCommitted(fg, state.context)
223 // The frame transaction is the pre-attach publication: it commits the first225 // The frame transaction is the pre-attach publication: it commits the first
224 // session bytes with the Connecting presentation and, through the foreground226 // session bytes with the Connecting presentation and, through the foreground
225 // shape rule, a public generation of zero. Attached is published only after227 // shape rule, a public generation of zero. Attached is published only after
@@ -284,7 +286,8 @@ func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground {
284 return overlay286 return overlay
285 }287 }
286 288
287-func attachmentNoteCommitted(fg AttachmentForeground, view protocol.ViewContext) {289+func (w *sessionAttachmentWorker) noteCommitted(fg AttachmentForeground, view protocol.ViewContext) {
290+ w.cfg.Tabs.remember(requestAuthority(w.cfg.Request), view.Route.Target, view.TabID)
288 if overlay := attachmentOverlay(fg); overlay != nil {291 if overlay := attachmentOverlay(fg); overlay != nil {
289 overlay.noteCommitted(view.Route.Target, view.TabID)292 overlay.noteCommitted(view.Route.Target, view.TabID)
290 }293 }
@@ -413,7 +416,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
413 samePeerUI.uiSamePeerArrived()416 samePeerUI.uiSamePeerArrived()
414 }417 }
415 }418 }
416- attachmentNoteCommitted(fg, state.context)419+ w.noteCommitted(fg, state.context)
417 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {420 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
418 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}421 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
419 }422 }
@@ -432,7 +435,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
432 continue435 continue
433 }436 }
434 state = next437 state = next
435- attachmentNoteCommitted(fg, state.context)438+ w.noteCommitted(fg, state.context)
436 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil {439 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil {
437 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)}440 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)}
438 }441 }
@@ -453,7 +456,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
453 // An endpoint-empty offer on this very connection: confirm456 // An endpoint-empty offer on this very connection: confirm
454 // it and the daemon moves this attachment in place.457 // it and the daemon moves this attachment in place.
455 samePeerRequests++458 samePeerRequests++
456- request := protocol.SamePeerSwitchRequest{RequestID: samePeerRequests, Target: *typed.ExactTarget, PreferredTabID: typed.PreferredTabID}459+ request := protocol.SamePeerSwitchRequest{
460+ RequestID: samePeerRequests, Target: *typed.ExactTarget,
461+ PreferredTabID: w.cfg.Tabs.preferred(requestAuthority(w.cfg.Request), *typed.ExactTarget, typed.PreferredTabID),
462+ }
457 if request.Validate() != nil {463 if request.Validate() != nil {
458 continue464 continue
459 }465 }
@@ -773,8 +779,8 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
773 // A stopped remote tab is restored through its exact selector.779 // A stopped remote tab is restored through its exact selector.
774 selector := *w.cfg.Tab.stopped780 selector := *w.cfg.Tab.stopped
775 hello.SessionTarget = &selector781 hello.SessionTarget = &selector
776- case w.cfg.Tab.preferred != "":782+ default:
777- hello.PreferredTabID = w.cfg.Tab.preferred783+ hello.PreferredTabID = w.cfg.Tabs.preferred(requestAuthority(request), target, w.cfg.Tab.preferred)
778 }784 }
779 case ports.BrokerAdmissionCreateNamed:785 case ports.BrokerAdmissionCreateNamed:
780 hello.Intent = protocol.IntentNew786 hello.Intent = protocol.IntentNew
A internal/usecase/client/session_tab_memory.go +56 -0
@@ -0,0 +1,56 @@
1+package client
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+// sessionTabMemory belongs to one client process and survives attachment swaps.
11+// Authority and lifecycle identity prevent unrelated hosts or recreated sessions
12+// from inheriting a selection. Only committed views update the memory.
13+type sessionTabMemory struct {
14+ mu sync.Mutex
15+ tabs map[sessionTabKey]domain.TabStableID
16+}
17+
18+type sessionTabKey struct {
19+ authority routeAuthority
20+ target protocol.ExactSessionTarget
21+}
22+
23+func (m *sessionTabMemory) remember(authority routeAuthority, target protocol.ExactSessionTarget, tab domain.TabStableID) {
24+ if m == nil || target.Validate() != nil || domain.ValidateTabStableID(tab) != nil {
25+ return
26+ }
27+ m.mu.Lock()
28+ defer m.mu.Unlock()
29+ if m.tabs == nil {
30+ m.tabs = make(map[sessionTabKey]domain.TabStableID)
31+ }
32+ key := sessionTabKey{authority: authority, target: target}
33+ if previous, known := m.tabs[key]; known {
34+ if previous != tab {
35+ m.tabs[key] = tab
36+ }
37+ return
38+ }
39+ // Reclaim obsolete lifecycles only on the first view of a session.
40+ // The exact key, not this cleanup, isolates recreated sessions.
41+ for old := range m.tabs {
42+ if old.authority == authority && old.target.SessionName == target.SessionName {
43+ delete(m.tabs, old)
44+ }
45+ }
46+ m.tabs[key] = tab
47+}
48+
49+func (m *sessionTabMemory) preferred(authority routeAuthority, target protocol.ExactSessionTarget, explicit domain.TabStableID) domain.TabStableID {
50+ if explicit != "" || m == nil {
51+ return explicit
52+ }
53+ m.mu.Lock()
54+ defer m.mu.Unlock()
55+ return m.tabs[sessionTabKey{authority: authority, target: target}]
56+}
@@ -0,0 +1,56 @@
1+package client
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+// sessionTabMemory belongs to one client process and survives attachment swaps.
11+// Authority and lifecycle identity prevent unrelated hosts or recreated sessions
12+// from inheriting a selection. Only committed views update the memory.
13+type sessionTabMemory struct {
14+ mu sync.Mutex
15+ tabs map[sessionTabKey]domain.TabStableID
16+}
17+
18+type sessionTabKey struct {
19+ authority routeAuthority
20+ target protocol.ExactSessionTarget
21+}
22+
23+func (m *sessionTabMemory) remember(authority routeAuthority, target protocol.ExactSessionTarget, tab domain.TabStableID) {
24+ if m == nil || target.Validate() != nil || domain.ValidateTabStableID(tab) != nil {
25+ return
26+ }
27+ m.mu.Lock()
28+ defer m.mu.Unlock()
29+ if m.tabs == nil {
30+ m.tabs = make(map[sessionTabKey]domain.TabStableID)
31+ }
32+ key := sessionTabKey{authority: authority, target: target}
33+ if previous, known := m.tabs[key]; known {
34+ if previous != tab {
35+ m.tabs[key] = tab
36+ }
37+ return
38+ }
39+ // Reclaim obsolete lifecycles only on the first view of a session.
40+ // The exact key, not this cleanup, isolates recreated sessions.
41+ for old := range m.tabs {
42+ if old.authority == authority && old.target.SessionName == target.SessionName {
43+ delete(m.tabs, old)
44+ }
45+ }
46+ m.tabs[key] = tab
47+}
48+
49+func (m *sessionTabMemory) preferred(authority routeAuthority, target protocol.ExactSessionTarget, explicit domain.TabStableID) domain.TabStableID {
50+ if explicit != "" || m == nil {
51+ return explicit
52+ }
53+ m.mu.Lock()
54+ defer m.mu.Unlock()
55+ return m.tabs[sessionTabKey{authority: authority, target: target}]
56+}
A internal/usecase/client/session_tab_memory_test.go +176 -0
@@ -0,0 +1,176 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+func TestSessionTabMemoryIsolation(t *testing.T) {
13+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
14+ local := routeAuthority{local: true}
15+ var memory sessionTabMemory
16+ memory.remember(local, target, "t_second")
17+ memory.remember(local, target, "t_latest")
18+ var otherClient sessionTabMemory
19+ for _, tt := range []struct {
20+ name string
21+ memory *sessionTabMemory
22+ authority routeAuthority
23+ target protocol.ExactSessionTarget
24+ explicit domain.TabStableID
25+ want domain.TabStableID
26+ }{
27+ {name: "latest committed tab", memory: &memory, authority: local, target: target, want: "t_latest"},
28+ {name: "explicit selection wins", memory: &memory, authority: local, target: target, explicit: "t_explicit", want: "t_explicit"},
29+ {name: "other host", memory: &memory, authority: routeAuthority{endpoint: "remote"}, target: target},
30+ {name: "other client", memory: &otherClient, authority: local, target: target},
31+ {name: "recreated session", memory: &memory, authority: local, target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}},
32+ {name: "no memory", authority: local, target: target},
33+ } {
34+ t.Run(tt.name, func(t *testing.T) {
35+ require.Equal(t, tt.want, tt.memory.preferred(tt.authority, tt.target, tt.explicit))
36+ })
37+ }
38+}
39+
40+func TestSessionTabMemorySurvivesAttachmentSwap(t *testing.T) {
41+ for _, local := range []bool{true, false} {
42+ name := "remote"
43+ if local {
44+ name = "local"
45+ }
46+ t.Run(name, func(t *testing.T) {
47+ terminal := newWorkerTestTerminal()
48+ supervisor := &Supervisor{
49+ cfg: SupervisorConfig{Terminal: terminal}, clientID: [16]byte{1},
50+ attachments: &attachmentHost{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, geometryValid: true},
51+ }
52+ request := sessionTestRequest(local)
53+ environment := sessionTestWorkerConfig(request).SessionEnvironment
54+ worker, err := supervisor.newAttachmentWorker(request, attachmentTab{}, nil, environment)
55+ require.NoError(t, err)
56+ view := *sessionTestOutput(1, "").Context
57+ view.TabID = "t_second"
58+ worker.(*sessionAttachmentWorker).noteCommitted(nil, view)
59+ other := view
60+ other.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
61+ other.TabID = "t_beta"
62+ worker.(*sessionAttachmentWorker).noteCommitted(nil, other)
63+
64+ for _, explicit := range []domain.TabStableID{"", "t_explicit"} {
65+ stream := newSessionTestStream()
66+ attachments := newSessionTestAttachments()
67+ next, err := supervisor.newAttachmentWorker(request, attachmentTab{preferred: explicit}, nil, environment)
68+ require.NoError(t, err)
69+ host := newWorkerTestHost(terminal, nil, attachments.record)
70+ events := make(chan AttachmentEvent, 1)
71+ go func() {
72+ event, _ := host.Run(t.Context(), AttachmentToken{Generation: 1, Attempt: 1}, next, stream)
73+ events <- event
74+ }()
75+ hello := awaitHello(t, stream)
76+ want := explicit
77+ if want == "" {
78+ want = "t_second"
79+ }
80+ require.Equal(t, want, hello.PreferredTabID)
81+ require.NoError(t, protocol.ValidateHello(hello))
82+ require.NoError(t, stream.Close())
83+ <-events
84+ }
85+ })
86+ }
87+}
88+
89+func TestSessionTabMemorySamePeerRoundTrip(t *testing.T) {
90+ stream := newSessionTestStream()
91+ terminal := newWorkerTestTerminal()
92+ attachments := newSessionTestAttachments()
93+ var memory sessionTabMemory
94+ cfg := sessionTestWorkerConfig(sessionTestRequest(true))
95+ cfg.Tabs = &memory
96+ events := sessionTestRun(t, stream, terminal, cfg, attachments)
97+ t.Cleanup(func() { _ = stream.Close(); <-events })
98+ awaitHello(t, stream)
99+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
100+ initial := sessionTestOutput(1, "initial")
101+ initial.Context.TabID = "t_second"
102+ stream.deliver(initial)
103+ attachments.token(t)
104+ alpha := initial.Context.Route.Target
105+ beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
106+ stream.deliver(protocol.AttachTarget{Session: "beta", Intent: protocol.IntentAttach, ExactTarget: &beta, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned})
107+ first := awaitSent(t, stream, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest)
108+ require.Empty(t, first.PreferredTabID)
109+ output := sessionTestOutput(2, "beta")
110+ output.Epoch = 2
111+ output.Context.Route.Target = beta
112+ output.Context.TabID = "t_beta"
113+ stream.deliver(output)
114+ awaitSent(t, stream, "Ack", func(message protocol.ClientMessage) bool {
115+ ack, ok := message.(protocol.Ack)
116+ return ok && ack.Epoch == output.Epoch
117+ })
118+ stream.deliver(protocol.AttachTarget{Session: "alpha", Intent: protocol.IntentAttach, ExactTarget: &alpha, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned})
119+ second := awaitSent(t, stream, "return switch", func(message protocol.ClientMessage) bool {
120+ request, ok := message.(protocol.SamePeerSwitchRequest)
121+ return ok && request.RequestID == 2
122+ }).(protocol.SamePeerSwitchRequest)
123+ require.Equal(t, domain.TabStableID("t_second"), second.PreferredTabID)
124+ stream.deliver(protocol.SamePeerSwitchFailure{RequestID: 2, Code: protocol.SamePeerSwitchStaleTarget})
125+ stream.deliver(protocol.AttachTarget{Session: "alpha", Intent: protocol.IntentAttach, ExactTarget: &alpha, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "t_explicit"})
126+ third := awaitSent(t, stream, "explicit switch", func(message protocol.ClientMessage) bool {
127+ request, ok := message.(protocol.SamePeerSwitchRequest)
128+ return ok && request.RequestID == 3
129+ }).(protocol.SamePeerSwitchRequest)
130+ require.Equal(t, domain.TabStableID("t_explicit"), third.PreferredTabID)
131+}
132+
133+func TestSessionTabMemoryLifecycleCleanup(t *testing.T) {
134+ local := routeAuthority{local: true}
135+ remote := routeAuthority{endpoint: "remote"}
136+ alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
137+ beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "beta"}
138+ replacement := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}
139+ var memory sessionTabMemory
140+ memory.remember(local, alpha, "t_old")
141+ memory.remember(remote, alpha, "t_remote")
142+ memory.remember(local, beta, "t_beta")
143+ memory.remember(local, replacement, "t_new")
144+ memory.remember(local, replacement, "t_latest")
145+ for _, tt := range []struct {
146+ name string
147+ authority routeAuthority
148+ target protocol.ExactSessionTarget
149+ want domain.TabStableID
150+ }{
151+ {name: "old lifecycle reclaimed", authority: local, target: alpha},
152+ {name: "new lifecycle retained", authority: local, target: replacement, want: "t_latest"},
153+ {name: "other host retained", authority: remote, target: alpha, want: "t_remote"},
154+ {name: "other session retained", authority: local, target: beta, want: "t_beta"},
155+ } {
156+ t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, memory.preferred(tt.authority, tt.target, "")) })
157+ }
158+}
159+
160+func TestSessionTabMemoryPreservesStoppedRemoteSelector(t *testing.T) {
161+ request := sessionTestRequest(false)
162+ var memory sessionTabMemory
163+ memory.remember(requestAuthority(request), request.Target, "t_remembered")
164+ selector := protocol.SessionAttachTarget{LifecycleID: request.Target.LifecycleID, SessionName: request.Target.SessionName, Stopped: true, TabID: "t_explicit", TabIndex: protocol.NoTabIndex}
165+ cfg := sessionTestWorkerConfig(request)
166+ cfg.Tabs = &memory
167+ cfg.Tab = attachmentTab{stopped: &selector}
168+ cfg.SessionEnvironment = SessionEnvironment{Provenance: SessionEnvironmentRemote}
169+ stream := newSessionTestStream()
170+ events := sessionTestRun(t, stream, newWorkerTestTerminal(), cfg, newSessionTestAttachments())
171+ t.Cleanup(func() { _ = stream.Close(); <-events })
172+ hello := awaitHello(t, stream)
173+ require.Equal(t, &selector, hello.SessionTarget)
174+ require.Empty(t, hello.PreferredTabID)
175+ require.NoError(t, protocol.ValidateHello(hello))
176+}
@@ -0,0 +1,176 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+func TestSessionTabMemoryIsolation(t *testing.T) {
13+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
14+ local := routeAuthority{local: true}
15+ var memory sessionTabMemory
16+ memory.remember(local, target, "t_second")
17+ memory.remember(local, target, "t_latest")
18+ var otherClient sessionTabMemory
19+ for _, tt := range []struct {
20+ name string
21+ memory *sessionTabMemory
22+ authority routeAuthority
23+ target protocol.ExactSessionTarget
24+ explicit domain.TabStableID
25+ want domain.TabStableID
26+ }{
27+ {name: "latest committed tab", memory: &memory, authority: local, target: target, want: "t_latest"},
28+ {name: "explicit selection wins", memory: &memory, authority: local, target: target, explicit: "t_explicit", want: "t_explicit"},
29+ {name: "other host", memory: &memory, authority: routeAuthority{endpoint: "remote"}, target: target},
30+ {name: "other client", memory: &otherClient, authority: local, target: target},
31+ {name: "recreated session", memory: &memory, authority: local, target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}},
32+ {name: "no memory", authority: local, target: target},
33+ } {
34+ t.Run(tt.name, func(t *testing.T) {
35+ require.Equal(t, tt.want, tt.memory.preferred(tt.authority, tt.target, tt.explicit))
36+ })
37+ }
38+}
39+
40+func TestSessionTabMemorySurvivesAttachmentSwap(t *testing.T) {
41+ for _, local := range []bool{true, false} {
42+ name := "remote"
43+ if local {
44+ name = "local"
45+ }
46+ t.Run(name, func(t *testing.T) {
47+ terminal := newWorkerTestTerminal()
48+ supervisor := &Supervisor{
49+ cfg: SupervisorConfig{Terminal: terminal}, clientID: [16]byte{1},
50+ attachments: &attachmentHost{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, geometryValid: true},
51+ }
52+ request := sessionTestRequest(local)
53+ environment := sessionTestWorkerConfig(request).SessionEnvironment
54+ worker, err := supervisor.newAttachmentWorker(request, attachmentTab{}, nil, environment)
55+ require.NoError(t, err)
56+ view := *sessionTestOutput(1, "").Context
57+ view.TabID = "t_second"
58+ worker.(*sessionAttachmentWorker).noteCommitted(nil, view)
59+ other := view
60+ other.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
61+ other.TabID = "t_beta"
62+ worker.(*sessionAttachmentWorker).noteCommitted(nil, other)
63+
64+ for _, explicit := range []domain.TabStableID{"", "t_explicit"} {
65+ stream := newSessionTestStream()
66+ attachments := newSessionTestAttachments()
67+ next, err := supervisor.newAttachmentWorker(request, attachmentTab{preferred: explicit}, nil, environment)
68+ require.NoError(t, err)
69+ host := newWorkerTestHost(terminal, nil, attachments.record)
70+ events := make(chan AttachmentEvent, 1)
71+ go func() {
72+ event, _ := host.Run(t.Context(), AttachmentToken{Generation: 1, Attempt: 1}, next, stream)
73+ events <- event
74+ }()
75+ hello := awaitHello(t, stream)
76+ want := explicit
77+ if want == "" {
78+ want = "t_second"
79+ }
80+ require.Equal(t, want, hello.PreferredTabID)
81+ require.NoError(t, protocol.ValidateHello(hello))
82+ require.NoError(t, stream.Close())
83+ <-events
84+ }
85+ })
86+ }
87+}
88+
89+func TestSessionTabMemorySamePeerRoundTrip(t *testing.T) {
90+ stream := newSessionTestStream()
91+ terminal := newWorkerTestTerminal()
92+ attachments := newSessionTestAttachments()
93+ var memory sessionTabMemory
94+ cfg := sessionTestWorkerConfig(sessionTestRequest(true))
95+ cfg.Tabs = &memory
96+ events := sessionTestRun(t, stream, terminal, cfg, attachments)
97+ t.Cleanup(func() { _ = stream.Close(); <-events })
98+ awaitHello(t, stream)
99+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
100+ initial := sessionTestOutput(1, "initial")
101+ initial.Context.TabID = "t_second"
102+ stream.deliver(initial)
103+ attachments.token(t)
104+ alpha := initial.Context.Route.Target
105+ beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
106+ stream.deliver(protocol.AttachTarget{Session: "beta", Intent: protocol.IntentAttach, ExactTarget: &beta, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned})
107+ first := awaitSent(t, stream, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest)
108+ require.Empty(t, first.PreferredTabID)
109+ output := sessionTestOutput(2, "beta")
110+ output.Epoch = 2
111+ output.Context.Route.Target = beta
112+ output.Context.TabID = "t_beta"
113+ stream.deliver(output)
114+ awaitSent(t, stream, "Ack", func(message protocol.ClientMessage) bool {
115+ ack, ok := message.(protocol.Ack)
116+ return ok && ack.Epoch == output.Epoch
117+ })
118+ stream.deliver(protocol.AttachTarget{Session: "alpha", Intent: protocol.IntentAttach, ExactTarget: &alpha, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned})
119+ second := awaitSent(t, stream, "return switch", func(message protocol.ClientMessage) bool {
120+ request, ok := message.(protocol.SamePeerSwitchRequest)
121+ return ok && request.RequestID == 2
122+ }).(protocol.SamePeerSwitchRequest)
123+ require.Equal(t, domain.TabStableID("t_second"), second.PreferredTabID)
124+ stream.deliver(protocol.SamePeerSwitchFailure{RequestID: 2, Code: protocol.SamePeerSwitchStaleTarget})
125+ stream.deliver(protocol.AttachTarget{Session: "alpha", Intent: protocol.IntentAttach, ExactTarget: &alpha, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "t_explicit"})
126+ third := awaitSent(t, stream, "explicit switch", func(message protocol.ClientMessage) bool {
127+ request, ok := message.(protocol.SamePeerSwitchRequest)
128+ return ok && request.RequestID == 3
129+ }).(protocol.SamePeerSwitchRequest)
130+ require.Equal(t, domain.TabStableID("t_explicit"), third.PreferredTabID)
131+}
132+
133+func TestSessionTabMemoryLifecycleCleanup(t *testing.T) {
134+ local := routeAuthority{local: true}
135+ remote := routeAuthority{endpoint: "remote"}
136+ alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
137+ beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "beta"}
138+ replacement := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}
139+ var memory sessionTabMemory
140+ memory.remember(local, alpha, "t_old")
141+ memory.remember(remote, alpha, "t_remote")
142+ memory.remember(local, beta, "t_beta")
143+ memory.remember(local, replacement, "t_new")
144+ memory.remember(local, replacement, "t_latest")
145+ for _, tt := range []struct {
146+ name string
147+ authority routeAuthority
148+ target protocol.ExactSessionTarget
149+ want domain.TabStableID
150+ }{
151+ {name: "old lifecycle reclaimed", authority: local, target: alpha},
152+ {name: "new lifecycle retained", authority: local, target: replacement, want: "t_latest"},
153+ {name: "other host retained", authority: remote, target: alpha, want: "t_remote"},
154+ {name: "other session retained", authority: local, target: beta, want: "t_beta"},
155+ } {
156+ t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, memory.preferred(tt.authority, tt.target, "")) })
157+ }
158+}
159+
160+func TestSessionTabMemoryPreservesStoppedRemoteSelector(t *testing.T) {
161+ request := sessionTestRequest(false)
162+ var memory sessionTabMemory
163+ memory.remember(requestAuthority(request), request.Target, "t_remembered")
164+ selector := protocol.SessionAttachTarget{LifecycleID: request.Target.LifecycleID, SessionName: request.Target.SessionName, Stopped: true, TabID: "t_explicit", TabIndex: protocol.NoTabIndex}
165+ cfg := sessionTestWorkerConfig(request)
166+ cfg.Tabs = &memory
167+ cfg.Tab = attachmentTab{stopped: &selector}
168+ cfg.SessionEnvironment = SessionEnvironment{Provenance: SessionEnvironmentRemote}
169+ stream := newSessionTestStream()
170+ events := sessionTestRun(t, stream, newWorkerTestTerminal(), cfg, newSessionTestAttachments())
171+ t.Cleanup(func() { _ = stream.Close(); <-events })
172+ hello := awaitHello(t, stream)
173+ require.Equal(t, &selector, hello.SessionTarget)
174+ require.Empty(t, hello.PreferredTabID)
175+ require.NoError(t, protocol.ValidateHello(hello))
176+}
M internal/usecase/client/supervisor.go +2 -0
@@ -444,6 +444,8 @@ type Supervisor struct {
444444 // theme retains the terminal-reported colors across attachments, so a
445445 // replacement attachment restores them before its own palette query ends.
446446 theme terminalThemeState
447+ // tabs remembers navigation only for this client process.
448+ tabs sessionTabMemory
447449 // capabilities is the outer terminal's probed and enabled capabilities,
448450 // written once in Run before any attachment starts.
449451 capabilities terminalCapabilities
@@ -444,6 +444,8 @@ type Supervisor struct {
444 // theme retains the terminal-reported colors across attachments, so a444 // theme retains the terminal-reported colors across attachments, so a
445 // replacement attachment restores them before its own palette query ends.445 // replacement attachment restores them before its own palette query ends.
446 theme terminalThemeState446 theme terminalThemeState
447+ // tabs remembers navigation only for this client process.
448+ tabs sessionTabMemory
447 // capabilities is the outer terminal's probed and enabled capabilities,449 // capabilities is the outer terminal's probed and enabled capabilities,
448 // written once in Run before any attachment starts.450 // written once in Run before any attachment starts.
449 capabilities terminalCapabilities451 capabilities terminalCapabilities
M internal/usecase/client/supervisor_attach.go +1 -0
@@ -356,6 +356,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
356356 Theme: &s.theme,
357357 Clipboard: s.cfg.Clipboard,
358358 Tab: tab,
359+ Tabs: &s.tabs,
359360 })
360361 if err != nil {
361362 return nil, err
@@ -356,6 +356,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
356 Theme: &s.theme,356 Theme: &s.theme,
357 Clipboard: s.cfg.Clipboard,357 Clipboard: s.cfg.Clipboard,
358 Tab: tab,358 Tab: tab,
359+ Tabs: &s.tabs,
359 })360 })
360 if err != nil {361 if err != nil {
361 return nil, err362 return nil, err

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