detached d1a05e5

fix(client): present Connecting instead of the picker while the initial navigation is pending

commit
d1a05e5ba0712de57cb593a2328799641936f09f
author
brice · · 2026-09-24 04:09 UTC
parent
c549dd3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +87 -15
M internal/app/broker_client_test.go +12 -5
@@ -737,11 +737,16 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin
737737 t.Fatal("the refused target reported no failure")
738738 }
739739 require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream")
740+ // The pending navigation presents Connecting, never the picker
741+ // first; the refusal then returns to the picker and never attaches.
742+ last := client.PresentConnecting
740743 for {
741744 select {
742745 case state := <-run.states:
743- require.Equal(t, client.PresentPicker, state.Presentation, "a refusal keeps the picker presentation")
746+ require.NotEqual(t, client.PresentAttached, state.Presentation, "a refusal never attaches")
747+ last = state.Presentation
744748 case <-time.After(100 * time.Millisecond):
749+ require.Equal(t, client.PresentPicker, last, "a refusal ends on the picker")
745750 return
746751 }
747752 }
@@ -1009,11 +1014,13 @@ func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T
10091014 t.Fatal("the exact attach reported no failure")
10101015 }
10111016 // The refused navigation never writes a session frame and never attaches:
1012- // only the Picker presentation may be published, never a Connecting or
1013- // Attached session presentation.
1014- for _, status := range terminal.publishes() {
1015- require.Equal(t, ports.UIStatusPicker, status, "a refused navigation publishes only Picker")
1017+ // it presents Connecting while pending and ends on the picker.
1018+ publishes := terminal.publishes()
1019+ for _, status := range publishes {
1020+ require.NotEqual(t, ports.UIStatusAttached, status, "a refused navigation never attaches")
10161021 }
1022+ require.NotEmpty(t, publishes)
1023+ require.Equal(t, ports.UIStatusPicker, publishes[len(publishes)-1], "a refused navigation ends on the picker")
10171024 cancel()
10181025 require.NoError(t, ignoreContextCancellation(<-done))
10191026 }
@@ -737,11 +737,16 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin
737 t.Fatal("the refused target reported no failure")737 t.Fatal("the refused target reported no failure")
738 }738 }
739 require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream")739 require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream")
740+ // The pending navigation presents Connecting, never the picker
741+ // first; the refusal then returns to the picker and never attaches.
742+ last := client.PresentConnecting
740 for {743 for {
741 select {744 select {
742 case state := <-run.states:745 case state := <-run.states:
743- require.Equal(t, client.PresentPicker, state.Presentation, "a refusal keeps the picker presentation")746+ require.NotEqual(t, client.PresentAttached, state.Presentation, "a refusal never attaches")
747+ last = state.Presentation
744 case <-time.After(100 * time.Millisecond):748 case <-time.After(100 * time.Millisecond):
749+ require.Equal(t, client.PresentPicker, last, "a refusal ends on the picker")
745 return750 return
746 }751 }
747 }752 }
@@ -1009,11 +1014,13 @@ func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T
1009 t.Fatal("the exact attach reported no failure")1014 t.Fatal("the exact attach reported no failure")
1010 }1015 }
1011 // The refused navigation never writes a session frame and never attaches:1016 // The refused navigation never writes a session frame and never attaches:
1012- // only the Picker presentation may be published, never a Connecting or1017+ // it presents Connecting while pending and ends on the picker.
1013- // Attached session presentation.1018+ publishes := terminal.publishes()
1014- for _, status := range terminal.publishes() {1019+ for _, status := range publishes {
1015- require.Equal(t, ports.UIStatusPicker, status, "a refused navigation publishes only Picker")1020+ require.NotEqual(t, ports.UIStatusAttached, status, "a refused navigation never attaches")
1016 }1021 }
1022+ require.NotEmpty(t, publishes)
1023+ require.Equal(t, ports.UIStatusPicker, publishes[len(publishes)-1], "a refused navigation ends on the picker")
1017 cancel()1024 cancel()
1018 require.NoError(t, ignoreContextCancellation(<-done))1025 require.NoError(t, ignoreContextCancellation(<-done))
1019 }1026 }
M internal/app/web_daemon_broker_test.go +11 -9
@@ -500,16 +500,17 @@ func TestWebGatewayTabsShareOneBrokerWithIndependentStreamsAndServices(t *testin
500500 require.Equal(t, carriageAfterFirst, fixture.broker.physicalCount(),
501501 "the second tab opened another daemonmux carriage instead of reusing the pooled one")
502502
503- // The identical state machine: each browser run presents Picker, Connecting,
504- // and Attached exactly like every other frontend. Consult the durable
505- // observation barrier: the two Attached notifications above were consumed
506- // while synchronizing startup and must still count here.
503+ // The identical state machine: each browser run's initial creation presents
504+ // Connecting then Attached exactly like every other frontend, never a
505+ // picker flash first. Consult the durable observation barrier: the two
506+ // Attached notifications above were consumed while synchronizing startup
507+ // and must still count here.
507508 require.Eventually(t, func() bool {
508509 presentations := fixture.observedPresentations()
509- return presentations[client.PresentPicker] >= 2 &&
510- presentations[client.PresentConnecting] >= 2 &&
510+ return presentations[client.PresentConnecting] >= 2 &&
511511 presentations[client.PresentAttached] >= 2
512- }, brokerTestWait, 5*time.Millisecond, "both browser runs must present picker, connecting, and attached; observed %v", fixture.observedPresentations())
512+ }, brokerTestWait, 5*time.Millisecond, "both browser runs must present connecting and attached; observed %v", fixture.observedPresentations())
513+ require.Zero(t, fixture.observedPresentations()[client.PresentPicker], "an initial creation never flashes the picker")
513514
514515 // Real content and real actions, and each tab renders only its own session.
515516 first.submit(t, "PING-FIRST")
@@ -674,12 +675,13 @@ func TestWebGatewayTerminalIsNotDialedBeforeConnect(t *testing.T) {
674675
675676 fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, newProductionBrokerConnector)
676677 tab := fixture.openTab(t)
677- fixture.awaitPresentation(t, client.PresentPicker)
678+ // The tab's initial creation is pending, so it presents Connecting.
679+ fixture.awaitPresentation(t, client.PresentConnecting)
678680 // Construction performs no I/O: the gateway reaches the broker only through
679681 // Connect, and an unreachable broker is an ordinary attempt failure.
680682 require.Eventually(t, func() bool { return connects.Load() >= 1 }, brokerTestWait, 5*time.Millisecond,
681683 "the gateway must reach the broker only through Connect")
682- // A broker that is never reachable keeps the tab usable in the picker.
684+ // A broker that is never reachable keeps the tab alive and repainting.
683685 before := tab.frameCount()
684686 tab.awaitMoreFrames(t, before+1)
685687 select {
@@ -500,16 +500,17 @@ func TestWebGatewayTabsShareOneBrokerWithIndependentStreamsAndServices(t *testin
500 require.Equal(t, carriageAfterFirst, fixture.broker.physicalCount(),500 require.Equal(t, carriageAfterFirst, fixture.broker.physicalCount(),
501 "the second tab opened another daemonmux carriage instead of reusing the pooled one")501 "the second tab opened another daemonmux carriage instead of reusing the pooled one")
502 502
503- // The identical state machine: each browser run presents Picker, Connecting,503+ // The identical state machine: each browser run's initial creation presents
504- // and Attached exactly like every other frontend. Consult the durable504+ // Connecting then Attached exactly like every other frontend, never a
505- // observation barrier: the two Attached notifications above were consumed505+ // picker flash first. Consult the durable observation barrier: the two
506- // while synchronizing startup and must still count here.506+ // Attached notifications above were consumed while synchronizing startup
507+ // and must still count here.
507 require.Eventually(t, func() bool {508 require.Eventually(t, func() bool {
508 presentations := fixture.observedPresentations()509 presentations := fixture.observedPresentations()
509- return presentations[client.PresentPicker] >= 2 &&510+ return presentations[client.PresentConnecting] >= 2 &&
510- presentations[client.PresentConnecting] >= 2 &&
511 presentations[client.PresentAttached] >= 2511 presentations[client.PresentAttached] >= 2
512- }, brokerTestWait, 5*time.Millisecond, "both browser runs must present picker, connecting, and attached; observed %v", fixture.observedPresentations())512+ }, brokerTestWait, 5*time.Millisecond, "both browser runs must present connecting and attached; observed %v", fixture.observedPresentations())
513+ require.Zero(t, fixture.observedPresentations()[client.PresentPicker], "an initial creation never flashes the picker")
513 514
514 // Real content and real actions, and each tab renders only its own session.515 // Real content and real actions, and each tab renders only its own session.
515 first.submit(t, "PING-FIRST")516 first.submit(t, "PING-FIRST")
@@ -674,12 +675,13 @@ func TestWebGatewayTerminalIsNotDialedBeforeConnect(t *testing.T) {
674 675
675 fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, newProductionBrokerConnector)676 fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, newProductionBrokerConnector)
676 tab := fixture.openTab(t)677 tab := fixture.openTab(t)
677- fixture.awaitPresentation(t, client.PresentPicker)678+ // The tab's initial creation is pending, so it presents Connecting.
679+ fixture.awaitPresentation(t, client.PresentConnecting)
678 // Construction performs no I/O: the gateway reaches the broker only through680 // Construction performs no I/O: the gateway reaches the broker only through
679 // Connect, and an unreachable broker is an ordinary attempt failure.681 // Connect, and an unreachable broker is an ordinary attempt failure.
680 require.Eventually(t, func() bool { return connects.Load() >= 1 }, brokerTestWait, 5*time.Millisecond,682 require.Eventually(t, func() bool { return connects.Load() >= 1 }, brokerTestWait, 5*time.Millisecond,
681 "the gateway must reach the broker only through Connect")683 "the gateway must reach the broker only through Connect")
682- // A broker that is never reachable keeps the tab usable in the picker.684+ // A broker that is never reachable keeps the tab alive and repainting.
683 before := tab.frameCount()685 before := tab.frameCount()
684 tab.awaitMoreFrames(t, before+1)686 tab.awaitMoreFrames(t, before+1)
685 select {687 select {
M internal/usecase/client/supervisor.go +34 -1
@@ -176,6 +176,9 @@ const (
176176 supervisorAttachEnded
177177 // supervisorNonRetryable is a failure that retrying can never fix.
178178 supervisorNonRetryable
179+ // supervisorNavigationSettled ends a pending initial navigation's
180+ // Connecting presentation when no attachment took over the terminal.
181+ supervisorNavigationSettled
179182 // supervisorTerminal ends the process: terminal EOF, process cancellation,
180183 // or a terminal broker failure.
181184 supervisorTerminal
@@ -189,6 +192,11 @@ const (
189192 type supervisorEvent struct {
190193 kind supervisorEventKind
191194 err error
195+ // navigating marks a supervisorBeginAttempt made while an initial
196+ // navigation is still pending: the user asked for a session, not the
197+ // picker, so the attempt presents Connecting instead of flashing the
198+ // picker until the navigation settles.
199+ navigating bool
192200 }
193201
194202 // reduceSupervisor is the pure connectivity/presentation reducer. It is
@@ -198,6 +206,9 @@ func reduceSupervisor(state State, event supervisorEvent) State {
198206 switch event.kind {
199207 case supervisorBeginAttempt:
200208 state.Presentation = PresentPicker
209+ if event.navigating {
210+ state.Presentation = PresentConnecting
211+ }
201212 state.Connectivity = ConnectivityConnectingBroker
202213 state.Generation++
203214 state.Err = nil
@@ -230,8 +241,17 @@ func reduceSupervisor(state State, event supervisorEvent) State {
230241 state.Presentation = PresentPicker
231242 state.Err = event.err
232243 case supervisorNonRetryable:
244+ // The run stays on the picker with the visible error; a pending
245+ // initial navigation can no longer present Connecting.
246+ state.Presentation = PresentPicker
233247 state.Connectivity = ConnectivityDisconnected
234248 state.Err = event.err
249+ case supervisorNavigationSettled:
250+ // An initial navigation that ended without an attachment (a resolved
251+ // picker, or a loss before it could be taken) leaves Connecting.
252+ if state.Presentation == PresentConnecting {
253+ state.Presentation = PresentPicker
254+ }
235255 case supervisorOverlayOpened:
236256 // Only a committed attachment can host the overlay: a connecting
237257 // foreground has not proven its first frame yet.
@@ -493,6 +513,18 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
493513 return supervisor, nil
494514 }
495515
516+// initialNavigationPending reports whether an initial navigation other than
517+// the picker is still armed. A resolver may still resolve to the picker; the
518+// navigation then settles back to it without an attachment.
519+func (s *Supervisor) initialNavigationPending() bool {
520+ s.mu.Lock()
521+ defer s.mu.Unlock()
522+ if s.navigationConsumed {
523+ return false
524+ }
525+ return s.cfg.ResolveInitialNavigation != nil || s.navigation.Kind != InitialNavigationPicker
526+}
527+
496528 // State returns the current immutable projection.
497529 func (s *Supervisor) State() State {
498530 s.mu.Lock()
@@ -551,7 +583,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
551583 // The generation is claimed before the connector runs, so the attempt is
552584 // always attributable to the state that started it and a completion can be
553585 // matched against the generation that produced it.
554- s.transition(supervisorEvent{kind: supervisorBeginAttempt})
586+ s.transition(supervisorEvent{kind: supervisorBeginAttempt, navigating: s.initialNavigationPending()})
555587 generation := s.State().Generation
556588 attemptCtx, cancelAttempt := context.WithCancel(ctx)
557589 outcome := make(chan supervisorAttempt, 1)
@@ -626,6 +658,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
626658 s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
627659 return termErr
628660 }
661+ s.transition(supervisorEvent{kind: supervisorNavigationSettled})
629662
630663 // Ready phase: fold publications, admit committed attachments one at a
631664 // time, and return to the picker after each. A committed attachment
@@ -176,6 +176,9 @@ const (
176 supervisorAttachEnded176 supervisorAttachEnded
177 // supervisorNonRetryable is a failure that retrying can never fix.177 // supervisorNonRetryable is a failure that retrying can never fix.
178 supervisorNonRetryable178 supervisorNonRetryable
179+ // supervisorNavigationSettled ends a pending initial navigation's
180+ // Connecting presentation when no attachment took over the terminal.
181+ supervisorNavigationSettled
179 // supervisorTerminal ends the process: terminal EOF, process cancellation,182 // supervisorTerminal ends the process: terminal EOF, process cancellation,
180 // or a terminal broker failure.183 // or a terminal broker failure.
181 supervisorTerminal184 supervisorTerminal
@@ -189,6 +192,11 @@ const (
189 type supervisorEvent struct {192 type supervisorEvent struct {
190 kind supervisorEventKind193 kind supervisorEventKind
191 err error194 err error
195+ // navigating marks a supervisorBeginAttempt made while an initial
196+ // navigation is still pending: the user asked for a session, not the
197+ // picker, so the attempt presents Connecting instead of flashing the
198+ // picker until the navigation settles.
199+ navigating bool
192 }200 }
193 201
194 // reduceSupervisor is the pure connectivity/presentation reducer. It is202 // reduceSupervisor is the pure connectivity/presentation reducer. It is
@@ -198,6 +206,9 @@ func reduceSupervisor(state State, event supervisorEvent) State {
198 switch event.kind {206 switch event.kind {
199 case supervisorBeginAttempt:207 case supervisorBeginAttempt:
200 state.Presentation = PresentPicker208 state.Presentation = PresentPicker
209+ if event.navigating {
210+ state.Presentation = PresentConnecting
211+ }
201 state.Connectivity = ConnectivityConnectingBroker212 state.Connectivity = ConnectivityConnectingBroker
202 state.Generation++213 state.Generation++
203 state.Err = nil214 state.Err = nil
@@ -230,8 +241,17 @@ func reduceSupervisor(state State, event supervisorEvent) State {
230 state.Presentation = PresentPicker241 state.Presentation = PresentPicker
231 state.Err = event.err242 state.Err = event.err
232 case supervisorNonRetryable:243 case supervisorNonRetryable:
244+ // The run stays on the picker with the visible error; a pending
245+ // initial navigation can no longer present Connecting.
246+ state.Presentation = PresentPicker
233 state.Connectivity = ConnectivityDisconnected247 state.Connectivity = ConnectivityDisconnected
234 state.Err = event.err248 state.Err = event.err
249+ case supervisorNavigationSettled:
250+ // An initial navigation that ended without an attachment (a resolved
251+ // picker, or a loss before it could be taken) leaves Connecting.
252+ if state.Presentation == PresentConnecting {
253+ state.Presentation = PresentPicker
254+ }
235 case supervisorOverlayOpened:255 case supervisorOverlayOpened:
236 // Only a committed attachment can host the overlay: a connecting256 // Only a committed attachment can host the overlay: a connecting
237 // foreground has not proven its first frame yet.257 // foreground has not proven its first frame yet.
@@ -493,6 +513,18 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
493 return supervisor, nil513 return supervisor, nil
494 }514 }
495 515
516+// initialNavigationPending reports whether an initial navigation other than
517+// the picker is still armed. A resolver may still resolve to the picker; the
518+// navigation then settles back to it without an attachment.
519+func (s *Supervisor) initialNavigationPending() bool {
520+ s.mu.Lock()
521+ defer s.mu.Unlock()
522+ if s.navigationConsumed {
523+ return false
524+ }
525+ return s.cfg.ResolveInitialNavigation != nil || s.navigation.Kind != InitialNavigationPicker
526+}
527+
496 // State returns the current immutable projection.528 // State returns the current immutable projection.
497 func (s *Supervisor) State() State {529 func (s *Supervisor) State() State {
498 s.mu.Lock()530 s.mu.Lock()
@@ -551,7 +583,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
551 // The generation is claimed before the connector runs, so the attempt is583 // The generation is claimed before the connector runs, so the attempt is
552 // always attributable to the state that started it and a completion can be584 // always attributable to the state that started it and a completion can be
553 // matched against the generation that produced it.585 // matched against the generation that produced it.
554- s.transition(supervisorEvent{kind: supervisorBeginAttempt})586+ s.transition(supervisorEvent{kind: supervisorBeginAttempt, navigating: s.initialNavigationPending()})
555 generation := s.State().Generation587 generation := s.State().Generation
556 attemptCtx, cancelAttempt := context.WithCancel(ctx)588 attemptCtx, cancelAttempt := context.WithCancel(ctx)
557 outcome := make(chan supervisorAttempt, 1)589 outcome := make(chan supervisorAttempt, 1)
@@ -626,6 +658,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
626 s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})658 s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
627 return termErr659 return termErr
628 }660 }
661+ s.transition(supervisorEvent{kind: supervisorNavigationSettled})
629 662
630 // Ready phase: fold publications, admit committed attachments one at a663 // Ready phase: fold publications, admit committed attachments one at a
631 // time, and return to the picker after each. A committed attachment664 // time, and return to the picker after each. A committed attachment
M internal/usecase/client/supervisor_test.go +30 -0
@@ -568,6 +568,36 @@ func TestSupervisorReducerTransitions(t *testing.T) {
568568 event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible},
569569 want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Attempt: 1, Generation: 3, Err: incompatible},
570570 },
571+ {
572+ name: "begin attempt with a pending navigation presents connecting, not the picker",
573+ state: picker,
574+ event: supervisorEvent{kind: supervisorBeginAttempt, navigating: true},
575+ want: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 1},
576+ },
577+ {
578+ name: "retry wait keeps a pending navigation connecting",
579+ state: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 2},
580+ event: supervisorEvent{kind: supervisorTransientFailure, err: transient},
581+ want: State{Presentation: PresentConnecting, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 2, Err: transient},
582+ },
583+ {
584+ name: "non-retryable failure during a pending navigation falls back to the picker",
585+ state: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 3},
586+ event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible},
587+ want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Generation: 3, Err: incompatible},
588+ },
589+ {
590+ name: "a navigation that settles without attaching returns to the picker",
591+ state: State{Presentation: PresentConnecting, Connectivity: ConnectivityReady, Generation: 4},
592+ event: supervisorEvent{kind: supervisorNavigationSettled},
593+ want: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 4},
594+ },
595+ {
596+ name: "navigation settled leaves an unrelated presentation alone",
597+ state: State{Presentation: PresentAttached, Connectivity: ConnectivityReady, Generation: 4},
598+ event: supervisorEvent{kind: supervisorNavigationSettled},
599+ want: State{Presentation: PresentAttached, Connectivity: ConnectivityReady, Generation: 4},
600+ },
571601 {
572602 name: "terminal failure terminates",
573603 state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 7},
@@ -568,6 +568,36 @@ func TestSupervisorReducerTransitions(t *testing.T) {
568 event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible},568 event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible},
569 want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Attempt: 1, Generation: 3, Err: incompatible},569 want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Attempt: 1, Generation: 3, Err: incompatible},
570 },570 },
571+ {
572+ name: "begin attempt with a pending navigation presents connecting, not the picker",
573+ state: picker,
574+ event: supervisorEvent{kind: supervisorBeginAttempt, navigating: true},
575+ want: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 1},
576+ },
577+ {
578+ name: "retry wait keeps a pending navigation connecting",
579+ state: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 2},
580+ event: supervisorEvent{kind: supervisorTransientFailure, err: transient},
581+ want: State{Presentation: PresentConnecting, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 2, Err: transient},
582+ },
583+ {
584+ name: "non-retryable failure during a pending navigation falls back to the picker",
585+ state: State{Presentation: PresentConnecting, Connectivity: ConnectivityConnectingBroker, Generation: 3},
586+ event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible},
587+ want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Generation: 3, Err: incompatible},
588+ },
589+ {
590+ name: "a navigation that settles without attaching returns to the picker",
591+ state: State{Presentation: PresentConnecting, Connectivity: ConnectivityReady, Generation: 4},
592+ event: supervisorEvent{kind: supervisorNavigationSettled},
593+ want: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 4},
594+ },
595+ {
596+ name: "navigation settled leaves an unrelated presentation alone",
597+ state: State{Presentation: PresentAttached, Connectivity: ConnectivityReady, Generation: 4},
598+ event: supervisorEvent{kind: supervisorNavigationSettled},
599+ want: State{Presentation: PresentAttached, Connectivity: ConnectivityReady, Generation: 4},
600+ },
571 {601 {
572 name: "terminal failure terminates",602 name: "terminal failure terminates",
573 state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 7},603 state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 7},

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