detached 8ea43d2

fix(status): show ringing sessions in every client's recent history (#283)

* fix(status): show ringing sessions in every client's recent history

* test(routes): pin visited-first ordering and ringing jump ranks, align history comments
commit
8ea43d2a8f7cda686ff0eadd41b6638df726e801
author
bnema · · 2026-09-24 10:14 UTC
committer
GitHub · · 2026-09-24 10:14 UTC
parent
32ce382
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +90 -32
M docs/session-navigation.md +1 -1
@@ -27,7 +27,7 @@ Open it with `SSP` in the palette. It lists local sessions and, if you added hos
2727 | `CNS` | create a named session; asks where when several daemons are available |
2828 | `CES` | create a numbered session |
2929
30-Recent-session history belongs to one client and is not saved. A new client starts with an empty history.
30+Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it.
3131
3232 ## Move panes and tabs
3333
@@ -27,7 +27,7 @@ Open it with `SSP` in the palette. It lists local sessions and, if you added hos
27 | `CNS` | create a named session; asks where when several daemons are available |27 | `CNS` | create a named session; asks where when several daemons are available |
28 | `CES` | create a numbered session |28 | `CES` | create a numbered session |
29 29
30-Recent-session history belongs to one client and is not saved. A new client starts with an empty history.30+Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it.
31 31
32 ## Move panes and tabs32 ## Move panes and tabs
33 33
M internal/protocol/routes.go +1 -1
@@ -319,7 +319,7 @@ type RecentRouteEntry struct {
319319 // observes: a smaller value is older. It is zero without attention.
320320 AttentionSeq uint64
321321 // Visited reports that this client attached to the route. The status-bar
322- // history and jump-recent ranks show only visited routes.
322+ // history and jump-recent ranks show visited routes plus ringing ones.
323323 Visited bool
324324 }
325325
@@ -319,7 +319,7 @@ type RecentRouteEntry struct {
319 // observes: a smaller value is older. It is zero without attention.319 // observes: a smaller value is older. It is zero without attention.
320 AttentionSeq uint64320 AttentionSeq uint64
321 // Visited reports that this client attached to the route. The status-bar321 // Visited reports that this client attached to the route. The status-bar
322- // history and jump-recent ranks show only visited routes.322+ // history and jump-recent ranks show visited routes plus ringing ones.
323 Visited bool323 Visited bool
324 }324 }
325 325
M internal/protocol/wire/schema/session.proto +1 -1
@@ -163,7 +163,7 @@ message RecentRouteEntry {
163163 // Client-assigned attention onset order; zero without attention.
164164 uint64 attention_seq = 10;
165165 // True once this client attached to the route; the status-bar history
166- // shows only visited routes.
166+ // shows visited routes plus ringing ones.
167167 bool visited = 11;
168168 }
169169
@@ -163,7 +163,7 @@ message RecentRouteEntry {
163 // Client-assigned attention onset order; zero without attention.163 // Client-assigned attention onset order; zero without attention.
164 uint64 attention_seq = 10;164 uint64 attention_seq = 10;
165 // True once this client attached to the route; the status-bar history165 // True once this client attached to the route; the status-bar history
166- // shows only visited routes.166+ // shows visited routes plus ringing ones.
167 bool visited = 11;167 bool visited = 11;
168 }168 }
169 169
M internal/protocol/wire/session.pb.go +1 -1 generated file: collapsed
M internal/usecase/client/route_ledger.go +2 -1
@@ -32,7 +32,8 @@ import (
3232 // - Visited marks entries this client attached to. Attach recency sorts
3333 // first, so visited entries are always a prefix of Entries and Previous
3434 // is the first of them; the daemon's status history and jump-recent
35-// ranks show only that prefix, while the palette shows every entry.
35+// ranks show that prefix plus any ringing entry after it, so visited
36+// ranks stay stable, while the palette shows every entry.
3637 // - The attached lifecycle is metadata only (ActiveEntry), never an entry.
3738 // - Attention is any(tab.Attention). AttentionSeq orders onsets as the
3839 // client observed them, across daemons, so the serving daemon can jump to
@@ -32,7 +32,8 @@ import (
32 // - Visited marks entries this client attached to. Attach recency sorts32 // - Visited marks entries this client attached to. Attach recency sorts
33 // first, so visited entries are always a prefix of Entries and Previous33 // first, so visited entries are always a prefix of Entries and Previous
34 // is the first of them; the daemon's status history and jump-recent34 // is the first of them; the daemon's status history and jump-recent
35-// ranks show only that prefix, while the palette shows every entry.35+// ranks show that prefix plus any ringing entry after it, so visited
36+// ranks stay stable, while the palette shows every entry.
36 // - The attached lifecycle is metadata only (ActiveEntry), never an entry.37 // - The attached lifecycle is metadata only (ActiveEntry), never an entry.
37 // - Attention is any(tab.Attention). AttentionSeq orders onsets as the38 // - Attention is any(tab.Attention). AttentionSeq orders onsets as the
38 // client observed them, across daemons, so the serving daemon can jump to39 // client observed them, across daemons, so the serving daemon can jump to
M internal/usecase/client/route_ledger_test.go +37 -0
@@ -256,6 +256,43 @@ func TestRouteLedgerAttention(t *testing.T) {
256256 }
257257 }
258258
259+// TestRouteLedgerVisitedPrefixPrecedesRingingRoutes pins the order the
260+// daemon's status history relies on: visited routes stay a prefix of Entries
261+// even when an unvisited route on a higher tier rings, so ringing routes never
262+// shift the jump-recent ranks of visited ones.
263+func TestRouteLedgerVisitedPrefixPrecedesRingingRoutes(t *testing.T) {
264+ tests := []struct {
265+ name string
266+ visited routeActive
267+ build ports.BrokerSnapshot
268+ }{
269+ {
270+ name: "visited stopped local before ringing live local",
271+ visited: routeTestActive(true, "old", 2),
272+ build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true), routeTestStopped("old", 2, 1))),
273+ },
274+ {
275+ name: "visited remote before ringing live local",
276+ visited: routeTestActive(false, "r", 3),
277+ build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true)), routeTestRemoteHost(routeTestLive("r", 3, 0))),
278+ },
279+ }
280+ for _, tt := range tests {
281+ t.Run(tt.name, func(t *testing.T) {
282+ ledger := newRouteLedger()
283+ ledger.build(tt.build, tt.visited)
284+ snapshot, _ := ledger.build(tt.build, routeTestActive(true, "here", 4))
285+ require.NoError(t, snapshot.Validate())
286+ require.Len(t, snapshot.Entries, 2)
287+ require.True(t, snapshot.Entries[0].Visited, "the visited route leads")
288+ require.Equal(t, tt.visited.target.SessionName, snapshot.Entries[0].Name)
289+ require.False(t, snapshot.Entries[1].Visited)
290+ require.True(t, snapshot.Entries[1].Attention, "the ringing route follows")
291+ require.Equal(t, "loud", snapshot.Entries[1].Name)
292+ })
293+ }
294+}
295+
259296 func TestRouteLedgerBounds(t *testing.T) {
260297 sessions := make([]catalogue.RemoteCatalogSession, 0, protocol.RouteSnapshotMaxEntries+5)
261298 for i := 0; i < protocol.RouteSnapshotMaxEntries+5; i++ {
@@ -256,6 +256,43 @@ func TestRouteLedgerAttention(t *testing.T) {
256 }256 }
257 }257 }
258 258
259+// TestRouteLedgerVisitedPrefixPrecedesRingingRoutes pins the order the
260+// daemon's status history relies on: visited routes stay a prefix of Entries
261+// even when an unvisited route on a higher tier rings, so ringing routes never
262+// shift the jump-recent ranks of visited ones.
263+func TestRouteLedgerVisitedPrefixPrecedesRingingRoutes(t *testing.T) {
264+ tests := []struct {
265+ name string
266+ visited routeActive
267+ build ports.BrokerSnapshot
268+ }{
269+ {
270+ name: "visited stopped local before ringing live local",
271+ visited: routeTestActive(true, "old", 2),
272+ build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true), routeTestStopped("old", 2, 1))),
273+ },
274+ {
275+ name: "visited remote before ringing live local",
276+ visited: routeTestActive(false, "r", 3),
277+ build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true)), routeTestRemoteHost(routeTestLive("r", 3, 0))),
278+ },
279+ }
280+ for _, tt := range tests {
281+ t.Run(tt.name, func(t *testing.T) {
282+ ledger := newRouteLedger()
283+ ledger.build(tt.build, tt.visited)
284+ snapshot, _ := ledger.build(tt.build, routeTestActive(true, "here", 4))
285+ require.NoError(t, snapshot.Validate())
286+ require.Len(t, snapshot.Entries, 2)
287+ require.True(t, snapshot.Entries[0].Visited, "the visited route leads")
288+ require.Equal(t, tt.visited.target.SessionName, snapshot.Entries[0].Name)
289+ require.False(t, snapshot.Entries[1].Visited)
290+ require.True(t, snapshot.Entries[1].Attention, "the ringing route follows")
291+ require.Equal(t, "loud", snapshot.Entries[1].Name)
292+ })
293+ }
294+}
295+
259 func TestRouteLedgerBounds(t *testing.T) {296 func TestRouteLedgerBounds(t *testing.T) {
260 sessions := make([]catalogue.RemoteCatalogSession, 0, protocol.RouteSnapshotMaxEntries+5)297 sessions := make([]catalogue.RemoteCatalogSession, 0, protocol.RouteSnapshotMaxEntries+5)
261 for i := 0; i < protocol.RouteSnapshotMaxEntries+5; i++ {298 for i := 0; i < protocol.RouteSnapshotMaxEntries+5; i++ {
M internal/usecase/daemon/palette.go +4 -4
@@ -282,7 +282,7 @@ func (d *Daemon) refreshPalette(ac *attachedClient) {
282282 }
283283
284284 func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints {
285- snapshot = visitedRouteSnapshot(snapshot)
285+ snapshot = historyRouteSnapshot(snapshot)
286286 formatted := formatRecentRouteSnapshot(snapshot)
287287 names := make([]string, len(formatted))
288288 for i, entry := range formatted {
@@ -1124,11 +1124,11 @@ func (e paletteExec) YankLastNotification() error {
11241124 }
11251125
11261126 func (e paletteExec) JumpRecentSession(rank int) error {
1127- visited := visitedRouteSnapshot(e.routeSnapshot)
1128- if visited.Generation == 0 || rank < 1 || rank > len(visited.Entries) {
1127+ history := historyRouteSnapshot(e.routeSnapshot)
1128+ if history.Generation == 0 || rank < 1 || rank > len(history.Entries) {
11291129 return command.ErrInvalidArguments
11301130 }
1131- entry := visited.Entries[rank-1]
1131+ entry := history.Entries[rank-1]
11321132 return e.NavigateRecentRoute(protocol.RouteNavigationAction{
11331133 SnapshotGeneration: e.routeSnapshot.Generation,
11341134 Key: entry.Key,
@@ -282,7 +282,7 @@ func (d *Daemon) refreshPalette(ac *attachedClient) {
282 }282 }
283 283
284 func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints {284 func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints {
285- snapshot = visitedRouteSnapshot(snapshot)285+ snapshot = historyRouteSnapshot(snapshot)
286 formatted := formatRecentRouteSnapshot(snapshot)286 formatted := formatRecentRouteSnapshot(snapshot)
287 names := make([]string, len(formatted))287 names := make([]string, len(formatted))
288 for i, entry := range formatted {288 for i, entry := range formatted {
@@ -1124,11 +1124,11 @@ func (e paletteExec) YankLastNotification() error {
1124 }1124 }
1125 1125
1126 func (e paletteExec) JumpRecentSession(rank int) error {1126 func (e paletteExec) JumpRecentSession(rank int) error {
1127- visited := visitedRouteSnapshot(e.routeSnapshot)1127+ history := historyRouteSnapshot(e.routeSnapshot)
1128- if visited.Generation == 0 || rank < 1 || rank > len(visited.Entries) {1128+ if history.Generation == 0 || rank < 1 || rank > len(history.Entries) {
1129 return command.ErrInvalidArguments1129 return command.ErrInvalidArguments
1130 }1130 }
1131- entry := visited.Entries[rank-1]1131+ entry := history.Entries[rank-1]
1132 return e.NavigateRecentRoute(protocol.RouteNavigationAction{1132 return e.NavigateRecentRoute(protocol.RouteNavigationAction{
1133 SnapshotGeneration: e.routeSnapshot.Generation,1133 SnapshotGeneration: e.routeSnapshot.Generation,
1134 Key: entry.Key,1134 Key: entry.Key,
M internal/usecase/daemon/recent_route_snapshot.go +11 -9
@@ -35,21 +35,23 @@ func recentRoutePresentationsFromSnapshot(snapshot protocol.RecentRouteSnapshot)
3535 return out
3636 }
3737
38-// visitedRouteSnapshot keeps only the routes this client attached to. The
39-// status-bar history and jump-recent ranks start from the current session and
40-// grow as the client visits others; the palette and attention keep the full
41-// snapshot.
42-func visitedRouteSnapshot(snapshot protocol.RecentRouteSnapshot) protocol.RecentRouteSnapshot {
38+// historyRouteSnapshot keeps the routes the status-bar history and
39+// jump-recent ranks show: every route this client attached to, plus every
40+// route that rings. Attention is shared by all clients, so a fresh client
41+// still sees a bell on a session it never visited. Visited routes lead the
42+// snapshot, so their ranks are unaffected by ringing ones. The palette and
43+// attention jumps keep the full snapshot.
44+func historyRouteSnapshot(snapshot protocol.RecentRouteSnapshot) protocol.RecentRouteSnapshot {
4345 if snapshot.Entries == nil {
4446 return snapshot
4547 }
46- visited := make([]protocol.RecentRouteEntry, 0, len(snapshot.Entries))
48+ history := make([]protocol.RecentRouteEntry, 0, len(snapshot.Entries))
4749 for _, entry := range snapshot.Entries {
48- if entry.Visited {
49- visited = append(visited, entry)
50+ if entry.Visited || entry.Attention {
51+ history = append(history, entry)
5052 }
5153 }
52- snapshot.Entries = visited
54+ snapshot.Entries = history
5355 return snapshot
5456 }
5557
@@ -35,21 +35,23 @@ func recentRoutePresentationsFromSnapshot(snapshot protocol.RecentRouteSnapshot)
35 return out35 return out
36 }36 }
37 37
38-// visitedRouteSnapshot keeps only the routes this client attached to. The38+// historyRouteSnapshot keeps the routes the status-bar history and
39-// status-bar history and jump-recent ranks start from the current session and39+// jump-recent ranks show: every route this client attached to, plus every
40-// grow as the client visits others; the palette and attention keep the full40+// route that rings. Attention is shared by all clients, so a fresh client
41-// snapshot.41+// still sees a bell on a session it never visited. Visited routes lead the
42-func visitedRouteSnapshot(snapshot protocol.RecentRouteSnapshot) protocol.RecentRouteSnapshot {42+// snapshot, so their ranks are unaffected by ringing ones. The palette and
43+// attention jumps keep the full snapshot.
44+func historyRouteSnapshot(snapshot protocol.RecentRouteSnapshot) protocol.RecentRouteSnapshot {
43 if snapshot.Entries == nil {45 if snapshot.Entries == nil {
44 return snapshot46 return snapshot
45 }47 }
46- visited := make([]protocol.RecentRouteEntry, 0, len(snapshot.Entries))48+ history := make([]protocol.RecentRouteEntry, 0, len(snapshot.Entries))
47 for _, entry := range snapshot.Entries {49 for _, entry := range snapshot.Entries {
48- if entry.Visited {50+ if entry.Visited || entry.Attention {
49- visited = append(visited, entry)51+ history = append(history, entry)
50 }52 }
51 }53 }
52- snapshot.Entries = visited54+ snapshot.Entries = history
53 return snapshot55 return snapshot
54 }56 }
55 57
M internal/usecase/daemon/recent_route_snapshot_test.go +30 -12
@@ -65,21 +65,30 @@ func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
6565 require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name)
6666 }
6767
68-func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) {
68+func TestPaletteJumpRecentSessionTargetsHistoryRank(t *testing.T) {
6969 unvisited := testRouteEntry(5, 1, "other", 5, protocol.RouteKindLocal)
7070 unvisited.Visited = false
71- snapshot := protocol.RecentRouteSnapshot{
71+ ringing := testRouteEntry(6, 1, "loud", 6, protocol.RouteKindLocal)
72+ ringing.Visited, ringing.Attention, ringing.AttentionSeq = false, true, 1
73+ quiet := protocol.RecentRouteSnapshot{
7274 Generation: 4,
7375 Entries: []protocol.RecentRouteEntry{unvisited, testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote)},
7476 }
77+ rung := protocol.RecentRouteSnapshot{
78+ Generation: 4,
79+ Entries: []protocol.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote), unvisited, ringing},
80+ }
7581 tests := []struct {
76- name string
77- rank int
78- wantKey uint64
79- wantErr error
82+ name string
83+ snapshot protocol.RecentRouteSnapshot
84+ rank int
85+ wantKey uint64
86+ wantGen uint64
87+ wantErr error
8088 }{
81- {name: "rank 1 skips the unvisited entry", rank: 1, wantKey: 2},
82- {name: "rank beyond the visited prefix is refused", rank: 2, wantErr: command.ErrInvalidArguments},
89+ {name: "rank 1 skips the unvisited entry", snapshot: quiet, rank: 1, wantKey: 2, wantGen: 3},
90+ {name: "rank beyond the history is refused", snapshot: quiet, rank: 2, wantErr: command.ErrInvalidArguments},
91+ {name: "rank after the visited prefix reaches a ringing entry", snapshot: rung, rank: 2, wantKey: 6, wantGen: 1},
8392 }
8493 for _, tt := range tests {
8594 t.Run(tt.name, func(t *testing.T) {
@@ -94,7 +103,7 @@ func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) {
94103 require.True(t, admitted)
95104 defer effect.End()
96105
97- exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: snapshot, effect: effect}
106+ exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: tt.snapshot, effect: effect}
98107 err := exec.JumpRecentSession(tt.rank)
99108 if tt.wantErr != nil {
100109 require.ErrorIs(t, err, tt.wantErr)
@@ -110,8 +119,8 @@ func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) {
110119 }
111120 }
112121 require.True(t, found)
113- require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: 3}, action)
114- hints := recentRouteHints(snapshot, nil)
122+ require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: tt.wantGen}, action)
123+ hints := recentRouteHints(tt.snapshot, nil)
115124 require.Equal(t, action.Key, hints.Recent[tt.rank-1].Key, "hint and jump share the rank mapping")
116125 })
117126 }
@@ -134,9 +143,13 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) {
134143 require.Equal(t, uint64(7), hints.Recent[0].Generation)
135144 }
136145
137-func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) {
146+func TestStatusHistoryShowsVisitedAndRingingRoutes(t *testing.T) {
138147 unvisited := testRouteEntry(4, 1, "other", 4, protocol.RouteKindLocal)
139148 unvisited.Visited = false
149+ ringingLocal := testRouteEntry(5, 1, "build", 5, protocol.RouteKindLocal)
150+ ringingLocal.Visited, ringingLocal.Attention, ringingLocal.AttentionSeq = false, true, 1
151+ ringingRemote := testRouteEntry(6, 1, "deploy", 6, protocol.RouteKindRemote)
152+ ringingRemote.Visited, ringingRemote.Attention, ringingRemote.AttentionSeq = false, true, 2
140153 tests := []struct {
141154 name string
142155 entries []protocol.RecentRouteEntry
@@ -144,6 +157,8 @@ func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) {
144157 }{
145158 {name: "fresh client shows no history", entries: []protocol.RecentRouteEntry{unvisited}, want: nil},
146159 {name: "visited routes keep their order", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, testRouteEntry(3, 1, "logs", 3, protocol.RouteKindLocal)}, want: []string{"work", "logs"}},
160+ {name: "fresh client still shows ringing local and remote routes", entries: []protocol.RecentRouteEntry{unvisited, ringingLocal, ringingRemote}, want: []string{"build", "deploy@remote"}},
161+ {name: "ringing routes follow visited ones", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, ringingLocal}, want: []string{"work", "build"}},
147162 }
148163 for _, tt := range tests {
149164 t.Run(tt.name, func(t *testing.T) {
@@ -155,6 +170,9 @@ func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) {
155170 var names []string
156171 for _, entry := range state.mru {
157172 names = append(names, entry.name)
173+ if entry.name == "build" || entry.name == "deploy@remote" {
174+ require.True(t, entry.attention, "%s must draw its bell", entry.name)
175+ }
158176 }
159177 require.Equal(t, tt.want, names)
160178 require.Equal(t, len(tt.want), len(recentRouteHints(ac.routeSnapshotCopy(), nil).Recent))
@@ -65,21 +65,30 @@ func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
65 require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name)65 require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name)
66 }66 }
67 67
68-func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) {68+func TestPaletteJumpRecentSessionTargetsHistoryRank(t *testing.T) {
69 unvisited := testRouteEntry(5, 1, "other", 5, protocol.RouteKindLocal)69 unvisited := testRouteEntry(5, 1, "other", 5, protocol.RouteKindLocal)
70 unvisited.Visited = false70 unvisited.Visited = false
71- snapshot := protocol.RecentRouteSnapshot{71+ ringing := testRouteEntry(6, 1, "loud", 6, protocol.RouteKindLocal)
72+ ringing.Visited, ringing.Attention, ringing.AttentionSeq = false, true, 1
73+ quiet := protocol.RecentRouteSnapshot{
72 Generation: 4,74 Generation: 4,
73 Entries: []protocol.RecentRouteEntry{unvisited, testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote)},75 Entries: []protocol.RecentRouteEntry{unvisited, testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote)},
74 }76 }
77+ rung := protocol.RecentRouteSnapshot{
78+ Generation: 4,
79+ Entries: []protocol.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote), unvisited, ringing},
80+ }
75 tests := []struct {81 tests := []struct {
76- name string82+ name string
77- rank int83+ snapshot protocol.RecentRouteSnapshot
78- wantKey uint6484+ rank int
79- wantErr error85+ wantKey uint64
86+ wantGen uint64
87+ wantErr error
80 }{88 }{
81- {name: "rank 1 skips the unvisited entry", rank: 1, wantKey: 2},89+ {name: "rank 1 skips the unvisited entry", snapshot: quiet, rank: 1, wantKey: 2, wantGen: 3},
82- {name: "rank beyond the visited prefix is refused", rank: 2, wantErr: command.ErrInvalidArguments},90+ {name: "rank beyond the history is refused", snapshot: quiet, rank: 2, wantErr: command.ErrInvalidArguments},
91+ {name: "rank after the visited prefix reaches a ringing entry", snapshot: rung, rank: 2, wantKey: 6, wantGen: 1},
83 }92 }
84 for _, tt := range tests {93 for _, tt := range tests {
85 t.Run(tt.name, func(t *testing.T) {94 t.Run(tt.name, func(t *testing.T) {
@@ -94,7 +103,7 @@ func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) {
94 require.True(t, admitted)103 require.True(t, admitted)
95 defer effect.End()104 defer effect.End()
96 105
97- exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: snapshot, effect: effect}106+ exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: tt.snapshot, effect: effect}
98 err := exec.JumpRecentSession(tt.rank)107 err := exec.JumpRecentSession(tt.rank)
99 if tt.wantErr != nil {108 if tt.wantErr != nil {
100 require.ErrorIs(t, err, tt.wantErr)109 require.ErrorIs(t, err, tt.wantErr)
@@ -110,8 +119,8 @@ func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) {
110 }119 }
111 }120 }
112 require.True(t, found)121 require.True(t, found)
113- require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: 3}, action)122+ require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: tt.wantGen}, action)
114- hints := recentRouteHints(snapshot, nil)123+ hints := recentRouteHints(tt.snapshot, nil)
115 require.Equal(t, action.Key, hints.Recent[tt.rank-1].Key, "hint and jump share the rank mapping")124 require.Equal(t, action.Key, hints.Recent[tt.rank-1].Key, "hint and jump share the rank mapping")
116 })125 })
117 }126 }
@@ -134,9 +143,13 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) {
134 require.Equal(t, uint64(7), hints.Recent[0].Generation)143 require.Equal(t, uint64(7), hints.Recent[0].Generation)
135 }144 }
136 145
137-func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) {146+func TestStatusHistoryShowsVisitedAndRingingRoutes(t *testing.T) {
138 unvisited := testRouteEntry(4, 1, "other", 4, protocol.RouteKindLocal)147 unvisited := testRouteEntry(4, 1, "other", 4, protocol.RouteKindLocal)
139 unvisited.Visited = false148 unvisited.Visited = false
149+ ringingLocal := testRouteEntry(5, 1, "build", 5, protocol.RouteKindLocal)
150+ ringingLocal.Visited, ringingLocal.Attention, ringingLocal.AttentionSeq = false, true, 1
151+ ringingRemote := testRouteEntry(6, 1, "deploy", 6, protocol.RouteKindRemote)
152+ ringingRemote.Visited, ringingRemote.Attention, ringingRemote.AttentionSeq = false, true, 2
140 tests := []struct {153 tests := []struct {
141 name string154 name string
142 entries []protocol.RecentRouteEntry155 entries []protocol.RecentRouteEntry
@@ -144,6 +157,8 @@ func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) {
144 }{157 }{
145 {name: "fresh client shows no history", entries: []protocol.RecentRouteEntry{unvisited}, want: nil},158 {name: "fresh client shows no history", entries: []protocol.RecentRouteEntry{unvisited}, want: nil},
146 {name: "visited routes keep their order", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, testRouteEntry(3, 1, "logs", 3, protocol.RouteKindLocal)}, want: []string{"work", "logs"}},159 {name: "visited routes keep their order", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, testRouteEntry(3, 1, "logs", 3, protocol.RouteKindLocal)}, want: []string{"work", "logs"}},
160+ {name: "fresh client still shows ringing local and remote routes", entries: []protocol.RecentRouteEntry{unvisited, ringingLocal, ringingRemote}, want: []string{"build", "deploy@remote"}},
161+ {name: "ringing routes follow visited ones", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, ringingLocal}, want: []string{"work", "build"}},
147 }162 }
148 for _, tt := range tests {163 for _, tt := range tests {
149 t.Run(tt.name, func(t *testing.T) {164 t.Run(tt.name, func(t *testing.T) {
@@ -155,6 +170,9 @@ func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) {
155 var names []string170 var names []string
156 for _, entry := range state.mru {171 for _, entry := range state.mru {
157 names = append(names, entry.name)172 names = append(names, entry.name)
173+ if entry.name == "build" || entry.name == "deploy@remote" {
174+ require.True(t, entry.attention, "%s must draw its bell", entry.name)
175+ }
158 }176 }
159 require.Equal(t, tt.want, names)177 require.Equal(t, tt.want, names)
160 require.Equal(t, len(tt.want), len(recentRouteHints(ac.routeSnapshotCopy(), nil).Recent))178 require.Equal(t, len(tt.want), len(recentRouteHints(ac.routeSnapshotCopy(), nil).Recent))
M internal/usecase/daemon/status.go +2 -2
@@ -237,7 +237,7 @@ func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot p
237237 }
238238 var formatted []recentRouteDisplay
239239 if snapshot.Generation != 0 {
240- formatted = formatRecentRouteSnapshot(visitedRouteSnapshot(snapshot))
240+ formatted = formatRecentRouteSnapshot(historyRouteSnapshot(snapshot))
241241 }
242242 entries := make([]rankedRecent, 0, len(hints.Recent))
243243 for i, hint := range hints.Recent {
@@ -296,7 +296,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
296296 if ranked != nil {
297297 state.rankedRecent = ranked
298298 } else if routeSnapshot.Generation != 0 {
299- state.mru = formatRecentRouteSnapshot(visitedRouteSnapshot(routeSnapshot))
299+ state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))
300300 }
301301 return state
302302 }
@@ -237,7 +237,7 @@ func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot p
237 }237 }
238 var formatted []recentRouteDisplay238 var formatted []recentRouteDisplay
239 if snapshot.Generation != 0 {239 if snapshot.Generation != 0 {
240- formatted = formatRecentRouteSnapshot(visitedRouteSnapshot(snapshot))240+ formatted = formatRecentRouteSnapshot(historyRouteSnapshot(snapshot))
241 }241 }
242 entries := make([]rankedRecent, 0, len(hints.Recent))242 entries := make([]rankedRecent, 0, len(hints.Recent))
243 for i, hint := range hints.Recent {243 for i, hint := range hints.Recent {
@@ -296,7 +296,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
296 if ranked != nil {296 if ranked != nil {
297 state.rankedRecent = ranked297 state.rankedRecent = ranked
298 } else if routeSnapshot.Generation != 0 {298 } else if routeSnapshot.Generation != 0 {
299- state.mru = formatRecentRouteSnapshot(visitedRouteSnapshot(routeSnapshot))299+ state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))
300 }300 }
301 return state301 return state
302 }302 }

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