detached 45a9fcd

feat(client): render connecting and retry transitions

commit
45a9fcdb9fae9b88bed727c361c3274dadb6cbfe
author
brice · · 2026-09-22 21:08 UTC
parent
467a7dd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +101 -12
M internal/app/broker_client.go +63 -9
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "os"
88 "sync"
9+ "time"
910
1011 "github.com/bnema/vev/internal/adapters/clipboard"
1112 "github.com/bnema/vev/internal/adapters/clock"
@@ -88,6 +89,9 @@ type brokerClientPresentation struct {
8889 // channel.
8990 ui *client.UI
9091 onState func(client.State)
92+ clock ports.Clock
93+ spinner ports.Timer
94+ frame int
9195
9296 mu sync.Mutex
9397 }
@@ -116,28 +120,40 @@ func (p *brokerClientPresentation) Render(state client.State) {
116120 if p.onState != nil {
117121 p.onState(state)
118122 }
119- if !client.PickerPresentation(state) {
123+ if !client.PickerPresentation(state) && (state.Presentation != client.PresentConnecting || p.clock == nil) {
120124 return
121125 }
126+ p.mu.Lock()
127+ defer p.mu.Unlock()
122128 geometry, err := p.terminal.Geometry()
123129 if err != nil {
124130 return
125131 }
126- frame := p.picker.Render(geometry.Size)
127- notice := p.picker.RenderNotice(geometry.Size)
132+ var frame, notice []byte
133+ if client.PickerPresentation(state) {
134+ frame = p.picker.Render(geometry.Size)
135+ notice = p.picker.RenderNotice(geometry.Size)
136+ } else {
137+ frame = client.RenderTransitionNotice(geometry.Size, p.frame, "Connecting to session…")
138+ }
139+ if state.Connectivity == client.ConnectivityRetryWait {
140+ notice = client.RenderTransitionNotice(geometry.Size, p.frame, "Connection lost; retrying…")
141+ }
128142 if len(frame) == 0 && len(notice) == 0 {
129143 return
130144 }
131- p.mu.Lock()
132- defer p.mu.Unlock()
133145 writer := p.terminal.Out()
134146 if writer == nil {
135147 return
136148 }
137149 transaction, _ := p.terminal.(ports.UIOutputTransaction)
150+ context := p.pickerContext()
151+ if state.Presentation == client.PresentConnecting {
152+ context.Status = ports.UIStatusConnecting
153+ }
138154 if transaction != nil {
139- transaction.BeginOutput(p.pickerContext())
140- if err := transaction.PublishContext(p.pickerContext()); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
155+ transaction.BeginOutput(context)
156+ if err := transaction.PublishContext(context); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
141157 transaction.EndOutput(false)
142158 return
143159 }
@@ -161,6 +177,33 @@ func (p *brokerClientPresentation) Render(state client.State) {
161177 }
162178 }
163179
180+func (p *brokerClientPresentation) Spinner() <-chan time.Time {
181+ p.mu.Lock()
182+ defer p.mu.Unlock()
183+ if p.spinner == nil {
184+ p.spinner = p.clock.NewTimer(120 * time.Millisecond)
185+ }
186+ return p.spinner.C()
187+}
188+
189+func (p *brokerClientPresentation) AdvanceSpinner(state client.State) {
190+ p.mu.Lock()
191+ p.frame++
192+ if p.spinner != nil {
193+ p.spinner.Reset(120 * time.Millisecond)
194+ }
195+ p.mu.Unlock()
196+ p.Render(state)
197+}
198+
199+func (p *brokerClientPresentation) StopSpinner() {
200+ p.mu.Lock()
201+ if p.spinner != nil {
202+ p.spinner.Stop()
203+ }
204+ p.mu.Unlock()
205+}
206+
164207 // pickerContext is the Picker presentation of this run: the stable UI handle and
165208 // the status alone. It is the shape every unattached publication uses, so a
166209 // capture of the painted picker never shows an old attachment's session
@@ -225,7 +268,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
225268 reader = clipboard.New()
226269 }
227270 picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)
228- presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}
271+ presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}
229272 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
230273 Connector: cfg.Connector,
231274 Terminal: cfg.Terminal,
@@ -246,7 +289,18 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
246289 }
247290 presentation.Failure(err)
248291 },
249- NotifyLifecycle: cfg.OnLifecycle,
292+ NotifyLifecycle: func(notice client.LifecycleNotice) {
293+ if cfg.OnLifecycle != nil {
294+ cfg.OnLifecycle(notice)
295+ }
296+ if notice.Kind == client.LifecycleNoticeBrokerLost {
297+ picker.OfferNotice("broker-lost", "Connection lost; retrying…")
298+ }
299+ if notice.Kind == client.LifecycleNoticeBrokerReconnected {
300+ picker.OfferNotice("broker-reconnected", "Connection restored")
301+ }
302+ },
303+ Spinner: presentation,
250304 })
251305 if err != nil {
252306 return err
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "sync"8 "sync"
9+ "time"
9 10
10 "github.com/bnema/vev/internal/adapters/clipboard"11 "github.com/bnema/vev/internal/adapters/clipboard"
11 "github.com/bnema/vev/internal/adapters/clock"12 "github.com/bnema/vev/internal/adapters/clock"
@@ -88,6 +89,9 @@ type brokerClientPresentation struct {
88 // channel.89 // channel.
89 ui *client.UI90 ui *client.UI
90 onState func(client.State)91 onState func(client.State)
92+ clock ports.Clock
93+ spinner ports.Timer
94+ frame int
91 95
92 mu sync.Mutex96 mu sync.Mutex
93 }97 }
@@ -116,28 +120,40 @@ func (p *brokerClientPresentation) Render(state client.State) {
116 if p.onState != nil {120 if p.onState != nil {
117 p.onState(state)121 p.onState(state)
118 }122 }
119- if !client.PickerPresentation(state) {123+ if !client.PickerPresentation(state) && (state.Presentation != client.PresentConnecting || p.clock == nil) {
120 return124 return
121 }125 }
126+ p.mu.Lock()
127+ defer p.mu.Unlock()
122 geometry, err := p.terminal.Geometry()128 geometry, err := p.terminal.Geometry()
123 if err != nil {129 if err != nil {
124 return130 return
125 }131 }
126- frame := p.picker.Render(geometry.Size)132+ var frame, notice []byte
127- notice := p.picker.RenderNotice(geometry.Size)133+ if client.PickerPresentation(state) {
134+ frame = p.picker.Render(geometry.Size)
135+ notice = p.picker.RenderNotice(geometry.Size)
136+ } else {
137+ frame = client.RenderTransitionNotice(geometry.Size, p.frame, "Connecting to session…")
138+ }
139+ if state.Connectivity == client.ConnectivityRetryWait {
140+ notice = client.RenderTransitionNotice(geometry.Size, p.frame, "Connection lost; retrying…")
141+ }
128 if len(frame) == 0 && len(notice) == 0 {142 if len(frame) == 0 && len(notice) == 0 {
129 return143 return
130 }144 }
131- p.mu.Lock()
132- defer p.mu.Unlock()
133 writer := p.terminal.Out()145 writer := p.terminal.Out()
134 if writer == nil {146 if writer == nil {
135 return147 return
136 }148 }
137 transaction, _ := p.terminal.(ports.UIOutputTransaction)149 transaction, _ := p.terminal.(ports.UIOutputTransaction)
150+ context := p.pickerContext()
151+ if state.Presentation == client.PresentConnecting {
152+ context.Status = ports.UIStatusConnecting
153+ }
138 if transaction != nil {154 if transaction != nil {
139- transaction.BeginOutput(p.pickerContext())155+ transaction.BeginOutput(context)
140- if err := transaction.PublishContext(p.pickerContext()); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {156+ if err := transaction.PublishContext(context); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
141 transaction.EndOutput(false)157 transaction.EndOutput(false)
142 return158 return
143 }159 }
@@ -161,6 +177,33 @@ func (p *brokerClientPresentation) Render(state client.State) {
161 }177 }
162 }178 }
163 179
180+func (p *brokerClientPresentation) Spinner() <-chan time.Time {
181+ p.mu.Lock()
182+ defer p.mu.Unlock()
183+ if p.spinner == nil {
184+ p.spinner = p.clock.NewTimer(120 * time.Millisecond)
185+ }
186+ return p.spinner.C()
187+}
188+
189+func (p *brokerClientPresentation) AdvanceSpinner(state client.State) {
190+ p.mu.Lock()
191+ p.frame++
192+ if p.spinner != nil {
193+ p.spinner.Reset(120 * time.Millisecond)
194+ }
195+ p.mu.Unlock()
196+ p.Render(state)
197+}
198+
199+func (p *brokerClientPresentation) StopSpinner() {
200+ p.mu.Lock()
201+ if p.spinner != nil {
202+ p.spinner.Stop()
203+ }
204+ p.mu.Unlock()
205+}
206+
164 // pickerContext is the Picker presentation of this run: the stable UI handle and207 // pickerContext is the Picker presentation of this run: the stable UI handle and
165 // the status alone. It is the shape every unattached publication uses, so a208 // the status alone. It is the shape every unattached publication uses, so a
166 // capture of the painted picker never shows an old attachment's session209 // capture of the painted picker never shows an old attachment's session
@@ -225,7 +268,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
225 reader = clipboard.New()268 reader = clipboard.New()
226 }269 }
227 picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)270 picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)
228- presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}271+ presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}
229 supervisor, err := client.NewSupervisor(client.SupervisorConfig{272 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
230 Connector: cfg.Connector,273 Connector: cfg.Connector,
231 Terminal: cfg.Terminal,274 Terminal: cfg.Terminal,
@@ -246,7 +289,18 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
246 }289 }
247 presentation.Failure(err)290 presentation.Failure(err)
248 },291 },
249- NotifyLifecycle: cfg.OnLifecycle,292+ NotifyLifecycle: func(notice client.LifecycleNotice) {
293+ if cfg.OnLifecycle != nil {
294+ cfg.OnLifecycle(notice)
295+ }
296+ if notice.Kind == client.LifecycleNoticeBrokerLost {
297+ picker.OfferNotice("broker-lost", "Connection lost; retrying…")
298+ }
299+ if notice.Kind == client.LifecycleNoticeBrokerReconnected {
300+ picker.OfferNotice("broker-reconnected", "Connection restored")
301+ }
302+ },
303+ Spinner: presentation,
250 })304 })
251 if err != nil {305 if err != nil {
252 return err306 return err
M internal/usecase/client/picker_composition.go +4 -2
@@ -130,13 +130,15 @@ func (p *Picker) SetOwnsInput(owns bool) {
130130 p.controller.SetOwnsInput(owns)
131131 }
132132
133-// offerNotice shows one bounded client-local notice on the picker.
134-func (p *Picker) offerNotice(id, message string) {
133+// OfferNotice shows one bounded client-local notice on the picker.
134+func (p *Picker) OfferNotice(id, message string) {
135135 if p != nil && p.controller != nil {
136136 p.controller.offerNotice(id, message)
137137 }
138138 }
139139
140+func (p *Picker) offerNotice(id, message string) { p.OfferNotice(id, message) }
141+
140142 // invalidatePresentation forces the next frame to redraw the whole box.
141143 func (p *Picker) invalidatePresentation() {
142144 if p != nil && p.controller != nil {
@@ -130,13 +130,15 @@ func (p *Picker) SetOwnsInput(owns bool) {
130 p.controller.SetOwnsInput(owns)130 p.controller.SetOwnsInput(owns)
131 }131 }
132 132
133-// offerNotice shows one bounded client-local notice on the picker.133+// OfferNotice shows one bounded client-local notice on the picker.
134-func (p *Picker) offerNotice(id, message string) {134+func (p *Picker) OfferNotice(id, message string) {
135 if p != nil && p.controller != nil {135 if p != nil && p.controller != nil {
136 p.controller.offerNotice(id, message)136 p.controller.offerNotice(id, message)
137 }137 }
138 }138 }
139 139
140+func (p *Picker) offerNotice(id, message string) { p.OfferNotice(id, message) }
141+
140 // invalidatePresentation forces the next frame to redraw the whole box.142 // invalidatePresentation forces the next frame to redraw the whole box.
141 func (p *Picker) invalidatePresentation() {143 func (p *Picker) invalidatePresentation() {
142 if p != nil && p.controller != nil {144 if p != nil && p.controller != nil {
M internal/usecase/client/supervisor.go +24 -1
@@ -281,6 +281,14 @@ type LifecycleNotice struct {
281281 // connector, terminal, and clock are required; render, notify, and jitter are
282282 // injectable seams with safe defaults. Render, notify, and jitter must not
283283 // block: the supervisor drives them from its single run goroutine.
284+// SpinnerPresentation advances a transition frame on the serialized control
285+// path, never from a competing terminal writer goroutine.
286+type SpinnerPresentation interface {
287+ Spinner() <-chan time.Time
288+ AdvanceSpinner(State)
289+ StopSpinner()
290+}
291+
284292 type SupervisorConfig struct {
285293 // Connector establishes the broker connection. Required.
286294 Connector ports.BrokerConnector
@@ -303,7 +311,8 @@ type SupervisorConfig struct {
303311 // overwrite session output. An attached foreground still owns the terminal
304312 // and a terminating process is leaving it, so neither is ever painted over
305313 // by a resize invalidation. Optional; the default renders nothing.
306- Render func(State)
314+ Render func(State)
315+ Spinner SpinnerPresentation
307316 // Notify surfaces a connectivity failure without leaving the picker.
308317 // Optional; the default notifies nothing.
309318 Notify func(State, error)
@@ -785,6 +794,8 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet
785794 return supervisorAttempt{}, true, terminalReadCause(err)
786795 case <-s.presentationInvalidation():
787796 s.renderResizeInvalidation()
797+ case <-s.spinnerTick():
798+ s.cfg.Spinner.AdvanceSpinner(s.State())
788799 }
789800 }
790801 }
@@ -985,6 +996,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti
985996 return true, terminalReadCause(err)
986997 case <-s.presentationInvalidation():
987998 s.renderResizeInvalidation()
999+ case <-s.spinnerTick():
1000+ s.cfg.Spinner.AdvanceSpinner(s.State())
9881001 }
9891002 }
9901003 }
@@ -1120,11 +1133,21 @@ func (s *Supervisor) transition(event supervisorEvent) {
11201133 if state.Presentation != previous {
11211134 s.publishPresentation(state.Presentation)
11221135 }
1136+ if s.cfg.Spinner != nil && state.Presentation != PresentConnecting && state.Connectivity != ConnectivityRetryWait && state.Connectivity != ConnectivityConnectingBroker {
1137+ s.cfg.Spinner.StopSpinner()
1138+ }
11231139 if s.cfg.Render != nil {
11241140 s.cfg.Render(state)
11251141 }
11261142 }
11271143
1144+func (s *Supervisor) spinnerTick() <-chan time.Time {
1145+ if s.cfg.Spinner == nil {
1146+ return nil
1147+ }
1148+ return s.cfg.Spinner.Spinner()
1149+}
1150+
11281151 // publishPresentation publishes one supervisor-owned presentation through the
11291152 // optional UI owner. Attached and terminating are refused: Attached belongs to
11301153 // the admitted attachment foreground, and terminating ends the service.
@@ -281,6 +281,14 @@ type LifecycleNotice struct {
281 // connector, terminal, and clock are required; render, notify, and jitter are281 // connector, terminal, and clock are required; render, notify, and jitter are
282 // injectable seams with safe defaults. Render, notify, and jitter must not282 // injectable seams with safe defaults. Render, notify, and jitter must not
283 // block: the supervisor drives them from its single run goroutine.283 // block: the supervisor drives them from its single run goroutine.
284+// SpinnerPresentation advances a transition frame on the serialized control
285+// path, never from a competing terminal writer goroutine.
286+type SpinnerPresentation interface {
287+ Spinner() <-chan time.Time
288+ AdvanceSpinner(State)
289+ StopSpinner()
290+}
291+
284 type SupervisorConfig struct {292 type SupervisorConfig struct {
285 // Connector establishes the broker connection. Required.293 // Connector establishes the broker connection. Required.
286 Connector ports.BrokerConnector294 Connector ports.BrokerConnector
@@ -303,7 +311,8 @@ type SupervisorConfig struct {
303 // overwrite session output. An attached foreground still owns the terminal311 // overwrite session output. An attached foreground still owns the terminal
304 // and a terminating process is leaving it, so neither is ever painted over312 // and a terminating process is leaving it, so neither is ever painted over
305 // by a resize invalidation. Optional; the default renders nothing.313 // by a resize invalidation. Optional; the default renders nothing.
306- Render func(State)314+ Render func(State)
315+ Spinner SpinnerPresentation
307 // Notify surfaces a connectivity failure without leaving the picker.316 // Notify surfaces a connectivity failure without leaving the picker.
308 // Optional; the default notifies nothing.317 // Optional; the default notifies nothing.
309 Notify func(State, error)318 Notify func(State, error)
@@ -785,6 +794,8 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet
785 return supervisorAttempt{}, true, terminalReadCause(err)794 return supervisorAttempt{}, true, terminalReadCause(err)
786 case <-s.presentationInvalidation():795 case <-s.presentationInvalidation():
787 s.renderResizeInvalidation()796 s.renderResizeInvalidation()
797+ case <-s.spinnerTick():
798+ s.cfg.Spinner.AdvanceSpinner(s.State())
788 }799 }
789 }800 }
790 }801 }
@@ -985,6 +996,8 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti
985 return true, terminalReadCause(err)996 return true, terminalReadCause(err)
986 case <-s.presentationInvalidation():997 case <-s.presentationInvalidation():
987 s.renderResizeInvalidation()998 s.renderResizeInvalidation()
999+ case <-s.spinnerTick():
1000+ s.cfg.Spinner.AdvanceSpinner(s.State())
988 }1001 }
989 }1002 }
990 }1003 }
@@ -1120,11 +1133,21 @@ func (s *Supervisor) transition(event supervisorEvent) {
1120 if state.Presentation != previous {1133 if state.Presentation != previous {
1121 s.publishPresentation(state.Presentation)1134 s.publishPresentation(state.Presentation)
1122 }1135 }
1136+ if s.cfg.Spinner != nil && state.Presentation != PresentConnecting && state.Connectivity != ConnectivityRetryWait && state.Connectivity != ConnectivityConnectingBroker {
1137+ s.cfg.Spinner.StopSpinner()
1138+ }
1123 if s.cfg.Render != nil {1139 if s.cfg.Render != nil {
1124 s.cfg.Render(state)1140 s.cfg.Render(state)
1125 }1141 }
1126 }1142 }
1127 1143
1144+func (s *Supervisor) spinnerTick() <-chan time.Time {
1145+ if s.cfg.Spinner == nil {
1146+ return nil
1147+ }
1148+ return s.cfg.Spinner.Spinner()
1149+}
1150+
1128 // publishPresentation publishes one supervisor-owned presentation through the1151 // publishPresentation publishes one supervisor-owned presentation through the
1129 // optional UI owner. Attached and terminating are refused: Attached belongs to1152 // optional UI owner. Attached and terminating are refused: Attached belongs to
1130 // the admitted attachment foreground, and terminating ends the service.1153 // the admitted attachment foreground, and terminating ends the service.
M internal/usecase/client/transition_ui.go +10 -0
@@ -1,6 +1,7 @@
11 package client
22
33 import (
4+ "bytes"
45 "fmt"
56 "io"
67 "time"
@@ -30,6 +31,15 @@ func transitionMessage(target protocol.AttachTarget) string {
3031 return verb + label + "…"
3132 }
3233
34+// RenderTransitionNotice draws one connecting frame for the terminal owner.
35+func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {
36+ var out bytes.Buffer
37+ if _, err := drawTransitionToast(&out, size, frame, message); err != nil {
38+ return nil
39+ }
40+ return out.Bytes()
41+}
42+
3343 func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) {
3444 spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)]
3545 return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message))
@@ -1,6 +1,7 @@
1 package client1 package client
2 2
3 import (3 import (
4+ "bytes"
4 "fmt"5 "fmt"
5 "io"6 "io"
6 "time"7 "time"
@@ -30,6 +31,15 @@ func transitionMessage(target protocol.AttachTarget) string {
30 return verb + label + "…"31 return verb + label + "…"
31 }32 }
32 33
34+// RenderTransitionNotice draws one connecting frame for the terminal owner.
35+func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {
36+ var out bytes.Buffer
37+ if _, err := drawTransitionToast(&out, size, frame, message); err != nil {
38+ return nil
39+ }
40+ return out.Bytes()
41+}
42+
33 func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) {43 func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) {
34 spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)]44 spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)]
35 return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message))45 return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message))

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