detached 5e20063

refactor(client): delete unreachable picker and transition runners

commit
5e20063eb82892b44f00edf9ecad36ff2249151a
author
brice · · 2026-09-22 21:13 UTC
parent
45a9fcd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +25 -629
M internal/usecase/client/handshake.go +5 -152
@@ -2,32 +2,22 @@ package client
22
33 import (
44 "context"
5- "errors"
65 "sync"
76 "time"
87
98 "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/internal/protocol"
119 )
1210
13-var errHandshakeTimeout = errors.New("handshake timed out")
14-
1511 type systemClock struct{}
16-type systemTimer struct{ *time.Timer }
12+type systemTimer struct{ timer *time.Timer }
1713
1814 func (systemClock) Now() time.Time { return time.Now() }
1915 func (systemClock) NewTimer(delay time.Duration) ports.Timer {
20- return systemTimer{Timer: time.NewTimer(delay)}
21-}
22-func (t systemTimer) C() <-chan time.Time { return t.Timer.C }
23-func (t systemTimer) Reset(d time.Duration) bool { return t.Timer.Reset(d) }
24-func (t systemTimer) Stop() bool { return t.Timer.Stop() }
25-
26-// newHandshakeContext owns one deadline for the complete outbound handshake.
27-// The caller must stop it before entering the long-lived connection loop.
28-func newHandshakeContext(parent context.Context, clock ports.Clock) (context.Context, <-chan struct{}, func()) {
29- return newBoundedContext(parent, clock, protocol.HandshakeTimeout)
16+ return systemTimer{timer: time.NewTimer(delay)}
3017 }
18+func (t systemTimer) C() <-chan time.Time { return t.timer.C }
19+func (t systemTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
20+func (t systemTimer) Stop() bool { return t.timer.Stop() }
3121
3222 // newBoundedContext owns one deadline of the requested length. The caller must
3323 // invoke finish when the bounded operation ends.
@@ -66,140 +56,3 @@ func newBoundedContext(parent context.Context, clock ports.Clock, timeout time.D
6656 }
6757 return ctx, timedOut, finish
6858 }
69-
70-// watchHandshakeTransport closes a transport when the handshake context ends.
71-// Transport.Close is required to interrupt blocked Send and Recv operations.
72-func watchHandshakeTransport(ctx context.Context, transport ports.ClientConnection) func() {
73- stop := make(chan struct{})
74- done := make(chan struct{})
75- go func() {
76- defer close(done)
77- select {
78- case <-ctx.Done():
79- _ = transport.Close()
80- case <-stop:
81- }
82- }()
83- var once sync.Once
84- return func() {
85- once.Do(func() {
86- close(stop)
87- <-done
88- })
89- }
90-}
91-
92-func boundedHandshakeOperationWithTransition(ctx context.Context, transport ports.ClientConnection, operation func() error, transition *transitionUI) error {
93- if err := ctx.Err(); err != nil {
94- _ = transport.Close()
95- return err
96- }
97- completed := make(chan error, 1)
98- go func() { completed <- operation() }()
99- for {
100- select {
101- case err := <-completed:
102- if ctxErr := ctx.Err(); ctxErr != nil {
103- _ = transport.Close()
104- return ctxErr
105- }
106- return err
107- case <-transition.tickC():
108- if err := transition.advance(); err != nil {
109- _ = transport.Close()
110- return err
111- }
112- case <-ctx.Done():
113- _ = transport.Close()
114- // The result channel is buffered, so the operation can publish its
115- // completion after Close without keeping this cancellation path stuck.
116- return ctx.Err()
117- }
118- }
119-}
120-
121-func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientConnection, error) {
122- return boundedDialWithTransition(ctx, dialer, nil)
123-}
124-
125-// retireSender bounds the wait for an attach loop's sender goroutine after its
126-// context is canceled. A retained transport must not keep a wedged sender when
127-// the cache takes over write ownership, so exceeding the shared handshake
128-// budget closes the transport, which the ClientConnection contract requires to
129-// unblock the send. The helper never waits past that budget.
130-func retireSender(ctx context.Context, clock ports.Clock, transport ports.ClientConnection, done <-chan struct{}) {
131- select {
132- case <-done:
133- return
134- default:
135- }
136- bounded, _, finish := newHandshakeContext(ctx, clock)
137- defer finish()
138- select {
139- case <-done:
140- case <-bounded.Done():
141- if transport != nil {
142- _ = transport.Close()
143- }
144- }
145-}
146-
147-func boundedDialWithTransition(ctx context.Context, dialer ports.ClientDialer, transition *transitionUI) (ports.ClientConnection, error) {
148- // The dial context only bounds startup. A successful carriage outlives the
149- // handshake context; canceling that context after Welcome must not kill SSH.
150- dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
151- type dialResult struct {
152- transport ports.ClientConnection
153- err error
154- }
155- result := make(chan dialResult, 1)
156- go func() {
157- transport, err := dialer.Dial(dialCtx)
158- result <- dialResult{transport: transport, err: err}
159- }()
160- abandon := func() {
161- cancel()
162- go func() {
163- late := <-result
164- if late.transport != nil {
165- _ = late.transport.Close()
166- }
167- }()
168- }
169- for {
170- select {
171- case result := <-result:
172- if err := ctx.Err(); err != nil {
173- cancel()
174- if result.transport != nil {
175- _ = result.transport.Close()
176- }
177- return nil, err
178- }
179- if result.err != nil {
180- cancel()
181- }
182- return result.transport, result.err
183- case <-transition.tickC():
184- if err := transition.advance(); err != nil {
185- abandon()
186- return nil, err
187- }
188- case <-ctx.Done():
189- abandon()
190- return nil, ctx.Err()
191- }
192- }
193-}
194-
195-func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
196- select {
197- case <-timedOut:
198- return errors.Join(errHandshakeTimeout, context.DeadlineExceeded)
199- default:
200- }
201- if err := parent.Err(); err != nil {
202- return err
203- }
204- return fallback
205-}
@@ -2,32 +2,22 @@ package client
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "sync"5 "sync"
7 "time"6 "time"
8 7
9 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/internal/protocol"
11 )9 )
12 10
13-var errHandshakeTimeout = errors.New("handshake timed out")
14-
15 type systemClock struct{}11 type systemClock struct{}
16-type systemTimer struct{ *time.Timer }12+type systemTimer struct{ timer *time.Timer }
17 13
18 func (systemClock) Now() time.Time { return time.Now() }14 func (systemClock) Now() time.Time { return time.Now() }
19 func (systemClock) NewTimer(delay time.Duration) ports.Timer {15 func (systemClock) NewTimer(delay time.Duration) ports.Timer {
20- return systemTimer{Timer: time.NewTimer(delay)}16+ return systemTimer{timer: time.NewTimer(delay)}
21-}
22-func (t systemTimer) C() <-chan time.Time { return t.Timer.C }
23-func (t systemTimer) Reset(d time.Duration) bool { return t.Timer.Reset(d) }
24-func (t systemTimer) Stop() bool { return t.Timer.Stop() }
25-
26-// newHandshakeContext owns one deadline for the complete outbound handshake.
27-// The caller must stop it before entering the long-lived connection loop.
28-func newHandshakeContext(parent context.Context, clock ports.Clock) (context.Context, <-chan struct{}, func()) {
29- return newBoundedContext(parent, clock, protocol.HandshakeTimeout)
30 }17 }
18+func (t systemTimer) C() <-chan time.Time { return t.timer.C }
19+func (t systemTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
20+func (t systemTimer) Stop() bool { return t.timer.Stop() }
31 21
32 // newBoundedContext owns one deadline of the requested length. The caller must22 // newBoundedContext owns one deadline of the requested length. The caller must
33 // invoke finish when the bounded operation ends.23 // invoke finish when the bounded operation ends.
@@ -66,140 +56,3 @@ func newBoundedContext(parent context.Context, clock ports.Clock, timeout time.D
66 }56 }
67 return ctx, timedOut, finish57 return ctx, timedOut, finish
68 }58 }
69-
70-// watchHandshakeTransport closes a transport when the handshake context ends.
71-// Transport.Close is required to interrupt blocked Send and Recv operations.
72-func watchHandshakeTransport(ctx context.Context, transport ports.ClientConnection) func() {
73- stop := make(chan struct{})
74- done := make(chan struct{})
75- go func() {
76- defer close(done)
77- select {
78- case <-ctx.Done():
79- _ = transport.Close()
80- case <-stop:
81- }
82- }()
83- var once sync.Once
84- return func() {
85- once.Do(func() {
86- close(stop)
87- <-done
88- })
89- }
90-}
91-
92-func boundedHandshakeOperationWithTransition(ctx context.Context, transport ports.ClientConnection, operation func() error, transition *transitionUI) error {
93- if err := ctx.Err(); err != nil {
94- _ = transport.Close()
95- return err
96- }
97- completed := make(chan error, 1)
98- go func() { completed <- operation() }()
99- for {
100- select {
101- case err := <-completed:
102- if ctxErr := ctx.Err(); ctxErr != nil {
103- _ = transport.Close()
104- return ctxErr
105- }
106- return err
107- case <-transition.tickC():
108- if err := transition.advance(); err != nil {
109- _ = transport.Close()
110- return err
111- }
112- case <-ctx.Done():
113- _ = transport.Close()
114- // The result channel is buffered, so the operation can publish its
115- // completion after Close without keeping this cancellation path stuck.
116- return ctx.Err()
117- }
118- }
119-}
120-
121-func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientConnection, error) {
122- return boundedDialWithTransition(ctx, dialer, nil)
123-}
124-
125-// retireSender bounds the wait for an attach loop's sender goroutine after its
126-// context is canceled. A retained transport must not keep a wedged sender when
127-// the cache takes over write ownership, so exceeding the shared handshake
128-// budget closes the transport, which the ClientConnection contract requires to
129-// unblock the send. The helper never waits past that budget.
130-func retireSender(ctx context.Context, clock ports.Clock, transport ports.ClientConnection, done <-chan struct{}) {
131- select {
132- case <-done:
133- return
134- default:
135- }
136- bounded, _, finish := newHandshakeContext(ctx, clock)
137- defer finish()
138- select {
139- case <-done:
140- case <-bounded.Done():
141- if transport != nil {
142- _ = transport.Close()
143- }
144- }
145-}
146-
147-func boundedDialWithTransition(ctx context.Context, dialer ports.ClientDialer, transition *transitionUI) (ports.ClientConnection, error) {
148- // The dial context only bounds startup. A successful carriage outlives the
149- // handshake context; canceling that context after Welcome must not kill SSH.
150- dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
151- type dialResult struct {
152- transport ports.ClientConnection
153- err error
154- }
155- result := make(chan dialResult, 1)
156- go func() {
157- transport, err := dialer.Dial(dialCtx)
158- result <- dialResult{transport: transport, err: err}
159- }()
160- abandon := func() {
161- cancel()
162- go func() {
163- late := <-result
164- if late.transport != nil {
165- _ = late.transport.Close()
166- }
167- }()
168- }
169- for {
170- select {
171- case result := <-result:
172- if err := ctx.Err(); err != nil {
173- cancel()
174- if result.transport != nil {
175- _ = result.transport.Close()
176- }
177- return nil, err
178- }
179- if result.err != nil {
180- cancel()
181- }
182- return result.transport, result.err
183- case <-transition.tickC():
184- if err := transition.advance(); err != nil {
185- abandon()
186- return nil, err
187- }
188- case <-ctx.Done():
189- abandon()
190- return nil, ctx.Err()
191- }
192- }
193-}
194-
195-func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
196- select {
197- case <-timedOut:
198- return errors.Join(errHandshakeTimeout, context.DeadlineExceeded)
199- default:
200- }
201- if err := parent.Err(); err != nil {
202- return err
203- }
204- return fallback
205-}
M internal/usecase/client/handshake_test.go +9 -0
@@ -8,6 +8,7 @@ import (
88 "github.com/stretchr/testify/require"
99
1010 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
1112 )
1213
1314 type failedContextDialer struct {
@@ -19,6 +20,14 @@ func (d *failedContextDialer) Dial(ctx context.Context) (ports.ClientConnection,
1920 return nil, errors.New("dial failed")
2021 }
2122
23+// boundedDial is retained only for this old timeout-contract test; the
24+// autonomous supervisor opens through the broker instead.
25+func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientConnection, error) {
26+ bounded, _, finish := newBoundedContext(ctx, systemClock{}, protocol.HandshakeTimeout)
27+ defer finish()
28+ return dialer.Dial(bounded)
29+}
30+
2231 func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) {
2332 dialer := &failedContextDialer{}
2433 _, err := boundedDial(context.Background(), dialer)
@@ -8,6 +8,7 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
11 )12 )
12 13
13 type failedContextDialer struct {14 type failedContextDialer struct {
@@ -19,6 +20,14 @@ func (d *failedContextDialer) Dial(ctx context.Context) (ports.ClientConnection,
19 return nil, errors.New("dial failed")20 return nil, errors.New("dial failed")
20 }21 }
21 22
23+// boundedDial is retained only for this old timeout-contract test; the
24+// autonomous supervisor opens through the broker instead.
25+func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientConnection, error) {
26+ bounded, _, finish := newBoundedContext(ctx, systemClock{}, protocol.HandshakeTimeout)
27+ defer finish()
28+ return dialer.Dial(bounded)
29+}
30+
22 func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) {31 func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) {
23 dialer := &failedContextDialer{}32 dialer := &failedContextDialer{}
24 _, err := boundedDial(context.Background(), dialer)33 _, err := boundedDial(context.Background(), dialer)
D internal/usecase/client/picker_control_box.go +0 -47
@@ -1,47 +0,0 @@
1-package client
2-
3-import (
4- "sync"
5-
6- "github.com/bnema/vev/internal/protocol"
7-)
8-
9-type pickerControlOutcome struct {
10- snapshot *protocol.PickerSnapshot
11- selection *protocol.PickerSelection
12- target *protocol.AttachTarget
13- err error
14-}
15-
16-type pickerControlBox struct {
17- mu sync.Mutex
18- outcomes []pickerControlOutcome
19- wake chan struct{}
20-}
21-
22-func newPickerControlBox() *pickerControlBox { return &pickerControlBox{wake: make(chan struct{}, 1)} }
23-func (b *pickerControlBox) offer(outcome pickerControlOutcome) {
24- b.mu.Lock()
25- b.outcomes = append(b.outcomes, outcome)
26- b.mu.Unlock()
27- select {
28- case b.wake <- struct{}{}:
29- default:
30- }
31-}
32-func (b *pickerControlBox) take() (pickerControlOutcome, bool) {
33- b.mu.Lock()
34- defer b.mu.Unlock()
35- if len(b.outcomes) == 0 {
36- return pickerControlOutcome{}, false
37- }
38- outcome := b.outcomes[0]
39- b.outcomes = b.outcomes[1:]
40- if len(b.outcomes) != 0 {
41- select {
42- case b.wake <- struct{}{}:
43- default:
44- }
45- }
46- return outcome, true
47-}
@@ -1,47 +0,0 @@
1-package client
2-
3-import (
4- "sync"
5-
6- "github.com/bnema/vev/internal/protocol"
7-)
8-
9-type pickerControlOutcome struct {
10- snapshot *protocol.PickerSnapshot
11- selection *protocol.PickerSelection
12- target *protocol.AttachTarget
13- err error
14-}
15-
16-type pickerControlBox struct {
17- mu sync.Mutex
18- outcomes []pickerControlOutcome
19- wake chan struct{}
20-}
21-
22-func newPickerControlBox() *pickerControlBox { return &pickerControlBox{wake: make(chan struct{}, 1)} }
23-func (b *pickerControlBox) offer(outcome pickerControlOutcome) {
24- b.mu.Lock()
25- b.outcomes = append(b.outcomes, outcome)
26- b.mu.Unlock()
27- select {
28- case b.wake <- struct{}{}:
29- default:
30- }
31-}
32-func (b *pickerControlBox) take() (pickerControlOutcome, bool) {
33- b.mu.Lock()
34- defer b.mu.Unlock()
35- if len(b.outcomes) == 0 {
36- return pickerControlOutcome{}, false
37- }
38- outcome := b.outcomes[0]
39- b.outcomes = b.outcomes[1:]
40- if len(b.outcomes) != 0 {
41- select {
42- case b.wake <- struct{}{}:
43- default:
44- }
45- }
46- return outcome, true
47-}
M internal/usecase/client/reconnect_toast.go +0 -34
@@ -10,32 +10,6 @@ import (
1010 "github.com/bnema/vev/internal/usecase/ui"
1111 )
1212
13-const reconnectToastMessage = "offline; retrying…"
14-
15-func reconnectStageMessage(stage reconnectStage) string {
16- switch stage {
17- case reconnectStageDegraded:
18- return "connection degraded"
19- case reconnectStageProbingUDP:
20- return "probing UDP path"
21- case reconnectStageSSH:
22- return "offline; retrying… reconnecting through SSH"
23- case reconnectStageOfflineRetrying:
24- return reconnectToastMessage
25- default:
26- return reconnectToastMessage
27- }
28-}
29-
30-func drawReconnectToast(out io.Writer, size domain.Size) error {
31- _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying)
32- return err
33-}
34-
35-func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
36- return drawClientToast(out, size, reconnectStageMessage(stage))
37-}
38-
3913 // drawClientToast draws a client-local toast without changing terminal state.
4014 // The attach main loop owns both this write and the later daemon-frame
4115 // reconciliation; input pumps must only publish a request for it.
@@ -47,18 +21,10 @@ func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Re
4721 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
4822 }
4923
50-func reconnectToastBounds(size domain.Size) domain.Rect {
51- return reconnectToastBoundsFor(size, reconnectToastMessage)
52-}
53-
5424 func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {
5525 return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter})
5626 }
5727
58-func reconnectToastLines(bounds domain.Rect) []string {
59- return reconnectToastLinesFor(bounds, reconnectToastMessage)
60-}
61-
6228 func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
6329 if bounds.Width <= 0 || bounds.Height <= 0 {
6430 return nil
@@ -10,32 +10,6 @@ import (
10 "github.com/bnema/vev/internal/usecase/ui"10 "github.com/bnema/vev/internal/usecase/ui"
11 )11 )
12 12
13-const reconnectToastMessage = "offline; retrying…"
14-
15-func reconnectStageMessage(stage reconnectStage) string {
16- switch stage {
17- case reconnectStageDegraded:
18- return "connection degraded"
19- case reconnectStageProbingUDP:
20- return "probing UDP path"
21- case reconnectStageSSH:
22- return "offline; retrying… reconnecting through SSH"
23- case reconnectStageOfflineRetrying:
24- return reconnectToastMessage
25- default:
26- return reconnectToastMessage
27- }
28-}
29-
30-func drawReconnectToast(out io.Writer, size domain.Size) error {
31- _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying)
32- return err
33-}
34-
35-func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
36- return drawClientToast(out, size, reconnectStageMessage(stage))
37-}
38-
39 // drawClientToast draws a client-local toast without changing terminal state.13 // drawClientToast draws a client-local toast without changing terminal state.
40 // The attach main loop owns both this write and the later daemon-frame14 // The attach main loop owns both this write and the later daemon-frame
41 // reconciliation; input pumps must only publish a request for it.15 // reconciliation; input pumps must only publish a request for it.
@@ -47,18 +21,10 @@ func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Re
47 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))21 return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
48 }22 }
49 23
50-func reconnectToastBounds(size domain.Size) domain.Rect {
51- return reconnectToastBoundsFor(size, reconnectToastMessage)
52-}
53-
54 func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {24 func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {
55 return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter})25 return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter})
56 }26 }
57 27
58-func reconnectToastLines(bounds domain.Rect) []string {
59- return reconnectToastLinesFor(bounds, reconnectToastMessage)
60-}
61-
62 func reconnectToastLinesFor(bounds domain.Rect, message string) []string {28 func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
63 if bounds.Width <= 0 || bounds.Height <= 0 {29 if bounds.Width <= 0 || bounds.Height <= 0 {
64 return nil30 return nil
M internal/usecase/client/runtime_primitives.go +0 -9
@@ -56,12 +56,3 @@ func (l *foregroundSendLease) stop() {
5656 }
5757
5858 const stdinBufSize = 4096
59-
60-type reconnectStage uint8
61-
62-const (
63- reconnectStageDegraded reconnectStage = iota + 1
64- reconnectStageProbingUDP
65- reconnectStageSSH
66- reconnectStageOfflineRetrying
67-)
@@ -56,12 +56,3 @@ func (l *foregroundSendLease) stop() {
56 }56 }
57 57
58 const stdinBufSize = 409658 const stdinBufSize = 4096
59-
60-type reconnectStage uint8
61-
62-const (
63- reconnectStageDegraded reconnectStage = iota + 1
64- reconnectStageProbingUDP
65- reconnectStageSSH
66- reconnectStageOfflineRetrying
67-)
M internal/usecase/client/transition_ui.go +1 -116
@@ -3,132 +3,17 @@ package client
33 import (
44 "bytes"
55 "fmt"
6- "io"
7- "time"
86
97 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/protocol"
12-)
13-
14-const (
15- transitionSpinnerDelay = 120 * time.Millisecond
16- transitionSpinnerInterval = 80 * time.Millisecond
178 )
189
1910 var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
2011
21-func transitionMessage(target protocol.AttachTarget) string {
22- origin := target.Endpoint
23- label := target.Session
24- if origin != "" {
25- label = domain.RemoteSessionDisplay(target.Session, origin)
26- }
27- verb := "Switching to "
28- if target.SessionTarget != nil && target.SessionTarget.Stopped {
29- verb = "Starting "
30- }
31- return verb + label + "…"
32-}
33-
3412 // RenderTransitionNotice draws one connecting frame for the terminal owner.
3513 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {
3614 var out bytes.Buffer
37- if _, err := drawTransitionToast(&out, size, frame, message); err != nil {
15+ if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message)); err != nil {
3816 return nil
3917 }
4018 return out.Bytes()
4119 }
42-
43-func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) {
44- spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)]
45- return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message))
46-}
47-
48-// transitionUI owns the client-local handoff presentation. Every method is
49-// called by the active attach loop, preserving the terminal's single writer
50-// rule while network operations expose timer/resize events.
51-type transitionUI struct {
52- term ports.Terminal
53- clock ports.Clock
54- rawEntered *bool
55- timer ports.Timer
56- active bool
57- showing bool
58- frame int
59- message string
60-}
61-
62-func newTransitionUI(term ports.Terminal, clock ports.Clock, rawEntered *bool) *transitionUI {
63- return &transitionUI{term: term, clock: clock, rawEntered: rawEntered}
64-}
65-
66-func (u *transitionUI) start(target protocol.AttachTarget) {
67- if u == nil {
68- return
69- }
70- u.stop()
71- u.active = true
72- u.frame = 0
73- u.message = transitionMessage(target)
74- if u.clock != nil {
75- u.timer = u.clock.NewTimer(transitionSpinnerDelay)
76- }
77-}
78-
79-func (u *transitionUI) tickC() <-chan time.Time {
80- if u == nil || !u.active || u.timer == nil {
81- return nil
82- }
83- return u.timer.C()
84-}
85-
86-func (u *transitionUI) advance() error {
87- if u == nil || !u.active {
88- return nil
89- }
90- geometry, err := u.term.Geometry()
91- if err != nil {
92- return fmt.Errorf("reading terminal geometry for transition toast: %w", err)
93- }
94- if err := u.redraw(geometry.Size); err != nil {
95- return err
96- }
97- u.frame = (u.frame + 1) % len(transitionSpinnerFrames)
98- if u.timer != nil {
99- u.timer.Reset(transitionSpinnerInterval)
100- }
101- return nil
102-}
103-
104-func (u *transitionUI) redraw(size domain.Size) error {
105- if u == nil || !u.active || u.rawEntered == nil || !*u.rawEntered {
106- return nil
107- }
108- bounds, err := drawTransitionToast(u.term.Out(), size, u.frame, u.message)
109- if err != nil {
110- return fmt.Errorf("drawing transition toast: %w", err)
111- }
112- if bounds.Width == 0 || bounds.Height == 0 {
113- return nil
114- }
115- if err := u.term.Flush(); err != nil {
116- return fmt.Errorf("flushing transition toast: %w", err)
117- }
118- u.showing = true
119- return nil
120-}
121-
122-func (u *transitionUI) stop() {
123- if u == nil {
124- return
125- }
126- if u.timer != nil {
127- u.timer.Stop()
128- u.timer = nil
129- }
130- u.active = false
131- u.showing = false
132- u.frame = 0
133- u.message = ""
134-}
@@ -3,132 +3,17 @@ package client
3 import (3 import (
4 "bytes"4 "bytes"
5 "fmt"5 "fmt"
6- "io"
7- "time"
8 6
9 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/protocol"
12-)
13-
14-const (
15- transitionSpinnerDelay = 120 * time.Millisecond
16- transitionSpinnerInterval = 80 * time.Millisecond
17 )8 )
18 9
19 var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}10 var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
20 11
21-func transitionMessage(target protocol.AttachTarget) string {
22- origin := target.Endpoint
23- label := target.Session
24- if origin != "" {
25- label = domain.RemoteSessionDisplay(target.Session, origin)
26- }
27- verb := "Switching to "
28- if target.SessionTarget != nil && target.SessionTarget.Stopped {
29- verb = "Starting "
30- }
31- return verb + label + "…"
32-}
33-
34 // RenderTransitionNotice draws one connecting frame for the terminal owner.12 // RenderTransitionNotice draws one connecting frame for the terminal owner.
35 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {13 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {
36 var out bytes.Buffer14 var out bytes.Buffer
37- if _, err := drawTransitionToast(&out, size, frame, message); err != nil {15+ if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message)); err != nil {
38 return nil16 return nil
39 }17 }
40 return out.Bytes()18 return out.Bytes()
41 }19 }
42-
43-func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) {
44- spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)]
45- return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message))
46-}
47-
48-// transitionUI owns the client-local handoff presentation. Every method is
49-// called by the active attach loop, preserving the terminal's single writer
50-// rule while network operations expose timer/resize events.
51-type transitionUI struct {
52- term ports.Terminal
53- clock ports.Clock
54- rawEntered *bool
55- timer ports.Timer
56- active bool
57- showing bool
58- frame int
59- message string
60-}
61-
62-func newTransitionUI(term ports.Terminal, clock ports.Clock, rawEntered *bool) *transitionUI {
63- return &transitionUI{term: term, clock: clock, rawEntered: rawEntered}
64-}
65-
66-func (u *transitionUI) start(target protocol.AttachTarget) {
67- if u == nil {
68- return
69- }
70- u.stop()
71- u.active = true
72- u.frame = 0
73- u.message = transitionMessage(target)
74- if u.clock != nil {
75- u.timer = u.clock.NewTimer(transitionSpinnerDelay)
76- }
77-}
78-
79-func (u *transitionUI) tickC() <-chan time.Time {
80- if u == nil || !u.active || u.timer == nil {
81- return nil
82- }
83- return u.timer.C()
84-}
85-
86-func (u *transitionUI) advance() error {
87- if u == nil || !u.active {
88- return nil
89- }
90- geometry, err := u.term.Geometry()
91- if err != nil {
92- return fmt.Errorf("reading terminal geometry for transition toast: %w", err)
93- }
94- if err := u.redraw(geometry.Size); err != nil {
95- return err
96- }
97- u.frame = (u.frame + 1) % len(transitionSpinnerFrames)
98- if u.timer != nil {
99- u.timer.Reset(transitionSpinnerInterval)
100- }
101- return nil
102-}
103-
104-func (u *transitionUI) redraw(size domain.Size) error {
105- if u == nil || !u.active || u.rawEntered == nil || !*u.rawEntered {
106- return nil
107- }
108- bounds, err := drawTransitionToast(u.term.Out(), size, u.frame, u.message)
109- if err != nil {
110- return fmt.Errorf("drawing transition toast: %w", err)
111- }
112- if bounds.Width == 0 || bounds.Height == 0 {
113- return nil
114- }
115- if err := u.term.Flush(); err != nil {
116- return fmt.Errorf("flushing transition toast: %w", err)
117- }
118- u.showing = true
119- return nil
120-}
121-
122-func (u *transitionUI) stop() {
123- if u == nil {
124- return
125- }
126- if u.timer != nil {
127- u.timer.Stop()
128- u.timer = nil
129- }
130- u.active = false
131- u.showing = false
132- u.frame = 0
133- u.message = ""
134-}
M internal/usecase/client/transition_ui_test.go +7 -115
@@ -1,126 +1,18 @@
11 package client
22
33 import (
4- "bytes"
5- "io"
6- "strings"
74 "testing"
8- "time"
95
106 "github.com/stretchr/testify/require"
117
128 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/internal/protocol"
159 )
1610
17-func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) {
18- live := protocol.AttachTarget{Endpoint: "remote", Session: "work"}
19- stoppedTarget := domain.RemoteSessionTarget{
20- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
21- SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"),
22- }
23- stopped := protocol.AttachTarget{Endpoint: "remote", Session: "work", SessionTarget: &protocol.SessionAttachTarget{LifecycleID: stoppedTarget.LifecycleID, SessionName: stoppedTarget.SessionName, TabIndex: protocol.NoTabIndex, Stopped: true}}
24-
25- require.Equal(t, "Switching to work@remote…", transitionMessage(live))
26- require.Equal(t, "Starting work@remote…", transitionMessage(stopped))
27-}
28-
29-func TestDrawTransitionToastUsesAnimatedFrames(t *testing.T) {
30- var first, second bytes.Buffer
31- _, err := drawTransitionToast(&first, domain.Size{Cols: 80, Rows: 24}, 0, "Switching to work@remote…")
32- require.NoError(t, err)
33- _, err = drawTransitionToast(&second, domain.Size{Cols: 80, Rows: 24}, 1, "Switching to work@remote…")
34- require.NoError(t, err)
35-
36- require.Contains(t, first.String(), string(transitionSpinnerFrames[0]))
37- require.Contains(t, second.String(), string(transitionSpinnerFrames[1]))
38- require.Contains(t, first.String(), "Switching to work@remote…")
39- require.NotEqual(t, first.String(), second.String())
40-}
41-
42-type transitionTestTimer struct {
43- ch chan time.Time
44- resets []time.Duration
45- stopped bool
46-}
47-
48-func (t *transitionTestTimer) C() <-chan time.Time { return t.ch }
49-func (t *transitionTestTimer) Reset(delay time.Duration) bool {
50- t.resets = append(t.resets, delay)
51- return true
52-}
53-func (t *transitionTestTimer) Stop() bool {
54- t.stopped = true
55- return true
56-}
57-
58-type transitionTestClock struct {
59- timer *transitionTestTimer
60- delays []time.Duration
61-}
62-
63-func (c *transitionTestClock) Now() time.Time { return time.Time{} }
64-func (c *transitionTestClock) NewTimer(delay time.Duration) ports.Timer {
65- c.delays = append(c.delays, delay)
66- return c.timer
67-}
68-
69-type transitionTestTerminal struct {
70- out bytes.Buffer
71-}
72-
73-func (*transitionTestTerminal) EnterRaw() (func() error, error) {
74- return func() error { return nil }, nil
75-}
76-func (*transitionTestTerminal) Geometry() (domain.Geometry, error) {
77- return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
78-}
79-func (*transitionTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
80-func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") }
81-func (t *transitionTestTerminal) Out() io.Writer { return &t.out }
82-func (*transitionTestTerminal) Flush() error { return nil }
83-
84-func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) {
85- for _, tt := range []struct {
86- name string
87- raw bool
88- }{
89- {name: "raw", raw: true},
90- {name: "not raw", raw: false},
91- } {
92- t.Run(tt.name, func(t *testing.T) {
93- timer := &transitionTestTimer{ch: make(chan time.Time, 1)}
94- clock := &transitionTestClock{timer: timer}
95- term := &transitionTestTerminal{}
96- raw := tt.raw
97- ui := newTransitionUI(term, clock, &raw)
98- ui.start(protocol.AttachTarget{Endpoint: "remote", Session: "work"})
99-
100- require.Equal(t, []time.Duration{transitionSpinnerDelay}, clock.delays)
101- require.NotNil(t, ui.tickC())
102- require.Empty(t, term.out.String(), "the delay prevents flashes on fast switches")
103- require.NoError(t, ui.advance())
104- require.Equal(t, []time.Duration{transitionSpinnerInterval}, timer.resets)
105- if !tt.raw {
106- require.Empty(t, term.out.String())
107- require.False(t, ui.showing)
108- ui.stop()
109- require.True(t, timer.stopped)
110- require.Nil(t, ui.tickC())
111- return
112- }
113-
114- require.Contains(t, term.out.String(), "Switching to work@remote…")
115- require.True(t, ui.showing)
116- beforeResize := term.out.Len()
117- require.NoError(t, ui.redraw(domain.Size{Cols: 100, Rows: 30}))
118- require.Greater(t, term.out.Len(), beforeResize)
119- require.True(t, strings.Contains(term.out.String(), "Switching to work@remote…"))
120-
121- ui.stop()
122- require.True(t, timer.stopped)
123- require.Nil(t, ui.tickC())
124- })
125- }
11+func TestRenderTransitionNoticeAnimatedFrames(t *testing.T) {
12+ size := domain.Size{Cols: 80, Rows: 24}
13+ first := RenderTransitionNotice(size, 0, "Connecting to session…")
14+ second := RenderTransitionNotice(size, 1, "Connecting to session…")
15+ require.Contains(t, string(first), string(transitionSpinnerFrames[0]))
16+ require.Contains(t, string(second), string(transitionSpinnerFrames[1]))
17+ require.NotEqual(t, first, second)
12618 }
@@ -1,126 +1,18 @@
1 package client1 package client
2 2
3 import (3 import (
4- "bytes"
5- "io"
6- "strings"
7 "testing"4 "testing"
8- "time"
9 5
10 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
11 7
12 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/internal/protocol"
15 )9 )
16 10
17-func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) {11+func TestRenderTransitionNoticeAnimatedFrames(t *testing.T) {
18- live := protocol.AttachTarget{Endpoint: "remote", Session: "work"}12+ size := domain.Size{Cols: 80, Rows: 24}
19- stoppedTarget := domain.RemoteSessionTarget{13+ first := RenderTransitionNotice(size, 0, "Connecting to session…")
20- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},14+ second := RenderTransitionNotice(size, 1, "Connecting to session…")
21- SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"),15+ require.Contains(t, string(first), string(transitionSpinnerFrames[0]))
22- }16+ require.Contains(t, string(second), string(transitionSpinnerFrames[1]))
23- stopped := protocol.AttachTarget{Endpoint: "remote", Session: "work", SessionTarget: &protocol.SessionAttachTarget{LifecycleID: stoppedTarget.LifecycleID, SessionName: stoppedTarget.SessionName, TabIndex: protocol.NoTabIndex, Stopped: true}}17+ require.NotEqual(t, first, second)
24-
25- require.Equal(t, "Switching to work@remote…", transitionMessage(live))
26- require.Equal(t, "Starting work@remote…", transitionMessage(stopped))
27-}
28-
29-func TestDrawTransitionToastUsesAnimatedFrames(t *testing.T) {
30- var first, second bytes.Buffer
31- _, err := drawTransitionToast(&first, domain.Size{Cols: 80, Rows: 24}, 0, "Switching to work@remote…")
32- require.NoError(t, err)
33- _, err = drawTransitionToast(&second, domain.Size{Cols: 80, Rows: 24}, 1, "Switching to work@remote…")
34- require.NoError(t, err)
35-
36- require.Contains(t, first.String(), string(transitionSpinnerFrames[0]))
37- require.Contains(t, second.String(), string(transitionSpinnerFrames[1]))
38- require.Contains(t, first.String(), "Switching to work@remote…")
39- require.NotEqual(t, first.String(), second.String())
40-}
41-
42-type transitionTestTimer struct {
43- ch chan time.Time
44- resets []time.Duration
45- stopped bool
46-}
47-
48-func (t *transitionTestTimer) C() <-chan time.Time { return t.ch }
49-func (t *transitionTestTimer) Reset(delay time.Duration) bool {
50- t.resets = append(t.resets, delay)
51- return true
52-}
53-func (t *transitionTestTimer) Stop() bool {
54- t.stopped = true
55- return true
56-}
57-
58-type transitionTestClock struct {
59- timer *transitionTestTimer
60- delays []time.Duration
61-}
62-
63-func (c *transitionTestClock) Now() time.Time { return time.Time{} }
64-func (c *transitionTestClock) NewTimer(delay time.Duration) ports.Timer {
65- c.delays = append(c.delays, delay)
66- return c.timer
67-}
68-
69-type transitionTestTerminal struct {
70- out bytes.Buffer
71-}
72-
73-func (*transitionTestTerminal) EnterRaw() (func() error, error) {
74- return func() error { return nil }, nil
75-}
76-func (*transitionTestTerminal) Geometry() (domain.Geometry, error) {
77- return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
78-}
79-func (*transitionTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
80-func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") }
81-func (t *transitionTestTerminal) Out() io.Writer { return &t.out }
82-func (*transitionTestTerminal) Flush() error { return nil }
83-
84-func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) {
85- for _, tt := range []struct {
86- name string
87- raw bool
88- }{
89- {name: "raw", raw: true},
90- {name: "not raw", raw: false},
91- } {
92- t.Run(tt.name, func(t *testing.T) {
93- timer := &transitionTestTimer{ch: make(chan time.Time, 1)}
94- clock := &transitionTestClock{timer: timer}
95- term := &transitionTestTerminal{}
96- raw := tt.raw
97- ui := newTransitionUI(term, clock, &raw)
98- ui.start(protocol.AttachTarget{Endpoint: "remote", Session: "work"})
99-
100- require.Equal(t, []time.Duration{transitionSpinnerDelay}, clock.delays)
101- require.NotNil(t, ui.tickC())
102- require.Empty(t, term.out.String(), "the delay prevents flashes on fast switches")
103- require.NoError(t, ui.advance())
104- require.Equal(t, []time.Duration{transitionSpinnerInterval}, timer.resets)
105- if !tt.raw {
106- require.Empty(t, term.out.String())
107- require.False(t, ui.showing)
108- ui.stop()
109- require.True(t, timer.stopped)
110- require.Nil(t, ui.tickC())
111- return
112- }
113-
114- require.Contains(t, term.out.String(), "Switching to work@remote…")
115- require.True(t, ui.showing)
116- beforeResize := term.out.Len()
117- require.NoError(t, ui.redraw(domain.Size{Cols: 100, Rows: 30}))
118- require.Greater(t, term.out.Len(), beforeResize)
119- require.True(t, strings.Contains(term.out.String(), "Switching to work@remote…"))
120-
121- ui.stop()
122- require.True(t, timer.stopped)
123- require.Nil(t, ui.tickC())
124- })
125- }
126 }18 }
M internal/usecase/client/ui.go +0 -73
@@ -18,79 +18,6 @@ const uiActionHistory = 64
1818 // UI owns attachment-local action and waiter bookkeeping. It never holds its
1919 // mutex across terminal access, transport I/O, or predicate evaluation.
2020
21-// pickerOutcomeQueue carries decoded picker operations from the stdin-pump
22-// goroutine to the attach loop. The pump never touches the terminal, the
23-// control channel, or the picker model: it only reports what the user
24-// typed, and the attach loop applies it through its sole terminal writer
25-// and flushes typed sends in controlCh order.
26-//
27-// The queue is bounded: physical input is now a producer, so an unbounded
28-// queue would let a paste or a stuck attach loop accumulate operations
29-// without limit. offer blocks until the loop drains or the attempt ends.
30-type pickerOutcomeQueue struct {
31- mu sync.Mutex
32- outcomes []pickerConsumeOutcome
33- signal chan struct{}
34- slots chan struct{}
35- done <-chan struct{}
36-}
37-
38-// pickerOutcomeCapacity bounds queued picker operations between the pump and
39-// the attach loop.
40-const pickerOutcomeCapacity = 256
41-
42-func newPickerOutcomeQueue(done <-chan struct{}) *pickerOutcomeQueue {
43- queue := &pickerOutcomeQueue{
44- signal: make(chan struct{}, 1),
45- slots: make(chan struct{}, pickerOutcomeCapacity),
46- done: done,
47- }
48- for i := 0; i < pickerOutcomeCapacity; i++ {
49- queue.slots <- struct{}{}
50- }
51- return queue
52-}
53-
54-// offer queues one decoded operation, waiting for room. A cancelled attempt
55-// drops it: nothing can apply the operation any more.
56-func (q *pickerOutcomeQueue) offer(outcome pickerConsumeOutcome) {
57- if q == nil {
58- return
59- }
60- select {
61- case <-q.slots:
62- case <-q.done:
63- return
64- }
65- q.mu.Lock()
66- q.outcomes = append(q.outcomes, outcome)
67- q.mu.Unlock()
68- select {
69- case q.signal <- struct{}{}:
70- default:
71- }
72-}
73-
74-// take drains queued operations in order and returns their capacity. The
75-// caller holds no locks; sends keep controlCh ordering because the attach
76-// loop flushes them before processing new frames.
77-func (q *pickerOutcomeQueue) take() []pickerConsumeOutcome {
78- if q == nil {
79- return nil
80- }
81- q.mu.Lock()
82- outcomes := q.outcomes
83- q.outcomes = nil
84- q.mu.Unlock()
85- for range outcomes {
86- select {
87- case q.slots <- struct{}{}:
88- default:
89- }
90- }
91- return outcomes
92-}
93-
9421 type UI struct {
9522 state ports.UIState
9623 clock ports.Clock
@@ -18,79 +18,6 @@ const uiActionHistory = 64
18 // UI owns attachment-local action and waiter bookkeeping. It never holds its18 // UI owns attachment-local action and waiter bookkeeping. It never holds its
19 // mutex across terminal access, transport I/O, or predicate evaluation.19 // mutex across terminal access, transport I/O, or predicate evaluation.
20 20
21-// pickerOutcomeQueue carries decoded picker operations from the stdin-pump
22-// goroutine to the attach loop. The pump never touches the terminal, the
23-// control channel, or the picker model: it only reports what the user
24-// typed, and the attach loop applies it through its sole terminal writer
25-// and flushes typed sends in controlCh order.
26-//
27-// The queue is bounded: physical input is now a producer, so an unbounded
28-// queue would let a paste or a stuck attach loop accumulate operations
29-// without limit. offer blocks until the loop drains or the attempt ends.
30-type pickerOutcomeQueue struct {
31- mu sync.Mutex
32- outcomes []pickerConsumeOutcome
33- signal chan struct{}
34- slots chan struct{}
35- done <-chan struct{}
36-}
37-
38-// pickerOutcomeCapacity bounds queued picker operations between the pump and
39-// the attach loop.
40-const pickerOutcomeCapacity = 256
41-
42-func newPickerOutcomeQueue(done <-chan struct{}) *pickerOutcomeQueue {
43- queue := &pickerOutcomeQueue{
44- signal: make(chan struct{}, 1),
45- slots: make(chan struct{}, pickerOutcomeCapacity),
46- done: done,
47- }
48- for i := 0; i < pickerOutcomeCapacity; i++ {
49- queue.slots <- struct{}{}
50- }
51- return queue
52-}
53-
54-// offer queues one decoded operation, waiting for room. A cancelled attempt
55-// drops it: nothing can apply the operation any more.
56-func (q *pickerOutcomeQueue) offer(outcome pickerConsumeOutcome) {
57- if q == nil {
58- return
59- }
60- select {
61- case <-q.slots:
62- case <-q.done:
63- return
64- }
65- q.mu.Lock()
66- q.outcomes = append(q.outcomes, outcome)
67- q.mu.Unlock()
68- select {
69- case q.signal <- struct{}{}:
70- default:
71- }
72-}
73-
74-// take drains queued operations in order and returns their capacity. The
75-// caller holds no locks; sends keep controlCh ordering because the attach
76-// loop flushes them before processing new frames.
77-func (q *pickerOutcomeQueue) take() []pickerConsumeOutcome {
78- if q == nil {
79- return nil
80- }
81- q.mu.Lock()
82- outcomes := q.outcomes
83- q.outcomes = nil
84- q.mu.Unlock()
85- for range outcomes {
86- select {
87- case q.slots <- struct{}{}:
88- default:
89- }
90- }
91- return outcomes
92-}
93-
94 type UI struct {21 type UI struct {
95 state ports.UIState22 state ports.UIState
96 clock ports.Clock23 clock ports.Clock
M internal/usecase/client/ui_actions.go +3 -1
@@ -274,7 +274,9 @@ func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan boo
274274 u.mu.Lock()
275275 if _, exists := u.records[id]; exists {
276276 u.dispatched[id] = true
277- u.completeHandoffLocked()
277+ if u.handoff != nil && u.handoff.boundary.Revision != 0 {
278+ u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary)
279+ }
278280 }
279281 u.mu.Unlock()
280282 }
@@ -274,7 +274,9 @@ func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan boo
274 u.mu.Lock()274 u.mu.Lock()
275 if _, exists := u.records[id]; exists {275 if _, exists := u.records[id]; exists {
276 u.dispatched[id] = true276 u.dispatched[id] = true
277- u.completeHandoffLocked()277+ if u.handoff != nil && u.handoff.boundary.Revision != 0 {
278+ u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary)
279+ }
278 }280 }
279 u.mu.Unlock()281 u.mu.Unlock()
280 }282 }
D internal/usecase/client/ui_handoff.go +0 -82
@@ -1,82 +0,0 @@
1-package client
2-
3-import (
4- "context"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-// follow joins an immutable navigation cause to the UI's existing handoff.
10-// It selects no route and never retries or opens a connection itself.
11-func (u *UI) follow(generation, actionID uint64) bool {
12- var dispatchContext context.Context
13- startDispatch := false
14- u.mu.Lock()
15- record, exists := u.records[actionID]
16- if actionID == 0 || !exists || generation != u.generation || record.Context.Generation != generation || record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed {
17- u.mu.Unlock()
18- return false
19- }
20- if u.pending != 0 && u.pending != actionID {
21- u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
22- }
23- if record.Status == ports.UIActionProcessed {
24- dispatchContext = u.foreground
25- if dispatchContext == nil {
26- dispatchContext = context.Background()
27- }
28- startDispatch = true
29- }
30- record.Status = ports.UIActionPending
31- u.records[actionID] = record
32- u.pending = actionID
33- u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation}
34- u.signalLocked()
35- u.mu.Unlock()
36- if startDispatch {
37- go u.trackDispatch(dispatchContext, actionID, nil)
38- }
39- return true
40-}
41-
42-// completeLocal runs after the foreground's FIFO callback barrier, without
43-// inventing a daemon fence for an action consumed entirely by the client.
44-func (u *UI) completeLocal(generation, actionID uint64) {
45- snapshot, err := u.state.Snapshot()
46- if err != nil {
47- return
48- }
49- u.mu.Lock()
50- defer u.mu.Unlock()
51- record, exists := u.records[actionID]
52- if !exists || record.Status != ports.UIActionPending || generation != u.generation || snapshot.Context.Generation != generation || u.handoff != nil {
53- return
54- }
55- u.finishLocked(actionID, ports.UIActionProcessed, ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context})
56-}
57-
58-func (u *UI) failHandoff() {
59- u.mu.Lock()
60- defer u.mu.Unlock()
61- if u.handoff != nil {
62- u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{})
63- }
64-}
65-
66-// destinationFull is called only after the attachment owner validates, writes,
67-// and publishes its destination full Output. Identity alone is not proof.
68-func (u *UI) destinationFull(generation uint64) {
69- u.mu.Lock()
70- defer u.mu.Unlock()
71- if u.handoff == nil || u.handoff.destinationGeneration != generation || generation == u.handoff.sourceGeneration || u.boundary.Context.Generation != generation || u.boundary.Context.Status != ports.UIStatusAttached {
72- return
73- }
74- u.handoff.boundary = u.boundary
75- u.completeHandoffLocked()
76-}
77-
78-func (u *UI) completeHandoffLocked() {
79- if u.handoff != nil && u.handoff.boundary.Revision != 0 && u.dispatched[u.handoff.actionID] {
80- u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary)
81- }
82-}
@@ -1,82 +0,0 @@
1-package client
2-
3-import (
4- "context"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-// follow joins an immutable navigation cause to the UI's existing handoff.
10-// It selects no route and never retries or opens a connection itself.
11-func (u *UI) follow(generation, actionID uint64) bool {
12- var dispatchContext context.Context
13- startDispatch := false
14- u.mu.Lock()
15- record, exists := u.records[actionID]
16- if actionID == 0 || !exists || generation != u.generation || record.Context.Generation != generation || record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed {
17- u.mu.Unlock()
18- return false
19- }
20- if u.pending != 0 && u.pending != actionID {
21- u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
22- }
23- if record.Status == ports.UIActionProcessed {
24- dispatchContext = u.foreground
25- if dispatchContext == nil {
26- dispatchContext = context.Background()
27- }
28- startDispatch = true
29- }
30- record.Status = ports.UIActionPending
31- u.records[actionID] = record
32- u.pending = actionID
33- u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation}
34- u.signalLocked()
35- u.mu.Unlock()
36- if startDispatch {
37- go u.trackDispatch(dispatchContext, actionID, nil)
38- }
39- return true
40-}
41-
42-// completeLocal runs after the foreground's FIFO callback barrier, without
43-// inventing a daemon fence for an action consumed entirely by the client.
44-func (u *UI) completeLocal(generation, actionID uint64) {
45- snapshot, err := u.state.Snapshot()
46- if err != nil {
47- return
48- }
49- u.mu.Lock()
50- defer u.mu.Unlock()
51- record, exists := u.records[actionID]
52- if !exists || record.Status != ports.UIActionPending || generation != u.generation || snapshot.Context.Generation != generation || u.handoff != nil {
53- return
54- }
55- u.finishLocked(actionID, ports.UIActionProcessed, ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context})
56-}
57-
58-func (u *UI) failHandoff() {
59- u.mu.Lock()
60- defer u.mu.Unlock()
61- if u.handoff != nil {
62- u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{})
63- }
64-}
65-
66-// destinationFull is called only after the attachment owner validates, writes,
67-// and publishes its destination full Output. Identity alone is not proof.
68-func (u *UI) destinationFull(generation uint64) {
69- u.mu.Lock()
70- defer u.mu.Unlock()
71- if u.handoff == nil || u.handoff.destinationGeneration != generation || generation == u.handoff.sourceGeneration || u.boundary.Context.Generation != generation || u.boundary.Context.Status != ports.UIStatusAttached {
72- return
73- }
74- u.handoff.boundary = u.boundary
75- u.completeHandoffLocked()
76-}
77-
78-func (u *UI) completeHandoffLocked() {
79- if u.handoff != nil && u.handoff.boundary.Revision != 0 && u.dispatched[u.handoff.actionID] {
80- u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary)
81- }
82-}

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