detached c56f581

refactor(daemon): remove daemon-owned picker control

commit
c56f581a941b048fe1bd427999e3f7bb763b7495
author
brice · · 2026-09-22 19:23 UTC
parent
68ce008
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +41 -1412
M internal/usecase/daemon/attachment_effect_gate_test.go +7 -32
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/vev/internal/protocol"
1616 "github.com/bnema/vev/internal/protocol/wire"
1717 "github.com/bnema/vev/internal/usecase/keys"
18+ "github.com/bnema/vev/internal/usecase/picker"
1819 )
1920
2021 func releaseTestGate(t *testing.T, ch chan struct{}) func() {
@@ -782,39 +783,11 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
782783 require.Equal(t, 1, testAttachmentTabIndex(target))
783784 }
784785
785-// openPickerForSession opens a navigation interaction on the attachment and
786-// returns a kill selection for the named session. The open runs on a
787-// short-lived effect: the interaction namespace lives on the attachment, not
788-// on the effect that carried its offer.
789-func openPickerForSession(t *testing.T, d *Daemon, sess *session, ac *attachedClient, killSession domain.SessionID) protocol.PickerSelection {
790- t.Helper()
791- effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
792- require.True(t, admitted)
793- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
794- effect.End()
795- ac.overlays.pickerMu.Lock()
796- interaction := ac.overlays.pickerInteraction
797- revision := ac.overlays.pickerRevisions[servingPickerSourceID]
798- key := ""
799- for candidate, target := range ac.overlays.pickerKeys {
800- if target.Session == killSession {
801- key = candidate
802- break
803- }
804- }
805- ac.overlays.pickerMu.Unlock()
806- require.NotEmpty(t, key, "no picker key resolves to session %s", killSession)
807- return protocol.PickerSelection{
808- InteractionID: interaction, SourceID: servingPickerSourceID,
809- SourceRevision: revision, Key: key, Action: protocol.PickerActionKill,
810- }
811-}
812-
813786 func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {
814787 p, releasePTY := newBlockingPTY(t)
815788 defer releasePTY()
816789 d, sess, old, _ := newManualSessionWithPTYs(t, p)
817- selection := openPickerForSession(t, d, sess, old, sess.id)
790+ target := picker.Target{Session: sess.id}
818791
819792 oldTransport := old.transport()
820793 rc := d.attachCoordinator(sess, nil, old, true)
@@ -837,7 +810,8 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
837810 }
838811 actionDone := make(chan struct{})
839812 go func() {
840- d.resolvePickerSelection(actionEffect, selection)
813+ actionEffect.bindActionEnd(d, "picker-delete")
814+ _ = d.killPickerTargetForAttachment(target, actionEffect)
841815 close(actionDone)
842816 }()
843817 <-admissionEnded
@@ -862,7 +836,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
862836 p, release := newBlockingPTY(t)
863837 defer release()
864838 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
865- selection := openPickerForSession(t, d, sess, ac, sess.id)
839+ target := picker.Target{Session: sess.id}
866840 rc := d.attachCoordinator(sess, nil, ac, true)
867841 token := sess.captureAttachmentCapability(ac, ac.transport())
868842 token.lease = rc.attachmentLease(ac)
@@ -870,7 +844,8 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
870844 actionEffect, admitted := ac.beginAttachmentEffect(token)
871845 require.True(t, admitted)
872846
873- d.resolvePickerSelection(actionEffect, selection)
847+ actionEffect.bindActionEnd(d, "picker-delete")
848+ _ = d.killPickerTargetForAttachment(target, actionEffect)
874849
875850 d.mu.Lock()
876851 _, registered := d.sessions[sess.id]
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
16 "github.com/bnema/vev/internal/protocol/wire"16 "github.com/bnema/vev/internal/protocol/wire"
17 "github.com/bnema/vev/internal/usecase/keys"17 "github.com/bnema/vev/internal/usecase/keys"
18+ "github.com/bnema/vev/internal/usecase/picker"
18 )19 )
19 20
20 func releaseTestGate(t *testing.T, ch chan struct{}) func() {21 func releaseTestGate(t *testing.T, ch chan struct{}) func() {
@@ -782,39 +783,11 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
782 require.Equal(t, 1, testAttachmentTabIndex(target))783 require.Equal(t, 1, testAttachmentTabIndex(target))
783 }784 }
784 785
785-// openPickerForSession opens a navigation interaction on the attachment and
786-// returns a kill selection for the named session. The open runs on a
787-// short-lived effect: the interaction namespace lives on the attachment, not
788-// on the effect that carried its offer.
789-func openPickerForSession(t *testing.T, d *Daemon, sess *session, ac *attachedClient, killSession domain.SessionID) protocol.PickerSelection {
790- t.Helper()
791- effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
792- require.True(t, admitted)
793- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
794- effect.End()
795- ac.overlays.pickerMu.Lock()
796- interaction := ac.overlays.pickerInteraction
797- revision := ac.overlays.pickerRevisions[servingPickerSourceID]
798- key := ""
799- for candidate, target := range ac.overlays.pickerKeys {
800- if target.Session == killSession {
801- key = candidate
802- break
803- }
804- }
805- ac.overlays.pickerMu.Unlock()
806- require.NotEmpty(t, key, "no picker key resolves to session %s", killSession)
807- return protocol.PickerSelection{
808- InteractionID: interaction, SourceID: servingPickerSourceID,
809- SourceRevision: revision, Key: key, Action: protocol.PickerActionKill,
810- }
811-}
812-
813 func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {786 func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {
814 p, releasePTY := newBlockingPTY(t)787 p, releasePTY := newBlockingPTY(t)
815 defer releasePTY()788 defer releasePTY()
816 d, sess, old, _ := newManualSessionWithPTYs(t, p)789 d, sess, old, _ := newManualSessionWithPTYs(t, p)
817- selection := openPickerForSession(t, d, sess, old, sess.id)790+ target := picker.Target{Session: sess.id}
818 791
819 oldTransport := old.transport()792 oldTransport := old.transport()
820 rc := d.attachCoordinator(sess, nil, old, true)793 rc := d.attachCoordinator(sess, nil, old, true)
@@ -837,7 +810,8 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
837 }810 }
838 actionDone := make(chan struct{})811 actionDone := make(chan struct{})
839 go func() {812 go func() {
840- d.resolvePickerSelection(actionEffect, selection)813+ actionEffect.bindActionEnd(d, "picker-delete")
814+ _ = d.killPickerTargetForAttachment(target, actionEffect)
841 close(actionDone)815 close(actionDone)
842 }()816 }()
843 <-admissionEnded817 <-admissionEnded
@@ -862,7 +836,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
862 p, release := newBlockingPTY(t)836 p, release := newBlockingPTY(t)
863 defer release()837 defer release()
864 d, sess, ac, _ := newManualSessionWithPTYs(t, p)838 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
865- selection := openPickerForSession(t, d, sess, ac, sess.id)839+ target := picker.Target{Session: sess.id}
866 rc := d.attachCoordinator(sess, nil, ac, true)840 rc := d.attachCoordinator(sess, nil, ac, true)
867 token := sess.captureAttachmentCapability(ac, ac.transport())841 token := sess.captureAttachmentCapability(ac, ac.transport())
868 token.lease = rc.attachmentLease(ac)842 token.lease = rc.attachmentLease(ac)
@@ -870,7 +844,8 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
870 actionEffect, admitted := ac.beginAttachmentEffect(token)844 actionEffect, admitted := ac.beginAttachmentEffect(token)
871 require.True(t, admitted)845 require.True(t, admitted)
872 846
873- d.resolvePickerSelection(actionEffect, selection)847+ actionEffect.bindActionEnd(d, "picker-delete")
848+ _ = d.killPickerTargetForAttachment(target, actionEffect)
874 849
875 d.mu.Lock()850 d.mu.Lock()
876 _, registered := d.sessions[sess.id]851 _, registered := d.sessions[sess.id]
M internal/usecase/daemon/client_frame_routing.go +0 -7
@@ -235,13 +235,6 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
235235 d.refreshPalette(effect.ac)
236236 d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication")
237237 }
238- case protocol.PickerBegin:
239- if effect.ac == nil || effect.ac.overlays == nil {
240- break
241- }
242- if err := d.openPickerForAttachment(effect.ac, effect, message.Intent, moveSourceLocator{}, message.RequestID); err != nil {
243- d.reportAttachmentError(effect.sess, err)
244- }
245238 case protocol.PickerClose:
246239 if effect.ac == nil || effect.ac.overlays == nil {
247240 break
@@ -235,13 +235,6 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
235 d.refreshPalette(effect.ac)235 d.refreshPalette(effect.ac)
236 d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication")236 d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication")
237 }237 }
238- case protocol.PickerBegin:
239- if effect.ac == nil || effect.ac.overlays == nil {
240- break
241- }
242- if err := d.openPickerForAttachment(effect.ac, effect, message.Intent, moveSourceLocator{}, message.RequestID); err != nil {
243- d.reportAttachmentError(effect.sess, err)
244- }
245 case protocol.PickerClose:238 case protocol.PickerClose:
246 if effect.ac == nil || effect.ac.overlays == nil {239 if effect.ac == nil || effect.ac.overlays == nil {
247 break240 break
M internal/usecase/daemon/client_transport_errors.go +10 -2
@@ -49,6 +49,10 @@ func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed
4949 }
5050
5151 func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool {
52+ return d.clientGoneForAttachmentReason(effect, explicit, protocol.ReasonDetach)
53+}
54+
55+func (d *Daemon) clientGoneForAttachmentReason(effect *attachmentEffect, explicit bool, reason uint8) bool {
5256 if effect == nil {
5357 return false
5458 }
@@ -67,11 +71,15 @@ func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool
6771 d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken)
6872 return false
6973 }
70- d.finishClientGone(sess, capability.ac, capability.transport.transport, explicit, true)
74+ d.finishClientGoneReason(sess, capability.ac, capability.transport.transport, explicit, true, reason)
7175 return true
7276 }
7377
7478 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool) {
79+ d.finishClientGoneReason(sess, ac, failed, explicit, notice, protocol.ReasonDetach)
80+}
81+
82+func (d *Daemon) finishClientGoneReason(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool, reason uint8) {
7583 if sess == nil || ac == nil {
7684 return
7785 }
@@ -116,7 +124,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
116124 // (the client is actively awaiting it), but deadline-bounded so a
117125 // wedged client cannot pin this conn handler and hang Serve's
118126 // connWg.Wait.
119- d.boundedSend(ac, serverDetached(protocol.ReasonDetach))
127+ d.boundedSend(ac, serverDetached(reason))
120128 }
121129 _ = ac.closeCapturedTransport(oldTr)
122130 d.log.Info("client detached", "session", name, "explicit", explicit)
@@ -49,6 +49,10 @@ func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed
49 }49 }
50 50
51 func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool {51 func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool {
52+ return d.clientGoneForAttachmentReason(effect, explicit, protocol.ReasonDetach)
53+}
54+
55+func (d *Daemon) clientGoneForAttachmentReason(effect *attachmentEffect, explicit bool, reason uint8) bool {
52 if effect == nil {56 if effect == nil {
53 return false57 return false
54 }58 }
@@ -67,11 +71,15 @@ func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool
67 d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken)71 d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken)
68 return false72 return false
69 }73 }
70- d.finishClientGone(sess, capability.ac, capability.transport.transport, explicit, true)74+ d.finishClientGoneReason(sess, capability.ac, capability.transport.transport, explicit, true, reason)
71 return true75 return true
72 }76 }
73 77
74 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool) {78 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool) {
79+ d.finishClientGoneReason(sess, ac, failed, explicit, notice, protocol.ReasonDetach)
80+}
81+
82+func (d *Daemon) finishClientGoneReason(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool, reason uint8) {
75 if sess == nil || ac == nil {83 if sess == nil || ac == nil {
76 return84 return
77 }85 }
@@ -116,7 +124,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
116 // (the client is actively awaiting it), but deadline-bounded so a124 // (the client is actively awaiting it), but deadline-bounded so a
117 // wedged client cannot pin this conn handler and hang Serve's125 // wedged client cannot pin this conn handler and hang Serve's
118 // connWg.Wait.126 // connWg.Wait.
119- d.boundedSend(ac, serverDetached(protocol.ReasonDetach))127+ d.boundedSend(ac, serverDetached(reason))
120 }128 }
121 _ = ac.closeCapturedTransport(oldTr)129 _ = ac.closeCapturedTransport(oldTr)
122 d.log.Info("client detached", "session", name, "explicit", explicit)130 d.log.Info("client detached", "session", name, "explicit", explicit)
M internal/usecase/daemon/daemon.go +0 -4
@@ -1222,10 +1222,6 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) {
12221222 stopTransport()
12231223 finishHandshake()
12241224 d.handleNavigationInventory(tr, message)
1225- case protocol.PickerControlRequest:
1226- stopTransport()
1227- finishHandshake()
1228- d.handlePickerControl(tr, message)
12291225 case protocol.Kill:
12301226 stopTransport()
12311227 finishHandshake()
@@ -1222,10 +1222,6 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) {
1222 stopTransport()1222 stopTransport()
1223 finishHandshake()1223 finishHandshake()
1224 d.handleNavigationInventory(tr, message)1224 d.handleNavigationInventory(tr, message)
1225- case protocol.PickerControlRequest:
1226- stopTransport()
1227- finishHandshake()
1228- d.handlePickerControl(tr, message)
1229 case protocol.Kill:1225 case protocol.Kill:
1230 stopTransport()1226 stopTransport()
1231 finishHandshake()1227 finishHandshake()
M internal/usecase/daemon/move_picker_test.go +3 -1
@@ -518,8 +518,10 @@ func TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability(t *test
518518 // concurrent reconnect or transition that won the gate after publication. It
519519 // republishes the current capability directly so it also works while a render
520520 // effect may still be in flight.
521+type replacementServerConnection struct{ *mockServerConnection }
522+
521523 func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) {
522- wrapper := &pickerSnapshotFailTransport{mockServerConnection: base}
524+ wrapper := &replacementServerConnection{mockServerConnection: base}
523525 published.ac.replaceTransport(wrapper)
524526 current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper)
525527 gate := &published.ac.lifecycle
@@ -518,8 +518,10 @@ func TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability(t *test
518 // concurrent reconnect or transition that won the gate after publication. It518 // concurrent reconnect or transition that won the gate after publication. It
519 // republishes the current capability directly so it also works while a render519 // republishes the current capability directly so it also works while a render
520 // effect may still be in flight.520 // effect may still be in flight.
521+type replacementServerConnection struct{ *mockServerConnection }
522+
521 func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) {523 func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) {
522- wrapper := &pickerSnapshotFailTransport{mockServerConnection: base}524+ wrapper := &replacementServerConnection{mockServerConnection: base}
523 published.ac.replaceTransport(wrapper)525 published.ac.replaceTransport(wrapper)
524 current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper)526 current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper)
525 gate := &published.ac.lifecycle527 gate := &published.ac.lifecycle
M internal/usecase/daemon/navigation_attachment_handoff_test.go +0 -7
@@ -20,13 +20,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
2020 run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error
2121 check func(*testing.T, *Daemon, *session, *session)
2222 }{
23- {
24- name: "picker selection", releasedAction: "detach",
25- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error {
26- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
27- return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
28- },
29- },
3023 {
3124 name: "palette session", releasedAction: "detach",
3225 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
@@ -20,13 +20,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
20 run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error20 run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error
21 check func(*testing.T, *Daemon, *session, *session)21 check func(*testing.T, *Daemon, *session, *session)
22 }{22 }{
23- {
24- name: "picker selection", releasedAction: "detach",
25- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error {
26- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
27- return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
28- },
29- },
30 {23 {
31 name: "palette session", releasedAction: "detach",24 name: "palette session", releasedAction: "detach",
32 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {25 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
M internal/usecase/daemon/overlay_runtime.go +1 -1
@@ -33,7 +33,7 @@ type overlayRuntime struct {
3333 // interactions reject); revisions are the per-source published versions
3434 // (older or duplicate revisions discard); keys maps opaque row keys of the
3535 // serving source to resolved targets; intent and moverSource are the
36- // daemon-side facts captured at open; requestID echoes a client PickerBegin.
36+ // daemon-side facts captured at open; requestID echoes a client client picker request.
3737 pickerOpen bool
3838 pickerInteraction uint64
3939 pickerRevisions map[string]uint64
@@ -33,7 +33,7 @@ type overlayRuntime struct {
33 // interactions reject); revisions are the per-source published versions33 // interactions reject); revisions are the per-source published versions
34 // (older or duplicate revisions discard); keys maps opaque row keys of the34 // (older or duplicate revisions discard); keys maps opaque row keys of the
35 // serving source to resolved targets; intent and moverSource are the35 // serving source to resolved targets; intent and moverSource are the
36- // daemon-side facts captured at open; requestID echoes a client PickerBegin.36+ // daemon-side facts captured at open; requestID echoes a client client picker request.
37 pickerOpen bool37 pickerOpen bool
38 pickerInteraction uint6438 pickerInteraction uint64
39 pickerRevisions map[string]uint6439 pickerRevisions map[string]uint64
M internal/usecase/daemon/palette.go +7 -31
@@ -737,12 +737,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
737737 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
738738 return
739739 }
740- // A client-owned picker may acquire presentation as soon as its offer's
741- // output barrier is displayed. Commit the palette-free base first so that
742- // barrier can never name a frame which still contains the command palette.
743- if cmd.Slug == "session-picker" {
744- d.invalidateRenderNow(sess, ac, true, "palette.go:session-picker-base")
745- }
746740 sess.dispatchMu.Lock()
747741 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
748742 sess.dispatchMu.Unlock()
@@ -1199,24 +1193,9 @@ func (e paletteExec) RenameTabTo(name string) error {
11991193 }
12001194
12011195 func (e paletteExec) OpenSessionPicker() error {
1202- if e.ac == nil || e.ac.overlays == nil {
1196+ if e.ac == nil {
12031197 return errAttachmentTransition
12041198 }
1205- // The palette already closed on execute (see handlePaletteInput):
1206- // only invalidate when the close actually changed overlay state, then
1207- // open the picker interaction on a freshly admitted effect so the
1208- // opener's fence retires on the authoritative repaint below.
1209- overlays := e.ac.overlays
1210- overlays.paletteMu.Lock()
1211- generation := overlays.paletteGeneration
1212- query := ""
1213- if overlays.palette != nil {
1214- query = overlays.palette.Query()
1215- }
1216- overlays.paletteMu.Unlock()
1217- if e.d.closeExecutedPalette(e.ac, e.effect, generation, query) {
1218- e.d.invalidateRender(e.sess, e.ac, true, "palette.go:session-picker")
1219- }
12201199 effect := e.effect
12211200 if effect == nil {
12221201 current := e.ac.transportSnapshot()
@@ -1226,16 +1205,13 @@ func (e paletteExec) OpenSessionPicker() error {
12261205 }
12271206 defer fresh.End()
12281207 effect = fresh
1229- } else {
1230- fresh, admitted := e.ac.beginAttachmentEffect(effect.capability())
1231- if !admitted {
1232- return errAttachmentTransition
1233- }
1234- fresh.uiActionID = effect.uiActionID
1235- defer fresh.End()
1236- effect = fresh
12371208 }
1238- return e.d.openPickerForAttachment(e.ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)
1209+ // End the attachment; the client supervisor, not an interaction on this
1210+ // connection, owns the next picker presentation and its input.
1211+ if !e.d.clientGoneForAttachmentReason(effect, true, protocol.ReasonDetachToPicker) {
1212+ return errAttachmentTransition
1213+ }
1214+ return nil
12391215 }
12401216
12411217 func (e paletteExec) OpenNotifications() error {
@@ -737,12 +737,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
737 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {737 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
738 return738 return
739 }739 }
740- // A client-owned picker may acquire presentation as soon as its offer's
741- // output barrier is displayed. Commit the palette-free base first so that
742- // barrier can never name a frame which still contains the command palette.
743- if cmd.Slug == "session-picker" {
744- d.invalidateRenderNow(sess, ac, true, "palette.go:session-picker-base")
745- }
746 sess.dispatchMu.Lock()740 sess.dispatchMu.Lock()
747 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)741 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
748 sess.dispatchMu.Unlock()742 sess.dispatchMu.Unlock()
@@ -1199,24 +1193,9 @@ func (e paletteExec) RenameTabTo(name string) error {
1199 }1193 }
1200 1194
1201 func (e paletteExec) OpenSessionPicker() error {1195 func (e paletteExec) OpenSessionPicker() error {
1202- if e.ac == nil || e.ac.overlays == nil {1196+ if e.ac == nil {
1203 return errAttachmentTransition1197 return errAttachmentTransition
1204 }1198 }
1205- // The palette already closed on execute (see handlePaletteInput):
1206- // only invalidate when the close actually changed overlay state, then
1207- // open the picker interaction on a freshly admitted effect so the
1208- // opener's fence retires on the authoritative repaint below.
1209- overlays := e.ac.overlays
1210- overlays.paletteMu.Lock()
1211- generation := overlays.paletteGeneration
1212- query := ""
1213- if overlays.palette != nil {
1214- query = overlays.palette.Query()
1215- }
1216- overlays.paletteMu.Unlock()
1217- if e.d.closeExecutedPalette(e.ac, e.effect, generation, query) {
1218- e.d.invalidateRender(e.sess, e.ac, true, "palette.go:session-picker")
1219- }
1220 effect := e.effect1199 effect := e.effect
1221 if effect == nil {1200 if effect == nil {
1222 current := e.ac.transportSnapshot()1201 current := e.ac.transportSnapshot()
@@ -1226,16 +1205,13 @@ func (e paletteExec) OpenSessionPicker() error {
1226 }1205 }
1227 defer fresh.End()1206 defer fresh.End()
1228 effect = fresh1207 effect = fresh
1229- } else {
1230- fresh, admitted := e.ac.beginAttachmentEffect(effect.capability())
1231- if !admitted {
1232- return errAttachmentTransition
1233- }
1234- fresh.uiActionID = effect.uiActionID
1235- defer fresh.End()
1236- effect = fresh
1237 }1208 }
1238- return e.d.openPickerForAttachment(e.ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)1209+ // End the attachment; the client supervisor, not an interaction on this
1210+ // connection, owns the next picker presentation and its input.
1211+ if !e.d.clientGoneForAttachmentReason(effect, true, protocol.ReasonDetachToPicker) {
1212+ return errAttachmentTransition
1213+ }
1214+ return nil
1239 }1215 }
1240 1216
1241 func (e paletteExec) OpenNotifications() error {1217 func (e paletteExec) OpenNotifications() error {
M internal/usecase/daemon/palette_test.go +2 -9
@@ -1343,15 +1343,8 @@ func TestPaletteExecMethods(t *testing.T) {
13431343 require.True(t, ac.overlays.promptActive())
13441344 d.closePrompt(ac)
13451345 require.True(t, sess.ephemeral)
1346- require.NoError(t, exec.OpenSessionPicker())
1347- require.True(t, ac.overlays.pickerClientActive())
1348- ac.overlays.pickerMu.Lock()
1349- interaction := ac.overlays.pickerInteraction
1350- ac.overlays.pickerMu.Unlock()
1351- effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
1352- require.True(t, admitted)
1353- require.True(t, d.closePickerForAttachment(ac, effect, interaction))
1354- effect.End()
1346+ // OpenSessionPicker ends this attachment; its lifecycle contract is tested
1347+ // separately by TestSessionPickerCommandDetachesToClientPicker.
13551348 require.NoError(t, exec.EnterVisualMode())
13561349 require.True(t, ac.overlays.copyActive())
13571350 d.exitCopyMode(ac)
@@ -1343,15 +1343,8 @@ func TestPaletteExecMethods(t *testing.T) {
1343 require.True(t, ac.overlays.promptActive())1343 require.True(t, ac.overlays.promptActive())
1344 d.closePrompt(ac)1344 d.closePrompt(ac)
1345 require.True(t, sess.ephemeral)1345 require.True(t, sess.ephemeral)
1346- require.NoError(t, exec.OpenSessionPicker())1346+ // OpenSessionPicker ends this attachment; its lifecycle contract is tested
1347- require.True(t, ac.overlays.pickerClientActive())1347+ // separately by TestSessionPickerCommandDetachesToClientPicker.
1348- ac.overlays.pickerMu.Lock()
1349- interaction := ac.overlays.pickerInteraction
1350- ac.overlays.pickerMu.Unlock()
1351- effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
1352- require.True(t, admitted)
1353- require.True(t, d.closePickerForAttachment(ac, effect, interaction))
1354- effect.End()
1355 require.NoError(t, exec.EnterVisualMode())1348 require.NoError(t, exec.EnterVisualMode())
1356 require.True(t, ac.overlays.copyActive())1349 require.True(t, ac.overlays.copyActive())
1357 d.exitCopyMode(ac)1350 d.exitCopyMode(ac)
D internal/usecase/daemon/picker_control.go +0 -153
@@ -1,153 +0,0 @@
1-package daemon
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- "github.com/bnema/vev/internal/usecase/picker"
8-)
9-
10-const controlPickerInteractionID uint64 = 1
11-
12-// handlePickerControl serves the stable local authority without creating an
13-// attachment. The snapshot and resolve paths rebuild from the same daemon
14-// projection, so eligibility and opaque-key ownership never move client-side.
15-func (d *Daemon) handlePickerControl(tr ports.ServerConnection, request protocol.PickerControlRequest) {
16- defer tr.Close()
17- response := d.answerPickerControl(request)
18- _ = d.boundedControlSend(tr, response)
19-}
20-
21-func (d *Daemon) answerPickerControl(request protocol.PickerControlRequest) protocol.PickerControlResponse {
22- response := protocol.PickerControlResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.PickerSourceUnavailable}
23- if protocol.ValidatePickerControlRequest(request) != nil || request.Version != protocol.Version {
24- return response
25- }
26- recentViews, groupedViews, _ := d.pickerViewProjections(nil, nil)
27- recent := pickerLineSetFor(recentViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{})
28- grouped := pickerLineSetFor(groupedViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{})
29- // Resolution revalidates the opaque key against the current catalogue.
30- // A constant revision avoids rejecting an otherwise current target merely
31- // because unrelated presentation text changed after the client snapshot.
32- revision := uint64(1)
33- if request.Operation == protocol.PickerControlSnapshot {
34- snapshot := protocol.PickerSnapshot{InteractionID: controlPickerInteractionID, SourceID: protocol.PickerHomeSourceID, SourceRevision: revision, Status: protocol.PickerSourceOK, Lines: recent.lines, Cursor: recent.cursor, Recent: protocol.PickerProjection{Lines: recent.lines, Cursor: recent.cursor}, Grouped: protocol.PickerProjection{Lines: grouped.lines, Cursor: grouped.cursor}}
35- response.Status, response.Snapshot = protocol.PickerSourceOK, &snapshot
36- return response
37- }
38- if request.Operation == protocol.PickerControlObserve {
39- response.Status = protocol.PickerSourceOK
40- response.Observations = d.observePickerRoutes(request.Targets)
41- return response
42- }
43- if request.SourceID != protocol.PickerHomeSourceID {
44- return response
45- }
46- target, ok := recent.keys[request.Key]
47- if !ok || !d.pickerTargetCurrent(target) {
48- return response
49- }
50- resolved, ok := d.pickerControlAttachTarget(target)
51- if !ok {
52- return response
53- }
54- response.Status, response.Resolved = protocol.PickerSourceOK, &resolved
55- return response
56-}
57-
58-func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []protocol.PickerRouteObservation {
59- observations := make([]protocol.PickerRouteObservation, len(targets))
60- d.mu.Lock()
61- sessions := d.sessionsSnapshotLocked()
62- d.mu.Unlock()
63- for i, target := range targets {
64- observation := protocol.PickerRouteObservation{Target: target, Presence: protocol.PickerRouteAbsent}
65- for _, sess := range sessions {
66- view := sess.snapshotView(viewOptions{})
67- if view.incarnation != target.LifecycleID || view.name != target.SessionName {
68- continue
69- }
70- observation.Presence = protocol.PickerRoutePresent
71- observation.Attention = view.hasAttention
72- break
73- }
74- observations[i] = observation
75- }
76- return observations
77-}
78-
79-func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) {
80- // A remote target names an endpoint-qualified remote session, not a local
81- // one. The structured route and lifecycle are revalidated against the
82- // remote catalogue by remoteCatalogTargetReady; the local resolver never
83- // sees a foreign target.
84- if target.RemoteTarget != nil && target.RemoteKey != nil {
85- remote := *target.RemoteTarget
86- if !d.remoteCatalogTargetReady(remote) {
87- return protocol.AttachTarget{}, false
88- }
89- return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, SessionTarget: ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(remote)), EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true
90- }
91- return d.pickerControlAttachTargetLocal(target)
92-}
93-
94-// pickerControlAttachTargetLocal preserves the hybrid live behavior for a
95-// local target while keeping the prepared resolver strict. The picker
96-// snapshot's tab is a best-effort cursor, so a tab that vanished between the
97-// snapshot and the resolve must not fail the whole attach: the client's own
98-// missing-tab fallback would have attached the session's first tab anyway.
99-// The wrapper therefore retries once without a preferred tab, which can only
100-// succeed when the exact local lifecycle is still current, so a replaced or
101-// renamed session and a foreign target still reject.
102-func (d *Daemon) pickerControlAttachTargetLocal(target picker.Target) (protocol.AttachTarget, bool) {
103- resolved, ok := d.localPickerControlAttachTarget(target)
104- if ok || target.TabID == "" {
105- return resolved, ok
106- }
107- target.TabID = ""
108- return d.localPickerControlAttachTarget(target)
109-}
110-
111-// localPickerControlAttachTarget is the prepared local-only control resolver
112-// (Plan 001 P4.3). It rejects a structured foreign target (remote route or
113-// remote session key) before any lookup, so a local resolve can never be
114-// satisfied by a foreign row. It then matches the exact session identity and
115-// lifecycle; a replaced lifecycle or a renamed session never falls back to a
116-// same-name session. A non-empty preferred tab must still exist in the current
117-// session, so a stale tab rejects instead of forwarding a dangling tab ID; the
118-// hybrid pickerControlAttachTarget retains its vanished-tab fallback on top of
119-// this strict check.
120-// Broken and purging stopped records are unreachable here: only live registry
121-// sessions are eligible.
122-func (d *Daemon) localPickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) {
123- if target.RemoteTarget != nil || target.RemoteKey != nil {
124- return protocol.AttachTarget{}, false
125- }
126- d.mu.Lock()
127- defer d.mu.Unlock()
128- sess := d.sessions[target.Session]
129- if sess == nil {
130- return protocol.AttachTarget{}, false
131- }
132- sess.mu.Lock()
133- defer sess.mu.Unlock()
134- if !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {
135- return protocol.AttachTarget{}, false
136- }
137- if target.TabID != "" && !sessionHasStableTabLocked(sess, target.TabID) {
138- return protocol.AttachTarget{}, false
139- }
140- exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
141- return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true
142-}
143-
144-// sessionHasStableTabLocked reports whether an exact stable tab identity is
145-// still present on a session. Caller holds the session lock.
146-func sessionHasStableTabLocked(sess *session, tabID domain.TabStableID) bool {
147- for _, tab := range sess.tabs {
148- if tab != nil && tab.stableID == string(tabID) {
149- return true
150- }
151- }
152- return false
153-}
@@ -1,153 +0,0 @@
1-package daemon
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- "github.com/bnema/vev/internal/usecase/picker"
8-)
9-
10-const controlPickerInteractionID uint64 = 1
11-
12-// handlePickerControl serves the stable local authority without creating an
13-// attachment. The snapshot and resolve paths rebuild from the same daemon
14-// projection, so eligibility and opaque-key ownership never move client-side.
15-func (d *Daemon) handlePickerControl(tr ports.ServerConnection, request protocol.PickerControlRequest) {
16- defer tr.Close()
17- response := d.answerPickerControl(request)
18- _ = d.boundedControlSend(tr, response)
19-}
20-
21-func (d *Daemon) answerPickerControl(request protocol.PickerControlRequest) protocol.PickerControlResponse {
22- response := protocol.PickerControlResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.PickerSourceUnavailable}
23- if protocol.ValidatePickerControlRequest(request) != nil || request.Version != protocol.Version {
24- return response
25- }
26- recentViews, groupedViews, _ := d.pickerViewProjections(nil, nil)
27- recent := pickerLineSetFor(recentViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{})
28- grouped := pickerLineSetFor(groupedViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{})
29- // Resolution revalidates the opaque key against the current catalogue.
30- // A constant revision avoids rejecting an otherwise current target merely
31- // because unrelated presentation text changed after the client snapshot.
32- revision := uint64(1)
33- if request.Operation == protocol.PickerControlSnapshot {
34- snapshot := protocol.PickerSnapshot{InteractionID: controlPickerInteractionID, SourceID: protocol.PickerHomeSourceID, SourceRevision: revision, Status: protocol.PickerSourceOK, Lines: recent.lines, Cursor: recent.cursor, Recent: protocol.PickerProjection{Lines: recent.lines, Cursor: recent.cursor}, Grouped: protocol.PickerProjection{Lines: grouped.lines, Cursor: grouped.cursor}}
35- response.Status, response.Snapshot = protocol.PickerSourceOK, &snapshot
36- return response
37- }
38- if request.Operation == protocol.PickerControlObserve {
39- response.Status = protocol.PickerSourceOK
40- response.Observations = d.observePickerRoutes(request.Targets)
41- return response
42- }
43- if request.SourceID != protocol.PickerHomeSourceID {
44- return response
45- }
46- target, ok := recent.keys[request.Key]
47- if !ok || !d.pickerTargetCurrent(target) {
48- return response
49- }
50- resolved, ok := d.pickerControlAttachTarget(target)
51- if !ok {
52- return response
53- }
54- response.Status, response.Resolved = protocol.PickerSourceOK, &resolved
55- return response
56-}
57-
58-func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []protocol.PickerRouteObservation {
59- observations := make([]protocol.PickerRouteObservation, len(targets))
60- d.mu.Lock()
61- sessions := d.sessionsSnapshotLocked()
62- d.mu.Unlock()
63- for i, target := range targets {
64- observation := protocol.PickerRouteObservation{Target: target, Presence: protocol.PickerRouteAbsent}
65- for _, sess := range sessions {
66- view := sess.snapshotView(viewOptions{})
67- if view.incarnation != target.LifecycleID || view.name != target.SessionName {
68- continue
69- }
70- observation.Presence = protocol.PickerRoutePresent
71- observation.Attention = view.hasAttention
72- break
73- }
74- observations[i] = observation
75- }
76- return observations
77-}
78-
79-func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) {
80- // A remote target names an endpoint-qualified remote session, not a local
81- // one. The structured route and lifecycle are revalidated against the
82- // remote catalogue by remoteCatalogTargetReady; the local resolver never
83- // sees a foreign target.
84- if target.RemoteTarget != nil && target.RemoteKey != nil {
85- remote := *target.RemoteTarget
86- if !d.remoteCatalogTargetReady(remote) {
87- return protocol.AttachTarget{}, false
88- }
89- return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, SessionTarget: ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(remote)), EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true
90- }
91- return d.pickerControlAttachTargetLocal(target)
92-}
93-
94-// pickerControlAttachTargetLocal preserves the hybrid live behavior for a
95-// local target while keeping the prepared resolver strict. The picker
96-// snapshot's tab is a best-effort cursor, so a tab that vanished between the
97-// snapshot and the resolve must not fail the whole attach: the client's own
98-// missing-tab fallback would have attached the session's first tab anyway.
99-// The wrapper therefore retries once without a preferred tab, which can only
100-// succeed when the exact local lifecycle is still current, so a replaced or
101-// renamed session and a foreign target still reject.
102-func (d *Daemon) pickerControlAttachTargetLocal(target picker.Target) (protocol.AttachTarget, bool) {
103- resolved, ok := d.localPickerControlAttachTarget(target)
104- if ok || target.TabID == "" {
105- return resolved, ok
106- }
107- target.TabID = ""
108- return d.localPickerControlAttachTarget(target)
109-}
110-
111-// localPickerControlAttachTarget is the prepared local-only control resolver
112-// (Plan 001 P4.3). It rejects a structured foreign target (remote route or
113-// remote session key) before any lookup, so a local resolve can never be
114-// satisfied by a foreign row. It then matches the exact session identity and
115-// lifecycle; a replaced lifecycle or a renamed session never falls back to a
116-// same-name session. A non-empty preferred tab must still exist in the current
117-// session, so a stale tab rejects instead of forwarding a dangling tab ID; the
118-// hybrid pickerControlAttachTarget retains its vanished-tab fallback on top of
119-// this strict check.
120-// Broken and purging stopped records are unreachable here: only live registry
121-// sessions are eligible.
122-func (d *Daemon) localPickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) {
123- if target.RemoteTarget != nil || target.RemoteKey != nil {
124- return protocol.AttachTarget{}, false
125- }
126- d.mu.Lock()
127- defer d.mu.Unlock()
128- sess := d.sessions[target.Session]
129- if sess == nil {
130- return protocol.AttachTarget{}, false
131- }
132- sess.mu.Lock()
133- defer sess.mu.Unlock()
134- if !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {
135- return protocol.AttachTarget{}, false
136- }
137- if target.TabID != "" && !sessionHasStableTabLocked(sess, target.TabID) {
138- return protocol.AttachTarget{}, false
139- }
140- exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
141- return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true
142-}
143-
144-// sessionHasStableTabLocked reports whether an exact stable tab identity is
145-// still present on a session. Caller holds the session lock.
146-func sessionHasStableTabLocked(sess *session, tabID domain.TabStableID) bool {
147- for _, tab := range sess.tabs {
148- if tab != nil && tab.stableID == string(tabID) {
149- return true
150- }
151- }
152- return false
153-}
D internal/usecase/daemon/picker_control_test.go +0 -31
@@ -1,31 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/protocol"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func TestPickerControlObserveReportsExactAttention(t *testing.T) {
12- d, sess, _, _ := newManualSessionWithPTYs(t, nil)
13- sess.mu.Lock()
14- sess.incarnation = domain.SessionLifecycleID{9}
15- sess.tabs[0].attention = true
16- target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
17- sess.mu.Unlock()
18- missing := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{8}, SessionName: sess.name}
19-
20- response := d.answerPickerControl(protocol.PickerControlRequest{
21- Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlObserve,
22- Targets: []protocol.ExactSessionTarget{target, missing},
23- })
24-
25- require.Equal(t, protocol.PickerSourceOK, response.Status)
26- require.Equal(t, []protocol.PickerRouteObservation{
27- {Target: target, Presence: protocol.PickerRoutePresent, Attention: true},
28- {Target: missing, Presence: protocol.PickerRouteAbsent},
29- }, response.Observations)
30- require.NoError(t, protocol.ValidatePickerControlResponse(response))
31-}
@@ -1,31 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/protocol"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func TestPickerControlObserveReportsExactAttention(t *testing.T) {
12- d, sess, _, _ := newManualSessionWithPTYs(t, nil)
13- sess.mu.Lock()
14- sess.incarnation = domain.SessionLifecycleID{9}
15- sess.tabs[0].attention = true
16- target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
17- sess.mu.Unlock()
18- missing := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{8}, SessionName: sess.name}
19-
20- response := d.answerPickerControl(protocol.PickerControlRequest{
21- Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlObserve,
22- Targets: []protocol.ExactSessionTarget{target, missing},
23- })
24-
25- require.Equal(t, protocol.PickerSourceOK, response.Status)
26- require.Equal(t, []protocol.PickerRouteObservation{
27- {Target: target, Presence: protocol.PickerRoutePresent, Attention: true},
28- {Target: missing, Presence: protocol.PickerRouteAbsent},
29- }, response.Observations)
30- require.NoError(t, protocol.ValidatePickerControlResponse(response))
31-}
M internal/usecase/daemon/picker_interaction.go +9 -4
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "errors"
45 "fmt"
56 "slices"
67
@@ -9,14 +10,15 @@ import (
910 "github.com/bnema/vev/internal/usecase/picker"
1011 )
1112
12-// This file owns the serving-daemon side of the picker interaction. The
13-// daemon is the data and action authority: it publishes one structured line
14-// set per source, resolves opaque keys, and performs the navigation, move, or
15-// kill the client asked for. It never owns a presentation model.
13+// This file retains the serving-daemon move-destination interaction. It must
14+// never admit a navigation intent: session-picker ends the attachment and the
15+// client supervisor owns all subsequent navigation and terminal input.
1616
1717 // servingPickerSourceID names the source the serving daemon itself owns.
1818 const servingPickerSourceID = protocol.PickerServingSourceID
1919
20+var errClientOwnedNavigation = errors.New("session navigation is client-owned")
21+
2022 // pickerDuplicateRows reports the first repeated row key with a readable
2123 // identity for both rows. A refused snapshot otherwise says only that a key
2224 // repeated, which is not enough to find the two rows that collided.
@@ -79,6 +81,9 @@ func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentE
7981 if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
8082 return errAttachmentTransition
8183 }
84+ if intent != protocol.PickerIntentMoveTab && intent != protocol.PickerIntentMovePane {
85+ return errClientOwnedNavigation
86+ }
8287 sess := effect.sess
8388 if intent != protocol.PickerIntentNavigation {
8489 if sess == nil || !sess.capabilities().yieldsMoves() {
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "slices"6 "slices"
6 7
@@ -9,14 +10,15 @@ import (
9 "github.com/bnema/vev/internal/usecase/picker"10 "github.com/bnema/vev/internal/usecase/picker"
10 )11 )
11 12
12-// This file owns the serving-daemon side of the picker interaction. The13+// This file retains the serving-daemon move-destination interaction. It must
13-// daemon is the data and action authority: it publishes one structured line14+// never admit a navigation intent: session-picker ends the attachment and the
14-// set per source, resolves opaque keys, and performs the navigation, move, or15+// client supervisor owns all subsequent navigation and terminal input.
15-// kill the client asked for. It never owns a presentation model.
16 16
17 // servingPickerSourceID names the source the serving daemon itself owns.17 // servingPickerSourceID names the source the serving daemon itself owns.
18 const servingPickerSourceID = protocol.PickerServingSourceID18 const servingPickerSourceID = protocol.PickerServingSourceID
19 19
20+var errClientOwnedNavigation = errors.New("session navigation is client-owned")
21+
20 // pickerDuplicateRows reports the first repeated row key with a readable22 // pickerDuplicateRows reports the first repeated row key with a readable
21 // identity for both rows. A refused snapshot otherwise says only that a key23 // identity for both rows. A refused snapshot otherwise says only that a key
22 // repeated, which is not enough to find the two rows that collided.24 // repeated, which is not enough to find the two rows that collided.
@@ -79,6 +81,9 @@ func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentE
79 if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {81 if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
80 return errAttachmentTransition82 return errAttachmentTransition
81 }83 }
84+ if intent != protocol.PickerIntentMoveTab && intent != protocol.PickerIntentMovePane {
85+ return errClientOwnedNavigation
86+ }
82 sess := effect.sess87 sess := effect.sess
83 if intent != protocol.PickerIntentNavigation {88 if intent != protocol.PickerIntentNavigation {
84 if sess == nil || !sess.capabilities().yieldsMoves() {89 if sess == nil || !sess.capabilities().yieldsMoves() {
D internal/usecase/daemon/picker_interaction_test.go +0 -819
@@ -1,819 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "strings"
8- "sync/atomic"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/protocol/wire"
16- "github.com/bnema/vev/internal/usecase/ui"
17- "github.com/stretchr/testify/require"
18-)
19-
20-// pickerClientTestUnit builds a fixture attachment and one extra session so
21-// snapshots are non-empty. It returns an admitted effect for the guarded
22-// sends.
23-func pickerClientTestUnit(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Envelope, *attachmentEffect) {
24- t.Helper()
25- p, releasePTY := newBlockingPTY(t)
26- return pickerClientTestUnitWithPTY(t, p, releasePTY)
27-}
28-
29-// pickerClientTestUnitWithPTY builds the same fixture over a caller-owned
30-// PTY, so tests can observe whether session input reached the child.
31-func pickerClientTestUnitWithPTY(t *testing.T, p ports.PTY, releasePTY func()) (*Daemon, *session, *attachedClient, chan wire.Envelope, *attachmentEffect) {
32- t.Helper()
33- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
34- t.Cleanup(releasePTY)
35- d.ptys = newFactorySeq(t, newQuietPTY())
36- _, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
37- require.NoError(t, err)
38- effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
39- require.True(t, ok)
40- t.Cleanup(effect.End)
41- return d, sess, ac, sends, effect
42-}
43-
44-func openTestPicker(t *testing.T, d *Daemon, ac *attachedClient, effect *attachmentEffect, sends chan wire.Envelope, intent protocol.PickerIntent) protocol.PickerOffer {
45- t.Helper()
46- require.NoError(t, d.openPickerForAttachment(ac, effect, intent, moveSourceLocator{}, 0))
47- offer := awaitPickerOffer(t, sends)
48- // The interaction namespace lives on the attachment, so the effect that
49- // carried the offer is released here: a later transition or move must be
50- // able to drain every other effect on this attachment.
51- effect.End()
52- return offer
53-}
54-
55-func awaitPickerOffer(t *testing.T, sends chan wire.Envelope) protocol.PickerOffer {
56- t.Helper()
57- for {
58- frame := awaitTestValue(t, sends, "picker offer was not published")
59- if offer, ok := decodeServerMessage(t, frame).(protocol.PickerOffer); ok {
60- return offer
61- }
62- }
63-}
64-
65-func awaitPickerSnapshot(t *testing.T, sends chan wire.Envelope) protocol.PickerSnapshot {
66- t.Helper()
67- for {
68- frame := awaitTestValue(t, sends, "picker snapshot was not published")
69- if snapshot, ok := decodeServerMessage(t, frame).(protocol.PickerSnapshot); ok {
70- return snapshot
71- }
72- }
73-}
74-
75-func awaitPickerFailure(t *testing.T, sends chan wire.Envelope) protocol.PickerFailure {
76- t.Helper()
77- for {
78- frame := awaitTestValue(t, sends, "picker failure was not published")
79- if failure, ok := decodeServerMessage(t, frame).(protocol.PickerFailure); ok {
80- return failure
81- }
82- }
83-}
84-
85-func pickerClientCapability(t *testing.T, ac *attachedClient, sess *session) attachmentCapability {
86- t.Helper()
87- return captureAttachmentCapability(sess, ac, ac.transport())
88-}
89-
90-// navigateSelection builds one typed commit for the displayed snapshot.
91-func navigateSelection(snapshot protocol.PickerSnapshot, key string) protocol.PickerSelection {
92- return protocol.PickerSelection{
93- InteractionID: snapshot.InteractionID, SourceID: servingPickerSourceID,
94- SourceRevision: snapshot.SourceRevision, Key: key, Action: protocol.PickerActionNavigate,
95- }
96-}
97-
98-// firstSelectableKey returns the first line the source authorised for the
99-// requested action.
100-func firstSelectableKey(t *testing.T, snapshot protocol.PickerSnapshot, action protocol.PickerLineActions) string {
101- t.Helper()
102- for _, line := range snapshot.Lines {
103- if line.Actions&action != 0 {
104- return line.Key
105- }
106- }
107- t.Fatalf("snapshot published no line admitting %d", action)
108- return ""
109-}
110-
111-func TestPickerOfferAndSnapshotPublishResolvableLines(t *testing.T) {
112- d, _, ac, sends, effect := pickerClientTestUnit(t)
113-
114- offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
115- snapshot := awaitPickerSnapshot(t, sends)
116-
117- require.NotZero(t, offer.InteractionID)
118- require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
119- require.Equal(t, offer.InteractionID, snapshot.InteractionID)
120- require.Equal(t, servingPickerSourceID, snapshot.SourceID)
121- require.Equal(t, uint64(1), snapshot.SourceRevision)
122- require.NotEmpty(t, snapshot.Lines)
123-
124- // Every selectable line resolves through the published keys, and the keys
125- // carry session names for the unchanged handoff.
126- ac.overlays.pickerMu.Lock()
127- keys := ac.overlays.pickerKeys
128- ac.overlays.pickerMu.Unlock()
129- selectable := 0
130- for _, line := range snapshot.Lines {
131- if line.Actions == 0 {
132- continue
133- }
134- selectable++
135- target, ok := keys[line.Key]
136- require.True(t, ok, "line %q has no resolved target", line.Key)
137- require.NotEmpty(t, target.Name, "resolved target carries no session name")
138- }
139- require.NotZero(t, selectable)
140-}
141-
142-func TestPickerSnapshotPublishesFocusEligibility(t *testing.T) {
143- d, _, ac, sends, effect := pickerClientTestUnit(t)
144- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
145- snapshot := awaitPickerSnapshot(t, sends)
146-
147- headers, tabs := 0, 0
148- for _, line := range snapshot.Lines {
149- switch line.Kind {
150- case protocol.PickerLineSection:
151- require.False(t, line.Focusable, "section %q is not a cursor destination", line.Label)
152- require.Zero(t, line.Actions, "section %q admits no action", line.Label)
153- case protocol.PickerLineSession:
154- headers++
155- // A live session header names a group of tabs: navigation belongs
156- // to its tab rows, so the header is rendered and skipped.
157- require.False(t, line.Focusable, "session header %q is not a destination", line.Label)
158- require.Zero(t, line.Actions)
159- case protocol.PickerLineTab:
160- tabs++
161- require.True(t, line.Focusable, "tab %q must be reachable", line.Label)
162- require.NotZero(t, line.Actions, "a reachable tab admits the action that made it reachable")
163- }
164- }
165- require.NotZero(t, headers)
166- require.NotZero(t, tabs)
167-
168- // The published cursor hint never names a row the client may not rest on.
169- require.NotEmpty(t, snapshot.Cursor.Key)
170- for _, line := range snapshot.Lines {
171- if line.Key == snapshot.Cursor.Key {
172- require.True(t, line.Focusable)
173- }
174- }
175-}
176-
177-func TestPickerOpenInstallsNoPresentationModel(t *testing.T) {
178- d, _, ac, sends, effect := pickerClientTestUnit(t)
179-
180- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
181- _ = awaitPickerSnapshot(t, sends)
182-
183- // The daemon publishes data and actions only: no model, no cursor, no
184- // search state of its own.
185- ac.overlays.pickerMu.Lock()
186- require.True(t, ac.overlays.pickerOpen)
187- require.NotZero(t, ac.overlays.pickerInteraction)
188- require.NotEmpty(t, ac.overlays.pickerKeys)
189- ac.overlays.pickerMu.Unlock()
190-}
191-
192-func TestPickerSelectionRejectsStaleAndUnknown(t *testing.T) {
193- d, sess, ac, sends, effect := pickerClientTestUnit(t)
194-
195- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
196- snapshot := awaitPickerSnapshot(t, sends)
197-
198- capability := pickerClientCapability(t, ac, sess)
199- // Unknown key on the open interaction reports UnknownKey without
200- // closing the interaction.
201- require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, "ff00/ghost")))
202- failure := awaitPickerFailure(t, sends)
203- require.Equal(t, protocol.PickerUnknownKey, failure.Code)
204- require.Equal(t, snapshot.InteractionID, failure.InteractionID)
205-
206- // A foreign source is rejected before any key lookup.
207- foreign := navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate))
208- foreign.SourceID = "elsewhere"
209- require.False(t, d.handleAttachmentClientMessage(capability, foreign))
210- require.Equal(t, protocol.PickerUnknownSource, awaitPickerFailure(t, sends).Code)
211-
212- // A retired interaction reports StaleRevision.
213- d.closePickerForAttachment(ac, effect, snapshot.InteractionID)
214- require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate))))
215- require.Equal(t, protocol.PickerStaleRevision, awaitPickerFailure(t, sends).Code)
216-}
217-
218-func TestPickerMoveIntentPublishesMoveLinesOnly(t *testing.T) {
219- d, sess, ac, sends, effect := pickerClientTestUnit(t)
220- source := moveSourceLocator{
221- Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
222- TabID: domain.TabStableID(testAttachmentTab(sess).stableID),
223- }
224- ac.overlays.pickerMu.Lock()
225- require.False(t, ac.overlays.pickerOpen)
226- ac.overlays.pickerMu.Unlock()
227-
228- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMoveTab, source, 0))
229- offer := awaitPickerOffer(t, sends)
230- snapshot := awaitPickerSnapshot(t, sends)
231- require.Equal(t, protocol.PickerIntentMoveTab, offer.Intent)
232-
233- // The source session never appears as its own destination, and rows that
234- // cannot accept a move carry no action.
235- for _, line := range snapshot.Lines {
236- require.NotEqual(t, sourceMoveKey(source), line.Key)
237- if line.Actions != 0 {
238- require.Equal(t, protocol.PickerCanMove, line.Actions)
239- }
240- }
241-}
242-
243-func sourceMoveKey(source moveSourceLocator) string {
244- return pickerMoveSourceKey(source)
245-}
246-
247-func TestPickerOpenRepaintsWithoutSessionChange(t *testing.T) {
248- d, _, ac, sends, effect := pickerClientTestUnit(t)
249-
250- // The open invalidates the session even when nothing else changed:
251- // without this repaint the opener's fence would never retire and
252- // the palette Enter would hang until its deadline.
253- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
254- _ = awaitPickerSnapshot(t, sends)
255- select {
256- case frame := <-sends:
257- require.Equal(t, "Output", envelopeMessageName(t, frame.Payload), "open must trigger an authoritative repaint")
258- default:
259- t.Fatal("open produced no repaint after the snapshot")
260- }
261-}
262-
263-func TestPickerSessionPickerCommandOpensInteraction(t *testing.T) {
264- p, release := newBlockingPTY(t)
265- defer release()
266- d, current, ac, sends := newManualSessionWithPTYs(t, p)
267- effect := beginRecentRoutePaletteEffect(t, d, current, ac)
268- effect.uiActionID = 77
269-
270- // Full path through overlay dispatch: palette query, then Enter on
271- // the same effect. The Enter routes through handlePaletteInput via
272- // the overlay runtime, exactly like a real client frame.
273- d.enterPalette(current, ac)
274- d.handlePaletteInput(ac, []byte("sessionpicker"), effect)
275- require.NotNil(t, ac.overlays.palette)
276- d.handlePaletteInput(ac, []byte("\r"), effect)
277-
278- offer := awaitPickerOffer(t, sends)
279- snapshot := awaitPickerSnapshot(t, sends)
280- require.NotZero(t, offer.InteractionID)
281- require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
282- require.NotEmpty(t, snapshot.Lines)
283- ac.overlays.pickerMu.Lock()
284- require.True(t, ac.overlays.pickerOpen)
285- ac.overlays.pickerMu.Unlock()
286-}
287-
288-func TestPickerCancelPublishesClosedAndAuthoritativeFullPaint(t *testing.T) {
289- d, sess, ac, sends, effect := pickerClientTestUnit(t)
290-
291- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
292- snapshot := awaitPickerSnapshot(t, sends)
293- // The open publishes its own restore paint after the snapshot; the release
294- // assertion below is about the paint that follows the close.
295- drainAllFrames(sends)
296-
297- // The client cancels: the daemon retires the interaction, confirms the
298- // close with its restore barrier, and publishes the authoritative full
299- // paint the client's release path waits for.
300- capability := pickerClientCapability(t, ac, sess)
301- require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
302- ac.overlays.pickerMu.Lock()
303- require.False(t, ac.overlays.pickerOpen)
304- ac.overlays.pickerMu.Unlock()
305-
306- var sawClosed bool
307- for settled := false; !settled; {
308- select {
309- case frame := <-sends:
310- if closed, ok := decodeServerMessage(t, frame).(protocol.PickerClosed); ok {
311- require.Equal(t, snapshot.InteractionID, closed.InteractionID)
312- sawClosed = true
313- continue
314- }
315- if output, ok := decodeServerMessage(t, frame).(protocol.Output); ok {
316- require.True(t, sawClosed, "the restore paint must follow the close")
317- require.True(t, output.Full, "cancel release paint must be authoritative")
318- settled = true
319- }
320- case <-time.After(200 * time.Millisecond):
321- settled = true
322- }
323- }
324- require.True(t, sawClosed, "cancel published no close confirmation")
325-}
326-
327-func TestPickerPaletteFramesOpenInteraction(t *testing.T) {
328- p, release := newBlockingPTY(t)
329- defer release()
330- d, _, ac, sends := newManualSessionWithPTYs(t, p)
331- capability := pickerClientCapability(t, ac, testAttachmentSession(t, ac))
332-
333- // Drive the real attached-client frame path instead of calling the
334- // palette handler: Alt+Space opens the palette, the query selects the
335- // session-picker command, and Enter executes it.
336- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 4, Data: []byte("\x1b ")}))
337- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 5, Data: []byte("SSP")}))
338- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 6, Data: []byte("\r")}))
339-
340- _ = awaitPickerOffer(t, sends)
341- snapshot := awaitPickerSnapshot(t, sends)
342- require.NotZero(t, snapshot.InteractionID)
343- require.NotEmpty(t, snapshot.Lines)
344- ac.overlays.pickerMu.Lock()
345- require.True(t, ac.overlays.pickerOpen)
346- ac.overlays.pickerMu.Unlock()
347-}
348-
349-func testAttachmentSession(t *testing.T, ac *attachedClient) *session {
350- t.Helper()
351- sess := ac.currentAttachmentSession()
352- require.NotNil(t, sess)
353- return sess
354-}
355-
356-// secondSessionForTest returns the fixture's extra session, the one the attach
357-// itself does not display.
358-func secondSessionForTest(t *testing.T, d *Daemon) *session {
359- t.Helper()
360- d.mu.Lock()
361- defer d.mu.Unlock()
362- for _, sess := range d.sessions {
363- if sess == nil {
364- continue
365- }
366- sess.mu.Lock()
367- name := sess.name
368- sess.mu.Unlock()
369- if name == "second" {
370- return sess
371- }
372- }
373- t.Fatal("fixture second session missing")
374- return nil
375-}
376-
377-// pickerBellLines reports the session headers carrying the bell glyph and the
378-// tab rows carrying the structured attention marker.
379-func pickerBellLines(snapshot protocol.PickerSnapshot) (headers int, tabRows int) {
380- bell := string(ui.AttentionGlyph)
381- for _, line := range snapshot.Lines {
382- switch line.Kind {
383- case protocol.PickerLineSession:
384- if strings.HasSuffix(line.Label, " "+bell) {
385- headers++
386- }
387- case protocol.PickerLineTab:
388- if line.Attention {
389- tabRows++
390- }
391- }
392- }
393- return headers, tabRows
394-}
395-
396-// TestPickerSnapshotCarriesAttentionBellsToTheClient pins the notification path
397-// into the client-owned modal: the daemon publishes the bell on the ringing
398-// session's header and the structured marker on its tab row, and the client
399-// renders both.
400-func TestPickerSnapshotCarriesAttentionBellsToTheClient(t *testing.T) {
401- d, _, ac, sends, effect := pickerClientTestUnit(t)
402- ringing := secondSessionForTest(t, d)
403- ringing.mu.Lock()
404- ringing.tabs[0].attention = true
405- ringing.tabs[0].attentionAt = time.Unix(1, 0)
406- ringing.mu.Unlock()
407-
408- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
409- snapshot := awaitPickerSnapshot(t, sends)
410-
411- headers, tabRows := pickerBellLines(snapshot)
412- require.Equal(t, 1, headers, "the ringing session header must carry the bell glyph")
413- require.Equal(t, 1, tabRows, "the ringing tab row must carry the structured attention marker")
414-}
415-
416-// TestAttentionPulseRepublishesTheOpenPickerSource pins that a bell raised
417-// while the client presents the modal reaches it: client-owned presentation
418-// suppresses the daemon's paints, so the pulse must advance the picker source
419-// the client presents from. A closed interaction republishes nothing.
420-func TestAttentionPulseRepublishesTheOpenPickerSource(t *testing.T) {
421- d, sess, ac, sends, releases := newManualTabSession(t, 2)
422- defer releases[0]()
423- defer releases[1]()
424- clk := newManualAttentionClock()
425- d.clock = clk
426- require.True(t, selectTestAttachmentTab(sess, 0))
427-
428- effect := admitPickerEffectForTest(t, sess, ac)
429- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
430- require.Equal(t, protocol.PickerIntentNavigation, awaitPickerOffer(t, sends).Intent)
431- first := awaitPickerSnapshot(t, sends)
432- require.Equal(t, 0, pickerBellCount(first), "a quiet session must publish no bell")
433-
434- ctx, cancel := context.WithCancel(context.Background())
435- defer cancel()
436- done := make(chan struct{})
437- go func() {
438- d.attentionAnimator(ctx)
439- close(done)
440- }()
441-
442- // A background tab rings while the modal is open. The client owns
443- // presentation, so only a newer source revision can update its rows.
444- sess.mu.Lock()
445- sess.tabs[1].attention = true
446- sess.tabs[1].attentionAt = time.Unix(1, 0)
447- sess.mu.Unlock()
448- d.pokeAttentionTicker()
449-
450- timer := clk.nextTimer(t)
451- timer.fire()
452- second := awaitPickerSnapshot(t, sends)
453- require.Greater(t, second.SourceRevision, first.SourceRevision)
454- headers, tabRows := pickerBellLines(second)
455- require.Equal(t, 1, headers, "the pulse must publish the new bell on the session header")
456- require.Equal(t, 1, tabRows, "the pulse must publish the new bell on the tab row")
457-
458- // A retired interaction must not be reopened by a later pulse. The
459- // animator keeps one timer while attention exists, so the same fired timer
460- // carries the next pulse.
461- retireEffect := admitPickerEffectForTest(t, sess, ac)
462- require.True(t, d.closePickerForAttachment(ac, retireEffect, second.InteractionID))
463- _ = drainAllFrames(sends)
464- sess.mu.Lock()
465- sess.tabs[0].attention = true
466- sess.tabs[0].attentionAt = time.Unix(2, 0)
467- sess.mu.Unlock()
468- d.pokeAttentionTicker()
469- timer.fire()
470- for _, frame := range drainAllFrames(sends) {
471- require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "a closed interaction republished a snapshot")
472- }
473-
474- cancel()
475- require.Eventually(t, func() bool {
476- select {
477- case <-done:
478- return true
479- default:
480- return false
481- }
482- }, time.Second, time.Millisecond)
483-}
484-
485-func pickerBellCount(snapshot protocol.PickerSnapshot) int {
486- headers, tabRows := pickerBellLines(snapshot)
487- return headers + tabRows
488-}
489-
490-func TestPickerSelectionRequiresExactSourceRevision(t *testing.T) {
491- d, sess, ac, sends, effect := pickerClientTestUnit(t)
492-
493- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
494- first := awaitPickerSnapshot(t, sends)
495- // A refresh that observes a real row change publishes a newer revision: the
496- // client may only commit the model it is displaying, so an older revision
497- // must fail even though it is not newer than the daemon's current one.
498- _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
499- require.NoError(t, err)
500- d.refreshPickerSnapshot(ac)
501- second := awaitPickerSnapshot(t, sends)
502- require.Greater(t, second.SourceRevision, first.SourceRevision)
503-
504- capability := pickerClientCapability(t, ac, sess)
505- key := firstSelectableKey(t, second, protocol.PickerCanNavigate)
506- for _, revision := range []uint64{second.SourceRevision - 1, second.SourceRevision + 1} {
507- selection := navigateSelection(second, key)
508- selection.SourceRevision = revision
509- require.False(t, d.handleAttachmentClientMessage(capability, selection))
510- failure := awaitPickerFailure(t, sends)
511- require.Equal(t, protocol.PickerStaleRevision, failure.Code, "revision %d must be rejected", revision)
512- ac.overlays.pickerMu.Lock()
513- require.True(t, ac.overlays.pickerOpen, "a rejected revision must not close the interaction")
514- ac.overlays.pickerMu.Unlock()
515- }
516-}
517-
518-func TestPickerInteractionDropsRawInput(t *testing.T) {
519- writes := make(chan []byte, 8)
520- p, releasePTY := newBlockingPTYWithWrites(t, writes)
521- d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY)
522- capability := pickerClientCapability(t, ac, sess)
523-
524- // A normal attachment forwards keys to the session.
525- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: []byte("x")}))
526- require.Equal(t, []byte("x"), awaitTestValue(t, writes, "session input never reached the pty"))
527-
528- // Positive control: with the pane reporting mouse mode, a mouse report
529- // reaches the pane's pty. Without this control the negative assertion
530- // below would pass even if mouse routing were broken outright.
531- tb := testAttachmentTab(sess)
532- require.NotNil(t, tb)
533- pane := tb.panes["pane-1"]
534- require.NotNil(t, pane)
535- pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
536- mouseReport := []byte(fmt.Sprintf("\x1b[<0;2;%dM", clientTopBarRows+2))
537- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: mouseReport}))
538- require.NotEmpty(t, awaitTestValue(t, writes, "positive control: mouse never reached the pane"))
539-
540- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
541- snapshot := awaitPickerSnapshot(t, sends)
542-
543- // While the picker is open it owns user input: keys and mouse reports
544- // must not reach the session, and the interaction stays open.
545- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 2, Data: []byte("hidden")}))
546- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 3, Data: mouseReport}))
547- select {
548- case frame := <-writes:
549- t.Fatalf("picker-owned input reached the pty: %q", frame)
550- case <-time.After(100 * time.Millisecond):
551- }
552-
553- // Closing the interaction restores normal routing, mouse included.
554- require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
555- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 4, ActionID: 4, Data: []byte("visible")}))
556- require.Equal(t, []byte("visible"), awaitTestValue(t, writes, "session input did not resume after the close"))
557- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 5, ActionID: 5, Data: mouseReport}))
558- require.NotEmpty(t, awaitTestValue(t, writes, "mouse routing did not resume after the close"))
559-}
560-
561-func TestPickerSelectionClosesBeforeTheDestinationPaint(t *testing.T) {
562- d, sess, ac, sends, effect := pickerClientTestUnit(t)
563-
564- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
565- snapshot := awaitPickerSnapshot(t, sends)
566- capability := pickerClientCapability(t, ac, sess)
567-
568- // Selecting the current session is a same-peer handoff. The interaction
569- // must be retired before that handoff: the client releases only on a
570- // paint accepted after the daemon's close, so the destination paint has
571- // to follow it on the wire. Drain the open's own repaint first.
572- drainAllFrames(sends)
573- key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
574- require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, key)))
575-
576- // Collect the handoff frames for a short settle window, then require the
577- // close to precede any paint.
578- var order []string
579- for settled := false; !settled; {
580- select {
581- case frame := <-sends:
582- order = append(order, envelopeMessageName(t, frame.Payload))
583- case <-time.After(200 * time.Millisecond):
584- settled = true
585- }
586- }
587- require.NotEmpty(t, order, "the handoff published nothing")
588- require.Equal(t, "PickerClosed", order[0], "the close must precede the handoff")
589- for i, frameType := range order {
590- if frameType == "Output" {
591- require.NotZero(t, i, "a paint must not precede the close")
592- }
593- }
594- ac.overlays.pickerMu.Lock()
595- require.False(t, ac.overlays.pickerOpen)
596- ac.overlays.pickerMu.Unlock()
597-}
598-
599-func TestPickerInteractionDropsClipboardImage(t *testing.T) {
600- writes := make(chan []byte, 8)
601- p, releasePTY := newBlockingPTYWithWrites(t, writes)
602- d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY)
603- capability := pickerClientCapability(t, ac, sess)
604-
605- temp := t.TempDir()
606- t.Setenv("TMPDIR", temp)
607- push := protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte("\x89PNG\r\n\x1a\n")}
608-
609- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
610- snapshot := awaitPickerSnapshot(t, sends)
611-
612- // An image push is user input too: the clipboard path must not reach the
613- // session while the picker owns input.
614- require.False(t, d.handleAttachmentClientMessage(capability, push))
615- select {
616- case frame := <-writes:
617- t.Fatalf("clipboard path reached the pty during the interaction: %q", frame)
618- case <-time.After(100 * time.Millisecond):
619- }
620-
621- // After the close the same push is delivered again.
622- require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
623- push.InputSeq = 2
624- require.False(t, d.handleAttachmentClientMessage(capability, push))
625- select {
626- case frame := <-writes:
627- require.Contains(t, string(frame), ".png")
628- case <-time.After(2 * time.Second):
629- t.Fatal("clipboard path did not resume after the close")
630- }
631-}
632-
633-func TestPickerSnapshotRefreshThroughDirectoryNotification(t *testing.T) {
634- d, _, ac, sends, effect := pickerClientTestUnit(t)
635-
636- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
637- first := awaitPickerSnapshot(t, sends)
638-
639- // The row set changes for real: a new session appears in the catalogue.
640- _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
641- require.NoError(t, err)
642-
643- // The catalogue notification path must reach the picker even though the
644- // daemon installs no overlay model: refreshRemoteDirectoryViews is the
645- // entry the directory subscription calls.
646- d.refreshRemoteDirectoryViews()
647- second := awaitPickerSnapshot(t, sends)
648- require.Equal(t, first.InteractionID, second.InteractionID)
649- require.Greater(t, second.SourceRevision, first.SourceRevision)
650- require.Greater(t, len(second.Lines), len(first.Lines), "the new session must appear as a line")
651-}
652-
653-// TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections pins that the
654-// serving daemon publishes the actual Recent and Grouped projections instead of
655-// one line set copied into both. Recent carries the flat recency order the
656-// default client sort consumes; Grouped carries the sectioned order the grouped
657-// sort consumes. Both stay one authority: the same keys admit the same actions.
658-func TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections(t *testing.T) {
659- d, _, ac, sends, effect := pickerClientTestUnit(t)
660- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
661- snapshot := awaitPickerSnapshot(t, sends)
662-
663- recentSections, groupedSections := 0, 0
664- recentActions := make(map[string]protocol.PickerLineActions, len(snapshot.Recent.Lines))
665- groupedActions := make(map[string]protocol.PickerLineActions, len(snapshot.Grouped.Lines))
666- for _, line := range snapshot.Recent.Lines {
667- if line.Kind == protocol.PickerLineSection {
668- recentSections++
669- }
670- if line.Key != "" {
671- recentActions[line.Key] = line.Actions
672- }
673- }
674- for _, line := range snapshot.Grouped.Lines {
675- if line.Kind == protocol.PickerLineSection {
676- groupedSections++
677- }
678- if line.Key != "" {
679- groupedActions[line.Key] = line.Actions
680- }
681- }
682-
683- // The sort inputs differ: the grouped order is sectioned so its run
684- // boundaries reach the grouped sort, and the recent order stays flat so the
685- // recency sort never inherits grouping it did not ask for.
686- require.Zero(t, recentSections, "recent projection published grouped section runs")
687- require.NotZero(t, groupedSections, "grouped projection published no section runs")
688- require.NotEqual(t, snapshot.Recent.Lines, snapshot.Grouped.Lines, "both projections carry the same sort input")
689- require.Equal(t, recentActions, groupedActions, "projections disagree on destination actions")
690-}
691-
692-// TestPickerRefreshPublishesOnlyChangedSource pins the revision contract: a
693-// repeated publish of an unchanged projection pair neither bumps the source
694-// revision nor reaches the client, while a real row-set change republishes a
695-// full snapshot with a newer revision.
696-func TestPickerRefreshPublishesOnlyChangedSource(t *testing.T) {
697- d, _, ac, sends, effect := pickerClientTestUnit(t)
698-
699- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
700- first := awaitPickerSnapshot(t, sends)
701- require.Equal(t, uint64(1), first.SourceRevision)
702- _ = drainAllFrames(sends)
703-
704- // The rows did not change, so the revision the client is displaying stays
705- // current: a refresh must publish nothing and send nothing.
706- d.refreshPickerSnapshot(ac)
707- d.refreshPickerSnapshot(ac)
708- for _, frame := range drainAllFrames(sends) {
709- require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "unchanged source republished a snapshot")
710- }
711- ac.overlays.pickerMu.Lock()
712- require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "unchanged source bumped the revision")
713- ac.overlays.pickerMu.Unlock()
714-
715- // A real row-set change republishes a full snapshot with a newer revision
716- // through the current attachment capability.
717- _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
718- require.NoError(t, err)
719- d.refreshPickerSnapshot(ac)
720- second := awaitPickerSnapshot(t, sends)
721- require.Equal(t, first.InteractionID, second.InteractionID)
722- require.Greater(t, second.SourceRevision, first.SourceRevision)
723- require.Greater(t, len(second.Lines), len(first.Lines))
724-
725- // A closed interaction republishes nothing: only the close confirmation
726- // and the authoritative repaint may follow.
727- effect = admitPickerEffectForTest(t, testAttachmentSession(t, ac), ac)
728- require.True(t, d.closePickerForAttachment(ac, effect, first.InteractionID))
729- for _, frame := range drainAllFrames(sends) {
730- require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "closed interaction republished a snapshot")
731- }
732-}
733-
734-// pickerSnapshotFailTransport delegates to the fixture's capturing transport
735-// but rejects the next authoritative snapshot, modelling a link that drops one
736-// publication while the daemon still owns it.
737-type pickerSnapshotFailTransport struct {
738- *mockServerConnection
739- failNext atomic.Bool
740-}
741-
742-func (t *pickerSnapshotFailTransport) SendServer(message protocol.ServerMessage) error {
743- if _, ok := message.(protocol.PickerSnapshot); ok && t.failNext.CompareAndSwap(true, false) {
744- return errPickerSnapshotSend
745- }
746- return t.mockServerConnection.SendServer(message)
747-}
748-
749-var errPickerSnapshotSend = errors.New("picker snapshot send failed")
750-
751-// TestPickerSnapshotRetryAfterFailedSend pins the publication commit point: a
752-// snapshot whose send fails must not advance the recorded revision or replace
753-// the keys the client is still selecting against, and the identical retry must
754-// republish instead of being suppressed as unchanged.
755-func TestPickerSnapshotRetryAfterFailedSend(t *testing.T) {
756- d, sess, ac, sends, effect := pickerClientTestUnit(t)
757-
758- offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
759- first := awaitPickerSnapshot(t, sends)
760- require.Equal(t, uint64(1), first.SourceRevision)
761-
762- // Swap in a link whose next snapshot send fails, then install it as the
763- // attachment authority so publication effects are admitted against it.
764- captured, ok := ac.transport().(*mockServerConnection)
765- require.True(t, ok, "fixture transport is not the capturing mock")
766- failTransport := &pickerSnapshotFailTransport{mockServerConnection: captured}
767- failTransport.failNext.Store(true)
768- ac.replaceTransport(failTransport)
769- ac.installTestAttachmentCapability(captureAttachmentCapability(sess, ac, ac.transport()))
770-
771- // A real row-set change is attempted while the send fails.
772- _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
773- require.NoError(t, err)
774- failedEffect := admitPickerEffectForTest(t, sess, ac)
775- failErr := d.publishPickerSourceForAttachment(ac, failedEffect, offer.InteractionID)
776- require.ErrorIs(t, failErr, errPickerSnapshotSend)
777- failedEffect.End()
778-
779- // The failed publication committed nothing: the revision the client is
780- // displaying still validates, and its keys still resolve.
781- ac.overlays.pickerMu.Lock()
782- require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "failed send advanced the revision")
783- require.True(t, ac.overlays.pickerSourcePublished, "failed send dropped the last published source")
784- displayedKeys := ac.overlays.pickerKeys
785- ac.overlays.pickerMu.Unlock()
786- for _, line := range first.Lines {
787- if line.Actions != 0 {
788- require.Contains(t, displayedKeys, line.Key, "failed send invalidated a displayed key")
789- }
790- }
791-
792- // The identical retry must reach the client with the next revision, and the
793- // committed keys must resolve every row of that snapshot.
794- failTransport.failNext.Store(false)
795- retryEffect := admitPickerEffectForTest(t, sess, ac)
796- require.NoError(t, d.publishPickerSourceForAttachment(ac, retryEffect, offer.InteractionID))
797- second := awaitPickerSnapshot(t, sends)
798- require.Equal(t, offer.InteractionID, second.InteractionID)
799- require.Equal(t, first.SourceRevision+1, second.SourceRevision)
800-
801- ac.overlays.pickerMu.Lock()
802- require.Equal(t, second.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "retry revision and client revision diverged")
803- require.Equal(t, second.Recent, ac.overlays.pickerRecent)
804- require.Equal(t, second.Grouped, ac.overlays.pickerGrouped)
805- retryKeys := ac.overlays.pickerKeys
806- ac.overlays.pickerMu.Unlock()
807- for _, line := range second.Lines {
808- if line.Actions != 0 {
809- require.Contains(t, retryKeys, line.Key, "retry committed no key for a displayed row")
810- }
811- }
812-
813- // A further identical publish is genuinely unchanged and must stay silent.
814- quietEffect := admitPickerEffectForTest(t, sess, ac)
815- require.NoError(t, d.publishPickerSourceForAttachment(ac, quietEffect, offer.InteractionID))
816- for _, frame := range drainAllFrames(sends) {
817- require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "committed retry republished an unchanged source")
818- }
819-}
@@ -1,819 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "strings"
8- "sync/atomic"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/protocol/wire"
16- "github.com/bnema/vev/internal/usecase/ui"
17- "github.com/stretchr/testify/require"
18-)
19-
20-// pickerClientTestUnit builds a fixture attachment and one extra session so
21-// snapshots are non-empty. It returns an admitted effect for the guarded
22-// sends.
23-func pickerClientTestUnit(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Envelope, *attachmentEffect) {
24- t.Helper()
25- p, releasePTY := newBlockingPTY(t)
26- return pickerClientTestUnitWithPTY(t, p, releasePTY)
27-}
28-
29-// pickerClientTestUnitWithPTY builds the same fixture over a caller-owned
30-// PTY, so tests can observe whether session input reached the child.
31-func pickerClientTestUnitWithPTY(t *testing.T, p ports.PTY, releasePTY func()) (*Daemon, *session, *attachedClient, chan wire.Envelope, *attachmentEffect) {
32- t.Helper()
33- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
34- t.Cleanup(releasePTY)
35- d.ptys = newFactorySeq(t, newQuietPTY())
36- _, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
37- require.NoError(t, err)
38- effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
39- require.True(t, ok)
40- t.Cleanup(effect.End)
41- return d, sess, ac, sends, effect
42-}
43-
44-func openTestPicker(t *testing.T, d *Daemon, ac *attachedClient, effect *attachmentEffect, sends chan wire.Envelope, intent protocol.PickerIntent) protocol.PickerOffer {
45- t.Helper()
46- require.NoError(t, d.openPickerForAttachment(ac, effect, intent, moveSourceLocator{}, 0))
47- offer := awaitPickerOffer(t, sends)
48- // The interaction namespace lives on the attachment, so the effect that
49- // carried the offer is released here: a later transition or move must be
50- // able to drain every other effect on this attachment.
51- effect.End()
52- return offer
53-}
54-
55-func awaitPickerOffer(t *testing.T, sends chan wire.Envelope) protocol.PickerOffer {
56- t.Helper()
57- for {
58- frame := awaitTestValue(t, sends, "picker offer was not published")
59- if offer, ok := decodeServerMessage(t, frame).(protocol.PickerOffer); ok {
60- return offer
61- }
62- }
63-}
64-
65-func awaitPickerSnapshot(t *testing.T, sends chan wire.Envelope) protocol.PickerSnapshot {
66- t.Helper()
67- for {
68- frame := awaitTestValue(t, sends, "picker snapshot was not published")
69- if snapshot, ok := decodeServerMessage(t, frame).(protocol.PickerSnapshot); ok {
70- return snapshot
71- }
72- }
73-}
74-
75-func awaitPickerFailure(t *testing.T, sends chan wire.Envelope) protocol.PickerFailure {
76- t.Helper()
77- for {
78- frame := awaitTestValue(t, sends, "picker failure was not published")
79- if failure, ok := decodeServerMessage(t, frame).(protocol.PickerFailure); ok {
80- return failure
81- }
82- }
83-}
84-
85-func pickerClientCapability(t *testing.T, ac *attachedClient, sess *session) attachmentCapability {
86- t.Helper()
87- return captureAttachmentCapability(sess, ac, ac.transport())
88-}
89-
90-// navigateSelection builds one typed commit for the displayed snapshot.
91-func navigateSelection(snapshot protocol.PickerSnapshot, key string) protocol.PickerSelection {
92- return protocol.PickerSelection{
93- InteractionID: snapshot.InteractionID, SourceID: servingPickerSourceID,
94- SourceRevision: snapshot.SourceRevision, Key: key, Action: protocol.PickerActionNavigate,
95- }
96-}
97-
98-// firstSelectableKey returns the first line the source authorised for the
99-// requested action.
100-func firstSelectableKey(t *testing.T, snapshot protocol.PickerSnapshot, action protocol.PickerLineActions) string {
101- t.Helper()
102- for _, line := range snapshot.Lines {
103- if line.Actions&action != 0 {
104- return line.Key
105- }
106- }
107- t.Fatalf("snapshot published no line admitting %d", action)
108- return ""
109-}
110-
111-func TestPickerOfferAndSnapshotPublishResolvableLines(t *testing.T) {
112- d, _, ac, sends, effect := pickerClientTestUnit(t)
113-
114- offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
115- snapshot := awaitPickerSnapshot(t, sends)
116-
117- require.NotZero(t, offer.InteractionID)
118- require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
119- require.Equal(t, offer.InteractionID, snapshot.InteractionID)
120- require.Equal(t, servingPickerSourceID, snapshot.SourceID)
121- require.Equal(t, uint64(1), snapshot.SourceRevision)
122- require.NotEmpty(t, snapshot.Lines)
123-
124- // Every selectable line resolves through the published keys, and the keys
125- // carry session names for the unchanged handoff.
126- ac.overlays.pickerMu.Lock()
127- keys := ac.overlays.pickerKeys
128- ac.overlays.pickerMu.Unlock()
129- selectable := 0
130- for _, line := range snapshot.Lines {
131- if line.Actions == 0 {
132- continue
133- }
134- selectable++
135- target, ok := keys[line.Key]
136- require.True(t, ok, "line %q has no resolved target", line.Key)
137- require.NotEmpty(t, target.Name, "resolved target carries no session name")
138- }
139- require.NotZero(t, selectable)
140-}
141-
142-func TestPickerSnapshotPublishesFocusEligibility(t *testing.T) {
143- d, _, ac, sends, effect := pickerClientTestUnit(t)
144- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
145- snapshot := awaitPickerSnapshot(t, sends)
146-
147- headers, tabs := 0, 0
148- for _, line := range snapshot.Lines {
149- switch line.Kind {
150- case protocol.PickerLineSection:
151- require.False(t, line.Focusable, "section %q is not a cursor destination", line.Label)
152- require.Zero(t, line.Actions, "section %q admits no action", line.Label)
153- case protocol.PickerLineSession:
154- headers++
155- // A live session header names a group of tabs: navigation belongs
156- // to its tab rows, so the header is rendered and skipped.
157- require.False(t, line.Focusable, "session header %q is not a destination", line.Label)
158- require.Zero(t, line.Actions)
159- case protocol.PickerLineTab:
160- tabs++
161- require.True(t, line.Focusable, "tab %q must be reachable", line.Label)
162- require.NotZero(t, line.Actions, "a reachable tab admits the action that made it reachable")
163- }
164- }
165- require.NotZero(t, headers)
166- require.NotZero(t, tabs)
167-
168- // The published cursor hint never names a row the client may not rest on.
169- require.NotEmpty(t, snapshot.Cursor.Key)
170- for _, line := range snapshot.Lines {
171- if line.Key == snapshot.Cursor.Key {
172- require.True(t, line.Focusable)
173- }
174- }
175-}
176-
177-func TestPickerOpenInstallsNoPresentationModel(t *testing.T) {
178- d, _, ac, sends, effect := pickerClientTestUnit(t)
179-
180- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
181- _ = awaitPickerSnapshot(t, sends)
182-
183- // The daemon publishes data and actions only: no model, no cursor, no
184- // search state of its own.
185- ac.overlays.pickerMu.Lock()
186- require.True(t, ac.overlays.pickerOpen)
187- require.NotZero(t, ac.overlays.pickerInteraction)
188- require.NotEmpty(t, ac.overlays.pickerKeys)
189- ac.overlays.pickerMu.Unlock()
190-}
191-
192-func TestPickerSelectionRejectsStaleAndUnknown(t *testing.T) {
193- d, sess, ac, sends, effect := pickerClientTestUnit(t)
194-
195- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
196- snapshot := awaitPickerSnapshot(t, sends)
197-
198- capability := pickerClientCapability(t, ac, sess)
199- // Unknown key on the open interaction reports UnknownKey without
200- // closing the interaction.
201- require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, "ff00/ghost")))
202- failure := awaitPickerFailure(t, sends)
203- require.Equal(t, protocol.PickerUnknownKey, failure.Code)
204- require.Equal(t, snapshot.InteractionID, failure.InteractionID)
205-
206- // A foreign source is rejected before any key lookup.
207- foreign := navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate))
208- foreign.SourceID = "elsewhere"
209- require.False(t, d.handleAttachmentClientMessage(capability, foreign))
210- require.Equal(t, protocol.PickerUnknownSource, awaitPickerFailure(t, sends).Code)
211-
212- // A retired interaction reports StaleRevision.
213- d.closePickerForAttachment(ac, effect, snapshot.InteractionID)
214- require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate))))
215- require.Equal(t, protocol.PickerStaleRevision, awaitPickerFailure(t, sends).Code)
216-}
217-
218-func TestPickerMoveIntentPublishesMoveLinesOnly(t *testing.T) {
219- d, sess, ac, sends, effect := pickerClientTestUnit(t)
220- source := moveSourceLocator{
221- Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
222- TabID: domain.TabStableID(testAttachmentTab(sess).stableID),
223- }
224- ac.overlays.pickerMu.Lock()
225- require.False(t, ac.overlays.pickerOpen)
226- ac.overlays.pickerMu.Unlock()
227-
228- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMoveTab, source, 0))
229- offer := awaitPickerOffer(t, sends)
230- snapshot := awaitPickerSnapshot(t, sends)
231- require.Equal(t, protocol.PickerIntentMoveTab, offer.Intent)
232-
233- // The source session never appears as its own destination, and rows that
234- // cannot accept a move carry no action.
235- for _, line := range snapshot.Lines {
236- require.NotEqual(t, sourceMoveKey(source), line.Key)
237- if line.Actions != 0 {
238- require.Equal(t, protocol.PickerCanMove, line.Actions)
239- }
240- }
241-}
242-
243-func sourceMoveKey(source moveSourceLocator) string {
244- return pickerMoveSourceKey(source)
245-}
246-
247-func TestPickerOpenRepaintsWithoutSessionChange(t *testing.T) {
248- d, _, ac, sends, effect := pickerClientTestUnit(t)
249-
250- // The open invalidates the session even when nothing else changed:
251- // without this repaint the opener's fence would never retire and
252- // the palette Enter would hang until its deadline.
253- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
254- _ = awaitPickerSnapshot(t, sends)
255- select {
256- case frame := <-sends:
257- require.Equal(t, "Output", envelopeMessageName(t, frame.Payload), "open must trigger an authoritative repaint")
258- default:
259- t.Fatal("open produced no repaint after the snapshot")
260- }
261-}
262-
263-func TestPickerSessionPickerCommandOpensInteraction(t *testing.T) {
264- p, release := newBlockingPTY(t)
265- defer release()
266- d, current, ac, sends := newManualSessionWithPTYs(t, p)
267- effect := beginRecentRoutePaletteEffect(t, d, current, ac)
268- effect.uiActionID = 77
269-
270- // Full path through overlay dispatch: palette query, then Enter on
271- // the same effect. The Enter routes through handlePaletteInput via
272- // the overlay runtime, exactly like a real client frame.
273- d.enterPalette(current, ac)
274- d.handlePaletteInput(ac, []byte("sessionpicker"), effect)
275- require.NotNil(t, ac.overlays.palette)
276- d.handlePaletteInput(ac, []byte("\r"), effect)
277-
278- offer := awaitPickerOffer(t, sends)
279- snapshot := awaitPickerSnapshot(t, sends)
280- require.NotZero(t, offer.InteractionID)
281- require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
282- require.NotEmpty(t, snapshot.Lines)
283- ac.overlays.pickerMu.Lock()
284- require.True(t, ac.overlays.pickerOpen)
285- ac.overlays.pickerMu.Unlock()
286-}
287-
288-func TestPickerCancelPublishesClosedAndAuthoritativeFullPaint(t *testing.T) {
289- d, sess, ac, sends, effect := pickerClientTestUnit(t)
290-
291- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
292- snapshot := awaitPickerSnapshot(t, sends)
293- // The open publishes its own restore paint after the snapshot; the release
294- // assertion below is about the paint that follows the close.
295- drainAllFrames(sends)
296-
297- // The client cancels: the daemon retires the interaction, confirms the
298- // close with its restore barrier, and publishes the authoritative full
299- // paint the client's release path waits for.
300- capability := pickerClientCapability(t, ac, sess)
301- require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
302- ac.overlays.pickerMu.Lock()
303- require.False(t, ac.overlays.pickerOpen)
304- ac.overlays.pickerMu.Unlock()
305-
306- var sawClosed bool
307- for settled := false; !settled; {
308- select {
309- case frame := <-sends:
310- if closed, ok := decodeServerMessage(t, frame).(protocol.PickerClosed); ok {
311- require.Equal(t, snapshot.InteractionID, closed.InteractionID)
312- sawClosed = true
313- continue
314- }
315- if output, ok := decodeServerMessage(t, frame).(protocol.Output); ok {
316- require.True(t, sawClosed, "the restore paint must follow the close")
317- require.True(t, output.Full, "cancel release paint must be authoritative")
318- settled = true
319- }
320- case <-time.After(200 * time.Millisecond):
321- settled = true
322- }
323- }
324- require.True(t, sawClosed, "cancel published no close confirmation")
325-}
326-
327-func TestPickerPaletteFramesOpenInteraction(t *testing.T) {
328- p, release := newBlockingPTY(t)
329- defer release()
330- d, _, ac, sends := newManualSessionWithPTYs(t, p)
331- capability := pickerClientCapability(t, ac, testAttachmentSession(t, ac))
332-
333- // Drive the real attached-client frame path instead of calling the
334- // palette handler: Alt+Space opens the palette, the query selects the
335- // session-picker command, and Enter executes it.
336- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 4, Data: []byte("\x1b ")}))
337- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 5, Data: []byte("SSP")}))
338- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 6, Data: []byte("\r")}))
339-
340- _ = awaitPickerOffer(t, sends)
341- snapshot := awaitPickerSnapshot(t, sends)
342- require.NotZero(t, snapshot.InteractionID)
343- require.NotEmpty(t, snapshot.Lines)
344- ac.overlays.pickerMu.Lock()
345- require.True(t, ac.overlays.pickerOpen)
346- ac.overlays.pickerMu.Unlock()
347-}
348-
349-func testAttachmentSession(t *testing.T, ac *attachedClient) *session {
350- t.Helper()
351- sess := ac.currentAttachmentSession()
352- require.NotNil(t, sess)
353- return sess
354-}
355-
356-// secondSessionForTest returns the fixture's extra session, the one the attach
357-// itself does not display.
358-func secondSessionForTest(t *testing.T, d *Daemon) *session {
359- t.Helper()
360- d.mu.Lock()
361- defer d.mu.Unlock()
362- for _, sess := range d.sessions {
363- if sess == nil {
364- continue
365- }
366- sess.mu.Lock()
367- name := sess.name
368- sess.mu.Unlock()
369- if name == "second" {
370- return sess
371- }
372- }
373- t.Fatal("fixture second session missing")
374- return nil
375-}
376-
377-// pickerBellLines reports the session headers carrying the bell glyph and the
378-// tab rows carrying the structured attention marker.
379-func pickerBellLines(snapshot protocol.PickerSnapshot) (headers int, tabRows int) {
380- bell := string(ui.AttentionGlyph)
381- for _, line := range snapshot.Lines {
382- switch line.Kind {
383- case protocol.PickerLineSession:
384- if strings.HasSuffix(line.Label, " "+bell) {
385- headers++
386- }
387- case protocol.PickerLineTab:
388- if line.Attention {
389- tabRows++
390- }
391- }
392- }
393- return headers, tabRows
394-}
395-
396-// TestPickerSnapshotCarriesAttentionBellsToTheClient pins the notification path
397-// into the client-owned modal: the daemon publishes the bell on the ringing
398-// session's header and the structured marker on its tab row, and the client
399-// renders both.
400-func TestPickerSnapshotCarriesAttentionBellsToTheClient(t *testing.T) {
401- d, _, ac, sends, effect := pickerClientTestUnit(t)
402- ringing := secondSessionForTest(t, d)
403- ringing.mu.Lock()
404- ringing.tabs[0].attention = true
405- ringing.tabs[0].attentionAt = time.Unix(1, 0)
406- ringing.mu.Unlock()
407-
408- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
409- snapshot := awaitPickerSnapshot(t, sends)
410-
411- headers, tabRows := pickerBellLines(snapshot)
412- require.Equal(t, 1, headers, "the ringing session header must carry the bell glyph")
413- require.Equal(t, 1, tabRows, "the ringing tab row must carry the structured attention marker")
414-}
415-
416-// TestAttentionPulseRepublishesTheOpenPickerSource pins that a bell raised
417-// while the client presents the modal reaches it: client-owned presentation
418-// suppresses the daemon's paints, so the pulse must advance the picker source
419-// the client presents from. A closed interaction republishes nothing.
420-func TestAttentionPulseRepublishesTheOpenPickerSource(t *testing.T) {
421- d, sess, ac, sends, releases := newManualTabSession(t, 2)
422- defer releases[0]()
423- defer releases[1]()
424- clk := newManualAttentionClock()
425- d.clock = clk
426- require.True(t, selectTestAttachmentTab(sess, 0))
427-
428- effect := admitPickerEffectForTest(t, sess, ac)
429- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
430- require.Equal(t, protocol.PickerIntentNavigation, awaitPickerOffer(t, sends).Intent)
431- first := awaitPickerSnapshot(t, sends)
432- require.Equal(t, 0, pickerBellCount(first), "a quiet session must publish no bell")
433-
434- ctx, cancel := context.WithCancel(context.Background())
435- defer cancel()
436- done := make(chan struct{})
437- go func() {
438- d.attentionAnimator(ctx)
439- close(done)
440- }()
441-
442- // A background tab rings while the modal is open. The client owns
443- // presentation, so only a newer source revision can update its rows.
444- sess.mu.Lock()
445- sess.tabs[1].attention = true
446- sess.tabs[1].attentionAt = time.Unix(1, 0)
447- sess.mu.Unlock()
448- d.pokeAttentionTicker()
449-
450- timer := clk.nextTimer(t)
451- timer.fire()
452- second := awaitPickerSnapshot(t, sends)
453- require.Greater(t, second.SourceRevision, first.SourceRevision)
454- headers, tabRows := pickerBellLines(second)
455- require.Equal(t, 1, headers, "the pulse must publish the new bell on the session header")
456- require.Equal(t, 1, tabRows, "the pulse must publish the new bell on the tab row")
457-
458- // A retired interaction must not be reopened by a later pulse. The
459- // animator keeps one timer while attention exists, so the same fired timer
460- // carries the next pulse.
461- retireEffect := admitPickerEffectForTest(t, sess, ac)
462- require.True(t, d.closePickerForAttachment(ac, retireEffect, second.InteractionID))
463- _ = drainAllFrames(sends)
464- sess.mu.Lock()
465- sess.tabs[0].attention = true
466- sess.tabs[0].attentionAt = time.Unix(2, 0)
467- sess.mu.Unlock()
468- d.pokeAttentionTicker()
469- timer.fire()
470- for _, frame := range drainAllFrames(sends) {
471- require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "a closed interaction republished a snapshot")
472- }
473-
474- cancel()
475- require.Eventually(t, func() bool {
476- select {
477- case <-done:
478- return true
479- default:
480- return false
481- }
482- }, time.Second, time.Millisecond)
483-}
484-
485-func pickerBellCount(snapshot protocol.PickerSnapshot) int {
486- headers, tabRows := pickerBellLines(snapshot)
487- return headers + tabRows
488-}
489-
490-func TestPickerSelectionRequiresExactSourceRevision(t *testing.T) {
491- d, sess, ac, sends, effect := pickerClientTestUnit(t)
492-
493- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
494- first := awaitPickerSnapshot(t, sends)
495- // A refresh that observes a real row change publishes a newer revision: the
496- // client may only commit the model it is displaying, so an older revision
497- // must fail even though it is not newer than the daemon's current one.
498- _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
499- require.NoError(t, err)
500- d.refreshPickerSnapshot(ac)
501- second := awaitPickerSnapshot(t, sends)
502- require.Greater(t, second.SourceRevision, first.SourceRevision)
503-
504- capability := pickerClientCapability(t, ac, sess)
505- key := firstSelectableKey(t, second, protocol.PickerCanNavigate)
506- for _, revision := range []uint64{second.SourceRevision - 1, second.SourceRevision + 1} {
507- selection := navigateSelection(second, key)
508- selection.SourceRevision = revision
509- require.False(t, d.handleAttachmentClientMessage(capability, selection))
510- failure := awaitPickerFailure(t, sends)
511- require.Equal(t, protocol.PickerStaleRevision, failure.Code, "revision %d must be rejected", revision)
512- ac.overlays.pickerMu.Lock()
513- require.True(t, ac.overlays.pickerOpen, "a rejected revision must not close the interaction")
514- ac.overlays.pickerMu.Unlock()
515- }
516-}
517-
518-func TestPickerInteractionDropsRawInput(t *testing.T) {
519- writes := make(chan []byte, 8)
520- p, releasePTY := newBlockingPTYWithWrites(t, writes)
521- d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY)
522- capability := pickerClientCapability(t, ac, sess)
523-
524- // A normal attachment forwards keys to the session.
525- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: []byte("x")}))
526- require.Equal(t, []byte("x"), awaitTestValue(t, writes, "session input never reached the pty"))
527-
528- // Positive control: with the pane reporting mouse mode, a mouse report
529- // reaches the pane's pty. Without this control the negative assertion
530- // below would pass even if mouse routing were broken outright.
531- tb := testAttachmentTab(sess)
532- require.NotNil(t, tb)
533- pane := tb.panes["pane-1"]
534- require.NotNil(t, pane)
535- pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
536- mouseReport := []byte(fmt.Sprintf("\x1b[<0;2;%dM", clientTopBarRows+2))
537- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: mouseReport}))
538- require.NotEmpty(t, awaitTestValue(t, writes, "positive control: mouse never reached the pane"))
539-
540- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
541- snapshot := awaitPickerSnapshot(t, sends)
542-
543- // While the picker is open it owns user input: keys and mouse reports
544- // must not reach the session, and the interaction stays open.
545- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 2, Data: []byte("hidden")}))
546- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 3, Data: mouseReport}))
547- select {
548- case frame := <-writes:
549- t.Fatalf("picker-owned input reached the pty: %q", frame)
550- case <-time.After(100 * time.Millisecond):
551- }
552-
553- // Closing the interaction restores normal routing, mouse included.
554- require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
555- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 4, ActionID: 4, Data: []byte("visible")}))
556- require.Equal(t, []byte("visible"), awaitTestValue(t, writes, "session input did not resume after the close"))
557- require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 5, ActionID: 5, Data: mouseReport}))
558- require.NotEmpty(t, awaitTestValue(t, writes, "mouse routing did not resume after the close"))
559-}
560-
561-func TestPickerSelectionClosesBeforeTheDestinationPaint(t *testing.T) {
562- d, sess, ac, sends, effect := pickerClientTestUnit(t)
563-
564- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
565- snapshot := awaitPickerSnapshot(t, sends)
566- capability := pickerClientCapability(t, ac, sess)
567-
568- // Selecting the current session is a same-peer handoff. The interaction
569- // must be retired before that handoff: the client releases only on a
570- // paint accepted after the daemon's close, so the destination paint has
571- // to follow it on the wire. Drain the open's own repaint first.
572- drainAllFrames(sends)
573- key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
574- require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, key)))
575-
576- // Collect the handoff frames for a short settle window, then require the
577- // close to precede any paint.
578- var order []string
579- for settled := false; !settled; {
580- select {
581- case frame := <-sends:
582- order = append(order, envelopeMessageName(t, frame.Payload))
583- case <-time.After(200 * time.Millisecond):
584- settled = true
585- }
586- }
587- require.NotEmpty(t, order, "the handoff published nothing")
588- require.Equal(t, "PickerClosed", order[0], "the close must precede the handoff")
589- for i, frameType := range order {
590- if frameType == "Output" {
591- require.NotZero(t, i, "a paint must not precede the close")
592- }
593- }
594- ac.overlays.pickerMu.Lock()
595- require.False(t, ac.overlays.pickerOpen)
596- ac.overlays.pickerMu.Unlock()
597-}
598-
599-func TestPickerInteractionDropsClipboardImage(t *testing.T) {
600- writes := make(chan []byte, 8)
601- p, releasePTY := newBlockingPTYWithWrites(t, writes)
602- d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY)
603- capability := pickerClientCapability(t, ac, sess)
604-
605- temp := t.TempDir()
606- t.Setenv("TMPDIR", temp)
607- push := protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte("\x89PNG\r\n\x1a\n")}
608-
609- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
610- snapshot := awaitPickerSnapshot(t, sends)
611-
612- // An image push is user input too: the clipboard path must not reach the
613- // session while the picker owns input.
614- require.False(t, d.handleAttachmentClientMessage(capability, push))
615- select {
616- case frame := <-writes:
617- t.Fatalf("clipboard path reached the pty during the interaction: %q", frame)
618- case <-time.After(100 * time.Millisecond):
619- }
620-
621- // After the close the same push is delivered again.
622- require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
623- push.InputSeq = 2
624- require.False(t, d.handleAttachmentClientMessage(capability, push))
625- select {
626- case frame := <-writes:
627- require.Contains(t, string(frame), ".png")
628- case <-time.After(2 * time.Second):
629- t.Fatal("clipboard path did not resume after the close")
630- }
631-}
632-
633-func TestPickerSnapshotRefreshThroughDirectoryNotification(t *testing.T) {
634- d, _, ac, sends, effect := pickerClientTestUnit(t)
635-
636- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
637- first := awaitPickerSnapshot(t, sends)
638-
639- // The row set changes for real: a new session appears in the catalogue.
640- _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
641- require.NoError(t, err)
642-
643- // The catalogue notification path must reach the picker even though the
644- // daemon installs no overlay model: refreshRemoteDirectoryViews is the
645- // entry the directory subscription calls.
646- d.refreshRemoteDirectoryViews()
647- second := awaitPickerSnapshot(t, sends)
648- require.Equal(t, first.InteractionID, second.InteractionID)
649- require.Greater(t, second.SourceRevision, first.SourceRevision)
650- require.Greater(t, len(second.Lines), len(first.Lines), "the new session must appear as a line")
651-}
652-
653-// TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections pins that the
654-// serving daemon publishes the actual Recent and Grouped projections instead of
655-// one line set copied into both. Recent carries the flat recency order the
656-// default client sort consumes; Grouped carries the sectioned order the grouped
657-// sort consumes. Both stay one authority: the same keys admit the same actions.
658-func TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections(t *testing.T) {
659- d, _, ac, sends, effect := pickerClientTestUnit(t)
660- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
661- snapshot := awaitPickerSnapshot(t, sends)
662-
663- recentSections, groupedSections := 0, 0
664- recentActions := make(map[string]protocol.PickerLineActions, len(snapshot.Recent.Lines))
665- groupedActions := make(map[string]protocol.PickerLineActions, len(snapshot.Grouped.Lines))
666- for _, line := range snapshot.Recent.Lines {
667- if line.Kind == protocol.PickerLineSection {
668- recentSections++
669- }
670- if line.Key != "" {
671- recentActions[line.Key] = line.Actions
672- }
673- }
674- for _, line := range snapshot.Grouped.Lines {
675- if line.Kind == protocol.PickerLineSection {
676- groupedSections++
677- }
678- if line.Key != "" {
679- groupedActions[line.Key] = line.Actions
680- }
681- }
682-
683- // The sort inputs differ: the grouped order is sectioned so its run
684- // boundaries reach the grouped sort, and the recent order stays flat so the
685- // recency sort never inherits grouping it did not ask for.
686- require.Zero(t, recentSections, "recent projection published grouped section runs")
687- require.NotZero(t, groupedSections, "grouped projection published no section runs")
688- require.NotEqual(t, snapshot.Recent.Lines, snapshot.Grouped.Lines, "both projections carry the same sort input")
689- require.Equal(t, recentActions, groupedActions, "projections disagree on destination actions")
690-}
691-
692-// TestPickerRefreshPublishesOnlyChangedSource pins the revision contract: a
693-// repeated publish of an unchanged projection pair neither bumps the source
694-// revision nor reaches the client, while a real row-set change republishes a
695-// full snapshot with a newer revision.
696-func TestPickerRefreshPublishesOnlyChangedSource(t *testing.T) {
697- d, _, ac, sends, effect := pickerClientTestUnit(t)
698-
699- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
700- first := awaitPickerSnapshot(t, sends)
701- require.Equal(t, uint64(1), first.SourceRevision)
702- _ = drainAllFrames(sends)
703-
704- // The rows did not change, so the revision the client is displaying stays
705- // current: a refresh must publish nothing and send nothing.
706- d.refreshPickerSnapshot(ac)
707- d.refreshPickerSnapshot(ac)
708- for _, frame := range drainAllFrames(sends) {
709- require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "unchanged source republished a snapshot")
710- }
711- ac.overlays.pickerMu.Lock()
712- require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "unchanged source bumped the revision")
713- ac.overlays.pickerMu.Unlock()
714-
715- // A real row-set change republishes a full snapshot with a newer revision
716- // through the current attachment capability.
717- _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
718- require.NoError(t, err)
719- d.refreshPickerSnapshot(ac)
720- second := awaitPickerSnapshot(t, sends)
721- require.Equal(t, first.InteractionID, second.InteractionID)
722- require.Greater(t, second.SourceRevision, first.SourceRevision)
723- require.Greater(t, len(second.Lines), len(first.Lines))
724-
725- // A closed interaction republishes nothing: only the close confirmation
726- // and the authoritative repaint may follow.
727- effect = admitPickerEffectForTest(t, testAttachmentSession(t, ac), ac)
728- require.True(t, d.closePickerForAttachment(ac, effect, first.InteractionID))
729- for _, frame := range drainAllFrames(sends) {
730- require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "closed interaction republished a snapshot")
731- }
732-}
733-
734-// pickerSnapshotFailTransport delegates to the fixture's capturing transport
735-// but rejects the next authoritative snapshot, modelling a link that drops one
736-// publication while the daemon still owns it.
737-type pickerSnapshotFailTransport struct {
738- *mockServerConnection
739- failNext atomic.Bool
740-}
741-
742-func (t *pickerSnapshotFailTransport) SendServer(message protocol.ServerMessage) error {
743- if _, ok := message.(protocol.PickerSnapshot); ok && t.failNext.CompareAndSwap(true, false) {
744- return errPickerSnapshotSend
745- }
746- return t.mockServerConnection.SendServer(message)
747-}
748-
749-var errPickerSnapshotSend = errors.New("picker snapshot send failed")
750-
751-// TestPickerSnapshotRetryAfterFailedSend pins the publication commit point: a
752-// snapshot whose send fails must not advance the recorded revision or replace
753-// the keys the client is still selecting against, and the identical retry must
754-// republish instead of being suppressed as unchanged.
755-func TestPickerSnapshotRetryAfterFailedSend(t *testing.T) {
756- d, sess, ac, sends, effect := pickerClientTestUnit(t)
757-
758- offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
759- first := awaitPickerSnapshot(t, sends)
760- require.Equal(t, uint64(1), first.SourceRevision)
761-
762- // Swap in a link whose next snapshot send fails, then install it as the
763- // attachment authority so publication effects are admitted against it.
764- captured, ok := ac.transport().(*mockServerConnection)
765- require.True(t, ok, "fixture transport is not the capturing mock")
766- failTransport := &pickerSnapshotFailTransport{mockServerConnection: captured}
767- failTransport.failNext.Store(true)
768- ac.replaceTransport(failTransport)
769- ac.installTestAttachmentCapability(captureAttachmentCapability(sess, ac, ac.transport()))
770-
771- // A real row-set change is attempted while the send fails.
772- _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
773- require.NoError(t, err)
774- failedEffect := admitPickerEffectForTest(t, sess, ac)
775- failErr := d.publishPickerSourceForAttachment(ac, failedEffect, offer.InteractionID)
776- require.ErrorIs(t, failErr, errPickerSnapshotSend)
777- failedEffect.End()
778-
779- // The failed publication committed nothing: the revision the client is
780- // displaying still validates, and its keys still resolve.
781- ac.overlays.pickerMu.Lock()
782- require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "failed send advanced the revision")
783- require.True(t, ac.overlays.pickerSourcePublished, "failed send dropped the last published source")
784- displayedKeys := ac.overlays.pickerKeys
785- ac.overlays.pickerMu.Unlock()
786- for _, line := range first.Lines {
787- if line.Actions != 0 {
788- require.Contains(t, displayedKeys, line.Key, "failed send invalidated a displayed key")
789- }
790- }
791-
792- // The identical retry must reach the client with the next revision, and the
793- // committed keys must resolve every row of that snapshot.
794- failTransport.failNext.Store(false)
795- retryEffect := admitPickerEffectForTest(t, sess, ac)
796- require.NoError(t, d.publishPickerSourceForAttachment(ac, retryEffect, offer.InteractionID))
797- second := awaitPickerSnapshot(t, sends)
798- require.Equal(t, offer.InteractionID, second.InteractionID)
799- require.Equal(t, first.SourceRevision+1, second.SourceRevision)
800-
801- ac.overlays.pickerMu.Lock()
802- require.Equal(t, second.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "retry revision and client revision diverged")
803- require.Equal(t, second.Recent, ac.overlays.pickerRecent)
804- require.Equal(t, second.Grouped, ac.overlays.pickerGrouped)
805- retryKeys := ac.overlays.pickerKeys
806- ac.overlays.pickerMu.Unlock()
807- for _, line := range second.Lines {
808- if line.Actions != 0 {
809- require.Contains(t, retryKeys, line.Key, "retry committed no key for a displayed row")
810- }
811- }
812-
813- // A further identical publish is genuinely unchanged and must stay silent.
814- quietEffect := admitPickerEffectForTest(t, sess, ac)
815- require.NoError(t, d.publishPickerSourceForAttachment(ac, quietEffect, offer.InteractionID))
816- for _, frame := range drainAllFrames(sends) {
817- require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "committed retry republished an unchanged source")
818- }
819-}
D internal/usecase/daemon/picker_preview_test.go +0 -307
@@ -1,307 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "testing"
6- "time"
7-
8- renderer "github.com/bnema/vev-vt"
9- "github.com/stretchr/testify/require"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13- "github.com/bnema/vev/internal/protocol"
14- "github.com/bnema/vev/internal/protocol/wire"
15- "github.com/bnema/vev/internal/usecase/picker"
16-)
17-
18-func awaitPickerPreview(t *testing.T, sends chan wire.Envelope) protocol.PickerPreview {
19- t.Helper()
20- for {
21- frame := awaitTestValue(t, sends, "picker preview was not published")
22- if preview, ok := decodeServerMessage(t, frame).(protocol.PickerPreview); ok {
23- return preview
24- }
25- }
26-}
27-
28-func pickerPreviewRequestFor(snapshot protocol.PickerSnapshot, key string, width, height uint16) protocol.PickerPreviewRequest {
29- return protocol.PickerPreviewRequest{
30- Version: protocol.PickerPreviewSchemaVersion, InteractionID: snapshot.InteractionID,
31- SourceID: servingPickerSourceID, Key: key, Width: width, Height: height,
32- }
33-}
34-
35-func TestPickerPreviewRequestPublishesCapturedViewport(t *testing.T) {
36- d, sess, ac, sends, effect := pickerClientTestUnit(t)
37- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
38- snapshot := awaitPickerSnapshot(t, sends)
39- key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
40-
41- capability := pickerClientCapability(t, ac, sess)
42- request := pickerPreviewRequestFor(snapshot, key, 20, 5)
43- require.NoError(t, protocol.ValidatePickerPreviewRequest(request))
44- d.handleAttachmentClientMessage(capability, request)
45-
46- preview := awaitPickerPreview(t, sends)
47- require.Equal(t, snapshot.InteractionID, preview.InteractionID)
48- require.Equal(t, servingPickerSourceID, preview.SourceID)
49- require.Equal(t, key, preview.Key)
50- require.Equal(t, protocol.PickerPreviewOK, preview.Status, "the fixture session must capture a viewport")
51- require.Equal(t, uint16(20), preview.Width)
52- require.LessOrEqual(t, preview.Height, uint16(5))
53- require.Len(t, preview.Cells, int(preview.Width)*int(preview.Height))
54- require.NotNil(t, preview.FrameRows())
55-}
56-
57-func TestPickerPreviewUnknownKeyReportsNoSuchTarget(t *testing.T) {
58- d, sess, ac, sends, effect := pickerClientTestUnit(t)
59- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
60- snapshot := awaitPickerSnapshot(t, sends)
61-
62- capability := pickerClientCapability(t, ac, sess)
63- request := pickerPreviewRequestFor(snapshot, "zz/retired", 20, 5)
64- d.handleAttachmentClientMessage(capability, request)
65-
66- preview := awaitPickerPreview(t, sends)
67- require.Equal(t, protocol.PickerPreviewNoSuchTarget, preview.Status)
68- require.Zero(t, preview.Width)
69- require.Empty(t, preview.Cells)
70-}
71-
72-func TestPickerPreviewStaleRequestPublishesNothing(t *testing.T) {
73- d, sess, ac, sends, effect := pickerClientTestUnit(t)
74- offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
75- snapshot := awaitPickerSnapshot(t, sends)
76- key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
77- capability := pickerClientCapability(t, ac, sess)
78-
79- for range 3 {
80- request := pickerPreviewRequestFor(snapshot, key, 20, 5)
81- request.InteractionID = offer.InteractionID + 1
82- d.handleAttachmentClientMessage(capability, request)
83- }
84- for _, frame := range drainAllFrames(sends) {
85- require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), "a request for another interaction must publish nothing")
86- }
87-
88- // A superseded generation is dropped even though its request was valid.
89- live := pickerPreviewRequestFor(snapshot, key, 20, 5)
90- d.handleAttachmentClientMessage(capability, live)
91- first := awaitPickerPreview(t, sends)
92- ac.overlays.pickerMu.Lock()
93- generation := ac.overlays.pickerPreviewGeneration
94- target, known := ac.overlays.pickerKeys[live.Key]
95- ac.overlays.pickerMu.Unlock()
96- require.True(t, known)
97- d.publishPickerPreviewForAttachment(ac, live, generation-1, target, protocol.PickerIntentNavigation, known)
98- for _, frame := range drainAllFrames(sends) {
99- require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), "a superseded generation must not publish")
100- }
101- require.Equal(t, key, first.Key)
102-}
103-
104-func TestPickerPreviewRetiredInteractionStopsPublishing(t *testing.T) {
105- d, sess, ac, sends, effect := pickerClientTestUnit(t)
106- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
107- snapshot := awaitPickerSnapshot(t, sends)
108- key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
109- capability := pickerClientCapability(t, ac, sess)
110- d.handleAttachmentClientMessage(capability, pickerPreviewRequestFor(snapshot, key, 20, 5))
111- require.Equal(t, protocol.PickerPreviewOK, awaitPickerPreview(t, sends).Status)
112-
113- require.True(t, d.closePickerForAttachment(ac, admitPickerEffectForTest(t, sess, ac), snapshot.InteractionID))
114- _ = drainAllFrames(sends)
115- ac.overlays.pickerMu.Lock()
116- generation := ac.overlays.pickerPreviewGeneration
117- ac.overlays.pickerMu.Unlock()
118- require.Zero(t, generation, "retiring the interaction must clear the preview generation")
119-
120- for _, frame := range drainAllFrames(sends) {
121- require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload))
122- }
123-}
124-
125-func TestClampPickerPreviewFitsTheRequestedBounds(t *testing.T) {
126- captured := picker.Preview{
127- Width: 4, Height: 2,
128- Rows: [][]renderer.Cell{
129- {{Rune: 'a'}, {Rune: 'b'}, {Rune: 'c'}, {Rune: 'd'}},
130- {{Rune: 'e'}, {Rune: '界'}, {Continuation: true}, {Rune: 'h'}},
131- },
132- }
133- fitted := clampPickerPreview(captured, 3, 1)
134- require.Equal(t, protocol.PickerPreviewOK, fitted.Status)
135- require.Equal(t, uint16(3), fitted.Width)
136- require.Equal(t, uint16(1), fitted.Height)
137- require.Len(t, fitted.Cells, 3)
138- require.Equal(t, 'a', fitted.Cells[0].Rune)
139-
140- // A double-width cell whose continuation column is cropped out becomes
141- // blank, so the run never ends on a wide cell.
142- wide := clampPickerPreview(captured, 2, 2)
143- require.Equal(t, protocol.PickerPreviewOK, wide.Status)
144- require.Len(t, wide.Cells, 4)
145- require.Equal(t, 'e', wide.Cells[2].Rune)
146- require.Zero(t, wide.Cells[3].Rune)
147- require.False(t, wide.Cells[3].Continuation)
148-
149- // Every row pads to the requested width, and the height stays the number of
150- // captured rows (never more than the request).
151- padded := clampPickerPreview(captured, 4, 3)
152- require.Equal(t, protocol.PickerPreviewOK, padded.Status)
153- require.Len(t, padded.Cells, 8)
154- require.Equal(t, uint16(2), padded.Height)
155-
156- require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(picker.Preview{}, 4, 2).Status)
157- require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(captured, 0, 2).Status)
158-}
159-
160-// TestPickerPreviewCapturesRemoteRowsThroughThePreviewClient pins the daemon
161-// half of the coexistence contract: a remote picker row is still previewed
162-// through the daemon's own remote viewport client and its cache, independent of
163-// whatever the launching client resolves for itself.
164-
165-// pickerPreviewKeyForSession returns the opaque row key whose target resolves
166-// to sess. The preview request names a key, so a cross-session test must read
167-// the same mapping the serving source published.
168-func pickerPreviewKeyForSession(t *testing.T, ac *attachedClient, sess *session) string {
169- t.Helper()
170- ac.overlays.pickerMu.Lock()
171- defer ac.overlays.pickerMu.Unlock()
172- for key, target := range ac.overlays.pickerKeys {
173- if target.RemoteTarget == nil && target.Session == sess.id {
174- return key
175- }
176- }
177- return ""
178-}
179-
180-// TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator pins the
181-// cross-session contract: the subscription that refreshes the displayed row
182-// lives on the target session's coordinator. A target render wakes the preview
183-// and keeps the headless target renderable, while a viewer-session render must
184-// not drive the target's preview at all.
185-func TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator(t *testing.T) {
186- viewerPTY, releaseViewer := newBlockingPTY(t)
187- t.Cleanup(releaseViewer)
188- d, viewerSess, ac, sends := newManualSessionWithPTYs(t, viewerPTY)
189- d.ptys = newFactorySeq(t, newQuietPTY())
190- targetSess, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
191- require.NoError(t, err)
192-
193- effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(viewerSess, ac, ac.transport()))
194- require.True(t, ok)
195- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
196- snapshot := awaitPickerSnapshot(t, sends)
197- key := pickerPreviewKeyForSession(t, ac, targetSess)
198- require.NotEmpty(t, key, "the snapshot must offer a navigable row for the target session")
199-
200- clock := newCoordinatorMockClock(t, 16)
201- d.clock = clock.clock
202- viewerCoordinator := attachmentRenderCoordinator(viewerSess)
203- require.Nil(t, attachmentRenderCoordinator(targetSess), "an un-previewed target owns no coordinator")
204-
205- d.handleAttachmentClientMessage(pickerClientCapability(t, ac, viewerSess), pickerPreviewRequestFor(snapshot, key, 20, 5))
206- first := awaitPickerPreview(t, sends)
207- require.Equal(t, protocol.PickerPreviewOK, first.Status)
208-
209- targetCoordinator := attachmentRenderCoordinator(targetSess)
210- require.NotNil(t, targetCoordinator, "the preview must subscribe on the target session's coordinator")
211- require.True(t, targetCoordinator.hasPreviewSubscribers())
212- if viewerCoordinator != nil {
213- require.False(t, viewerCoordinator.hasPreviewSubscribers(), "the viewer session must not own the target's subscription")
214- }
215- targetTab := targetSess.tabs[0]
216- require.True(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()),
217- "a previewed headless target must stay renderable")
218-
219- // A target render wakes the target's subscription and republishes.
220- d.processPTYData(targetSess, targetTab, targetTab.focusedPane(), []byte("target repaint"), false)
221- fireCoordinatorTimer(t, targetCoordinator, drainCoordinatorTimers(clock), minOutputRenderDeadline)
222- refreshed := awaitPickerPreview(t, sends)
223- require.Equal(t, key, refreshed.Key)
224- require.Equal(t, protocol.PickerPreviewOK, refreshed.Status)
225-
226- // A viewer-session render must not publish the target's preview. The direct
227- // invalidateRender path is urgent, so it arms the urgent deadline.
228- viewerCoordinator = d.attachCoordinator(viewerSess, nil, ac, true)
229- d.invalidateRender(viewerSess, ac, true, "picker_preview_test.go:viewer")
230- fireCoordinatorTimer(t, viewerCoordinator, drainCoordinatorTimers(clock), urgentRenderDeadline)
231- for _, frame := range drainAllFrames(sends) {
232- require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload),
233- "a viewer-session render must not publish the target's preview")
234- }
235-
236- // Retiring the interaction removes the subscription from the target's own
237- // coordinator, so the headless target stops being renderable. A nil effect
238- // retires the interaction without a close confirmation.
239- require.True(t, d.closePickerForAttachment(ac, nil, snapshot.InteractionID))
240- require.False(t, targetCoordinator.hasPreviewSubscribers(),
241- "teardown must remove the exact target subscription")
242- require.False(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()),
243- "a retired preview must stop keeping the target renderable")
244-}
245-
246-// manualPreviewClock is a deterministic ports.Clock for the remote preview
247-// refresher. Timers never fire on their own, so a test can prove the worker
248-// armed one and then advance it explicitly without any wall-clock sleep.
249-type manualPreviewClock struct {
250- mu sync.Mutex
251- now time.Time
252- waiting chan time.Time
253- armed chan struct{}
254-}
255-
256-func newManualPreviewClock(now time.Time) *manualPreviewClock {
257- return &manualPreviewClock{now: now, armed: make(chan struct{}, 64)}
258-}
259-
260-func (c *manualPreviewClock) Now() time.Time {
261- c.mu.Lock()
262- defer c.mu.Unlock()
263- return c.now
264-}
265-
266-func (c *manualPreviewClock) NewTimer(time.Duration) ports.Timer {
267- ch := make(chan time.Time, 1)
268- c.mu.Lock()
269- c.waiting = ch
270- c.mu.Unlock()
271- select {
272- case c.armed <- struct{}{}:
273- default:
274- }
275- return manualPreviewTimer{ch: ch}
276-}
277-
278-func (c *manualPreviewClock) awaitTimer(t *testing.T) {
279- t.Helper()
280- select {
281- case <-c.armed:
282- case <-time.After(2 * time.Second):
283- t.Fatal("quiet viewer never armed a remote preview refresh")
284- }
285-}
286-
287-func (c *manualPreviewClock) fire(now time.Time) {
288- c.mu.Lock()
289- ch := c.waiting
290- c.waiting = nil
291- c.now = now
292- c.mu.Unlock()
293- if ch != nil {
294- ch <- now
295- }
296-}
297-
298-type manualPreviewTimer struct{ ch chan time.Time }
299-
300-func (t manualPreviewTimer) C() <-chan time.Time { return t.ch }
301-func (t manualPreviewTimer) Reset(time.Duration) bool { return false }
302-func (t manualPreviewTimer) Stop() bool { return true }
303-
304-// TestRemotePickerPreviewRefreshesWithoutViewerRenderWake is the regression for
305-// the quiet-viewer stale delay: a selected remote row must keep refreshing on
306-// its own cadence even when the viewer renders nothing at all. Relying on a
307-// viewer render wake left a quiet viewer stale indefinitely.
@@ -1,307 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "testing"
6- "time"
7-
8- renderer "github.com/bnema/vev-vt"
9- "github.com/stretchr/testify/require"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13- "github.com/bnema/vev/internal/protocol"
14- "github.com/bnema/vev/internal/protocol/wire"
15- "github.com/bnema/vev/internal/usecase/picker"
16-)
17-
18-func awaitPickerPreview(t *testing.T, sends chan wire.Envelope) protocol.PickerPreview {
19- t.Helper()
20- for {
21- frame := awaitTestValue(t, sends, "picker preview was not published")
22- if preview, ok := decodeServerMessage(t, frame).(protocol.PickerPreview); ok {
23- return preview
24- }
25- }
26-}
27-
28-func pickerPreviewRequestFor(snapshot protocol.PickerSnapshot, key string, width, height uint16) protocol.PickerPreviewRequest {
29- return protocol.PickerPreviewRequest{
30- Version: protocol.PickerPreviewSchemaVersion, InteractionID: snapshot.InteractionID,
31- SourceID: servingPickerSourceID, Key: key, Width: width, Height: height,
32- }
33-}
34-
35-func TestPickerPreviewRequestPublishesCapturedViewport(t *testing.T) {
36- d, sess, ac, sends, effect := pickerClientTestUnit(t)
37- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
38- snapshot := awaitPickerSnapshot(t, sends)
39- key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
40-
41- capability := pickerClientCapability(t, ac, sess)
42- request := pickerPreviewRequestFor(snapshot, key, 20, 5)
43- require.NoError(t, protocol.ValidatePickerPreviewRequest(request))
44- d.handleAttachmentClientMessage(capability, request)
45-
46- preview := awaitPickerPreview(t, sends)
47- require.Equal(t, snapshot.InteractionID, preview.InteractionID)
48- require.Equal(t, servingPickerSourceID, preview.SourceID)
49- require.Equal(t, key, preview.Key)
50- require.Equal(t, protocol.PickerPreviewOK, preview.Status, "the fixture session must capture a viewport")
51- require.Equal(t, uint16(20), preview.Width)
52- require.LessOrEqual(t, preview.Height, uint16(5))
53- require.Len(t, preview.Cells, int(preview.Width)*int(preview.Height))
54- require.NotNil(t, preview.FrameRows())
55-}
56-
57-func TestPickerPreviewUnknownKeyReportsNoSuchTarget(t *testing.T) {
58- d, sess, ac, sends, effect := pickerClientTestUnit(t)
59- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
60- snapshot := awaitPickerSnapshot(t, sends)
61-
62- capability := pickerClientCapability(t, ac, sess)
63- request := pickerPreviewRequestFor(snapshot, "zz/retired", 20, 5)
64- d.handleAttachmentClientMessage(capability, request)
65-
66- preview := awaitPickerPreview(t, sends)
67- require.Equal(t, protocol.PickerPreviewNoSuchTarget, preview.Status)
68- require.Zero(t, preview.Width)
69- require.Empty(t, preview.Cells)
70-}
71-
72-func TestPickerPreviewStaleRequestPublishesNothing(t *testing.T) {
73- d, sess, ac, sends, effect := pickerClientTestUnit(t)
74- offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
75- snapshot := awaitPickerSnapshot(t, sends)
76- key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
77- capability := pickerClientCapability(t, ac, sess)
78-
79- for range 3 {
80- request := pickerPreviewRequestFor(snapshot, key, 20, 5)
81- request.InteractionID = offer.InteractionID + 1
82- d.handleAttachmentClientMessage(capability, request)
83- }
84- for _, frame := range drainAllFrames(sends) {
85- require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), "a request for another interaction must publish nothing")
86- }
87-
88- // A superseded generation is dropped even though its request was valid.
89- live := pickerPreviewRequestFor(snapshot, key, 20, 5)
90- d.handleAttachmentClientMessage(capability, live)
91- first := awaitPickerPreview(t, sends)
92- ac.overlays.pickerMu.Lock()
93- generation := ac.overlays.pickerPreviewGeneration
94- target, known := ac.overlays.pickerKeys[live.Key]
95- ac.overlays.pickerMu.Unlock()
96- require.True(t, known)
97- d.publishPickerPreviewForAttachment(ac, live, generation-1, target, protocol.PickerIntentNavigation, known)
98- for _, frame := range drainAllFrames(sends) {
99- require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), "a superseded generation must not publish")
100- }
101- require.Equal(t, key, first.Key)
102-}
103-
104-func TestPickerPreviewRetiredInteractionStopsPublishing(t *testing.T) {
105- d, sess, ac, sends, effect := pickerClientTestUnit(t)
106- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
107- snapshot := awaitPickerSnapshot(t, sends)
108- key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
109- capability := pickerClientCapability(t, ac, sess)
110- d.handleAttachmentClientMessage(capability, pickerPreviewRequestFor(snapshot, key, 20, 5))
111- require.Equal(t, protocol.PickerPreviewOK, awaitPickerPreview(t, sends).Status)
112-
113- require.True(t, d.closePickerForAttachment(ac, admitPickerEffectForTest(t, sess, ac), snapshot.InteractionID))
114- _ = drainAllFrames(sends)
115- ac.overlays.pickerMu.Lock()
116- generation := ac.overlays.pickerPreviewGeneration
117- ac.overlays.pickerMu.Unlock()
118- require.Zero(t, generation, "retiring the interaction must clear the preview generation")
119-
120- for _, frame := range drainAllFrames(sends) {
121- require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload))
122- }
123-}
124-
125-func TestClampPickerPreviewFitsTheRequestedBounds(t *testing.T) {
126- captured := picker.Preview{
127- Width: 4, Height: 2,
128- Rows: [][]renderer.Cell{
129- {{Rune: 'a'}, {Rune: 'b'}, {Rune: 'c'}, {Rune: 'd'}},
130- {{Rune: 'e'}, {Rune: '界'}, {Continuation: true}, {Rune: 'h'}},
131- },
132- }
133- fitted := clampPickerPreview(captured, 3, 1)
134- require.Equal(t, protocol.PickerPreviewOK, fitted.Status)
135- require.Equal(t, uint16(3), fitted.Width)
136- require.Equal(t, uint16(1), fitted.Height)
137- require.Len(t, fitted.Cells, 3)
138- require.Equal(t, 'a', fitted.Cells[0].Rune)
139-
140- // A double-width cell whose continuation column is cropped out becomes
141- // blank, so the run never ends on a wide cell.
142- wide := clampPickerPreview(captured, 2, 2)
143- require.Equal(t, protocol.PickerPreviewOK, wide.Status)
144- require.Len(t, wide.Cells, 4)
145- require.Equal(t, 'e', wide.Cells[2].Rune)
146- require.Zero(t, wide.Cells[3].Rune)
147- require.False(t, wide.Cells[3].Continuation)
148-
149- // Every row pads to the requested width, and the height stays the number of
150- // captured rows (never more than the request).
151- padded := clampPickerPreview(captured, 4, 3)
152- require.Equal(t, protocol.PickerPreviewOK, padded.Status)
153- require.Len(t, padded.Cells, 8)
154- require.Equal(t, uint16(2), padded.Height)
155-
156- require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(picker.Preview{}, 4, 2).Status)
157- require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(captured, 0, 2).Status)
158-}
159-
160-// TestPickerPreviewCapturesRemoteRowsThroughThePreviewClient pins the daemon
161-// half of the coexistence contract: a remote picker row is still previewed
162-// through the daemon's own remote viewport client and its cache, independent of
163-// whatever the launching client resolves for itself.
164-
165-// pickerPreviewKeyForSession returns the opaque row key whose target resolves
166-// to sess. The preview request names a key, so a cross-session test must read
167-// the same mapping the serving source published.
168-func pickerPreviewKeyForSession(t *testing.T, ac *attachedClient, sess *session) string {
169- t.Helper()
170- ac.overlays.pickerMu.Lock()
171- defer ac.overlays.pickerMu.Unlock()
172- for key, target := range ac.overlays.pickerKeys {
173- if target.RemoteTarget == nil && target.Session == sess.id {
174- return key
175- }
176- }
177- return ""
178-}
179-
180-// TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator pins the
181-// cross-session contract: the subscription that refreshes the displayed row
182-// lives on the target session's coordinator. A target render wakes the preview
183-// and keeps the headless target renderable, while a viewer-session render must
184-// not drive the target's preview at all.
185-func TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator(t *testing.T) {
186- viewerPTY, releaseViewer := newBlockingPTY(t)
187- t.Cleanup(releaseViewer)
188- d, viewerSess, ac, sends := newManualSessionWithPTYs(t, viewerPTY)
189- d.ptys = newFactorySeq(t, newQuietPTY())
190- targetSess, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
191- require.NoError(t, err)
192-
193- effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(viewerSess, ac, ac.transport()))
194- require.True(t, ok)
195- openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
196- snapshot := awaitPickerSnapshot(t, sends)
197- key := pickerPreviewKeyForSession(t, ac, targetSess)
198- require.NotEmpty(t, key, "the snapshot must offer a navigable row for the target session")
199-
200- clock := newCoordinatorMockClock(t, 16)
201- d.clock = clock.clock
202- viewerCoordinator := attachmentRenderCoordinator(viewerSess)
203- require.Nil(t, attachmentRenderCoordinator(targetSess), "an un-previewed target owns no coordinator")
204-
205- d.handleAttachmentClientMessage(pickerClientCapability(t, ac, viewerSess), pickerPreviewRequestFor(snapshot, key, 20, 5))
206- first := awaitPickerPreview(t, sends)
207- require.Equal(t, protocol.PickerPreviewOK, first.Status)
208-
209- targetCoordinator := attachmentRenderCoordinator(targetSess)
210- require.NotNil(t, targetCoordinator, "the preview must subscribe on the target session's coordinator")
211- require.True(t, targetCoordinator.hasPreviewSubscribers())
212- if viewerCoordinator != nil {
213- require.False(t, viewerCoordinator.hasPreviewSubscribers(), "the viewer session must not own the target's subscription")
214- }
215- targetTab := targetSess.tabs[0]
216- require.True(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()),
217- "a previewed headless target must stay renderable")
218-
219- // A target render wakes the target's subscription and republishes.
220- d.processPTYData(targetSess, targetTab, targetTab.focusedPane(), []byte("target repaint"), false)
221- fireCoordinatorTimer(t, targetCoordinator, drainCoordinatorTimers(clock), minOutputRenderDeadline)
222- refreshed := awaitPickerPreview(t, sends)
223- require.Equal(t, key, refreshed.Key)
224- require.Equal(t, protocol.PickerPreviewOK, refreshed.Status)
225-
226- // A viewer-session render must not publish the target's preview. The direct
227- // invalidateRender path is urgent, so it arms the urgent deadline.
228- viewerCoordinator = d.attachCoordinator(viewerSess, nil, ac, true)
229- d.invalidateRender(viewerSess, ac, true, "picker_preview_test.go:viewer")
230- fireCoordinatorTimer(t, viewerCoordinator, drainCoordinatorTimers(clock), urgentRenderDeadline)
231- for _, frame := range drainAllFrames(sends) {
232- require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload),
233- "a viewer-session render must not publish the target's preview")
234- }
235-
236- // Retiring the interaction removes the subscription from the target's own
237- // coordinator, so the headless target stops being renderable. A nil effect
238- // retires the interaction without a close confirmation.
239- require.True(t, d.closePickerForAttachment(ac, nil, snapshot.InteractionID))
240- require.False(t, targetCoordinator.hasPreviewSubscribers(),
241- "teardown must remove the exact target subscription")
242- require.False(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()),
243- "a retired preview must stop keeping the target renderable")
244-}
245-
246-// manualPreviewClock is a deterministic ports.Clock for the remote preview
247-// refresher. Timers never fire on their own, so a test can prove the worker
248-// armed one and then advance it explicitly without any wall-clock sleep.
249-type manualPreviewClock struct {
250- mu sync.Mutex
251- now time.Time
252- waiting chan time.Time
253- armed chan struct{}
254-}
255-
256-func newManualPreviewClock(now time.Time) *manualPreviewClock {
257- return &manualPreviewClock{now: now, armed: make(chan struct{}, 64)}
258-}
259-
260-func (c *manualPreviewClock) Now() time.Time {
261- c.mu.Lock()
262- defer c.mu.Unlock()
263- return c.now
264-}
265-
266-func (c *manualPreviewClock) NewTimer(time.Duration) ports.Timer {
267- ch := make(chan time.Time, 1)
268- c.mu.Lock()
269- c.waiting = ch
270- c.mu.Unlock()
271- select {
272- case c.armed <- struct{}{}:
273- default:
274- }
275- return manualPreviewTimer{ch: ch}
276-}
277-
278-func (c *manualPreviewClock) awaitTimer(t *testing.T) {
279- t.Helper()
280- select {
281- case <-c.armed:
282- case <-time.After(2 * time.Second):
283- t.Fatal("quiet viewer never armed a remote preview refresh")
284- }
285-}
286-
287-func (c *manualPreviewClock) fire(now time.Time) {
288- c.mu.Lock()
289- ch := c.waiting
290- c.waiting = nil
291- c.now = now
292- c.mu.Unlock()
293- if ch != nil {
294- ch <- now
295- }
296-}
297-
298-type manualPreviewTimer struct{ ch chan time.Time }
299-
300-func (t manualPreviewTimer) C() <-chan time.Time { return t.ch }
301-func (t manualPreviewTimer) Reset(time.Duration) bool { return false }
302-func (t manualPreviewTimer) Stop() bool { return true }
303-
304-// TestRemotePickerPreviewRefreshesWithoutViewerRenderWake is the regression for
305-// the quiet-viewer stale delay: a selected remote row must keep refreshing on
306-// its own cadence even when the viewer renders nothing at all. Relying on a
307-// viewer render wake left a quiet viewer stale indefinitely.
M internal/usecase/daemon/render_coordinator_test.go +1 -1
@@ -1226,7 +1226,7 @@ func TestProducerInvalidations(t *testing.T) {
12261226 interaction: true,
12271227 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
12281228 effect := admitPickerEffectForTest(t, sess, ac)
1229- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
1229+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMovePane, moveSourceLocator{}, 0))
12301230 },
12311231 },
12321232 {
@@ -1226,7 +1226,7 @@ func TestProducerInvalidations(t *testing.T) {
1226 interaction: true,1226 interaction: true,
1227 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {1227 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1228 effect := admitPickerEffectForTest(t, sess, ac)1228 effect := admitPickerEffectForTest(t, sess, ac)
1229- require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))1229+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMovePane, moveSourceLocator{}, 0))
1230 },1230 },
1231 },1231 },
1232 {1232 {
M internal/usecase/daemon/session_capabilities_test.go +1 -1
@@ -28,7 +28,7 @@ func TestOpenPickerForIntentRejectsNonYieldingSource(t *testing.T) {
2828 wantErr error
2929 }{
3030 {name: "move tab", intent: protocol.PickerIntentMoveTab, wantErr: errSessionCannotYieldMoves},
31- {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: nil},
31+ {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: errClientOwnedNavigation},
3232 }
3333 for _, tt := range tests {
3434 t.Run(tt.name, func(t *testing.T) {
@@ -28,7 +28,7 @@ func TestOpenPickerForIntentRejectsNonYieldingSource(t *testing.T) {
28 wantErr error28 wantErr error
29 }{29 }{
30 {name: "move tab", intent: protocol.PickerIntentMoveTab, wantErr: errSessionCannotYieldMoves},30 {name: "move tab", intent: protocol.PickerIntentMoveTab, wantErr: errSessionCannotYieldMoves},
31- {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: nil},31+ {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: errClientOwnedNavigation},
32 }32 }
33 for _, tt := range tests {33 for _, tt := range tests {
34 t.Run(tt.name, func(t *testing.T) {34 t.Run(tt.name, func(t *testing.T) {
M internal/usecase/daemon/typed_connection_test.go +0 -2
@@ -201,8 +201,6 @@ func serverMessageName(message protocol.ServerMessage) string {
201201 return "PickerFailure"
202202 case protocol.PickerPreview:
203203 return "PickerPreview"
204- case protocol.PickerControlResponse:
205- return "PickerControlResponse"
206204 default:
207205 return ""
208206 }
@@ -201,8 +201,6 @@ func serverMessageName(message protocol.ServerMessage) string {
201 return "PickerFailure"201 return "PickerFailure"
202 case protocol.PickerPreview:202 case protocol.PickerPreview:
203 return "PickerPreview"203 return "PickerPreview"
204- case protocol.PickerControlResponse:
205- return "PickerControlResponse"
206 default:204 default:
207 return ""205 return ""
208 }206 }

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