detached 68ce008

feat(client): drive picker previews through the broker

commit
68ce008cb179fcbc164f3fb0492766060253e0b8
author
brice · · 2026-09-22 19:23 UTC
parent
24e162b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +631 -547
M internal/usecase/client/attachment_session.go +15 -5
@@ -301,6 +301,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
301301 case protocol.ErrorMsg:
302302 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
303303 case protocol.Detached:
304+ if typed.Reason == protocol.ReasonDetachToPicker {
305+ return attachmentLifecycleEnd(token, AttachmentDetachToPicker)
306+ }
304307 return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
305308 }
306309 case event := <-outgoing:
@@ -322,11 +325,8 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
322325 }
323326 }
324327 }
325- if event.action == AttachmentDetachToPicker {
326- return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachToPicker}
327- }
328- if event.action == AttachmentDetachAndExit {
329- return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachAndExit}
328+ if event.action != 0 {
329+ return attachmentLifecycleEnd(token, event.action)
330330 }
331331 case <-ctx.Done():
332332 return w.settle(ctx, fg, stream, token, ctx.Err())
@@ -336,6 +336,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
336336 }
337337 }
338338
339+// attachmentLifecycleEnd gives local and daemon-triggered lifecycle actions
340+// the same settlement path: drain/revoke foreground before publishing picker.
341+func attachmentLifecycleEnd(token AttachmentToken, action AttachmentLifecycleActionKind) AttachmentEvent {
342+ err := errDetachToPicker
343+ if action == AttachmentDetachAndExit {
344+ err = errDetachAndExit
345+ }
346+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: err}
347+}
348+
339349 type attachmentUIForeground interface {
340350 actionableGeneration() uint64
341351 uiReceipt(protocol.UIReceipt)
@@ -301,6 +301,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
301 case protocol.ErrorMsg:301 case protocol.ErrorMsg:
302 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}302 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
303 case protocol.Detached:303 case protocol.Detached:
304+ if typed.Reason == protocol.ReasonDetachToPicker {
305+ return attachmentLifecycleEnd(token, AttachmentDetachToPicker)
306+ }
304 return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}307 return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
305 }308 }
306 case event := <-outgoing:309 case event := <-outgoing:
@@ -322,11 +325,8 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
322 }325 }
323 }326 }
324 }327 }
325- if event.action == AttachmentDetachToPicker {328+ if event.action != 0 {
326- return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachToPicker}329+ return attachmentLifecycleEnd(token, event.action)
327- }
328- if event.action == AttachmentDetachAndExit {
329- return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachAndExit}
330 }330 }
331 case <-ctx.Done():331 case <-ctx.Done():
332 return w.settle(ctx, fg, stream, token, ctx.Err())332 return w.settle(ctx, fg, stream, token, ctx.Err())
@@ -336,6 +336,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
336 }336 }
337 }337 }
338 338
339+// attachmentLifecycleEnd gives local and daemon-triggered lifecycle actions
340+// the same settlement path: drain/revoke foreground before publishing picker.
341+func attachmentLifecycleEnd(token AttachmentToken, action AttachmentLifecycleActionKind) AttachmentEvent {
342+ err := errDetachToPicker
343+ if action == AttachmentDetachAndExit {
344+ err = errDetachAndExit
345+ }
346+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: err}
347+}
348+
339 type attachmentUIForeground interface {349 type attachmentUIForeground interface {
340 actionableGeneration() uint64350 actionableGeneration() uint64
341 uiReceipt(protocol.UIReceipt)351 uiReceipt(protocol.UIReceipt)
M internal/usecase/client/broker_operations_test.go +4 -0
@@ -177,6 +177,10 @@ func (s *brokerOpsTestService) Subscribe() (ports.BrokerSubscription, error) {
177177 return nil, errors.New("broker ops test service does not support subscriptions")
178178 }
179179
180+func (s *brokerOpsTestService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
181+ return nil, errors.New("broker ops test service does not support preview")
182+}
183+
180184 func (s *brokerOpsTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
181185 s.mu.Lock()
182186 s.opens = append(s.opens, request)
@@ -177,6 +177,10 @@ func (s *brokerOpsTestService) Subscribe() (ports.BrokerSubscription, error) {
177 return nil, errors.New("broker ops test service does not support subscriptions")177 return nil, errors.New("broker ops test service does not support subscriptions")
178 }178 }
179 179
180+func (s *brokerOpsTestService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
181+ return nil, errors.New("broker ops test service does not support preview")
182+}
183+
180 func (s *brokerOpsTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {184 func (s *brokerOpsTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
181 s.mu.Lock()185 s.mu.Lock()
182 s.opens = append(s.opens, request)186 s.opens = append(s.opens, request)
M internal/usecase/client/picker_catalogue.go +8 -6
@@ -421,11 +421,11 @@ func (c *pickerCatalogue) projectLocked() {
421421 lines = append(lines, protocol.PickerLine{
422422 Key: pickerHostRowKey(observation),
423423 Kind: protocol.PickerLineHost,
424- Label: origin,
424+ Label: "status",
425425 Detail: pickerHostDetail(observation),
426426 Status: pickerObservationStatus(observation, fresh),
427427 StatusDetail: pickerObservationReason(observation, fresh),
428- Focusable: true,
428+ Focusable: false,
429429 })
430430
431431 for i := range observation.Sessions {
@@ -668,8 +668,9 @@ type pickerHostDiagnostic struct {
668668 Message string
669669 }
670670
671-// diagnostics reports every projected host that is not a clean, fresh,
672-// compatible, reachable daemon, so the controller can surface it.
671+// diagnostics reports actionable host failures. Ordinary observation progress
672+// and catalogue aging remain visible on the host status row and never become
673+// intrusive toasts.
673674 func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {
674675 if c == nil {
675676 return nil
@@ -685,13 +686,14 @@ func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {
685686 }
686687 observation := host.observation
687688 fresh := c.freshLocked(observation, now)
688- if pickerObservationCompatible(observation) && observation.Availability == domain.RemoteAvailabilityReachable && fresh && !observation.Checking {
689+ status := pickerObservationStatus(observation, fresh)
690+ if status != protocol.PickerLineStatusDown && status != protocol.PickerLineStatusError && status != protocol.PickerLineStatusVersion {
689691 continue
690692 }
691693 diagnostics = append(diagnostics, pickerHostDiagnostic{
692694 Key: pickerHostRowKey(observation),
693695 Origin: pickerOriginLabel(observation),
694- Status: pickerObservationStatus(observation, fresh),
696+ Status: status,
695697 Message: pickerObservationReason(observation, fresh),
696698 })
697699 }
@@ -421,11 +421,11 @@ func (c *pickerCatalogue) projectLocked() {
421 lines = append(lines, protocol.PickerLine{421 lines = append(lines, protocol.PickerLine{
422 Key: pickerHostRowKey(observation),422 Key: pickerHostRowKey(observation),
423 Kind: protocol.PickerLineHost,423 Kind: protocol.PickerLineHost,
424- Label: origin,424+ Label: "status",
425 Detail: pickerHostDetail(observation),425 Detail: pickerHostDetail(observation),
426 Status: pickerObservationStatus(observation, fresh),426 Status: pickerObservationStatus(observation, fresh),
427 StatusDetail: pickerObservationReason(observation, fresh),427 StatusDetail: pickerObservationReason(observation, fresh),
428- Focusable: true,428+ Focusable: false,
429 })429 })
430 430
431 for i := range observation.Sessions {431 for i := range observation.Sessions {
@@ -668,8 +668,9 @@ type pickerHostDiagnostic struct {
668 Message string668 Message string
669 }669 }
670 670
671-// diagnostics reports every projected host that is not a clean, fresh,671+// diagnostics reports actionable host failures. Ordinary observation progress
672-// compatible, reachable daemon, so the controller can surface it.672+// and catalogue aging remain visible on the host status row and never become
673+// intrusive toasts.
673 func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {674 func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {
674 if c == nil {675 if c == nil {
675 return nil676 return nil
@@ -685,13 +686,14 @@ func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {
685 }686 }
686 observation := host.observation687 observation := host.observation
687 fresh := c.freshLocked(observation, now)688 fresh := c.freshLocked(observation, now)
688- if pickerObservationCompatible(observation) && observation.Availability == domain.RemoteAvailabilityReachable && fresh && !observation.Checking {689+ status := pickerObservationStatus(observation, fresh)
690+ if status != protocol.PickerLineStatusDown && status != protocol.PickerLineStatusError && status != protocol.PickerLineStatusVersion {
689 continue691 continue
690 }692 }
691 diagnostics = append(diagnostics, pickerHostDiagnostic{693 diagnostics = append(diagnostics, pickerHostDiagnostic{
692 Key: pickerHostRowKey(observation),694 Key: pickerHostRowKey(observation),
693 Origin: pickerOriginLabel(observation),695 Origin: pickerOriginLabel(observation),
694- Status: pickerObservationStatus(observation, fresh),696+ Status: status,
695 Message: pickerObservationReason(observation, fresh),697 Message: pickerObservationReason(observation, fresh),
696 })698 })
697 }699 }
M internal/usecase/client/picker_catalogue_test.go +16 -1
@@ -2,6 +2,7 @@ package client
22
33 import (
44 "fmt"
5+ "strings"
56 "sync"
67 "testing"
78 "time"
@@ -115,6 +116,15 @@ func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.Pick
115116 return protocol.PickerLine{}, false
116117 }
117118
119+func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) {
120+ for _, line := range lines {
121+ if line.Kind == protocol.PickerLineHost {
122+ return line, true
123+ }
124+ }
125+ return protocol.PickerLine{}, false
126+}
127+
118128 func pickerSectionLabels(lines []protocol.PickerLine) []string {
119129 sections := make([]string, 0)
120130 for _, line := range lines {
@@ -266,6 +276,9 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
266276 }
267277 for label, status := range tt.wantStatus {
268278 line, ok := pickerLineByLabel(lines, label)
279+ if !ok && (label == "local" || strings.Contains(label, "@")) {
280+ line, ok = pickerHostLine(lines)
281+ }
269282 require.True(t, ok, "row %q must be projected", label)
270283 require.Equal(t, status, line.Status, "row %q badge", label)
271284 }
@@ -878,8 +891,10 @@ func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) {
878891 lines := catalogue.Lines()
879892 require.Equal(t, []string{"local"}, pickerSectionLabels(lines))
880893 require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines))
881- host, ok := pickerLineByLabel(lines, "local")
894+ host, ok := pickerHostLine(lines)
882895 require.True(t, ok)
896+ require.Equal(t, "status", host.Label)
897+ require.False(t, host.Focusable, "host status is informational, not a duplicate destination")
883898 require.Equal(t, protocol.PickerLineStatusStale, host.Status)
884899 require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")
885900 session, ok := pickerLineByLabel(lines, "alpha")
@@ -2,6 +2,7 @@ package client
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "strings"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -115,6 +116,15 @@ func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.Pick
115 return protocol.PickerLine{}, false116 return protocol.PickerLine{}, false
116 }117 }
117 118
119+func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) {
120+ for _, line := range lines {
121+ if line.Kind == protocol.PickerLineHost {
122+ return line, true
123+ }
124+ }
125+ return protocol.PickerLine{}, false
126+}
127+
118 func pickerSectionLabels(lines []protocol.PickerLine) []string {128 func pickerSectionLabels(lines []protocol.PickerLine) []string {
119 sections := make([]string, 0)129 sections := make([]string, 0)
120 for _, line := range lines {130 for _, line := range lines {
@@ -266,6 +276,9 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
266 }276 }
267 for label, status := range tt.wantStatus {277 for label, status := range tt.wantStatus {
268 line, ok := pickerLineByLabel(lines, label)278 line, ok := pickerLineByLabel(lines, label)
279+ if !ok && (label == "local" || strings.Contains(label, "@")) {
280+ line, ok = pickerHostLine(lines)
281+ }
269 require.True(t, ok, "row %q must be projected", label)282 require.True(t, ok, "row %q must be projected", label)
270 require.Equal(t, status, line.Status, "row %q badge", label)283 require.Equal(t, status, line.Status, "row %q badge", label)
271 }284 }
@@ -878,8 +891,10 @@ func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) {
878 lines := catalogue.Lines()891 lines := catalogue.Lines()
879 require.Equal(t, []string{"local"}, pickerSectionLabels(lines))892 require.Equal(t, []string{"local"}, pickerSectionLabels(lines))
880 require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines))893 require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines))
881- host, ok := pickerLineByLabel(lines, "local")894+ host, ok := pickerHostLine(lines)
882 require.True(t, ok)895 require.True(t, ok)
896+ require.Equal(t, "status", host.Label)
897+ require.False(t, host.Focusable, "host status is informational, not a duplicate destination")
883 require.Equal(t, protocol.PickerLineStatusStale, host.Status)898 require.Equal(t, protocol.PickerLineStatusStale, host.Status)
884 require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")899 require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")
885 session, ok := pickerLineByLabel(lines, "alpha")900 session, ok := pickerLineByLabel(lines, "alpha")
M internal/usecase/client/picker_composition.go +20 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol"
89 )
910
1011 // Picker is the composition seam over the client-owned picker.
@@ -75,6 +76,25 @@ func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpe
7576 }
7677
7778 // SetOwnsInput updates picker input ownership at an attachment boundary.
79+func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {
80+ if p == nil || p.controller == nil {
81+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
82+ }
83+ return p.controller.PreviewRequest(connection, stream, size)
84+}
85+
86+func (p *Picker) SetPreview(preview protocol.RemotePreview) {
87+ if p != nil && p.controller != nil {
88+ p.controller.SetPreview(preview)
89+ }
90+}
91+
92+func (p *Picker) ClearPreview() {
93+ if p != nil && p.controller != nil {
94+ p.controller.ClearPreview()
95+ }
96+}
97+
7898 func (p *Picker) SetOwnsInput(owns bool) {
7999 if p == nil || p.controller == nil {
80100 return
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol"
8 )9 )
9 10
10 // Picker is the composition seam over the client-owned picker.11 // Picker is the composition seam over the client-owned picker.
@@ -75,6 +76,25 @@ func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpe
75 }76 }
76 77
77 // SetOwnsInput updates picker input ownership at an attachment boundary.78 // SetOwnsInput updates picker input ownership at an attachment boundary.
79+func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {
80+ if p == nil || p.controller == nil {
81+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
82+ }
83+ return p.controller.PreviewRequest(connection, stream, size)
84+}
85+
86+func (p *Picker) SetPreview(preview protocol.RemotePreview) {
87+ if p != nil && p.controller != nil {
88+ p.controller.SetPreview(preview)
89+ }
90+}
91+
92+func (p *Picker) ClearPreview() {
93+ if p != nil && p.controller != nil {
94+ p.controller.ClearPreview()
95+ }
96+}
97+
78 func (p *Picker) SetOwnsInput(owns bool) {98 func (p *Picker) SetOwnsInput(owns bool) {
79 if p == nil || p.controller == nil {99 if p == nil || p.controller == nil {
80 return100 return
M internal/usecase/client/picker_controller.go +93 -7
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/vev/internal/domain"
1010 "github.com/bnema/vev/internal/ports"
1111 "github.com/bnema/vev/internal/protocol"
12+ pickerusecase "github.com/bnema/vev/internal/usecase/picker"
1213 "github.com/bnema/vev/internal/usecase/ui"
1314 )
1415
@@ -102,6 +103,7 @@ type pickerController struct {
102103 // lastCommitKey is the catalogue key captured with the pending commit
103104 // decision, so TakeOp can hand a driver the exact committed row.
104105 lastCommitKey string
106+ preview pickerusecase.Preview
105107 }
106108
107109 // newPickerController builds a picker over an empty catalogue. The picker owns
@@ -192,7 +194,7 @@ func (p *pickerController) recordOpLocked(op pickerOp) {
192194 p.lastCommitKey = p.commitKeyLocked()
193195 }
194196 p.lastOp = mergePickerOps(p.lastOp, op)
195- if p.lastOp.commit {
197+ if p.lastOp.commit || p.lastOp.close {
196198 // Coalesce exactly one wakeup: a driver that has not yet run TakeOp
197199 // re-reads the accumulated decision, so a dropped signal is harmless.
198200 select {
@@ -202,7 +204,7 @@ func (p *pickerController) recordOpLocked(op pickerOp) {
202204 }
203205 }
204206
205-// OpsReady wakes the supervisor when a commit decision was recorded. It is a
207+// OpsReady wakes the supervisor when a commit or close was recorded. It is a
206208 // capacity-one coalescing signal; TakeOp remains the authority on what was
207209 // decided, so a driver never infers the decision from the wakeup alone.
208210 func (p *pickerController) OpsReady() <-chan struct{} {
@@ -289,7 +291,77 @@ func (p *pickerController) Render(size domain.Size) []byte {
289291 if p.loop == nil {
290292 return nil
291293 }
292- return p.renderer.render(p.loop, size, emptyPickerPreview())
294+ return p.renderer.render(p.loop, size, p.preview)
295+}
296+
297+func (p *pickerController) SetPreview(preview protocol.RemotePreview) {
298+ if p == nil {
299+ return
300+ }
301+ p.mu.Lock()
302+ defer p.mu.Unlock()
303+ rows := preview.FrameRows()
304+ if preview.Status != protocol.RemotePreviewOK || rows == nil {
305+ p.preview = pickerusecase.Preview{}
306+ return
307+ }
308+ p.preview = pickerusecase.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
309+}
310+
311+// PreviewRequest derives preview authority from the exact catalogue ref behind
312+// the selected row. Only live exact sessions with an authoritative active tab
313+// are previewable; create rows and stopped sessions are deliberately refused.
314+func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {
315+ key, ok := p.CursorKey()
316+ if !ok || p.catalogue == nil {
317+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
318+ }
319+ ref, ok := p.catalogue.Ref(key)
320+ if !ok || ref.kind != pickerSelectionExact {
321+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
322+ }
323+ route, err := p.catalogue.ResolveRef(ref, pickerResolveBase{Connection: connection, Stream: stream})
324+ if err != nil {
325+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
326+ }
327+ p.catalogue.mu.Lock()
328+ authority := p.catalogue.authorityForRefLocked(ref)
329+ p.catalogue.mu.Unlock()
330+ if !authority.found {
331+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
332+ }
333+ session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
334+ if !ok || session.State == "down" || session.ActiveTabID == "" {
335+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
336+ }
337+ viewport := pickerPreviewSize(size)
338+ endpoint := authority.observation.Endpoint
339+ if authority.observation.Local {
340+ // RemoteSessionTarget still requires a non-empty route identity for the
341+ // local daemon; "local" is the broker's canonical local authority.
342+ endpoint = "local"
343+ }
344+ target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: domain.TabStableID(session.ActiveTabID)}
345+ preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)}
346+ route.Purpose = ports.BrokerStreamObservation
347+ route.Admission = 0
348+ route.Name = ""
349+ route.Target = protocol.ExactSessionTarget{}
350+ route.Env = nil
351+ route.StartMode = ports.BrokerDaemonExistingOnly
352+ if route.Validate() != nil || protocol.ValidateRemotePreviewRequest(preview) != nil {
353+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
354+ }
355+ return route, preview, true
356+}
357+
358+func (p *pickerController) ClearPreview() {
359+ if p == nil {
360+ return
361+ }
362+ p.mu.Lock()
363+ p.preview = pickerusecase.Preview{}
364+ p.mu.Unlock()
293365 }
294366
295367 // RenderNotice composes the newest bounded catalogue notice as a client-local
@@ -417,13 +489,27 @@ func (p *pickerController) handleRead(data []byte) bool {
417489 if !consumed {
418490 return false
419491 }
420- if p.loop == nil || outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
421- // The picker owns input before its first publication: the batch is
422- // consumed and dropped, never forwarded.
492+ if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
423493 return true
424494 }
425- op, _ := applyPickerBatch(p.loop, outcome.events)
495+ if p.loop == nil {
496+ // Before a catalogue exists only an explicit interrupt is actionable.
497+ // Use decoded events so a pasted Ctrl-C is not mistaken for an exit.
498+ for _, event := range outcome.events {
499+ if event.kind == pickerEventKey && event.key == "Ctrl+C" {
500+ p.recordOpLocked(pickerOp{close: true})
501+ }
502+ }
503+ return true
504+ }
505+ op, changed := applyPickerBatch(p.loop, outcome.events)
426506 p.recordOpLocked(op)
507+ if changed {
508+ select {
509+ case p.opsReady <- struct{}{}:
510+ default:
511+ }
512+ }
427513 return true
428514 }
429515
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
12+ pickerusecase "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/bnema/vev/internal/usecase/ui"13 "github.com/bnema/vev/internal/usecase/ui"
13 )14 )
14 15
@@ -102,6 +103,7 @@ type pickerController struct {
102 // lastCommitKey is the catalogue key captured with the pending commit103 // lastCommitKey is the catalogue key captured with the pending commit
103 // decision, so TakeOp can hand a driver the exact committed row.104 // decision, so TakeOp can hand a driver the exact committed row.
104 lastCommitKey string105 lastCommitKey string
106+ preview pickerusecase.Preview
105 }107 }
106 108
107 // newPickerController builds a picker over an empty catalogue. The picker owns109 // newPickerController builds a picker over an empty catalogue. The picker owns
@@ -192,7 +194,7 @@ func (p *pickerController) recordOpLocked(op pickerOp) {
192 p.lastCommitKey = p.commitKeyLocked()194 p.lastCommitKey = p.commitKeyLocked()
193 }195 }
194 p.lastOp = mergePickerOps(p.lastOp, op)196 p.lastOp = mergePickerOps(p.lastOp, op)
195- if p.lastOp.commit {197+ if p.lastOp.commit || p.lastOp.close {
196 // Coalesce exactly one wakeup: a driver that has not yet run TakeOp198 // Coalesce exactly one wakeup: a driver that has not yet run TakeOp
197 // re-reads the accumulated decision, so a dropped signal is harmless.199 // re-reads the accumulated decision, so a dropped signal is harmless.
198 select {200 select {
@@ -202,7 +204,7 @@ func (p *pickerController) recordOpLocked(op pickerOp) {
202 }204 }
203 }205 }
204 206
205-// OpsReady wakes the supervisor when a commit decision was recorded. It is a207+// OpsReady wakes the supervisor when a commit or close was recorded. It is a
206 // capacity-one coalescing signal; TakeOp remains the authority on what was208 // capacity-one coalescing signal; TakeOp remains the authority on what was
207 // decided, so a driver never infers the decision from the wakeup alone.209 // decided, so a driver never infers the decision from the wakeup alone.
208 func (p *pickerController) OpsReady() <-chan struct{} {210 func (p *pickerController) OpsReady() <-chan struct{} {
@@ -289,7 +291,77 @@ func (p *pickerController) Render(size domain.Size) []byte {
289 if p.loop == nil {291 if p.loop == nil {
290 return nil292 return nil
291 }293 }
292- return p.renderer.render(p.loop, size, emptyPickerPreview())294+ return p.renderer.render(p.loop, size, p.preview)
295+}
296+
297+func (p *pickerController) SetPreview(preview protocol.RemotePreview) {
298+ if p == nil {
299+ return
300+ }
301+ p.mu.Lock()
302+ defer p.mu.Unlock()
303+ rows := preview.FrameRows()
304+ if preview.Status != protocol.RemotePreviewOK || rows == nil {
305+ p.preview = pickerusecase.Preview{}
306+ return
307+ }
308+ p.preview = pickerusecase.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
309+}
310+
311+// PreviewRequest derives preview authority from the exact catalogue ref behind
312+// the selected row. Only live exact sessions with an authoritative active tab
313+// are previewable; create rows and stopped sessions are deliberately refused.
314+func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {
315+ key, ok := p.CursorKey()
316+ if !ok || p.catalogue == nil {
317+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
318+ }
319+ ref, ok := p.catalogue.Ref(key)
320+ if !ok || ref.kind != pickerSelectionExact {
321+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
322+ }
323+ route, err := p.catalogue.ResolveRef(ref, pickerResolveBase{Connection: connection, Stream: stream})
324+ if err != nil {
325+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
326+ }
327+ p.catalogue.mu.Lock()
328+ authority := p.catalogue.authorityForRefLocked(ref)
329+ p.catalogue.mu.Unlock()
330+ if !authority.found {
331+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
332+ }
333+ session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
334+ if !ok || session.State == "down" || session.ActiveTabID == "" {
335+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
336+ }
337+ viewport := pickerPreviewSize(size)
338+ endpoint := authority.observation.Endpoint
339+ if authority.observation.Local {
340+ // RemoteSessionTarget still requires a non-empty route identity for the
341+ // local daemon; "local" is the broker's canonical local authority.
342+ endpoint = "local"
343+ }
344+ target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: domain.TabStableID(session.ActiveTabID)}
345+ preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)}
346+ route.Purpose = ports.BrokerStreamObservation
347+ route.Admission = 0
348+ route.Name = ""
349+ route.Target = protocol.ExactSessionTarget{}
350+ route.Env = nil
351+ route.StartMode = ports.BrokerDaemonExistingOnly
352+ if route.Validate() != nil || protocol.ValidateRemotePreviewRequest(preview) != nil {
353+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
354+ }
355+ return route, preview, true
356+}
357+
358+func (p *pickerController) ClearPreview() {
359+ if p == nil {
360+ return
361+ }
362+ p.mu.Lock()
363+ p.preview = pickerusecase.Preview{}
364+ p.mu.Unlock()
293 }365 }
294 366
295 // RenderNotice composes the newest bounded catalogue notice as a client-local367 // RenderNotice composes the newest bounded catalogue notice as a client-local
@@ -417,13 +489,27 @@ func (p *pickerController) handleRead(data []byte) bool {
417 if !consumed {489 if !consumed {
418 return false490 return false
419 }491 }
420- if p.loop == nil || outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {492+ if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
421- // The picker owns input before its first publication: the batch is
422- // consumed and dropped, never forwarded.
423 return true493 return true
424 }494 }
425- op, _ := applyPickerBatch(p.loop, outcome.events)495+ if p.loop == nil {
496+ // Before a catalogue exists only an explicit interrupt is actionable.
497+ // Use decoded events so a pasted Ctrl-C is not mistaken for an exit.
498+ for _, event := range outcome.events {
499+ if event.kind == pickerEventKey && event.key == "Ctrl+C" {
500+ p.recordOpLocked(pickerOp{close: true})
501+ }
502+ }
503+ return true
504+ }
505+ op, changed := applyPickerBatch(p.loop, outcome.events)
426 p.recordOpLocked(op)506 p.recordOpLocked(op)
507+ if changed {
508+ select {
509+ case p.opsReady <- struct{}{}:
510+ default:
511+ }
512+ }
427 return true513 return true
428 }514 }
429 515
M internal/usecase/client/picker_controller_test.go +11 -8
@@ -240,7 +240,7 @@ func TestPickerControllerInputRouting(t *testing.T) {
240240 wantOp pickerOp
241241 }{
242242 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
243- {name: "up rests on the host row", chunk: []byte("\x1b[A"), wantNoSelection: true},
243+ {name: "up skips informational host row", chunk: []byte("\x1b[A"), wantCursor: "alpha"},
244244 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
245245 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},
246246 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
@@ -299,12 +299,12 @@ func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) {
299299 require.True(t, controller.ConsumeTerminalRead([]byte("j")))
300300 }
301301
302-func TestPickerControllerSurfacesProgressAndFailuresAsNotices(t *testing.T) {
302+func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) {
303303 controller, clock := pickerTestController(t)
304304 checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
305305 checking.Checking = true
306306 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})
307- require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "a refreshing host is surfaced as progress")
307+ require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the host row")
308308
309309 unreachable := pickerTestLocalObservation(clock.Now())
310310 unreachable.Availability = domain.RemoteAvailabilityUnreachable
@@ -585,8 +585,8 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
585585
586586 // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the
587587 // availability-first classification at the controller boundary: an unobserved
588-// daemon (Availability Unknown, ProtocolVersion 0) surfaces a refreshing
589-// notice, never a version_mismatch.
588+// daemon (Availability Unknown, ProtocolVersion 0) keeps refreshing on its
589+// row and surfaces no failure toast.
590590 func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {
591591 controller, clock := pickerTestController(t)
592592 unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
@@ -595,9 +595,12 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi
595595 controller.mu.Lock()
596596 active := controller.notices.Active(clock.Now())
597597 controller.mu.Unlock()
598- require.Len(t, active, 1)
599- require.Equal(t, "local: "+domain.RemoteReasonRefreshing, active[0].Message)
600- require.NotContains(t, active[0].Message, domain.RemoteReasonVersionMismatch)
598+ require.Empty(t, active)
599+ lines := controller.Catalogue().Lines()
600+ host, ok := pickerHostLine(lines)
601+ require.True(t, ok)
602+ require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail)
603+ require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail)
601604 }
602605
603606 // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is
@@ -240,7 +240,7 @@ func TestPickerControllerInputRouting(t *testing.T) {
240 wantOp pickerOp240 wantOp pickerOp
241 }{241 }{
242 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},242 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
243- {name: "up rests on the host row", chunk: []byte("\x1b[A"), wantNoSelection: true},243+ {name: "up skips informational host row", chunk: []byte("\x1b[A"), wantCursor: "alpha"},
244 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},244 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
245 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},245 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},
246 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},246 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
@@ -299,12 +299,12 @@ func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) {
299 require.True(t, controller.ConsumeTerminalRead([]byte("j")))299 require.True(t, controller.ConsumeTerminalRead([]byte("j")))
300 }300 }
301 301
302-func TestPickerControllerSurfacesProgressAndFailuresAsNotices(t *testing.T) {302+func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) {
303 controller, clock := pickerTestController(t)303 controller, clock := pickerTestController(t)
304 checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))304 checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
305 checking.Checking = true305 checking.Checking = true
306 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})306 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})
307- require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "a refreshing host is surfaced as progress")307+ require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the host row")
308 308
309 unreachable := pickerTestLocalObservation(clock.Now())309 unreachable := pickerTestLocalObservation(clock.Now())
310 unreachable.Availability = domain.RemoteAvailabilityUnreachable310 unreachable.Availability = domain.RemoteAvailabilityUnreachable
@@ -585,8 +585,8 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
585 585
586 // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the586 // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the
587 // availability-first classification at the controller boundary: an unobserved587 // availability-first classification at the controller boundary: an unobserved
588-// daemon (Availability Unknown, ProtocolVersion 0) surfaces a refreshing588+// daemon (Availability Unknown, ProtocolVersion 0) keeps refreshing on its
589-// notice, never a version_mismatch.589+// row and surfaces no failure toast.
590 func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {590 func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {
591 controller, clock := pickerTestController(t)591 controller, clock := pickerTestController(t)
592 unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))592 unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
@@ -595,9 +595,12 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi
595 controller.mu.Lock()595 controller.mu.Lock()
596 active := controller.notices.Active(clock.Now())596 active := controller.notices.Active(clock.Now())
597 controller.mu.Unlock()597 controller.mu.Unlock()
598- require.Len(t, active, 1)598+ require.Empty(t, active)
599- require.Equal(t, "local: "+domain.RemoteReasonRefreshing, active[0].Message)599+ lines := controller.Catalogue().Lines()
600- require.NotContains(t, active[0].Message, domain.RemoteReasonVersionMismatch)600+ host, ok := pickerHostLine(lines)
601+ require.True(t, ok)
602+ require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail)
603+ require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail)
601 }604 }
602 605
603 // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is606 // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is
D internal/usecase/client/picker_lease.go +0 -277
@@ -1,277 +0,0 @@
1-package client
2-
3-import "github.com/bnema/vev/internal/protocol"
4-
5-// This file owns the client-side presentation lease for one client-picker
6-// interaction. The daemon keeps mutation authority (it resolves opaque
7-// row keys and performs the handoff); the lease decides who writes the
8-// terminal: while it is acquiring, owned, or releasing the client
9-// consumes picker input and never lets a daemon paint overwrite the modal,
10-// yet every accepted daemon frame is still applied to the output shadow and
11-// acknowledged inside the bounded window.
12-//
13-// The lease is value-oriented and owns no locks: the attach loop is its
14-// only caller and already owns the terminal writer.
15-
16-// pickerLeaseState is the client-side presentation state of one
17-// client-picker interaction.
18-type pickerLeaseState uint8
19-
20-const (
21- // pickerLeaseClosed displays daemon output normally.
22- pickerLeaseClosed pickerLeaseState = iota
23- // pickerLeaseAcquiring validated a snapshot whose acquisition barrier
24- // (epoch, state) is not applied yet. Daemon frames up to that barrier
25- // are still displayed; the picker frame is not.
26- pickerLeaseAcquiring
27- // pickerLeaseOwned displays the picker frame and consumes picker
28- // input. Daemon frames are applied and acknowledged but never written.
29- pickerLeaseOwned
30- // pickerLeaseReleasing retired the interaction on PickerClose. The
31- // picker frame stays until one authoritative full paint arrives; then
32- // the client releases the terminal and resumes normal input.
33- pickerLeaseReleasing
34-)
35-
36-// pickerLeaseEvent reports the presentation transition one accepted daemon
37-// frame triggers.
38-type pickerLeaseEvent uint8
39-
40-const (
41- // pickerLeaseIdle displays the frame normally and leaves presentation
42- // unchanged.
43- pickerLeaseIdle pickerLeaseEvent = iota
44- // pickerLeaseSuppress applies and acknowledges the frame without
45- // writing it: the client owns the terminal.
46- pickerLeaseSuppress
47- // pickerLeaseAcquire displays the frame normally, then displays the
48- // pending picker frame: the barrier is now applied.
49- pickerLeaseAcquire
50- // pickerLeaseRelease displays the authoritative full paint normally,
51- // then clears the lease.
52- pickerLeaseRelease
53-)
54-
55-// pickerLease is the client's presentation identity for one interaction:
56-// attachment UI generation, interaction ID, displayed revision, and the
57-// acquisition barrier the snapshot was built against. A generation change
58-// (reconnect) aborts the lease instead of carrying it into a new
59-// attachment.
60-type pickerLease struct {
61- state pickerLeaseState
62- generation uint64
63- interaction uint64
64- revision uint64
65- barrierEpoch uint64
66- barrierState uint64
67- // pending holds the validated model until the barrier is applied; it
68- // is nil in every other state.
69- pending *pickerLoop
70- // releaseAction is the ui-driver action waiting on the release paint
71- // and releaseLocal reports whether it completes locally (a cancel
72- // consumed by the client) instead of through the daemon handoff.
73- releaseAction uint64
74- releaseLocal bool
75- // releaseEpoch/releaseState name the applied output boundary that was
76- // current when the daemon's close for this interaction was observed.
77- // Only a full paint accepted after that boundary releases the terminal:
78- // a Full already in flight before the close is a suppressed artifact,
79- // not the authoritative restore the release waits for.
80- releaseEpoch uint64
81- releaseState uint64
82- // daemonClosed reports whether the serving daemon confirmed the close.
83- // A client-side retire alone never releases: the daemon sends the close
84- // before its restore paint, so waiting for it orders the release after
85- // the close instead of after an in-flight paint.
86- daemonClosed bool
87-}
88-
89-// active reports whether the lease owns picker presentation.
90-func (l *pickerLease) active() bool {
91- return l != nil && l.state != pickerLeaseClosed
92-}
93-
94-// owns reports whether the client has taken the terminal over: only then is
95-// picker input applied to the displayed model.
96-func (l *pickerLease) owns() bool {
97- return l != nil && l.state == pickerLeaseOwned
98-}
99-
100-// releasingFor reports whether this lease is retiring the named
101-// interaction, which is the only case where a close may extend its drain.
102-func (l *pickerLease) releasingFor(interaction uint64) bool {
103- return l != nil && l.state == pickerLeaseReleasing && l.interaction == interaction
104-}
105-
106-// reset returns the lease to closed presentation.
107-func (l *pickerLease) reset() {
108- *l = pickerLease{}
109-}
110-
111-// abort ends the lease without a release paint (disconnect, generation
112-// change, or a superseding interaction). It reports the pending local
113-// action that can no longer complete.
114-func (l *pickerLease) abort() (actionID uint64, local bool) {
115- if l == nil {
116- return 0, false
117- }
118- actionID, local = l.releaseAction, l.releaseLocal
119- if actionID != 0 && !local {
120- // A handoff-bound action stays unresolved: the daemon owns its
121- // outcome and the caller reports the unknown outcome.
122- actionID, local = 0, false
123- }
124- l.reset()
125- return actionID, local
126-}
127-
128-// pickerBarrier is the daemon output boundary one offer names: the client
129-// must display admitted output through it before it takes the terminal.
130-type pickerBarrier struct {
131- epoch uint64
132- state uint64
133-}
134-
135-// admitSnapshot admits one validated snapshot against the client's applied
136-// output state. It reports ready=true when the barrier named by the offer is
137-// already applied and the caller must display the picker frame now.
138-// superseded is a copy of the previous lease when a new interaction replaces
139-// it, so the caller can resolve the old pending action without touching the
140-// newly installed lease.
141-//
142-// A snapshot for the current interaction only replaces the pending model
143-// with a newer source revision, and never after the release started: a
144-// superseding interaction is a new namespace whose close/full pair cannot
145-// release the previous one.
146-func (l *pickerLease) admitSnapshot(snapshot protocol.PickerSnapshot, barrier pickerBarrier, loop *pickerLoop, applied outputApplyState, generation uint64) (ready bool, superseded *pickerLease) {
147- if l == nil || snapshot.InteractionID == 0 {
148- return false, nil
149- }
150- if l.active() {
151- if l.interaction == snapshot.InteractionID {
152- if l.state == pickerLeaseReleasing || snapshot.SourceRevision <= l.revision {
153- return false, nil
154- }
155- }
156- }
157- if l.active() && l.interaction != snapshot.InteractionID {
158- previous := *l
159- superseded = &previous
160- }
161- *l = pickerLease{
162- state: pickerLeaseAcquiring, generation: generation, interaction: snapshot.InteractionID,
163- revision: snapshot.SourceRevision, barrierEpoch: barrier.epoch, barrierState: barrier.state,
164- pending: loop,
165- }
166- if barrierReached(applied, barrier.epoch, barrier.state) {
167- // The barrier is already displayed: the client owns the terminal
168- // from this moment, before any later daemon frame can be written.
169- l.state = pickerLeaseOwned
170- return true, superseded
171- }
172- return false, superseded
173-}
174-
175-// observe reports the presentation decision for one accepted daemon frame.
176-// Only accepted frames reach it, so the output dependency chain, reset
177-// handling, and bounded ACK window keep advancing in every state.
178-func (l *pickerLease) observe(output protocol.Output, applied outputApplyState) pickerLeaseEvent {
179- if l == nil {
180- return pickerLeaseIdle
181- }
182- switch l.state {
183- case pickerLeaseAcquiring:
184- if barrierReached(applied, l.barrierEpoch, l.barrierState) {
185- l.state = pickerLeaseOwned
186- return pickerLeaseAcquire
187- }
188- return pickerLeaseIdle
189- case pickerLeaseOwned:
190- return pickerLeaseSuppress
191- case pickerLeaseReleasing:
192- // Only an authoritative full paint accepted after the serving
193- // daemon's close releases the terminal: an incremental delta, a Full
194- // already in flight before the close, or any paint seen before the
195- // close was confirmed may itself be a suppressed artifact.
196- if l.daemonClosed && output.Full && stateAfter(applied, l.releaseEpoch, l.releaseState) {
197- return pickerLeaseRelease
198- }
199- return pickerLeaseSuppress
200- }
201- return pickerLeaseIdle
202-}
203-
204-// stateAfter reports whether the applied output state is strictly newer
205-// than the named (epoch, state) boundary. An uninitialized state has
206-// applied only the implicit empty stream: epoch 1, state 0.
207-func stateAfter(applied outputApplyState, epoch, state uint64) bool {
208- appliedEpoch, appliedState := applied.epoch, applied.state
209- if !applied.initialized {
210- appliedEpoch, appliedState = 1, 0
211- }
212- if appliedEpoch != epoch {
213- return appliedEpoch > epoch
214- }
215- return appliedState > state
216-}
217-
218-// beginRelease retires the interaction named by one close. It reports
219-// false for a duplicate or foreign close, which must not restart or extend
220-// the release. fromDaemon marks the serving daemon's own close, which is
221-// the ordering barrier for the release: a client-side retire waits for it
222-// before any paint can release the terminal. The pending action completes
223-// after the release paint: local for a cancel the client consumed, through
224-// the daemon handoff otherwise.
225-func (l *pickerLease) beginRelease(interaction, actionID uint64, local, fromDaemon bool, applied outputApplyState) bool {
226- if l == nil || !l.active() || l.state == pickerLeaseReleasing || l.interaction != interaction {
227- return false
228- }
229- l.state = pickerLeaseReleasing
230- l.pending = nil
231- l.releaseAction, l.releaseLocal = actionID, local
232- l.releaseEpoch, l.releaseState = applied.epoch, applied.state
233- if !applied.initialized {
234- l.releaseEpoch, l.releaseState = 1, 0
235- }
236- l.daemonClosed = fromDaemon
237- return true
238-}
239-
240-// confirmDaemonClose records the serving daemon's close for the releasing
241-// interaction and re-captures the release boundary at that point. Only
242-// paints accepted after it can release the terminal.
243-func (l *pickerLease) confirmDaemonClose(interaction uint64, applied outputApplyState) bool {
244- if l == nil || l.state != pickerLeaseReleasing || l.interaction != interaction {
245- return false
246- }
247- l.daemonClosed = true
248- l.releaseEpoch, l.releaseState = applied.epoch, applied.state
249- if !applied.initialized {
250- l.releaseEpoch, l.releaseState = 1, 0
251- }
252- return true
253-}
254-
255-// finishRelease clears the lease after the release paint was displayed and
256-// published. It reports the pending local action to complete.
257-func (l *pickerLease) finishRelease() (actionID uint64, local bool) {
258- if l == nil {
259- return 0, false
260- }
261- actionID, local = l.releaseAction, l.releaseLocal
262- l.reset()
263- return actionID, local
264-}
265-
266-// barrierReached reports whether the applied output state covers the
267-// acquisition barrier (epoch, state). An uninitialized state has applied
268-// only the implicit empty stream: epoch 1, state 0.
269-func barrierReached(applied outputApplyState, epoch, state uint64) bool {
270- if !applied.initialized {
271- return epoch <= 1 && state == 0
272- }
273- if applied.epoch != epoch {
274- return applied.epoch > epoch
275- }
276- return applied.state >= state
277-}
@@ -1,277 +0,0 @@
1-package client
2-
3-import "github.com/bnema/vev/internal/protocol"
4-
5-// This file owns the client-side presentation lease for one client-picker
6-// interaction. The daemon keeps mutation authority (it resolves opaque
7-// row keys and performs the handoff); the lease decides who writes the
8-// terminal: while it is acquiring, owned, or releasing the client
9-// consumes picker input and never lets a daemon paint overwrite the modal,
10-// yet every accepted daemon frame is still applied to the output shadow and
11-// acknowledged inside the bounded window.
12-//
13-// The lease is value-oriented and owns no locks: the attach loop is its
14-// only caller and already owns the terminal writer.
15-
16-// pickerLeaseState is the client-side presentation state of one
17-// client-picker interaction.
18-type pickerLeaseState uint8
19-
20-const (
21- // pickerLeaseClosed displays daemon output normally.
22- pickerLeaseClosed pickerLeaseState = iota
23- // pickerLeaseAcquiring validated a snapshot whose acquisition barrier
24- // (epoch, state) is not applied yet. Daemon frames up to that barrier
25- // are still displayed; the picker frame is not.
26- pickerLeaseAcquiring
27- // pickerLeaseOwned displays the picker frame and consumes picker
28- // input. Daemon frames are applied and acknowledged but never written.
29- pickerLeaseOwned
30- // pickerLeaseReleasing retired the interaction on PickerClose. The
31- // picker frame stays until one authoritative full paint arrives; then
32- // the client releases the terminal and resumes normal input.
33- pickerLeaseReleasing
34-)
35-
36-// pickerLeaseEvent reports the presentation transition one accepted daemon
37-// frame triggers.
38-type pickerLeaseEvent uint8
39-
40-const (
41- // pickerLeaseIdle displays the frame normally and leaves presentation
42- // unchanged.
43- pickerLeaseIdle pickerLeaseEvent = iota
44- // pickerLeaseSuppress applies and acknowledges the frame without
45- // writing it: the client owns the terminal.
46- pickerLeaseSuppress
47- // pickerLeaseAcquire displays the frame normally, then displays the
48- // pending picker frame: the barrier is now applied.
49- pickerLeaseAcquire
50- // pickerLeaseRelease displays the authoritative full paint normally,
51- // then clears the lease.
52- pickerLeaseRelease
53-)
54-
55-// pickerLease is the client's presentation identity for one interaction:
56-// attachment UI generation, interaction ID, displayed revision, and the
57-// acquisition barrier the snapshot was built against. A generation change
58-// (reconnect) aborts the lease instead of carrying it into a new
59-// attachment.
60-type pickerLease struct {
61- state pickerLeaseState
62- generation uint64
63- interaction uint64
64- revision uint64
65- barrierEpoch uint64
66- barrierState uint64
67- // pending holds the validated model until the barrier is applied; it
68- // is nil in every other state.
69- pending *pickerLoop
70- // releaseAction is the ui-driver action waiting on the release paint
71- // and releaseLocal reports whether it completes locally (a cancel
72- // consumed by the client) instead of through the daemon handoff.
73- releaseAction uint64
74- releaseLocal bool
75- // releaseEpoch/releaseState name the applied output boundary that was
76- // current when the daemon's close for this interaction was observed.
77- // Only a full paint accepted after that boundary releases the terminal:
78- // a Full already in flight before the close is a suppressed artifact,
79- // not the authoritative restore the release waits for.
80- releaseEpoch uint64
81- releaseState uint64
82- // daemonClosed reports whether the serving daemon confirmed the close.
83- // A client-side retire alone never releases: the daemon sends the close
84- // before its restore paint, so waiting for it orders the release after
85- // the close instead of after an in-flight paint.
86- daemonClosed bool
87-}
88-
89-// active reports whether the lease owns picker presentation.
90-func (l *pickerLease) active() bool {
91- return l != nil && l.state != pickerLeaseClosed
92-}
93-
94-// owns reports whether the client has taken the terminal over: only then is
95-// picker input applied to the displayed model.
96-func (l *pickerLease) owns() bool {
97- return l != nil && l.state == pickerLeaseOwned
98-}
99-
100-// releasingFor reports whether this lease is retiring the named
101-// interaction, which is the only case where a close may extend its drain.
102-func (l *pickerLease) releasingFor(interaction uint64) bool {
103- return l != nil && l.state == pickerLeaseReleasing && l.interaction == interaction
104-}
105-
106-// reset returns the lease to closed presentation.
107-func (l *pickerLease) reset() {
108- *l = pickerLease{}
109-}
110-
111-// abort ends the lease without a release paint (disconnect, generation
112-// change, or a superseding interaction). It reports the pending local
113-// action that can no longer complete.
114-func (l *pickerLease) abort() (actionID uint64, local bool) {
115- if l == nil {
116- return 0, false
117- }
118- actionID, local = l.releaseAction, l.releaseLocal
119- if actionID != 0 && !local {
120- // A handoff-bound action stays unresolved: the daemon owns its
121- // outcome and the caller reports the unknown outcome.
122- actionID, local = 0, false
123- }
124- l.reset()
125- return actionID, local
126-}
127-
128-// pickerBarrier is the daemon output boundary one offer names: the client
129-// must display admitted output through it before it takes the terminal.
130-type pickerBarrier struct {
131- epoch uint64
132- state uint64
133-}
134-
135-// admitSnapshot admits one validated snapshot against the client's applied
136-// output state. It reports ready=true when the barrier named by the offer is
137-// already applied and the caller must display the picker frame now.
138-// superseded is a copy of the previous lease when a new interaction replaces
139-// it, so the caller can resolve the old pending action without touching the
140-// newly installed lease.
141-//
142-// A snapshot for the current interaction only replaces the pending model
143-// with a newer source revision, and never after the release started: a
144-// superseding interaction is a new namespace whose close/full pair cannot
145-// release the previous one.
146-func (l *pickerLease) admitSnapshot(snapshot protocol.PickerSnapshot, barrier pickerBarrier, loop *pickerLoop, applied outputApplyState, generation uint64) (ready bool, superseded *pickerLease) {
147- if l == nil || snapshot.InteractionID == 0 {
148- return false, nil
149- }
150- if l.active() {
151- if l.interaction == snapshot.InteractionID {
152- if l.state == pickerLeaseReleasing || snapshot.SourceRevision <= l.revision {
153- return false, nil
154- }
155- }
156- }
157- if l.active() && l.interaction != snapshot.InteractionID {
158- previous := *l
159- superseded = &previous
160- }
161- *l = pickerLease{
162- state: pickerLeaseAcquiring, generation: generation, interaction: snapshot.InteractionID,
163- revision: snapshot.SourceRevision, barrierEpoch: barrier.epoch, barrierState: barrier.state,
164- pending: loop,
165- }
166- if barrierReached(applied, barrier.epoch, barrier.state) {
167- // The barrier is already displayed: the client owns the terminal
168- // from this moment, before any later daemon frame can be written.
169- l.state = pickerLeaseOwned
170- return true, superseded
171- }
172- return false, superseded
173-}
174-
175-// observe reports the presentation decision for one accepted daemon frame.
176-// Only accepted frames reach it, so the output dependency chain, reset
177-// handling, and bounded ACK window keep advancing in every state.
178-func (l *pickerLease) observe(output protocol.Output, applied outputApplyState) pickerLeaseEvent {
179- if l == nil {
180- return pickerLeaseIdle
181- }
182- switch l.state {
183- case pickerLeaseAcquiring:
184- if barrierReached(applied, l.barrierEpoch, l.barrierState) {
185- l.state = pickerLeaseOwned
186- return pickerLeaseAcquire
187- }
188- return pickerLeaseIdle
189- case pickerLeaseOwned:
190- return pickerLeaseSuppress
191- case pickerLeaseReleasing:
192- // Only an authoritative full paint accepted after the serving
193- // daemon's close releases the terminal: an incremental delta, a Full
194- // already in flight before the close, or any paint seen before the
195- // close was confirmed may itself be a suppressed artifact.
196- if l.daemonClosed && output.Full && stateAfter(applied, l.releaseEpoch, l.releaseState) {
197- return pickerLeaseRelease
198- }
199- return pickerLeaseSuppress
200- }
201- return pickerLeaseIdle
202-}
203-
204-// stateAfter reports whether the applied output state is strictly newer
205-// than the named (epoch, state) boundary. An uninitialized state has
206-// applied only the implicit empty stream: epoch 1, state 0.
207-func stateAfter(applied outputApplyState, epoch, state uint64) bool {
208- appliedEpoch, appliedState := applied.epoch, applied.state
209- if !applied.initialized {
210- appliedEpoch, appliedState = 1, 0
211- }
212- if appliedEpoch != epoch {
213- return appliedEpoch > epoch
214- }
215- return appliedState > state
216-}
217-
218-// beginRelease retires the interaction named by one close. It reports
219-// false for a duplicate or foreign close, which must not restart or extend
220-// the release. fromDaemon marks the serving daemon's own close, which is
221-// the ordering barrier for the release: a client-side retire waits for it
222-// before any paint can release the terminal. The pending action completes
223-// after the release paint: local for a cancel the client consumed, through
224-// the daemon handoff otherwise.
225-func (l *pickerLease) beginRelease(interaction, actionID uint64, local, fromDaemon bool, applied outputApplyState) bool {
226- if l == nil || !l.active() || l.state == pickerLeaseReleasing || l.interaction != interaction {
227- return false
228- }
229- l.state = pickerLeaseReleasing
230- l.pending = nil
231- l.releaseAction, l.releaseLocal = actionID, local
232- l.releaseEpoch, l.releaseState = applied.epoch, applied.state
233- if !applied.initialized {
234- l.releaseEpoch, l.releaseState = 1, 0
235- }
236- l.daemonClosed = fromDaemon
237- return true
238-}
239-
240-// confirmDaemonClose records the serving daemon's close for the releasing
241-// interaction and re-captures the release boundary at that point. Only
242-// paints accepted after it can release the terminal.
243-func (l *pickerLease) confirmDaemonClose(interaction uint64, applied outputApplyState) bool {
244- if l == nil || l.state != pickerLeaseReleasing || l.interaction != interaction {
245- return false
246- }
247- l.daemonClosed = true
248- l.releaseEpoch, l.releaseState = applied.epoch, applied.state
249- if !applied.initialized {
250- l.releaseEpoch, l.releaseState = 1, 0
251- }
252- return true
253-}
254-
255-// finishRelease clears the lease after the release paint was displayed and
256-// published. It reports the pending local action to complete.
257-func (l *pickerLease) finishRelease() (actionID uint64, local bool) {
258- if l == nil {
259- return 0, false
260- }
261- actionID, local = l.releaseAction, l.releaseLocal
262- l.reset()
263- return actionID, local
264-}
265-
266-// barrierReached reports whether the applied output state covers the
267-// acquisition barrier (epoch, state). An uninitialized state has applied
268-// only the implicit empty stream: epoch 1, state 0.
269-func barrierReached(applied outputApplyState, epoch, state uint64) bool {
270- if !applied.initialized {
271- return epoch <= 1 && state == 0
272- }
273- if applied.epoch != epoch {
274- return applied.epoch > epoch
275- }
276- return applied.state >= state
277-}
D internal/usecase/client/picker_lease_test.go +0 -222
@@ -1,222 +0,0 @@
1-package client
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/protocol"
7- "github.com/stretchr/testify/require"
8-)
9-
10-// pickerLeaseTestState builds an applied output state for barrier checks.
11-func pickerLeaseTestState(epoch, state uint64) outputApplyState {
12- return outputApplyState{epoch: epoch, state: state, initialized: true}
13-}
14-
15-// pickerLeaseTestSnapshot builds one validated source publication for the
16-// lease. The acquisition barrier travels in the offer, so it is passed
17-// separately to admitSnapshot.
18-func pickerLeaseTestSnapshot(interaction, revision uint64) protocol.PickerSnapshot {
19- return protocol.PickerSnapshot{
20- InteractionID: interaction, SourceID: "serving", SourceRevision: revision,
21- Status: protocol.PickerSourceOK,
22- Lines: []protocol.PickerLine{
23- {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate},
24- },
25- Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
26- }
27-}
28-
29-func pickerLeaseTestLoop() *pickerLoop {
30- return &pickerLoop{interaction: 1}
31-}
32-
33-func TestPickerLeaseAcquiresOnlyAtTheBarrier(t *testing.T) {
34- tests := []struct {
35- name string
36- applied outputApplyState
37- barrierEpoch uint64
38- barrierState uint64
39- ready bool
40- owned bool
41- }{
42- {name: "uninitialized zero barrier is already applied", applied: outputApplyState{}, barrierEpoch: 1, ready: true, owned: true},
43- {name: "applied state equals the barrier", applied: pickerLeaseTestState(4, 3), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
44- {name: "applied state passed the barrier", applied: pickerLeaseTestState(4, 5), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
45- {name: "newer epoch passed the barrier", applied: pickerLeaseTestState(6, 1), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
46- {name: "barrier still ahead", applied: pickerLeaseTestState(4, 1), barrierEpoch: 4, barrierState: 3, ready: false, owned: false},
47- {name: "barrier in a future epoch", applied: pickerLeaseTestState(4, 9), barrierEpoch: 5, barrierState: 1, ready: false, owned: false},
48- }
49- for _, test := range tests {
50- t.Run(test.name, func(t *testing.T) {
51- var lease pickerLease
52- ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: test.barrierEpoch, state: test.barrierState}, pickerLeaseTestLoop(), test.applied, 3)
53- require.Nil(t, superseded)
54- require.Equal(t, test.ready, ready)
55- require.Equal(t, test.owned, lease.state == pickerLeaseOwned)
56- require.Equal(t, uint64(7), lease.interaction)
57- require.Equal(t, uint64(3), lease.generation)
58- })
59- }
60-}
61-
62-func TestPickerLeaseAcquiresWhenAHeldBarrierArrives(t *testing.T) {
63- var lease pickerLease
64- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
65- require.False(t, ready)
66- require.Equal(t, pickerLeaseAcquiring, lease.state)
67-
68- // A still-short frame keeps the lease acquiring and displays the frame.
69- event := lease.observe(protocol.Output{Epoch: 4, New: 2, Full: true}, pickerLeaseTestState(4, 1))
70- require.Equal(t, pickerLeaseIdle, event)
71- require.Equal(t, pickerLeaseAcquiring, lease.state)
72-
73- // The exact barrier is applied: the frame is displayed first and the
74- // picker follows it. The pending model stays for the caller to install.
75- event = lease.observe(protocol.Output{Epoch: 4, New: 3, Base: 2}, pickerLeaseTestState(4, 3))
76- require.Equal(t, pickerLeaseAcquire, event)
77- require.Equal(t, pickerLeaseOwned, lease.state)
78- require.NotNil(t, lease.pending)
79- require.Equal(t, uint64(1), lease.pending.interaction)
80-}
81-
82-func TestPickerLeaseSuppressesOwnedOutputAndPublishesBoundary(t *testing.T) {
83- var lease pickerLease
84- require.True(t, func() bool {
85- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
86- return ready
87- }())
88-
89- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2)))
90- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)))
91- require.Equal(t, pickerLeaseOwned, lease.state)
92-}
93-
94-func TestPickerLeaseReleasesOnlyAfterTheDaemonCloseAndANewerFullPaint(t *testing.T) {
95- var lease pickerLease
96- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 2)
97- require.True(t, ready)
98-
99- // The client cancels: the client-side retire alone never releases, because
100- // it cannot tell a post-close restore from a paint already in flight.
101- require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
102- require.Equal(t, pickerLeaseReleasing, lease.state)
103- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2)))
104- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)),
105- "a Full in flight before the daemon close must not release")
106-
107- // The daemon confirms the close at the applied boundary it was rendering:
108- // only paints accepted after that boundary release the terminal.
109- require.True(t, lease.confirmDaemonClose(7, pickerLeaseTestState(5, 1)))
110- require.False(t, lease.confirmDaemonClose(8, pickerLeaseTestState(5, 1)), "a foreign confirmation must not move the boundary")
111- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)),
112- "a paint at the close boundary is not newer than it")
113- require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 2, Full: true}, pickerLeaseTestState(5, 2)))
114-
115- actionID, local := lease.finishRelease()
116- require.Equal(t, uint64(42), actionID)
117- require.True(t, local)
118- require.False(t, lease.active())
119-}
120-
121-func TestPickerLeaseDaemonCloseReleasesWithoutAClientRetire(t *testing.T) {
122- var lease pickerLease
123- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
124- require.True(t, ready)
125-
126- // A daemon-initiated close (a resolved selection) carries its own barrier.
127- require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 1)))
128- require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)))
129-}
130-
131-func TestPickerLeaseForeignAndDuplicateCloseNeverRelease(t *testing.T) {
132- var lease pickerLease
133- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
134- require.True(t, ready)
135-
136- require.False(t, lease.beginRelease(6, 42, true, false, pickerLeaseTestState(4, 1)), "foreign interaction must not release")
137- require.False(t, lease.beginRelease(0, 42, true, false, pickerLeaseTestState(4, 1)), "zero interaction must not release")
138- require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
139- require.False(t, lease.beginRelease(7, 43, true, false, pickerLeaseTestState(4, 1)), "duplicate close must not restart the release")
140- require.Equal(t, uint64(42), lease.releaseAction)
141-}
142-
143-func TestPickerLeaseSupersedingSnapshotStartsANewNamespace(t *testing.T) {
144- var lease pickerLease
145- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
146- require.True(t, ready)
147- require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
148-
149- // A superseding interaction cannot be released by the prior close.
150- ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(8, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
151- require.True(t, ready)
152- require.NotNil(t, superseded)
153- require.Equal(t, uint64(7), superseded.interaction)
154- require.Equal(t, uint64(8), lease.interaction)
155- require.Equal(t, pickerLeaseOwned, lease.state)
156- require.False(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
157-}
158-
159-func TestPickerLeaseSameInteractionRevisionRules(t *testing.T) {
160- var lease pickerLease
161- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
162- require.True(t, ready)
163-
164- // Duplicate and older revisions never replace the displayed model.
165- ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
166- require.False(t, ready)
167- require.Nil(t, superseded)
168- ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 2), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
169- require.False(t, ready)
170- require.Nil(t, superseded)
171-
172- // A newer revision refreshes in place.
173- ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
174- require.False(t, ready)
175- require.Nil(t, superseded)
176- require.Equal(t, uint64(4), lease.revision)
177-
178- // A newer revision that also moved the barrier to an applied state
179- // acquires immediately.
180- ready, _ = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 5), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1)
181- require.True(t, ready)
182-
183- // A release in progress refuses revisions: the close/full pair cannot be
184- // replaced by a late refresh.
185- require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 2)))
186- ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 6), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1)
187- require.False(t, ready)
188- require.Nil(t, superseded)
189-}
190-
191-func TestPickerLeaseAbortReportsPendingLocalAction(t *testing.T) {
192- var lease pickerLease
193- lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
194- require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
195-
196- actionID, local := lease.abort()
197- require.Equal(t, uint64(42), actionID)
198- require.True(t, local)
199- require.False(t, lease.active())
200-
201- // A handoff-bound action stays unresolved: the daemon owns its outcome.
202- lease.admitSnapshot(pickerLeaseTestSnapshot(9, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
203- lease.beginRelease(9, 77, false, false, pickerLeaseTestState(4, 1))
204- actionID, local = lease.abort()
205- require.Zero(t, actionID)
206- require.False(t, local)
207-}
208-
209-func TestPickerLeaseRejectsUnusableSnapshots(t *testing.T) {
210- var lease pickerLease
211- ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(0, 1), pickerBarrier{epoch: 1, state: 0}, pickerLeaseTestLoop(), outputApplyState{}, 1)
212- require.False(t, ready)
213- require.Nil(t, superseded)
214- require.False(t, lease.active())
215-}
216-
217-func TestPickerLeaseClosedPresentationNeverSuppresses(t *testing.T) {
218- var lease pickerLease
219- require.False(t, lease.active())
220- require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 1, Full: true}, pickerLeaseTestState(1, 1)))
221- require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 2, Base: 1}, pickerLeaseTestState(1, 2)))
222-}
@@ -1,222 +0,0 @@
1-package client
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/protocol"
7- "github.com/stretchr/testify/require"
8-)
9-
10-// pickerLeaseTestState builds an applied output state for barrier checks.
11-func pickerLeaseTestState(epoch, state uint64) outputApplyState {
12- return outputApplyState{epoch: epoch, state: state, initialized: true}
13-}
14-
15-// pickerLeaseTestSnapshot builds one validated source publication for the
16-// lease. The acquisition barrier travels in the offer, so it is passed
17-// separately to admitSnapshot.
18-func pickerLeaseTestSnapshot(interaction, revision uint64) protocol.PickerSnapshot {
19- return protocol.PickerSnapshot{
20- InteractionID: interaction, SourceID: "serving", SourceRevision: revision,
21- Status: protocol.PickerSourceOK,
22- Lines: []protocol.PickerLine{
23- {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate},
24- },
25- Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
26- }
27-}
28-
29-func pickerLeaseTestLoop() *pickerLoop {
30- return &pickerLoop{interaction: 1}
31-}
32-
33-func TestPickerLeaseAcquiresOnlyAtTheBarrier(t *testing.T) {
34- tests := []struct {
35- name string
36- applied outputApplyState
37- barrierEpoch uint64
38- barrierState uint64
39- ready bool
40- owned bool
41- }{
42- {name: "uninitialized zero barrier is already applied", applied: outputApplyState{}, barrierEpoch: 1, ready: true, owned: true},
43- {name: "applied state equals the barrier", applied: pickerLeaseTestState(4, 3), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
44- {name: "applied state passed the barrier", applied: pickerLeaseTestState(4, 5), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
45- {name: "newer epoch passed the barrier", applied: pickerLeaseTestState(6, 1), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
46- {name: "barrier still ahead", applied: pickerLeaseTestState(4, 1), barrierEpoch: 4, barrierState: 3, ready: false, owned: false},
47- {name: "barrier in a future epoch", applied: pickerLeaseTestState(4, 9), barrierEpoch: 5, barrierState: 1, ready: false, owned: false},
48- }
49- for _, test := range tests {
50- t.Run(test.name, func(t *testing.T) {
51- var lease pickerLease
52- ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: test.barrierEpoch, state: test.barrierState}, pickerLeaseTestLoop(), test.applied, 3)
53- require.Nil(t, superseded)
54- require.Equal(t, test.ready, ready)
55- require.Equal(t, test.owned, lease.state == pickerLeaseOwned)
56- require.Equal(t, uint64(7), lease.interaction)
57- require.Equal(t, uint64(3), lease.generation)
58- })
59- }
60-}
61-
62-func TestPickerLeaseAcquiresWhenAHeldBarrierArrives(t *testing.T) {
63- var lease pickerLease
64- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
65- require.False(t, ready)
66- require.Equal(t, pickerLeaseAcquiring, lease.state)
67-
68- // A still-short frame keeps the lease acquiring and displays the frame.
69- event := lease.observe(protocol.Output{Epoch: 4, New: 2, Full: true}, pickerLeaseTestState(4, 1))
70- require.Equal(t, pickerLeaseIdle, event)
71- require.Equal(t, pickerLeaseAcquiring, lease.state)
72-
73- // The exact barrier is applied: the frame is displayed first and the
74- // picker follows it. The pending model stays for the caller to install.
75- event = lease.observe(protocol.Output{Epoch: 4, New: 3, Base: 2}, pickerLeaseTestState(4, 3))
76- require.Equal(t, pickerLeaseAcquire, event)
77- require.Equal(t, pickerLeaseOwned, lease.state)
78- require.NotNil(t, lease.pending)
79- require.Equal(t, uint64(1), lease.pending.interaction)
80-}
81-
82-func TestPickerLeaseSuppressesOwnedOutputAndPublishesBoundary(t *testing.T) {
83- var lease pickerLease
84- require.True(t, func() bool {
85- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
86- return ready
87- }())
88-
89- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2)))
90- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)))
91- require.Equal(t, pickerLeaseOwned, lease.state)
92-}
93-
94-func TestPickerLeaseReleasesOnlyAfterTheDaemonCloseAndANewerFullPaint(t *testing.T) {
95- var lease pickerLease
96- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 2)
97- require.True(t, ready)
98-
99- // The client cancels: the client-side retire alone never releases, because
100- // it cannot tell a post-close restore from a paint already in flight.
101- require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
102- require.Equal(t, pickerLeaseReleasing, lease.state)
103- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2)))
104- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)),
105- "a Full in flight before the daemon close must not release")
106-
107- // The daemon confirms the close at the applied boundary it was rendering:
108- // only paints accepted after that boundary release the terminal.
109- require.True(t, lease.confirmDaemonClose(7, pickerLeaseTestState(5, 1)))
110- require.False(t, lease.confirmDaemonClose(8, pickerLeaseTestState(5, 1)), "a foreign confirmation must not move the boundary")
111- require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)),
112- "a paint at the close boundary is not newer than it")
113- require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 2, Full: true}, pickerLeaseTestState(5, 2)))
114-
115- actionID, local := lease.finishRelease()
116- require.Equal(t, uint64(42), actionID)
117- require.True(t, local)
118- require.False(t, lease.active())
119-}
120-
121-func TestPickerLeaseDaemonCloseReleasesWithoutAClientRetire(t *testing.T) {
122- var lease pickerLease
123- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
124- require.True(t, ready)
125-
126- // A daemon-initiated close (a resolved selection) carries its own barrier.
127- require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 1)))
128- require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)))
129-}
130-
131-func TestPickerLeaseForeignAndDuplicateCloseNeverRelease(t *testing.T) {
132- var lease pickerLease
133- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
134- require.True(t, ready)
135-
136- require.False(t, lease.beginRelease(6, 42, true, false, pickerLeaseTestState(4, 1)), "foreign interaction must not release")
137- require.False(t, lease.beginRelease(0, 42, true, false, pickerLeaseTestState(4, 1)), "zero interaction must not release")
138- require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
139- require.False(t, lease.beginRelease(7, 43, true, false, pickerLeaseTestState(4, 1)), "duplicate close must not restart the release")
140- require.Equal(t, uint64(42), lease.releaseAction)
141-}
142-
143-func TestPickerLeaseSupersedingSnapshotStartsANewNamespace(t *testing.T) {
144- var lease pickerLease
145- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
146- require.True(t, ready)
147- require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
148-
149- // A superseding interaction cannot be released by the prior close.
150- ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(8, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
151- require.True(t, ready)
152- require.NotNil(t, superseded)
153- require.Equal(t, uint64(7), superseded.interaction)
154- require.Equal(t, uint64(8), lease.interaction)
155- require.Equal(t, pickerLeaseOwned, lease.state)
156- require.False(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
157-}
158-
159-func TestPickerLeaseSameInteractionRevisionRules(t *testing.T) {
160- var lease pickerLease
161- ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
162- require.True(t, ready)
163-
164- // Duplicate and older revisions never replace the displayed model.
165- ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
166- require.False(t, ready)
167- require.Nil(t, superseded)
168- ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 2), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
169- require.False(t, ready)
170- require.Nil(t, superseded)
171-
172- // A newer revision refreshes in place.
173- ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
174- require.False(t, ready)
175- require.Nil(t, superseded)
176- require.Equal(t, uint64(4), lease.revision)
177-
178- // A newer revision that also moved the barrier to an applied state
179- // acquires immediately.
180- ready, _ = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 5), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1)
181- require.True(t, ready)
182-
183- // A release in progress refuses revisions: the close/full pair cannot be
184- // replaced by a late refresh.
185- require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 2)))
186- ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 6), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1)
187- require.False(t, ready)
188- require.Nil(t, superseded)
189-}
190-
191-func TestPickerLeaseAbortReportsPendingLocalAction(t *testing.T) {
192- var lease pickerLease
193- lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
194- require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
195-
196- actionID, local := lease.abort()
197- require.Equal(t, uint64(42), actionID)
198- require.True(t, local)
199- require.False(t, lease.active())
200-
201- // A handoff-bound action stays unresolved: the daemon owns its outcome.
202- lease.admitSnapshot(pickerLeaseTestSnapshot(9, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
203- lease.beginRelease(9, 77, false, false, pickerLeaseTestState(4, 1))
204- actionID, local = lease.abort()
205- require.Zero(t, actionID)
206- require.False(t, local)
207-}
208-
209-func TestPickerLeaseRejectsUnusableSnapshots(t *testing.T) {
210- var lease pickerLease
211- ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(0, 1), pickerBarrier{epoch: 1, state: 0}, pickerLeaseTestLoop(), outputApplyState{}, 1)
212- require.False(t, ready)
213- require.Nil(t, superseded)
214- require.False(t, lease.active())
215-}
216-
217-func TestPickerLeaseClosedPresentationNeverSuppresses(t *testing.T) {
218- var lease pickerLease
219- require.False(t, lease.active())
220- require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 1, Full: true}, pickerLeaseTestState(1, 1)))
221- require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 2, Base: 1}, pickerLeaseTestState(1, 2)))
222-}
A internal/usecase/client/preview_manager.go +93 -0
@@ -0,0 +1,93 @@
1+package client
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/protocol"
7+)
8+
9+// previewManager is driven only by the supervisor's serialized ready loop.
10+// generation never decreases, including across broker reconnections.
11+type pickerPreviewHost interface {
12+ PreviewRequest(ports.BrokerConnectionID, ports.BrokerStreamID, domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool)
13+ SetPreview(protocol.RemotePreview)
14+ ClearPreview()
15+}
16+
17+type previewManager struct {
18+ generation ports.BrokerPreviewGeneration
19+ sub ports.BrokerPreviewSubscription
20+ request ports.BrokerPreviewRequest
21+}
22+
23+func (m *previewManager) close(host pickerHost) {
24+ picker, _ := host.(pickerPreviewHost)
25+ m.generation++ // invalidate a publication already selected by the runtime
26+ if m.sub != nil {
27+ m.sub.Close()
28+ m.sub = nil
29+ }
30+ m.request = ports.BrokerPreviewRequest{}
31+ if picker != nil {
32+ picker.ClearPreview()
33+ }
34+}
35+
36+func (m *previewManager) refresh(service ports.BrokerService, host pickerHost, size domain.Size) {
37+ picker, ok := host.(pickerPreviewHost)
38+ if service == nil || !ok {
39+ m.close(host)
40+ return
41+ }
42+ // Probe selection eligibility before consuming a logical stream ID. The
43+ // controller only uses the provisional non-zero ID for validation.
44+ _, preview, ok := picker.PreviewRequest(service.ConnectionID(), 1, size)
45+ if !ok {
46+ m.close(host)
47+ return
48+ }
49+ if m.sub != nil && m.request.Preview.Target == preview.Target && m.request.Preview.Width == preview.Width && m.request.Preview.Height == preview.Height {
50+ return
51+ }
52+ stream, err := service.NextStreamID()
53+ if err != nil {
54+ m.close(host)
55+ return
56+ }
57+ route, preview, ok := picker.PreviewRequest(service.ConnectionID(), stream, size)
58+ if !ok {
59+ m.close(host)
60+ return
61+ }
62+ m.close(host) // contract requires old observation closed before replacement
63+ m.generation++
64+ req := ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: service.ConnectionID(), Generation: m.generation, Route: route, Preview: preview}
65+ sub, err := service.SubscribePreview(req)
66+ if err != nil || supervisorNil(sub) {
67+ return
68+ }
69+ m.request, m.sub = req, sub
70+}
71+
72+func (m *previewManager) changed() <-chan struct{} {
73+ if m.sub == nil {
74+ return nil
75+ }
76+ return m.sub.Changed()
77+}
78+
79+func (m *previewManager) publish(host pickerHost) bool {
80+ picker, ok := host.(pickerPreviewHost)
81+ if m.sub == nil || !ok {
82+ return false
83+ }
84+ publication := m.sub.Latest()
85+ if publication.Validate() != nil || publication.Err != nil ||
86+ publication.Epoch != m.request.Epoch || publication.Connection != m.request.Connection ||
87+ publication.Generation != m.request.Generation || publication.Target != m.request.Preview.Target ||
88+ publication.Width != m.request.Preview.Width || publication.Height != m.request.Preview.Height {
89+ return false
90+ }
91+ picker.SetPreview(publication.Preview)
92+ return true
93+}
@@ -0,0 +1,93 @@
1+package client
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/protocol"
7+)
8+
9+// previewManager is driven only by the supervisor's serialized ready loop.
10+// generation never decreases, including across broker reconnections.
11+type pickerPreviewHost interface {
12+ PreviewRequest(ports.BrokerConnectionID, ports.BrokerStreamID, domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool)
13+ SetPreview(protocol.RemotePreview)
14+ ClearPreview()
15+}
16+
17+type previewManager struct {
18+ generation ports.BrokerPreviewGeneration
19+ sub ports.BrokerPreviewSubscription
20+ request ports.BrokerPreviewRequest
21+}
22+
23+func (m *previewManager) close(host pickerHost) {
24+ picker, _ := host.(pickerPreviewHost)
25+ m.generation++ // invalidate a publication already selected by the runtime
26+ if m.sub != nil {
27+ m.sub.Close()
28+ m.sub = nil
29+ }
30+ m.request = ports.BrokerPreviewRequest{}
31+ if picker != nil {
32+ picker.ClearPreview()
33+ }
34+}
35+
36+func (m *previewManager) refresh(service ports.BrokerService, host pickerHost, size domain.Size) {
37+ picker, ok := host.(pickerPreviewHost)
38+ if service == nil || !ok {
39+ m.close(host)
40+ return
41+ }
42+ // Probe selection eligibility before consuming a logical stream ID. The
43+ // controller only uses the provisional non-zero ID for validation.
44+ _, preview, ok := picker.PreviewRequest(service.ConnectionID(), 1, size)
45+ if !ok {
46+ m.close(host)
47+ return
48+ }
49+ if m.sub != nil && m.request.Preview.Target == preview.Target && m.request.Preview.Width == preview.Width && m.request.Preview.Height == preview.Height {
50+ return
51+ }
52+ stream, err := service.NextStreamID()
53+ if err != nil {
54+ m.close(host)
55+ return
56+ }
57+ route, preview, ok := picker.PreviewRequest(service.ConnectionID(), stream, size)
58+ if !ok {
59+ m.close(host)
60+ return
61+ }
62+ m.close(host) // contract requires old observation closed before replacement
63+ m.generation++
64+ req := ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: service.ConnectionID(), Generation: m.generation, Route: route, Preview: preview}
65+ sub, err := service.SubscribePreview(req)
66+ if err != nil || supervisorNil(sub) {
67+ return
68+ }
69+ m.request, m.sub = req, sub
70+}
71+
72+func (m *previewManager) changed() <-chan struct{} {
73+ if m.sub == nil {
74+ return nil
75+ }
76+ return m.sub.Changed()
77+}
78+
79+func (m *previewManager) publish(host pickerHost) bool {
80+ picker, ok := host.(pickerPreviewHost)
81+ if m.sub == nil || !ok {
82+ return false
83+ }
84+ publication := m.sub.Latest()
85+ if publication.Validate() != nil || publication.Err != nil ||
86+ publication.Epoch != m.request.Epoch || publication.Connection != m.request.Connection ||
87+ publication.Generation != m.request.Generation || publication.Target != m.request.Preview.Target ||
88+ publication.Width != m.request.Preview.Width || publication.Height != m.request.Preview.Height {
89+ return false
90+ }
91+ picker.SetPreview(publication.Preview)
92+ return true
93+}
A internal/usecase/client/preview_manager_test.go +178 -0
@@ -0,0 +1,178 @@
1+package client
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+ "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/protocol/catalogue"
14+)
15+
16+func previewTestSession(name string, seed byte, tab string) catalogue.RemoteCatalogSession {
17+ session := pickerTestSession(name, seed, catalogue_Up)
18+ session.Tabs = []catalogue.RemoteCatalogTab{{ID: tab, Name: tab}}
19+ session.ActiveTabID = tab
20+ return session
21+}
22+
23+func previewTestController(t *testing.T, observations ...ports.BrokerDaemonObservation) *pickerController {
24+ t.Helper()
25+ controller, clock := pickerTestController(t)
26+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: observations})
27+ require.NotEmpty(t, controller.Render(domain.Size{Cols: 100, Rows: 40}))
28+ _ = clock
29+ return controller
30+}
31+
32+func expectPreviewSubscription(t *testing.T, service *portsmocks.MockBrokerService, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, expected ports.BrokerPreviewRequest) *portsmocks.MockBrokerPreviewSubscription {
33+ t.Helper()
34+ sub := portsmocks.NewMockBrokerPreviewSubscription(t)
35+ service.EXPECT().ConnectionID().Return(connection).Times(3)
36+ service.EXPECT().NextStreamID().Return(stream, nil).Once()
37+ service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
38+ return sub
39+}
40+
41+func expectedPreviewRequest(t *testing.T, controller *pickerController, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, generation ports.BrokerPreviewGeneration, size domain.Size) ports.BrokerPreviewRequest {
42+ t.Helper()
43+ route, preview, ok := controller.PreviewRequest(connection, stream, size)
44+ if !ok {
45+ key, selected := controller.CursorKey()
46+ t.Fatalf("preview request refused: selected=%v key=%q connection=%v stream=%v size=%+v", selected, key, connection, stream, size)
47+ }
48+ return ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: connection, Generation: generation, Route: route, Preview: preview}
49+}
50+
51+func TestPreviewManagerInitialSelectionUsesExactLocalOrRemoteRequestAndDimensions(t *testing.T) {
52+ now := time.Unix(100, 0)
53+ cases := []struct {
54+ name string
55+ observations []ports.BrokerDaemonObservation
56+ wantEndpoint string
57+ }{
58+ {name: "local", observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("local-session", 1, "tab-local"))}, wantEndpoint: "local"},
59+ {name: "remote", observations: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("ssh://example.test", 2, 3, now, previewTestSession("remote-session", 4, "tab-remote"))}, wantEndpoint: "ssh://example.test"},
60+ }
61+ for _, tc := range cases {
62+ t.Run(tc.name, func(t *testing.T) {
63+ controller := previewTestController(t, tc.observations...)
64+ service := portsmocks.NewMockBrokerService(t)
65+ connection := ports.BrokerConnectionID{11}
66+ size := domain.Size{Cols: 100, Rows: 40}
67+ expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
68+ require.Equal(t, tc.wantEndpoint, expected.Preview.Target.Endpoint)
69+ require.Equal(t, pickerPreviewSize(size), domain.Size{Cols: int(expected.Preview.Width), Rows: int(expected.Preview.Height)})
70+ expectPreviewSubscription(t, service, connection, 21, expected)
71+
72+ var manager previewManager
73+ manager.refresh(service, controller, size)
74+ require.Equal(t, expected, manager.request)
75+ })
76+ }
77+}
78+
79+func TestPreviewManagerSelectionAndResizeReplaceAndCloseOld(t *testing.T) {
80+ now := time.Unix(100, 0)
81+ controller := previewTestController(t, pickerTestLocalObservation(now,
82+ previewTestSession("alpha", 1, "tab-alpha"),
83+ previewTestSession("beta", 2, "tab-beta"),
84+ ))
85+ service := portsmocks.NewMockBrokerService(t)
86+ connection := ports.BrokerConnectionID{11}
87+ size := domain.Size{Cols: 100, Rows: 40}
88+
89+ firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
90+ first := expectPreviewSubscription(t, service, connection, 21, firstExpected)
91+ first.EXPECT().Close().Once()
92+ var manager previewManager
93+ manager.refresh(service, controller, size)
94+
95+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
96+ secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size)
97+ second := expectPreviewSubscription(t, service, connection, 22, secondExpected)
98+ second.EXPECT().Close().Once()
99+ manager.refresh(service, controller, size)
100+ require.NotEqual(t, firstExpected.Preview.Target, secondExpected.Preview.Target)
101+
102+ resized := domain.Size{Cols: 120, Rows: 50}
103+ thirdExpected := expectedPreviewRequest(t, controller, connection, 23, 6, resized)
104+ third := expectPreviewSubscription(t, service, connection, 23, thirdExpected)
105+ manager.refresh(service, controller, resized)
106+ require.NotEqual(t, secondExpected.Preview.Width, thirdExpected.Preview.Width)
107+ require.NotEqual(t, secondExpected.Preview.Height, thirdExpected.Preview.Height)
108+
109+ third.EXPECT().Close().Once()
110+ manager.close(controller)
111+}
112+
113+func TestPreviewManagerIgnoresOldLatePublicationAfterReplacement(t *testing.T) {
114+ now := time.Unix(100, 0)
115+ controller := previewTestController(t, pickerTestLocalObservation(now,
116+ previewTestSession("alpha", 1, "tab-alpha"),
117+ previewTestSession("beta", 2, "tab-beta"),
118+ ))
119+ service := portsmocks.NewMockBrokerService(t)
120+ connection := ports.BrokerConnectionID{11}
121+ size := domain.Size{Cols: 100, Rows: 40}
122+ firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
123+ first := expectPreviewSubscription(t, service, connection, 21, firstExpected)
124+ first.EXPECT().Close().Once()
125+ var manager previewManager
126+ manager.refresh(service, controller, size)
127+
128+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
129+ secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size)
130+ second := expectPreviewSubscription(t, service, connection, 22, secondExpected)
131+ manager.refresh(service, controller, size)
132+
133+ // A wake already selected from the retired subscription is fenced by all
134+ // repeated authority fields and must not replace the current preview.
135+ late := ports.BrokerPreviewPublication{Epoch: firstExpected.Epoch, Connection: connection, Generation: firstExpected.Generation, Target: firstExpected.Preview.Target, Width: firstExpected.Preview.Width, Height: firstExpected.Preview.Height, Preview: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}}
136+ second.EXPECT().Latest().Return(late).Once()
137+ require.False(t, manager.publish(controller))
138+
139+ second.EXPECT().Close().Once()
140+ manager.close(controller)
141+}
142+
143+func TestSupervisorRefreshPreviewWiresTerminalGeometryToPreviewManager(t *testing.T) {
144+ now := time.Unix(100, 0)
145+ controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha")))
146+ service := portsmocks.NewMockBrokerService(t)
147+ connection := ports.BrokerConnectionID{11}
148+ size := domain.Size{Cols: 80, Rows: 24}
149+ expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
150+ sub := expectPreviewSubscription(t, service, connection, 21, expected)
151+
152+ supervisor := &Supervisor{cfg: SupervisorConfig{Picker: controller, Terminal: &pickerRecordingTerminal{}}}
153+ supervisor.refreshPreview(service)
154+ require.Equal(t, expected, supervisor.preview.request)
155+
156+ sub.EXPECT().Close().Once()
157+ supervisor.preview.close(controller)
158+}
159+
160+func TestPreviewManagerBrokerRetirementAndCommitClearAndClose(t *testing.T) {
161+ for _, reason := range []string{"broker retirement", "commit"} {
162+ t.Run(reason, func(t *testing.T) {
163+ now := time.Unix(100, 0)
164+ controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha")))
165+ service := portsmocks.NewMockBrokerService(t)
166+ connection := ports.BrokerConnectionID{11}
167+ size := domain.Size{Cols: 100, Rows: 40}
168+ expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
169+ sub := expectPreviewSubscription(t, service, connection, 21, expected)
170+ sub.EXPECT().Close().Once()
171+ var manager previewManager
172+ manager.refresh(service, controller, size)
173+ manager.close(controller)
174+ require.Nil(t, manager.sub)
175+ require.Equal(t, ports.BrokerPreviewRequest{}, manager.request)
176+ })
177+ }
178+}
@@ -0,0 +1,178 @@
1+package client
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+ "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/protocol/catalogue"
14+)
15+
16+func previewTestSession(name string, seed byte, tab string) catalogue.RemoteCatalogSession {
17+ session := pickerTestSession(name, seed, catalogue_Up)
18+ session.Tabs = []catalogue.RemoteCatalogTab{{ID: tab, Name: tab}}
19+ session.ActiveTabID = tab
20+ return session
21+}
22+
23+func previewTestController(t *testing.T, observations ...ports.BrokerDaemonObservation) *pickerController {
24+ t.Helper()
25+ controller, clock := pickerTestController(t)
26+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: observations})
27+ require.NotEmpty(t, controller.Render(domain.Size{Cols: 100, Rows: 40}))
28+ _ = clock
29+ return controller
30+}
31+
32+func expectPreviewSubscription(t *testing.T, service *portsmocks.MockBrokerService, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, expected ports.BrokerPreviewRequest) *portsmocks.MockBrokerPreviewSubscription {
33+ t.Helper()
34+ sub := portsmocks.NewMockBrokerPreviewSubscription(t)
35+ service.EXPECT().ConnectionID().Return(connection).Times(3)
36+ service.EXPECT().NextStreamID().Return(stream, nil).Once()
37+ service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
38+ return sub
39+}
40+
41+func expectedPreviewRequest(t *testing.T, controller *pickerController, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, generation ports.BrokerPreviewGeneration, size domain.Size) ports.BrokerPreviewRequest {
42+ t.Helper()
43+ route, preview, ok := controller.PreviewRequest(connection, stream, size)
44+ if !ok {
45+ key, selected := controller.CursorKey()
46+ t.Fatalf("preview request refused: selected=%v key=%q connection=%v stream=%v size=%+v", selected, key, connection, stream, size)
47+ }
48+ return ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: connection, Generation: generation, Route: route, Preview: preview}
49+}
50+
51+func TestPreviewManagerInitialSelectionUsesExactLocalOrRemoteRequestAndDimensions(t *testing.T) {
52+ now := time.Unix(100, 0)
53+ cases := []struct {
54+ name string
55+ observations []ports.BrokerDaemonObservation
56+ wantEndpoint string
57+ }{
58+ {name: "local", observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("local-session", 1, "tab-local"))}, wantEndpoint: "local"},
59+ {name: "remote", observations: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("ssh://example.test", 2, 3, now, previewTestSession("remote-session", 4, "tab-remote"))}, wantEndpoint: "ssh://example.test"},
60+ }
61+ for _, tc := range cases {
62+ t.Run(tc.name, func(t *testing.T) {
63+ controller := previewTestController(t, tc.observations...)
64+ service := portsmocks.NewMockBrokerService(t)
65+ connection := ports.BrokerConnectionID{11}
66+ size := domain.Size{Cols: 100, Rows: 40}
67+ expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
68+ require.Equal(t, tc.wantEndpoint, expected.Preview.Target.Endpoint)
69+ require.Equal(t, pickerPreviewSize(size), domain.Size{Cols: int(expected.Preview.Width), Rows: int(expected.Preview.Height)})
70+ expectPreviewSubscription(t, service, connection, 21, expected)
71+
72+ var manager previewManager
73+ manager.refresh(service, controller, size)
74+ require.Equal(t, expected, manager.request)
75+ })
76+ }
77+}
78+
79+func TestPreviewManagerSelectionAndResizeReplaceAndCloseOld(t *testing.T) {
80+ now := time.Unix(100, 0)
81+ controller := previewTestController(t, pickerTestLocalObservation(now,
82+ previewTestSession("alpha", 1, "tab-alpha"),
83+ previewTestSession("beta", 2, "tab-beta"),
84+ ))
85+ service := portsmocks.NewMockBrokerService(t)
86+ connection := ports.BrokerConnectionID{11}
87+ size := domain.Size{Cols: 100, Rows: 40}
88+
89+ firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
90+ first := expectPreviewSubscription(t, service, connection, 21, firstExpected)
91+ first.EXPECT().Close().Once()
92+ var manager previewManager
93+ manager.refresh(service, controller, size)
94+
95+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
96+ secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size)
97+ second := expectPreviewSubscription(t, service, connection, 22, secondExpected)
98+ second.EXPECT().Close().Once()
99+ manager.refresh(service, controller, size)
100+ require.NotEqual(t, firstExpected.Preview.Target, secondExpected.Preview.Target)
101+
102+ resized := domain.Size{Cols: 120, Rows: 50}
103+ thirdExpected := expectedPreviewRequest(t, controller, connection, 23, 6, resized)
104+ third := expectPreviewSubscription(t, service, connection, 23, thirdExpected)
105+ manager.refresh(service, controller, resized)
106+ require.NotEqual(t, secondExpected.Preview.Width, thirdExpected.Preview.Width)
107+ require.NotEqual(t, secondExpected.Preview.Height, thirdExpected.Preview.Height)
108+
109+ third.EXPECT().Close().Once()
110+ manager.close(controller)
111+}
112+
113+func TestPreviewManagerIgnoresOldLatePublicationAfterReplacement(t *testing.T) {
114+ now := time.Unix(100, 0)
115+ controller := previewTestController(t, pickerTestLocalObservation(now,
116+ previewTestSession("alpha", 1, "tab-alpha"),
117+ previewTestSession("beta", 2, "tab-beta"),
118+ ))
119+ service := portsmocks.NewMockBrokerService(t)
120+ connection := ports.BrokerConnectionID{11}
121+ size := domain.Size{Cols: 100, Rows: 40}
122+ firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
123+ first := expectPreviewSubscription(t, service, connection, 21, firstExpected)
124+ first.EXPECT().Close().Once()
125+ var manager previewManager
126+ manager.refresh(service, controller, size)
127+
128+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
129+ secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size)
130+ second := expectPreviewSubscription(t, service, connection, 22, secondExpected)
131+ manager.refresh(service, controller, size)
132+
133+ // A wake already selected from the retired subscription is fenced by all
134+ // repeated authority fields and must not replace the current preview.
135+ late := ports.BrokerPreviewPublication{Epoch: firstExpected.Epoch, Connection: connection, Generation: firstExpected.Generation, Target: firstExpected.Preview.Target, Width: firstExpected.Preview.Width, Height: firstExpected.Preview.Height, Preview: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}}
136+ second.EXPECT().Latest().Return(late).Once()
137+ require.False(t, manager.publish(controller))
138+
139+ second.EXPECT().Close().Once()
140+ manager.close(controller)
141+}
142+
143+func TestSupervisorRefreshPreviewWiresTerminalGeometryToPreviewManager(t *testing.T) {
144+ now := time.Unix(100, 0)
145+ controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha")))
146+ service := portsmocks.NewMockBrokerService(t)
147+ connection := ports.BrokerConnectionID{11}
148+ size := domain.Size{Cols: 80, Rows: 24}
149+ expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
150+ sub := expectPreviewSubscription(t, service, connection, 21, expected)
151+
152+ supervisor := &Supervisor{cfg: SupervisorConfig{Picker: controller, Terminal: &pickerRecordingTerminal{}}}
153+ supervisor.refreshPreview(service)
154+ require.Equal(t, expected, supervisor.preview.request)
155+
156+ sub.EXPECT().Close().Once()
157+ supervisor.preview.close(controller)
158+}
159+
160+func TestPreviewManagerBrokerRetirementAndCommitClearAndClose(t *testing.T) {
161+ for _, reason := range []string{"broker retirement", "commit"} {
162+ t.Run(reason, func(t *testing.T) {
163+ now := time.Unix(100, 0)
164+ controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha")))
165+ service := portsmocks.NewMockBrokerService(t)
166+ connection := ports.BrokerConnectionID{11}
167+ size := domain.Size{Cols: 100, Rows: 40}
168+ expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
169+ sub := expectPreviewSubscription(t, service, connection, 21, expected)
170+ sub.EXPECT().Close().Once()
171+ var manager previewManager
172+ manager.refresh(service, controller, size)
173+ manager.close(controller)
174+ require.Nil(t, manager.sub)
175+ require.Equal(t, ports.BrokerPreviewRequest{}, manager.request)
176+ })
177+ }
178+}
A internal/usecase/client/session_picker_cutover_test.go +54 -0
@@ -0,0 +1,54 @@
1+package client
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// The daemon's SSP action ends the attachment, not an in-attachment modal.
15+// Exercise the real worker and supervisor so a notice-only or paint-only fix
16+// cannot pass while the attachment still owns terminal input.
17+func TestSessionPickerDetachRevokesForegroundBeforePicker(t *testing.T) {
18+ picker := newAttachTestPicker()
19+ notices := make(chan LifecycleNotice, 4)
20+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
21+ cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
22+ })
23+ stream := newSessionTestStream()
24+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
25+ return stream, nil
26+ })
27+ picker.commit(sessionTestRequest(true))
28+ awaitStreamHello(t, &sync.Mutex{}, &stream)
29+ deliverReadyStream(t, stream)
30+ awaitAttachedState(t, harness.sup)
31+ foreground := harness.sup.attachments.authority.foreground()
32+ require.NotNil(t, foreground)
33+ stream.deliver(protocol.Detached{Reason: protocol.ReasonDetachToPicker})
34+ awaitPickerState(t, harness.sup)
35+ require.Equal(t, LifecycleNoticeDetachToPicker, (<-notices).Kind)
36+ require.Nil(t, harness.sup.attachments.authority.foreground())
37+ select {
38+ case <-foreground.Done():
39+ default:
40+ t.Fatal("picker published before attachment foreground drained")
41+ }
42+ require.True(t, picker.owns())
43+ require.Len(t, harness.service.openedRequests(), 1, "picker transfer must not reattach")
44+ // Reopening is possible only through a fresh client-owned selection.
45+ next := newSessionTestStream()
46+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
47+ return next, nil
48+ })
49+ picker.commit(sessionTestRequest(true))
50+ awaitStreamHello(t, &sync.Mutex{}, &next)
51+ deliverReadyStream(t, next)
52+ awaitAttachedState(t, harness.sup)
53+ require.NotEqual(t, foreground.Token(), harness.sup.attachments.authority.currentToken())
54+}
@@ -0,0 +1,54 @@
1+package client
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// The daemon's SSP action ends the attachment, not an in-attachment modal.
15+// Exercise the real worker and supervisor so a notice-only or paint-only fix
16+// cannot pass while the attachment still owns terminal input.
17+func TestSessionPickerDetachRevokesForegroundBeforePicker(t *testing.T) {
18+ picker := newAttachTestPicker()
19+ notices := make(chan LifecycleNotice, 4)
20+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
21+ cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
22+ })
23+ stream := newSessionTestStream()
24+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
25+ return stream, nil
26+ })
27+ picker.commit(sessionTestRequest(true))
28+ awaitStreamHello(t, &sync.Mutex{}, &stream)
29+ deliverReadyStream(t, stream)
30+ awaitAttachedState(t, harness.sup)
31+ foreground := harness.sup.attachments.authority.foreground()
32+ require.NotNil(t, foreground)
33+ stream.deliver(protocol.Detached{Reason: protocol.ReasonDetachToPicker})
34+ awaitPickerState(t, harness.sup)
35+ require.Equal(t, LifecycleNoticeDetachToPicker, (<-notices).Kind)
36+ require.Nil(t, harness.sup.attachments.authority.foreground())
37+ select {
38+ case <-foreground.Done():
39+ default:
40+ t.Fatal("picker published before attachment foreground drained")
41+ }
42+ require.True(t, picker.owns())
43+ require.Len(t, harness.service.openedRequests(), 1, "picker transfer must not reattach")
44+ // Reopening is possible only through a fresh client-owned selection.
45+ next := newSessionTestStream()
46+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
47+ return next, nil
48+ })
49+ picker.commit(sessionTestRequest(true))
50+ awaitStreamHello(t, &sync.Mutex{}, &next)
51+ deliverReadyStream(t, next)
52+ awaitAttachedState(t, harness.sup)
53+ require.NotEqual(t, foreground.Token(), harness.sup.attachments.authority.currentToken())
54+}
M internal/usecase/client/supervisor.go +66 -13
@@ -349,6 +349,11 @@ type AttachmentLifecycleAction struct {
349349 type Supervisor struct {
350350 cfg SupervisorConfig
351351
352+ // pendingPickerKey retains a commit observed while connecting. It is
353+ // revalidated against the adopted service just like a ready-phase commit.
354+ pendingPickerKey string
355+ preview previewManager
356+
352357 mu sync.Mutex
353358 state State
354359
@@ -468,6 +473,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
468473 sub ports.BrokerSubscription
469474 )
470475 retire := func() {
476+ s.preview.close(s.cfg.Picker)
471477 if sub != nil {
472478 sub.Close()
473479 sub = nil
@@ -545,6 +551,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
545551 }
546552 wasReadyLost := s.State().ReadyLost
547553 s.transition(supervisorEvent{kind: supervisorReady})
554+ s.refreshPreview(service)
548555 if wasReadyLost {
549556 s.notifyLifecycle(LifecycleNoticeBrokerReconnected)
550557 s.mu.Lock()
@@ -720,6 +727,10 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet
720727 select {
721728 case result := <-outcome:
722729 return result, false, nil
730+ case <-s.pickerOps():
731+ if s.takePickerClose() {
732+ return supervisorAttempt{}, true, nil
733+ }
723734 case <-ctx.Done():
724735 return supervisorAttempt{}, true, ctx.Err()
725736 case err := <-input.EOF():
@@ -753,6 +764,10 @@ type readyOutcome struct {
753764 // the supervisor can admit exactly the row the user committed. It opens no
754765 // stream and starts no terminal work itself.
755766 func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome {
767+ if key := s.pendingPickerKey; key != "" {
768+ s.pendingPickerKey = ""
769+ return readyOutcome{commitKey: key}
770+ }
756771 var changed <-chan struct{}
757772 var ops <-chan struct{}
758773 if s.cfg.Picker != nil {
@@ -767,9 +782,25 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
767782 return readyOutcome{lossErr: service.Err()}
768783 case <-changed:
769784 s.cfg.Picker.ApplySnapshot(service.Snapshot())
785+ s.refreshPreview(service)
770786 s.renderCurrent()
787+ case <-s.preview.changed():
788+ if s.preview.publish(s.cfg.Picker) {
789+ s.renderCurrent()
790+ }
771791 case <-ops:
772- if key, ok := s.takeCommittedKey(); ok {
792+ op, key := s.cfg.Picker.TakeOp()
793+ if !op.close && !op.commit {
794+ s.refreshPreview(service)
795+ s.renderCurrent()
796+ continue
797+ }
798+ if op.close {
799+ s.preview.close(s.cfg.Picker)
800+ return readyOutcome{terminated: true}
801+ }
802+ if op.commit && key != "" {
803+ s.preview.close(s.cfg.Picker)
773804 return readyOutcome{commitKey: key}
774805 }
775806 case <-ctx.Done():
@@ -777,25 +808,39 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
777808 case err := <-input.EOF():
778809 return readyOutcome{terminated: true, termErr: terminalReadCause(err)}
779810 case <-s.presentationInvalidation():
811+ s.refreshPreview(service)
780812 s.renderResizeInvalidation()
781813 }
782814 }
783815 }
784816
785-// takeCommittedKey drains exactly one committed presentation decision. Only a
786-// commit with a captured catalogue key admits an attachment; every other
787-// decision (cursor moves, search, kill, close) is consumed and dropped here,
788-// so picker input alone can never open, write, or retarget a session.
789-func (s *Supervisor) takeCommittedKey() (string, bool) {
790- picker := s.cfg.Picker
791- if picker == nil {
792- return "", false
817+func (s *Supervisor) refreshPreview(service ports.BrokerService) {
818+ if s == nil || s.cfg.Picker == nil {
819+ return
793820 }
794- op, key := picker.TakeOp()
795- if !op.commit || key == "" {
796- return "", false
821+ geometry, err := s.cfg.Terminal.Geometry()
822+ if err != nil || !geometry.Valid() {
823+ s.preview.close(s.cfg.Picker)
824+ return
797825 }
798- return key, true
826+ s.preview.refresh(service, s.cfg.Picker, geometry.Size)
827+}
828+
829+// pickerOps is nil when no picker owns presentation decisions.
830+func (s *Supervisor) pickerOps() <-chan struct{} {
831+ if s.cfg.Picker == nil {
832+ return nil
833+ }
834+ return s.cfg.Picker.OpsReady()
835+}
836+
837+// takePickerClose honors exit while offline without losing a racing commit.
838+func (s *Supervisor) takePickerClose() bool {
839+ op, key := s.cfg.Picker.TakeOp()
840+ if op.commit && key != "" {
841+ s.pendingPickerKey = key
842+ }
843+ return op.close
799844 }
800845
801846 // awaitTermination parks the supervisor on the picker after a non-retryable
@@ -804,6 +849,10 @@ func (s *Supervisor) takeCommittedKey() (string, bool) {
804849 func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error {
805850 for {
806851 select {
852+ case <-s.pickerOps():
853+ if s.takePickerClose() {
854+ return nil
855+ }
807856 case <-ctx.Done():
808857 return ctx.Err()
809858 case err := <-input.EOF():
@@ -826,6 +875,10 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti
826875 defer stopSupervisorTimer(timer)
827876 for {
828877 select {
878+ case <-s.pickerOps():
879+ if s.takePickerClose() {
880+ return true, nil
881+ }
829882 case <-timer.C():
830883 return false, nil
831884 case <-ctx.Done():
@@ -349,6 +349,11 @@ type AttachmentLifecycleAction struct {
349 type Supervisor struct {349 type Supervisor struct {
350 cfg SupervisorConfig350 cfg SupervisorConfig
351 351
352+ // pendingPickerKey retains a commit observed while connecting. It is
353+ // revalidated against the adopted service just like a ready-phase commit.
354+ pendingPickerKey string
355+ preview previewManager
356+
352 mu sync.Mutex357 mu sync.Mutex
353 state State358 state State
354 359
@@ -468,6 +473,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
468 sub ports.BrokerSubscription473 sub ports.BrokerSubscription
469 )474 )
470 retire := func() {475 retire := func() {
476+ s.preview.close(s.cfg.Picker)
471 if sub != nil {477 if sub != nil {
472 sub.Close()478 sub.Close()
473 sub = nil479 sub = nil
@@ -545,6 +551,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
545 }551 }
546 wasReadyLost := s.State().ReadyLost552 wasReadyLost := s.State().ReadyLost
547 s.transition(supervisorEvent{kind: supervisorReady})553 s.transition(supervisorEvent{kind: supervisorReady})
554+ s.refreshPreview(service)
548 if wasReadyLost {555 if wasReadyLost {
549 s.notifyLifecycle(LifecycleNoticeBrokerReconnected)556 s.notifyLifecycle(LifecycleNoticeBrokerReconnected)
550 s.mu.Lock()557 s.mu.Lock()
@@ -720,6 +727,10 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet
720 select {727 select {
721 case result := <-outcome:728 case result := <-outcome:
722 return result, false, nil729 return result, false, nil
730+ case <-s.pickerOps():
731+ if s.takePickerClose() {
732+ return supervisorAttempt{}, true, nil
733+ }
723 case <-ctx.Done():734 case <-ctx.Done():
724 return supervisorAttempt{}, true, ctx.Err()735 return supervisorAttempt{}, true, ctx.Err()
725 case err := <-input.EOF():736 case err := <-input.EOF():
@@ -753,6 +764,10 @@ type readyOutcome struct {
753 // the supervisor can admit exactly the row the user committed. It opens no764 // the supervisor can admit exactly the row the user committed. It opens no
754 // stream and starts no terminal work itself.765 // stream and starts no terminal work itself.
755 func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome {766 func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome {
767+ if key := s.pendingPickerKey; key != "" {
768+ s.pendingPickerKey = ""
769+ return readyOutcome{commitKey: key}
770+ }
756 var changed <-chan struct{}771 var changed <-chan struct{}
757 var ops <-chan struct{}772 var ops <-chan struct{}
758 if s.cfg.Picker != nil {773 if s.cfg.Picker != nil {
@@ -767,9 +782,25 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
767 return readyOutcome{lossErr: service.Err()}782 return readyOutcome{lossErr: service.Err()}
768 case <-changed:783 case <-changed:
769 s.cfg.Picker.ApplySnapshot(service.Snapshot())784 s.cfg.Picker.ApplySnapshot(service.Snapshot())
785+ s.refreshPreview(service)
770 s.renderCurrent()786 s.renderCurrent()
787+ case <-s.preview.changed():
788+ if s.preview.publish(s.cfg.Picker) {
789+ s.renderCurrent()
790+ }
771 case <-ops:791 case <-ops:
772- if key, ok := s.takeCommittedKey(); ok {792+ op, key := s.cfg.Picker.TakeOp()
793+ if !op.close && !op.commit {
794+ s.refreshPreview(service)
795+ s.renderCurrent()
796+ continue
797+ }
798+ if op.close {
799+ s.preview.close(s.cfg.Picker)
800+ return readyOutcome{terminated: true}
801+ }
802+ if op.commit && key != "" {
803+ s.preview.close(s.cfg.Picker)
773 return readyOutcome{commitKey: key}804 return readyOutcome{commitKey: key}
774 }805 }
775 case <-ctx.Done():806 case <-ctx.Done():
@@ -777,25 +808,39 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
777 case err := <-input.EOF():808 case err := <-input.EOF():
778 return readyOutcome{terminated: true, termErr: terminalReadCause(err)}809 return readyOutcome{terminated: true, termErr: terminalReadCause(err)}
779 case <-s.presentationInvalidation():810 case <-s.presentationInvalidation():
811+ s.refreshPreview(service)
780 s.renderResizeInvalidation()812 s.renderResizeInvalidation()
781 }813 }
782 }814 }
783 }815 }
784 816
785-// takeCommittedKey drains exactly one committed presentation decision. Only a817+func (s *Supervisor) refreshPreview(service ports.BrokerService) {
786-// commit with a captured catalogue key admits an attachment; every other818+ if s == nil || s.cfg.Picker == nil {
787-// decision (cursor moves, search, kill, close) is consumed and dropped here,819+ return
788-// so picker input alone can never open, write, or retarget a session.
789-func (s *Supervisor) takeCommittedKey() (string, bool) {
790- picker := s.cfg.Picker
791- if picker == nil {
792- return "", false
793 }820 }
794- op, key := picker.TakeOp()821+ geometry, err := s.cfg.Terminal.Geometry()
795- if !op.commit || key == "" {822+ if err != nil || !geometry.Valid() {
796- return "", false823+ s.preview.close(s.cfg.Picker)
824+ return
797 }825 }
798- return key, true826+ s.preview.refresh(service, s.cfg.Picker, geometry.Size)
827+}
828+
829+// pickerOps is nil when no picker owns presentation decisions.
830+func (s *Supervisor) pickerOps() <-chan struct{} {
831+ if s.cfg.Picker == nil {
832+ return nil
833+ }
834+ return s.cfg.Picker.OpsReady()
835+}
836+
837+// takePickerClose honors exit while offline without losing a racing commit.
838+func (s *Supervisor) takePickerClose() bool {
839+ op, key := s.cfg.Picker.TakeOp()
840+ if op.commit && key != "" {
841+ s.pendingPickerKey = key
842+ }
843+ return op.close
799 }844 }
800 845
801 // awaitTermination parks the supervisor on the picker after a non-retryable846 // awaitTermination parks the supervisor on the picker after a non-retryable
@@ -804,6 +849,10 @@ func (s *Supervisor) takeCommittedKey() (string, bool) {
804 func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error {849 func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error {
805 for {850 for {
806 select {851 select {
852+ case <-s.pickerOps():
853+ if s.takePickerClose() {
854+ return nil
855+ }
807 case <-ctx.Done():856 case <-ctx.Done():
808 return ctx.Err()857 return ctx.Err()
809 case err := <-input.EOF():858 case err := <-input.EOF():
@@ -826,6 +875,10 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti
826 defer stopSupervisorTimer(timer)875 defer stopSupervisorTimer(timer)
827 for {876 for {
828 select {877 select {
878+ case <-s.pickerOps():
879+ if s.takePickerClose() {
880+ return true, nil
881+ }
829 case <-timer.C():882 case <-timer.C():
830 return false, nil883 return false, nil
831 case <-ctx.Done():884 case <-ctx.Done():
M internal/usecase/client/supervisor_integration_test.go +4 -0
@@ -61,6 +61,10 @@ func (c *integrationCore) Subscribe() (ports.BrokerSubscription, error) {
6161 return integrationSubscription{changed: c.changed}, nil
6262 }
6363
64+func (c *integrationCore) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
65+ return nil, errors.New("integration core does not support preview")
66+}
67+
6468 func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
6569 return nil, errors.New("integration core does not support streams")
6670 }
@@ -61,6 +61,10 @@ func (c *integrationCore) Subscribe() (ports.BrokerSubscription, error) {
61 return integrationSubscription{changed: c.changed}, nil61 return integrationSubscription{changed: c.changed}, nil
62 }62 }
63 63
64+func (c *integrationCore) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
65+ return nil, errors.New("integration core does not support preview")
66+}
67+
64 func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {68 func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
65 return nil, errors.New("integration core does not support streams")69 return nil, errors.New("integration core does not support streams")
66 }70 }
M internal/usecase/client/supervisor_test.go +53 -0
@@ -371,6 +371,10 @@ func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) {
371371 }), nil
372372 }
373373
374+func (s *supervisorTestService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
375+ return nil, errors.New("supervisor test service does not support preview")
376+}
377+
374378 func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
375379 s.mu.Lock()
376380 s.openCalls = append(s.openCalls, request)
@@ -1437,3 +1441,52 @@ func TestSupervisorTestHubModelsSubscriptionContract(t *testing.T) {
14371441 }
14381442 require.Equal(t, ports.BrokerRevision(4), hub.current().Revision)
14391443 }
1444+
1445+// Raw-mode Ctrl-C is input, not SIGINT. It must work before a broker exists,
1446+// including during a blocked connect and after permanent or transient failures.
1447+func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
1448+ for _, phase := range []string{"connecting", "retry", "nonretryable"} {
1449+ t.Run(phase, func(t *testing.T) {
1450+ reader := newPickerChunkReader()
1451+ defer reader.close()
1452+ terminal := newSupervisorTestTerminal(reader)
1453+ clock := newSupervisorTestClock()
1454+ reached := make(chan struct{}, 1)
1455+ connector := newSupervisorTestConnector(func(ctx context.Context, _ int) (ports.BrokerService, error) {
1456+ if phase == "connecting" {
1457+ reached <- struct{}{}
1458+ <-ctx.Done()
1459+ return nil, ctx.Err()
1460+ }
1461+ code := ports.BrokerErrorUnavailable
1462+ if phase == "nonretryable" {
1463+ code = ports.BrokerErrorIncompatible
1464+ }
1465+ return nil, ports.BrokerError{Code: code, Text: "test refusal"}
1466+ })
1467+ s := mustSupervisor(t, SupervisorConfig{
1468+ Connector: connector, Terminal: terminal, Clock: clock,
1469+ Picker: NewPicker(clock, 0),
1470+ Notify: func(State, error) { reached <- struct{}{} },
1471+ })
1472+ ctx, cancel := context.WithCancel(context.Background())
1473+ defer cancel()
1474+ result := make(chan error, 1)
1475+ go func() { result <- s.Run(ctx) }()
1476+ select {
1477+ case <-reached:
1478+ case <-time.After(5 * time.Second):
1479+ t.Fatal("did not reach test phase")
1480+ }
1481+ reader.push([]byte{3})
1482+ select {
1483+ case err := <-result:
1484+ require.NoError(t, err)
1485+ case <-time.After(5 * time.Second):
1486+ t.Fatal("Ctrl-C did not exit with stdin still open")
1487+ }
1488+ require.Equal(t, 1, terminal.restoreCount())
1489+ require.True(t, clock.allStopped())
1490+ })
1491+ }
1492+}
@@ -371,6 +371,10 @@ func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) {
371 }), nil371 }), nil
372 }372 }
373 373
374+func (s *supervisorTestService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
375+ return nil, errors.New("supervisor test service does not support preview")
376+}
377+
374 func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {378 func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
375 s.mu.Lock()379 s.mu.Lock()
376 s.openCalls = append(s.openCalls, request)380 s.openCalls = append(s.openCalls, request)
@@ -1437,3 +1441,52 @@ func TestSupervisorTestHubModelsSubscriptionContract(t *testing.T) {
1437 }1441 }
1438 require.Equal(t, ports.BrokerRevision(4), hub.current().Revision)1442 require.Equal(t, ports.BrokerRevision(4), hub.current().Revision)
1439 }1443 }
1444+
1445+// Raw-mode Ctrl-C is input, not SIGINT. It must work before a broker exists,
1446+// including during a blocked connect and after permanent or transient failures.
1447+func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
1448+ for _, phase := range []string{"connecting", "retry", "nonretryable"} {
1449+ t.Run(phase, func(t *testing.T) {
1450+ reader := newPickerChunkReader()
1451+ defer reader.close()
1452+ terminal := newSupervisorTestTerminal(reader)
1453+ clock := newSupervisorTestClock()
1454+ reached := make(chan struct{}, 1)
1455+ connector := newSupervisorTestConnector(func(ctx context.Context, _ int) (ports.BrokerService, error) {
1456+ if phase == "connecting" {
1457+ reached <- struct{}{}
1458+ <-ctx.Done()
1459+ return nil, ctx.Err()
1460+ }
1461+ code := ports.BrokerErrorUnavailable
1462+ if phase == "nonretryable" {
1463+ code = ports.BrokerErrorIncompatible
1464+ }
1465+ return nil, ports.BrokerError{Code: code, Text: "test refusal"}
1466+ })
1467+ s := mustSupervisor(t, SupervisorConfig{
1468+ Connector: connector, Terminal: terminal, Clock: clock,
1469+ Picker: NewPicker(clock, 0),
1470+ Notify: func(State, error) { reached <- struct{}{} },
1471+ })
1472+ ctx, cancel := context.WithCancel(context.Background())
1473+ defer cancel()
1474+ result := make(chan error, 1)
1475+ go func() { result <- s.Run(ctx) }()
1476+ select {
1477+ case <-reached:
1478+ case <-time.After(5 * time.Second):
1479+ t.Fatal("did not reach test phase")
1480+ }
1481+ reader.push([]byte{3})
1482+ select {
1483+ case err := <-result:
1484+ require.NoError(t, err)
1485+ case <-time.After(5 * time.Second):
1486+ t.Fatal("Ctrl-C did not exit with stdin still open")
1487+ }
1488+ require.Equal(t, 1, terminal.restoreCount())
1489+ require.True(t, clock.allStopped())
1490+ })
1491+ }
1492+}
M internal/usecase/client/wire_external_test_helpers_test.go +0 -6
@@ -110,16 +110,12 @@ func clientMessageName(t *testing.T, envelope wire.Envelope) string {
110110 return "NavigationInventoryPublication"
111111 case protocol.NavigationInventoryFailure:
112112 return "NavigationInventoryFailure"
113- case protocol.PickerBegin:
114- return "PickerBegin"
115113 case protocol.PickerClose:
116114 return "PickerClose"
117115 case protocol.PickerSelection:
118116 return "PickerSelection"
119117 case protocol.PickerPreviewRequest:
120118 return "PickerPreviewRequest"
121- case protocol.PickerControlRequest:
122- return "PickerControlRequest"
123119 default:
124120 return ""
125121 }
@@ -189,8 +185,6 @@ func serverMessageNameForTest(payload []byte) string {
189185 return "PickerFailure"
190186 case protocol.PickerPreview:
191187 return "PickerPreview"
192- case protocol.PickerControlResponse:
193- return "PickerControlResponse"
194188 default:
195189 return ""
196190 }
@@ -110,16 +110,12 @@ func clientMessageName(t *testing.T, envelope wire.Envelope) string {
110 return "NavigationInventoryPublication"110 return "NavigationInventoryPublication"
111 case protocol.NavigationInventoryFailure:111 case protocol.NavigationInventoryFailure:
112 return "NavigationInventoryFailure"112 return "NavigationInventoryFailure"
113- case protocol.PickerBegin:
114- return "PickerBegin"
115 case protocol.PickerClose:113 case protocol.PickerClose:
116 return "PickerClose"114 return "PickerClose"
117 case protocol.PickerSelection:115 case protocol.PickerSelection:
118 return "PickerSelection"116 return "PickerSelection"
119 case protocol.PickerPreviewRequest:117 case protocol.PickerPreviewRequest:
120 return "PickerPreviewRequest"118 return "PickerPreviewRequest"
121- case protocol.PickerControlRequest:
122- return "PickerControlRequest"
123 default:119 default:
124 return ""120 return ""
125 }121 }
@@ -189,8 +185,6 @@ func serverMessageNameForTest(payload []byte) string {
189 return "PickerFailure"185 return "PickerFailure"
190 case protocol.PickerPreview:186 case protocol.PickerPreview:
191 return "PickerPreview"187 return "PickerPreview"
192- case protocol.PickerControlResponse:
193- return "PickerControlResponse"
194 default:188 default:
195 return ""189 return ""
196 }190 }
M internal/usecase/picker/modal.go +8 -0
@@ -28,3 +28,11 @@ func PreviewRect(terminal domain.Size) domain.Rect {
2828 func Size(rect domain.Rect) domain.Size {
2929 return domain.Size{Cols: rect.Width, Rows: rect.Height}
3030 }
31+
32+// PreviewSize returns the preview viewport for one terminal size: the preview
33+// pane inside the shared modal geometry. A zero size means this terminal has
34+// no room for a preview. The serving daemon and the presenting client both
35+// derive it from PreviewRect, so the captured viewport always fits its pane.
36+func PreviewSize(terminal domain.Size) domain.Size {
37+ return Size(PreviewRect(terminal))
38+}
@@ -28,3 +28,11 @@ func PreviewRect(terminal domain.Size) domain.Rect {
28 func Size(rect domain.Rect) domain.Size {28 func Size(rect domain.Rect) domain.Size {
29 return domain.Size{Cols: rect.Width, Rows: rect.Height}29 return domain.Size{Cols: rect.Width, Rows: rect.Height}
30 }30 }
31+
32+// PreviewSize returns the preview viewport for one terminal size: the preview
33+// pane inside the shared modal geometry. A zero size means this terminal has
34+// no room for a preview. The serving daemon and the presenting client both
35+// derive it from PreviewRect, so the captured viewport always fits its pane.
36+func PreviewSize(terminal domain.Size) domain.Size {
37+ return Size(PreviewRect(terminal))
38+}
M internal/usecase/picker/model.go +7 -1
@@ -508,7 +508,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
508508 name = " " + name
509509 }
510510 badge := ""
511- if r.rendersAsHeader() {
511+ if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) {
512512 badge = lineStatusBadge(r.line.Status)
513513 }
514514 contentClipX := clipX
@@ -535,6 +535,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
535535 continue
536536 }
537537 if r.rendersAsHeader() {
538+ if r.line.Detail != "" && x < contentClipX {
539+ x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle)
540+ }
538541 detail := ui.TruncateText(r.line.Detail, contentClipX-x)
539542 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
540543 drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
@@ -549,6 +552,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
549552 x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)
550553 }
551554
555+ if r.line.Detail != "" && x < clipX {
556+ x = ui.DrawText(frame, x, rect.Y+y, clipX, " ", detailStyle)
557+ }
552558 detail := ui.TruncateText(r.line.Detail, clipX-x)
553559 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
554560 drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions)
@@ -508,7 +508,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
508 name = " " + name508 name = " " + name
509 }509 }
510 badge := ""510 badge := ""
511- if r.rendersAsHeader() {511+ if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) {
512 badge = lineStatusBadge(r.line.Status)512 badge = lineStatusBadge(r.line.Status)
513 }513 }
514 contentClipX := clipX514 contentClipX := clipX
@@ -535,6 +535,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
535 continue535 continue
536 }536 }
537 if r.rendersAsHeader() {537 if r.rendersAsHeader() {
538+ if r.line.Detail != "" && x < contentClipX {
539+ x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle)
540+ }
538 detail := ui.TruncateText(r.line.Detail, contentClipX-x)541 detail := ui.TruncateText(r.line.Detail, contentClipX-x)
539 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)542 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
540 drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)543 drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
@@ -549,6 +552,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
549 x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)552 x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)
550 }553 }
551 554
555+ if r.line.Detail != "" && x < clipX {
556+ x = ui.DrawText(frame, x, rect.Y+y, clipX, " ", detailStyle)
557+ }
552 detail := ui.TruncateText(r.line.Detail, clipX-x)558 detail := ui.TruncateText(r.line.Detail, clipX-x)
553 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)559 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
554 drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions)560 drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions)
M internal/usecase/picker/model_test.go +1 -1
@@ -297,7 +297,7 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
297297 frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})
298298 require.Equal(t, 60, frame.Width)
299299 require.Equal(t, 8, frame.Height)
300- require.Contains(t, rowText(frame.Row(0)), "[up]")
300+ require.NotContains(t, rowText(frame.Row(0)), "[up]")
301301 require.Contains(t, rowText(frame.Row(1)), "[stopped]")
302302 }
303303
@@ -297,7 +297,7 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
297 frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})297 frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})
298 require.Equal(t, 60, frame.Width)298 require.Equal(t, 60, frame.Width)
299 require.Equal(t, 8, frame.Height)299 require.Equal(t, 8, frame.Height)
300- require.Contains(t, rowText(frame.Row(0)), "[up]")300+ require.NotContains(t, rowText(frame.Row(0)), "[up]")
301 require.Contains(t, rowText(frame.Row(1)), "[stopped]")301 require.Contains(t, rowText(frame.Row(1)), "[stopped]")
302 }302 }
303 303

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