refactor(daemon): separate recent route presentation
- commit
- a27f761df844312b86e8a6a7476f0184e6be762a
- author
- brice · · 2026-08-09 11:09 UTC
- parent
- b9f0565
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +341 -70
M internal/usecase/daemon/floating_render_test.go +1 -1
| @@ -749,7 +749,7 @@ func benchmarkCapturedRenderState() capturedRenderState { | |||
| 749 | 749 | status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}}, | |
| 750 | 750 | topRight: "vev", | |
| 751 | 751 | bottomRight: "main", | |
| 752 | - | mru: []recentSession{{name: "work"}, {name: "logs"}}, | |
| 752 | + | mru: []recentRouteDisplay{{name: "work"}, {name: "logs"}}, | |
| 753 | 753 | theme: terminalTheme, | |
| 754 | 754 | }, | |
| 755 | 755 | theme: terminalTheme, | |
| @@ -749,7 +749,7 @@ func benchmarkCapturedRenderState() capturedRenderState { | |||||
| 749 | status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}}, | 749 | status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}}, | ||
| 750 | topRight: "vev", | 750 | topRight: "vev", | ||
| 751 | bottomRight: "main", | 751 | bottomRight: "main", | ||
| 752 | - | mru: []recentSession{{name: "work"}, {name: "logs"}}, | 752 | + | mru: []recentRouteDisplay{{name: "work"}, {name: "logs"}}, |
| 753 | theme: terminalTheme, | 753 | theme: terminalTheme, | ||
| 754 | }, | 754 | }, | ||
| 755 | theme: terminalTheme, | 755 | theme: terminalTheme, | ||
M internal/usecase/daemon/palette.go +3 -2
| @@ -94,8 +94,9 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | 96 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | |
| 97 | - | names := make([]string, len(recent)) | |
| 98 | - | for i, entry := range recent { | |
| 97 | + | formatted := formatRecentRoutePresentations(recentRoutePresentations(recent)) | |
| 98 | + | names := make([]string, len(formatted)) | |
| 99 | + | for i, entry := range formatted { | |
| 99 | 100 | names[i] = entry.name | |
| 100 | 101 | } | |
| 101 | 102 | return palette.BuildRecentSessionHints(names, args) | |
| @@ -94,8 +94,9 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | 96 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | ||
| 97 | - | names := make([]string, len(recent)) | 97 | + | formatted := formatRecentRoutePresentations(recentRoutePresentations(recent)) |
| 98 | - | for i, entry := range recent { | 98 | + | names := make([]string, len(formatted)) |
| 99 | + | for i, entry := range formatted { | |||
| 99 | names[i] = entry.name | 100 | names[i] = entry.name | ||
| 100 | } | 101 | } | ||
| 101 | return palette.BuildRecentSessionHints(names, args) | 102 | return palette.BuildRecentSessionHints(names, args) | ||
A internal/usecase/daemon/recent_route_presentation.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // recentRouteKind identifies the origin represented by a presentation value. | |
| 4 | + | // It is intentionally daemon-local in this layer; the client route protocol | |
| 5 | + | // and its wire kind are introduced by the next stacked branch. | |
| 6 | + | type recentRouteKind uint8 | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | recentRouteLocal recentRouteKind = iota | |
| 10 | + | recentRouteRemote | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // recentRoutePresentation is the compact immutable value shared by collection, | |
| 14 | + | // formatting, and the future client-published snapshot. It contains only | |
| 15 | + | // display data; it never carries session ownership, selection identity, | |
| 16 | + | // transport capabilities, credentials, or attach targets. | |
| 17 | + | type recentRoutePresentation struct { | |
| 18 | + | name string | |
| 19 | + | hostLabel string | |
| 20 | + | kind recentRouteKind | |
| 21 | + | ephemeral bool | |
| 22 | + | attention bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // recentRouteDisplay is a formatted, render-ready copy of a presentation. The | |
| 26 | + | // source value is never mutated while duplicate names are qualified. | |
| 27 | + | type recentRouteDisplay struct { | |
| 28 | + | name string | |
| 29 | + | kind recentRouteKind | |
| 30 | + | ephemeral bool | |
| 31 | + | attention bool | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // routePresentation returns the local daemon's display-only view of a | |
| 35 | + | // selection value. Selection identity remains in recentSession for palette | |
| 36 | + | // execution and is not passed into rendering. | |
| 37 | + | func (entry recentSession) routePresentation() recentRoutePresentation { | |
| 38 | + | return recentRoutePresentation{ | |
| 39 | + | name: entry.name, | |
| 40 | + | kind: recentRouteLocal, | |
| 41 | + | attention: entry.attention, | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func recentRoutePresentations(entries []recentSession) []recentRoutePresentation { | |
| 46 | + | if entries == nil { | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | out := make([]recentRoutePresentation, len(entries)) | |
| 50 | + | for i, entry := range entries { | |
| 51 | + | out[i] = entry.routePresentation() | |
| 52 | + | } | |
| 53 | + | return out | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // formatRecentRoutePresentations is the single label formatter used by the | |
| 57 | + | // normal MRU bar and ranked recent-session palette rendering. It returns an | |
| 58 | + | // owned slice so callers can retain the result without consulting live state. | |
| 59 | + | func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentRouteDisplay { | |
| 60 | + | if entries == nil { | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | nameCounts := make(map[string]int, len(entries)) | |
| 64 | + | for _, entry := range entries { | |
| 65 | + | nameCounts[entry.name]++ | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | out := make([]recentRouteDisplay, len(entries)) | |
| 69 | + | for i, entry := range entries { | |
| 70 | + | out[i] = recentRouteDisplay{ | |
| 71 | + | name: formatRecentRouteName(entry, nameCounts[entry.name] > 1), | |
| 72 | + | kind: entry.kind, | |
| 73 | + | ephemeral: entry.ephemeral, | |
| 74 | + | attention: entry.attention, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return out | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string { | |
| 81 | + | name := entry.name | |
| 82 | + | if entry.ephemeral { | |
| 83 | + | name += "*" | |
| 84 | + | } | |
| 85 | + | if entry.kind != recentRouteRemote && !ambiguous { | |
| 86 | + | return name | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | host := entry.hostLabel | |
| 90 | + | if host == "" { | |
| 91 | + | if entry.kind == recentRouteRemote { | |
| 92 | + | host = "remote" | |
| 93 | + | } else { | |
| 94 | + | host = "local" | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | return name + "@" + host | |
| 98 | + | } | |
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | // recentRouteKind identifies the origin represented by a presentation value. | |||
| 4 | + | // It is intentionally daemon-local in this layer; the client route protocol | |||
| 5 | + | // and its wire kind are introduced by the next stacked branch. | |||
| 6 | + | type recentRouteKind uint8 | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | recentRouteLocal recentRouteKind = iota | |||
| 10 | + | recentRouteRemote | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // recentRoutePresentation is the compact immutable value shared by collection, | |||
| 14 | + | // formatting, and the future client-published snapshot. It contains only | |||
| 15 | + | // display data; it never carries session ownership, selection identity, | |||
| 16 | + | // transport capabilities, credentials, or attach targets. | |||
| 17 | + | type recentRoutePresentation struct { | |||
| 18 | + | name string | |||
| 19 | + | hostLabel string | |||
| 20 | + | kind recentRouteKind | |||
| 21 | + | ephemeral bool | |||
| 22 | + | attention bool | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // recentRouteDisplay is a formatted, render-ready copy of a presentation. The | |||
| 26 | + | // source value is never mutated while duplicate names are qualified. | |||
| 27 | + | type recentRouteDisplay struct { | |||
| 28 | + | name string | |||
| 29 | + | kind recentRouteKind | |||
| 30 | + | ephemeral bool | |||
| 31 | + | attention bool | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // routePresentation returns the local daemon's display-only view of a | |||
| 35 | + | // selection value. Selection identity remains in recentSession for palette | |||
| 36 | + | // execution and is not passed into rendering. | |||
| 37 | + | func (entry recentSession) routePresentation() recentRoutePresentation { | |||
| 38 | + | return recentRoutePresentation{ | |||
| 39 | + | name: entry.name, | |||
| 40 | + | kind: recentRouteLocal, | |||
| 41 | + | attention: entry.attention, | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func recentRoutePresentations(entries []recentSession) []recentRoutePresentation { | |||
| 46 | + | if entries == nil { | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | out := make([]recentRoutePresentation, len(entries)) | |||
| 50 | + | for i, entry := range entries { | |||
| 51 | + | out[i] = entry.routePresentation() | |||
| 52 | + | } | |||
| 53 | + | return out | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // formatRecentRoutePresentations is the single label formatter used by the | |||
| 57 | + | // normal MRU bar and ranked recent-session palette rendering. It returns an | |||
| 58 | + | // owned slice so callers can retain the result without consulting live state. | |||
| 59 | + | func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentRouteDisplay { | |||
| 60 | + | if entries == nil { | |||
| 61 | + | return nil | |||
| 62 | + | } | |||
| 63 | + | nameCounts := make(map[string]int, len(entries)) | |||
| 64 | + | for _, entry := range entries { | |||
| 65 | + | nameCounts[entry.name]++ | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | out := make([]recentRouteDisplay, len(entries)) | |||
| 69 | + | for i, entry := range entries { | |||
| 70 | + | out[i] = recentRouteDisplay{ | |||
| 71 | + | name: formatRecentRouteName(entry, nameCounts[entry.name] > 1), | |||
| 72 | + | kind: entry.kind, | |||
| 73 | + | ephemeral: entry.ephemeral, | |||
| 74 | + | attention: entry.attention, | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | return out | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string { | |||
| 81 | + | name := entry.name | |||
| 82 | + | if entry.ephemeral { | |||
| 83 | + | name += "*" | |||
| 84 | + | } | |||
| 85 | + | if entry.kind != recentRouteRemote && !ambiguous { | |||
| 86 | + | return name | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | host := entry.hostLabel | |||
| 90 | + | if host == "" { | |||
| 91 | + | if entry.kind == recentRouteRemote { | |||
| 92 | + | host = "remote" | |||
| 93 | + | } else { | |||
| 94 | + | host = "local" | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | return name + "@" + host | |||
| 98 | + | } | |||
A internal/usecase/daemon/recent_route_presentation_test.go +163 -0
| @@ -0,0 +1,163 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestFormatRecentRoutePresentations(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | input []recentRoutePresentation | |
| 15 | + | want []recentRouteDisplay | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "unique local name stays compact", | |
| 19 | + | input: []recentRoutePresentation{{ | |
| 20 | + | name: "editor", kind: recentRouteLocal, | |
| 21 | + | }}, | |
| 22 | + | want: []recentRouteDisplay{{ | |
| 23 | + | name: "editor", kind: recentRouteLocal, | |
| 24 | + | }}, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "remote name is host qualified", | |
| 28 | + | input: []recentRoutePresentation{{ | |
| 29 | + | name: "logs", hostLabel: "edge", kind: recentRouteRemote, | |
| 30 | + | }}, | |
| 31 | + | want: []recentRouteDisplay{{ | |
| 32 | + | name: "logs@edge", kind: recentRouteRemote, | |
| 33 | + | }}, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "duplicate local and remote names qualify every entry", | |
| 37 | + | input: []recentRoutePresentation{ | |
| 38 | + | {name: "logs", kind: recentRouteLocal}, | |
| 39 | + | {name: "logs", hostLabel: "edge", kind: recentRouteRemote}, | |
| 40 | + | }, | |
| 41 | + | want: []recentRouteDisplay{ | |
| 42 | + | {name: "logs@local", kind: recentRouteLocal}, | |
| 43 | + | {name: "logs@edge", kind: recentRouteRemote}, | |
| 44 | + | }, | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "missing host uses kind fallback", | |
| 48 | + | input: []recentRoutePresentation{{ | |
| 49 | + | name: "logs", kind: recentRouteRemote, | |
| 50 | + | }}, | |
| 51 | + | want: []recentRouteDisplay{{ | |
| 52 | + | name: "logs@remote", kind: recentRouteRemote, | |
| 53 | + | }}, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "authoritative markers survive formatting", | |
| 57 | + | input: []recentRoutePresentation{{ | |
| 58 | + | name: "scratch", kind: recentRouteLocal, ephemeral: true, attention: true, | |
| 59 | + | }}, | |
| 60 | + | want: []recentRouteDisplay{{ | |
| 61 | + | name: "scratch*", kind: recentRouteLocal, ephemeral: true, attention: true, | |
| 62 | + | }}, | |
| 63 | + | }, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for _, tt := range tests { | |
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |
| 68 | + | input := append([]recentRoutePresentation(nil), tt.input...) | |
| 69 | + | got := formatRecentRoutePresentations(input) | |
| 70 | + | ||
| 71 | + | require.Equal(t, tt.want, got) | |
| 72 | + | require.Equal(t, input, tt.input, "formatting must not mutate the source snapshot") | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestRecentRoutePresentationsProjectSelectionValues(t *testing.T) { | |
| 78 | + | entries := []recentSession{{id: "session-secret", name: "work", attention: true, mruAt: 17}} | |
| 79 | + | ||
| 80 | + | got := recentRoutePresentations(entries) | |
| 81 | + | ||
| 82 | + | require.Equal(t, []recentRoutePresentation{{ | |
| 83 | + | name: "work", kind: recentRouteLocal, attention: true, | |
| 84 | + | }}, got) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) { | |
| 88 | + | recent := []recentSession{ | |
| 89 | + | {name: "logs", attention: true}, | |
| 90 | + | {name: "logs"}, | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | hints := recentSessionHints(recent, nil) | |
| 94 | + | require.Equal(t, []palette.RecentSessionHint{ | |
| 95 | + | {Rank: 1, Name: "logs@local"}, | |
| 96 | + | {Rank: 2, Name: "logs@local"}, | |
| 97 | + | }, hints.Recent) | |
| 98 | + | ||
| 99 | + | hints = palette.ContextualHints{ | |
| 100 | + | Kind: command.ContextHintRecentSessions, | |
| 101 | + | SelectedRank: 9, | |
| 102 | + | Recent: []palette.RecentSessionHint{ | |
| 103 | + | {Rank: 4, Name: "stale-label"}, | |
| 104 | + | {Rank: 9, Name: "stale-label"}, | |
| 105 | + | }, | |
| 106 | + | } | |
| 107 | + | ranked := rankedRecentForHints(&hints, recent) | |
| 108 | + | ||
| 109 | + | require.Equal(t, []rankedRecent{ | |
| 110 | + | {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true}, | |
| 111 | + | {rank: 9, name: "logs@local", kind: recentRouteLocal, selected: true}, | |
| 112 | + | }, ranked) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestFitRecentRouteEntriesKeepsWholeWideAndAttentionEntries(t *testing.T) { | |
| 116 | + | tests := []struct { | |
| 117 | + | name string | |
| 118 | + | entries []recentRouteDisplay | |
| 119 | + | width int | |
| 120 | + | want []string | |
| 121 | + | }{ | |
| 122 | + | { | |
| 123 | + | name: "wide rune consumes its full cell width", | |
| 124 | + | entries: []recentRouteDisplay{ | |
| 125 | + | {name: "界"}, | |
| 126 | + | {name: "b"}, | |
| 127 | + | }, | |
| 128 | + | width: 12, | |
| 129 | + | want: []string{"界"}, | |
| 130 | + | }, | |
| 131 | + | { | |
| 132 | + | name: "attention bell is part of the entry width", | |
| 133 | + | entries: []recentRouteDisplay{ | |
| 134 | + | {name: "a", attention: true}, | |
| 135 | + | {name: "b"}, | |
| 136 | + | }, | |
| 137 | + | width: 12, | |
| 138 | + | want: []string{"a"}, | |
| 139 | + | }, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | for _, tt := range tests { | |
| 143 | + | t.Run(tt.name, func(t *testing.T) { | |
| 144 | + | got := fitMRU(tt.entries, tt.width, 0, "copy") | |
| 145 | + | names := make([]string, len(got)) | |
| 146 | + | for i, entry := range got { | |
| 147 | + | names[i] = entry.name | |
| 148 | + | } | |
| 149 | + | require.Equal(t, tt.want, names) | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestRankedRecentKeepsCapturedRanksWhenFitting(t *testing.T) { | |
| 155 | + | entries := []rankedRecent{ | |
| 156 | + | {rank: 3, name: "wide界"}, | |
| 157 | + | {rank: 7, name: "next"}, | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | got := fitRankedRecent(entries, 17, 0, "") | |
| 161 | + | ||
| 162 | + | require.Equal(t, []rankedRecent{{rank: 3, name: "wide界"}}, got) | |
| 163 | + | } | |
| @@ -0,0 +1,163 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/palette" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestFormatRecentRoutePresentations(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | input []recentRoutePresentation | |||
| 15 | + | want []recentRouteDisplay | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "unique local name stays compact", | |||
| 19 | + | input: []recentRoutePresentation{{ | |||
| 20 | + | name: "editor", kind: recentRouteLocal, | |||
| 21 | + | }}, | |||
| 22 | + | want: []recentRouteDisplay{{ | |||
| 23 | + | name: "editor", kind: recentRouteLocal, | |||
| 24 | + | }}, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "remote name is host qualified", | |||
| 28 | + | input: []recentRoutePresentation{{ | |||
| 29 | + | name: "logs", hostLabel: "edge", kind: recentRouteRemote, | |||
| 30 | + | }}, | |||
| 31 | + | want: []recentRouteDisplay{{ | |||
| 32 | + | name: "logs@edge", kind: recentRouteRemote, | |||
| 33 | + | }}, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "duplicate local and remote names qualify every entry", | |||
| 37 | + | input: []recentRoutePresentation{ | |||
| 38 | + | {name: "logs", kind: recentRouteLocal}, | |||
| 39 | + | {name: "logs", hostLabel: "edge", kind: recentRouteRemote}, | |||
| 40 | + | }, | |||
| 41 | + | want: []recentRouteDisplay{ | |||
| 42 | + | {name: "logs@local", kind: recentRouteLocal}, | |||
| 43 | + | {name: "logs@edge", kind: recentRouteRemote}, | |||
| 44 | + | }, | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "missing host uses kind fallback", | |||
| 48 | + | input: []recentRoutePresentation{{ | |||
| 49 | + | name: "logs", kind: recentRouteRemote, | |||
| 50 | + | }}, | |||
| 51 | + | want: []recentRouteDisplay{{ | |||
| 52 | + | name: "logs@remote", kind: recentRouteRemote, | |||
| 53 | + | }}, | |||
| 54 | + | }, | |||
| 55 | + | { | |||
| 56 | + | name: "authoritative markers survive formatting", | |||
| 57 | + | input: []recentRoutePresentation{{ | |||
| 58 | + | name: "scratch", kind: recentRouteLocal, ephemeral: true, attention: true, | |||
| 59 | + | }}, | |||
| 60 | + | want: []recentRouteDisplay{{ | |||
| 61 | + | name: "scratch*", kind: recentRouteLocal, ephemeral: true, attention: true, | |||
| 62 | + | }}, | |||
| 63 | + | }, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | for _, tt := range tests { | |||
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 68 | + | input := append([]recentRoutePresentation(nil), tt.input...) | |||
| 69 | + | got := formatRecentRoutePresentations(input) | |||
| 70 | + | ||||
| 71 | + | require.Equal(t, tt.want, got) | |||
| 72 | + | require.Equal(t, input, tt.input, "formatting must not mutate the source snapshot") | |||
| 73 | + | }) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestRecentRoutePresentationsProjectSelectionValues(t *testing.T) { | |||
| 78 | + | entries := []recentSession{{id: "session-secret", name: "work", attention: true, mruAt: 17}} | |||
| 79 | + | ||||
| 80 | + | got := recentRoutePresentations(entries) | |||
| 81 | + | ||||
| 82 | + | require.Equal(t, []recentRoutePresentation{{ | |||
| 83 | + | name: "work", kind: recentRouteLocal, attention: true, | |||
| 84 | + | }}, got) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) { | |||
| 88 | + | recent := []recentSession{ | |||
| 89 | + | {name: "logs", attention: true}, | |||
| 90 | + | {name: "logs"}, | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | hints := recentSessionHints(recent, nil) | |||
| 94 | + | require.Equal(t, []palette.RecentSessionHint{ | |||
| 95 | + | {Rank: 1, Name: "logs@local"}, | |||
| 96 | + | {Rank: 2, Name: "logs@local"}, | |||
| 97 | + | }, hints.Recent) | |||
| 98 | + | ||||
| 99 | + | hints = palette.ContextualHints{ | |||
| 100 | + | Kind: command.ContextHintRecentSessions, | |||
| 101 | + | SelectedRank: 9, | |||
| 102 | + | Recent: []palette.RecentSessionHint{ | |||
| 103 | + | {Rank: 4, Name: "stale-label"}, | |||
| 104 | + | {Rank: 9, Name: "stale-label"}, | |||
| 105 | + | }, | |||
| 106 | + | } | |||
| 107 | + | ranked := rankedRecentForHints(&hints, recent) | |||
| 108 | + | ||||
| 109 | + | require.Equal(t, []rankedRecent{ | |||
| 110 | + | {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true}, | |||
| 111 | + | {rank: 9, name: "logs@local", kind: recentRouteLocal, selected: true}, | |||
| 112 | + | }, ranked) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestFitRecentRouteEntriesKeepsWholeWideAndAttentionEntries(t *testing.T) { | |||
| 116 | + | tests := []struct { | |||
| 117 | + | name string | |||
| 118 | + | entries []recentRouteDisplay | |||
| 119 | + | width int | |||
| 120 | + | want []string | |||
| 121 | + | }{ | |||
| 122 | + | { | |||
| 123 | + | name: "wide rune consumes its full cell width", | |||
| 124 | + | entries: []recentRouteDisplay{ | |||
| 125 | + | {name: "界"}, | |||
| 126 | + | {name: "b"}, | |||
| 127 | + | }, | |||
| 128 | + | width: 12, | |||
| 129 | + | want: []string{"界"}, | |||
| 130 | + | }, | |||
| 131 | + | { | |||
| 132 | + | name: "attention bell is part of the entry width", | |||
| 133 | + | entries: []recentRouteDisplay{ | |||
| 134 | + | {name: "a", attention: true}, | |||
| 135 | + | {name: "b"}, | |||
| 136 | + | }, | |||
| 137 | + | width: 12, | |||
| 138 | + | want: []string{"a"}, | |||
| 139 | + | }, | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | for _, tt := range tests { | |||
| 143 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 144 | + | got := fitMRU(tt.entries, tt.width, 0, "copy") | |||
| 145 | + | names := make([]string, len(got)) | |||
| 146 | + | for i, entry := range got { | |||
| 147 | + | names[i] = entry.name | |||
| 148 | + | } | |||
| 149 | + | require.Equal(t, tt.want, names) | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestRankedRecentKeepsCapturedRanksWhenFitting(t *testing.T) { | |||
| 155 | + | entries := []rankedRecent{ | |||
| 156 | + | {rank: 3, name: "wide界"}, | |||
| 157 | + | {rank: 7, name: "next"}, | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | got := fitRankedRecent(entries, 17, 0, "") | |||
| 161 | + | ||||
| 162 | + | require.Equal(t, []rankedRecent{{rank: 3, name: "wide界"}}, got) | |||
| 163 | + | } | |||
M internal/usecase/daemon/recent_sessions.go +4 -2
| @@ -6,8 +6,10 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | // recentSession is an immutable, value-only description of a session for one | |
| 10 | - | // interaction. It deliberately does not retain a live session pointer. | |
| 9 | + | // recentSession is the immutable selection snapshot for one interaction. Its | |
| 10 | + | // identity and MRU sequence are retained only for daemon-side execution and | |
| 11 | + | // ordering; render paths project it into recentRoutePresentation, which has no | |
| 12 | + | // selection identity or lifecycle authority. | |
| 11 | 13 | type recentSession struct { | |
| 12 | 14 | id domain.SessionID | |
| 13 | 15 | name string | |
| @@ -6,8 +6,10 @@ import ( | |||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | // recentSession is an immutable, value-only description of a session for one | 9 | + | // recentSession is the immutable selection snapshot for one interaction. Its |
| 10 | - | // interaction. It deliberately does not retain a live session pointer. | 10 | + | // identity and MRU sequence are retained only for daemon-side execution and |
| 11 | + | // ordering; render paths project it into recentRoutePresentation, which has no | |||
| 12 | + | // selection identity or lifecycle authority. | |||
| 11 | type recentSession struct { | 13 | type recentSession struct { | ||
| 12 | id domain.SessionID | 14 | id domain.SessionID | ||
| 13 | name string | 15 | name string | ||
M internal/usecase/daemon/render_capture.go +1 -1
| @@ -28,7 +28,7 @@ type renderCaptureScratch struct { | |||
| 28 | 28 | layoutFingerprint string | |
| 29 | 29 | layoutValid bool | |
| 30 | 30 | statusTabs []statusTab | |
| 31 | - | mru []recentSession | |
| 31 | + | mru []recentRouteDisplay | |
| 32 | 32 | ranked []rankedRecent | |
| 33 | 33 | titleIDs []layout.PaneID | |
| 34 | 34 | receipts []damageReceipt | |
| @@ -28,7 +28,7 @@ type renderCaptureScratch struct { | |||||
| 28 | layoutFingerprint string | 28 | layoutFingerprint string | ||
| 29 | layoutValid bool | 29 | layoutValid bool | ||
| 30 | statusTabs []statusTab | 30 | statusTabs []statusTab | ||
| 31 | - | mru []recentSession | 31 | + | mru []recentRouteDisplay |
| 32 | ranked []rankedRecent | 32 | ranked []rankedRecent | ||
| 33 | titleIDs []layout.PaneID | 33 | titleIDs []layout.PaneID | ||
| 34 | receipts []damageReceipt | 34 | receipts []damageReceipt | ||
M internal/usecase/daemon/status.go +55 -48
| @@ -87,7 +87,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||
| 87 | 87 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 88 | 88 | for i, sess := range fittedMRU { | |
| 89 | 89 | style := styles.MRUStyle(i, len(fittedMRU)) | |
| 90 | - | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | |
| 90 | + | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | |
| 91 | 91 | } | |
| 92 | 92 | } | |
| 93 | 93 | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | |
| @@ -96,6 +96,8 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||
| 96 | 96 | type rankedRecent struct { | |
| 97 | 97 | rank int | |
| 98 | 98 | name string | |
| 99 | + | kind recentRouteKind | |
| 100 | + | ephemeral bool | |
| 99 | 101 | attention bool | |
| 100 | 102 | selected bool | |
| 101 | 103 | } | |
| @@ -112,9 +114,9 @@ func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent | |||
| 112 | 114 | writeStatusText(row, x, " ", style) | |
| 113 | 115 | } | |
| 114 | 116 | ||
| 115 | - | func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, attention bool, style renderer.Style, attentionFrame int) { | |
| 116 | - | writeStatusText(row, x, " "+name, style) | |
| 117 | - | if attention { | |
| 117 | + | func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) { | |
| 118 | + | writeStatusText(row, x, " "+entry.name, style) | |
| 119 | + | if entry.attention { | |
| 118 | 120 | writeStatusText(row, x, " ", style) | |
| 119 | 121 | writeBell(row, x, attentionFrame, style) | |
| 120 | 122 | } | |
| @@ -160,7 +162,7 @@ type barState struct { | |||
| 160 | 162 | topRight string | |
| 161 | 163 | bottomRight string | |
| 162 | 164 | statusFeedback string | |
| 163 | - | mru []recentSession | |
| 165 | + | mru []recentRouteDisplay | |
| 164 | 166 | rankedRecent []rankedRecent | |
| 165 | 167 | attentionFrame int | |
| 166 | 168 | // theme is the client's terminal theme, if reported. Its zero value | |
| @@ -226,15 +228,25 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession | |||
| 226 | 228 | if hints == nil || hints.Kind != command.ContextHintRecentSessions { | |
| 227 | 229 | return nil | |
| 228 | 230 | } | |
| 231 | + | formatted := formatRecentRoutePresentations(recentRoutePresentations(recent)) | |
| 229 | 232 | entries := make([]rankedRecent, 0, len(hints.Recent)) | |
| 230 | 233 | for i, hint := range hints.Recent { | |
| 231 | - | entry := rankedRecent{rank: hint.Rank, name: hint.Name, selected: hint.Rank == hints.SelectedRank} | |
| 234 | + | display := recentRouteDisplay{name: hint.Name} | |
| 232 | 235 | // recent was copied with the hint under paletteMu, so this only enriches | |
| 233 | - | // the render snapshot and never performs a live domain lookup. | |
| 234 | - | if i < len(recent) { | |
| 235 | - | entry.attention = recent[i].attention | |
| 236 | + | // the render snapshot and never performs a live domain lookup. The | |
| 237 | + | // formatted capture is authoritative when available, keeping ranked | |
| 238 | + | // JRS labels identical to normal MRU labels. | |
| 239 | + | if i < len(formatted) { | |
| 240 | + | display = formatted[i] | |
| 236 | 241 | } | |
| 237 | - | entries = append(entries, entry) | |
| 242 | + | entries = append(entries, rankedRecent{ | |
| 243 | + | rank: hint.Rank, | |
| 244 | + | name: display.name, | |
| 245 | + | kind: display.kind, | |
| 246 | + | ephemeral: display.ephemeral, | |
| 247 | + | attention: display.attention, | |
| 248 | + | selected: hint.Rank == hints.SelectedRank, | |
| 249 | + | }) | |
| 238 | 250 | } | |
| 239 | 251 | return entries | |
| 240 | 252 | } | |
| @@ -276,14 +288,14 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||
| 276 | 288 | if ranked != nil { | |
| 277 | 289 | state.rankedRecent = ranked | |
| 278 | 290 | } else if d != nil { | |
| 279 | - | state.mru = d.recentSessions(cur) | |
| 291 | + | state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur))) | |
| 280 | 292 | } | |
| 281 | 293 | return state | |
| 282 | 294 | } | |
| 283 | 295 | ||
| 284 | 296 | func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { | |
| 285 | 297 | state := d.barStateBase(cur, statusFeedback) | |
| 286 | - | state.mru = d.recentSessions(cur) | |
| 298 | + | state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur))) | |
| 287 | 299 | return state | |
| 288 | 300 | } | |
| 289 | 301 | ||
| @@ -383,33 +395,45 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLab | |||
| 383 | 395 | return labels | |
| 384 | 396 | } | |
| 385 | 397 | ||
| 386 | - | func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent { | |
| 387 | - | reserve := 1 | |
| 388 | - | if feedback != "" { | |
| 389 | - | reserve = statusTextWidth(feedback) + 2 | |
| 398 | + | func recentRouteDisplayWidth(entry recentRouteDisplay) int { | |
| 399 | + | width := 2 + statusTextWidth(entry.name) | |
| 400 | + | if entry.attention { | |
| 401 | + | width += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |
| 390 | 402 | } | |
| 391 | - | budget := rowLen - leftUsed - reserve | |
| 392 | - | if budget <= 0 { | |
| 403 | + | return width | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T { | |
| 407 | + | if budget <= 0 || len(entries) == 0 { | |
| 393 | 408 | return nil | |
| 394 | 409 | } | |
| 395 | - | cost := func(e rankedRecent) int { | |
| 396 | - | width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(e.name) | |
| 397 | - | if e.attention { | |
| 398 | - | width += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |
| 399 | - | } | |
| 400 | - | return width | |
| 401 | - | } | |
| 402 | 410 | used := 0 | |
| 403 | 411 | for i, entry := range entries { | |
| 404 | - | if used+cost(entry) > budget { | |
| 405 | - | return entries[:i] // whole entries only; ranks remain their original MRU ranks. | |
| 406 | - | } | |
| 407 | 412 | used += cost(entry) | |
| 413 | + | if used > budget { | |
| 414 | + | return entries[:i] | |
| 415 | + | } | |
| 408 | 416 | } | |
| 409 | 417 | return entries | |
| 410 | 418 | } | |
| 411 | 419 | ||
| 412 | - | func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession { | |
| 420 | + | func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent { | |
| 421 | + | reserve := 1 | |
| 422 | + | if feedback != "" { | |
| 423 | + | reserve = statusTextWidth(feedback) + 2 | |
| 424 | + | } | |
| 425 | + | budget := rowLen - leftUsed - reserve | |
| 426 | + | return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int { | |
| 427 | + | return recentRouteDisplayWidth(recentRouteDisplay{ | |
| 428 | + | name: entry.name, | |
| 429 | + | kind: entry.kind, | |
| 430 | + | ephemeral: entry.ephemeral, | |
| 431 | + | attention: entry.attention, | |
| 432 | + | }) + statusTextWidth(strconv.Itoa(entry.rank)+":") | |
| 433 | + | }) | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay { | |
| 413 | 437 | // With no feedback, keep one blank trailing cell; with feedback, reserve | |
| 414 | 438 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | |
| 415 | 439 | copyReserve := 1 | |
| @@ -420,34 +444,17 @@ func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []re | |||
| 420 | 444 | if physicalBudget <= 0 || len(entries) == 0 { | |
| 421 | 445 | return nil | |
| 422 | 446 | } | |
| 423 | - | cost := func(e recentSession) int { | |
| 424 | - | n := 2 + statusTextWidth(e.name) | |
| 425 | - | if e.attention { | |
| 426 | - | n += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |
| 427 | - | } | |
| 428 | - | return n | |
| 429 | - | } | |
| 430 | 447 | ||
| 431 | 448 | budget := physicalBudget | |
| 432 | 449 | if feedback == "" { | |
| 433 | 450 | budget -= mruFutureRightReserve(rowLen) | |
| 434 | 451 | // Keep at least one recent session when it physically fits; the reserved | |
| 435 | 452 | // right side is only a budget preference, not a reason to hide all recents. | |
| 436 | - | if firstCost := cost(entries[0]); firstCost <= physicalBudget && budget < firstCost { | |
| 453 | + | if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost { | |
| 437 | 454 | budget = firstCost | |
| 438 | 455 | } | |
| 439 | 456 | } | |
| 440 | - | if budget <= 0 { | |
| 441 | - | return nil | |
| 442 | - | } | |
| 443 | - | used := 0 | |
| 444 | - | for i, e := range entries { | |
| 445 | - | used += cost(e) | |
| 446 | - | if used > budget { | |
| 447 | - | return entries[:i] | |
| 448 | - | } | |
| 449 | - | } | |
| 450 | - | return entries | |
| 457 | + | return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth) | |
| 451 | 458 | } | |
| 452 | 459 | ||
| 453 | 460 | const ( | |
| @@ -87,7 +87,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||||
| 87 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | 87 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | ||
| 88 | for i, sess := range fittedMRU { | 88 | for i, sess := range fittedMRU { | ||
| 89 | style := styles.MRUStyle(i, len(fittedMRU)) | 89 | style := styles.MRUStyle(i, len(fittedMRU)) | ||
| 90 | - | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | 90 | + | drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame) |
| 91 | } | 91 | } | ||
| 92 | } | 92 | } | ||
| 93 | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | 93 | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | ||
| @@ -96,6 +96,8 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||||
| 96 | type rankedRecent struct { | 96 | type rankedRecent struct { | ||
| 97 | rank int | 97 | rank int | ||
| 98 | name string | 98 | name string | ||
| 99 | + | kind recentRouteKind | |||
| 100 | + | ephemeral bool | |||
| 99 | attention bool | 101 | attention bool | ||
| 100 | selected bool | 102 | selected bool | ||
| 101 | } | 103 | } | ||
| @@ -112,9 +114,9 @@ func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent | |||||
| 112 | writeStatusText(row, x, " ", style) | 114 | writeStatusText(row, x, " ", style) | ||
| 113 | } | 115 | } | ||
| 114 | 116 | ||||
| 115 | - | func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, attention bool, style renderer.Style, attentionFrame int) { | 117 | + | func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) { |
| 116 | - | writeStatusText(row, x, " "+name, style) | 118 | + | writeStatusText(row, x, " "+entry.name, style) |
| 117 | - | if attention { | 119 | + | if entry.attention { |
| 118 | writeStatusText(row, x, " ", style) | 120 | writeStatusText(row, x, " ", style) | ||
| 119 | writeBell(row, x, attentionFrame, style) | 121 | writeBell(row, x, attentionFrame, style) | ||
| 120 | } | 122 | } | ||
| @@ -160,7 +162,7 @@ type barState struct { | |||||
| 160 | topRight string | 162 | topRight string | ||
| 161 | bottomRight string | 163 | bottomRight string | ||
| 162 | statusFeedback string | 164 | statusFeedback string | ||
| 163 | - | mru []recentSession | 165 | + | mru []recentRouteDisplay |
| 164 | rankedRecent []rankedRecent | 166 | rankedRecent []rankedRecent | ||
| 165 | attentionFrame int | 167 | attentionFrame int | ||
| 166 | // theme is the client's terminal theme, if reported. Its zero value | 168 | // theme is the client's terminal theme, if reported. Its zero value | ||
| @@ -226,15 +228,25 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession | |||||
| 226 | if hints == nil || hints.Kind != command.ContextHintRecentSessions { | 228 | if hints == nil || hints.Kind != command.ContextHintRecentSessions { | ||
| 227 | return nil | 229 | return nil | ||
| 228 | } | 230 | } | ||
| 231 | + | formatted := formatRecentRoutePresentations(recentRoutePresentations(recent)) | |||
| 229 | entries := make([]rankedRecent, 0, len(hints.Recent)) | 232 | entries := make([]rankedRecent, 0, len(hints.Recent)) | ||
| 230 | for i, hint := range hints.Recent { | 233 | for i, hint := range hints.Recent { | ||
| 231 | - | entry := rankedRecent{rank: hint.Rank, name: hint.Name, selected: hint.Rank == hints.SelectedRank} | 234 | + | display := recentRouteDisplay{name: hint.Name} |
| 232 | // recent was copied with the hint under paletteMu, so this only enriches | 235 | // recent was copied with the hint under paletteMu, so this only enriches | ||
| 233 | - | // the render snapshot and never performs a live domain lookup. | 236 | + | // the render snapshot and never performs a live domain lookup. The |
| 234 | - | if i < len(recent) { | 237 | + | // formatted capture is authoritative when available, keeping ranked |
| 235 | - | entry.attention = recent[i].attention | 238 | + | // JRS labels identical to normal MRU labels. |
| 239 | + | if i < len(formatted) { | |||
| 240 | + | display = formatted[i] | |||
| 236 | } | 241 | } | ||
| 237 | - | entries = append(entries, entry) | 242 | + | entries = append(entries, rankedRecent{ |
| 243 | + | rank: hint.Rank, | |||
| 244 | + | name: display.name, | |||
| 245 | + | kind: display.kind, | |||
| 246 | + | ephemeral: display.ephemeral, | |||
| 247 | + | attention: display.attention, | |||
| 248 | + | selected: hint.Rank == hints.SelectedRank, | |||
| 249 | + | }) | |||
| 238 | } | 250 | } | ||
| 239 | return entries | 251 | return entries | ||
| 240 | } | 252 | } | ||
| @@ -276,14 +288,14 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||||
| 276 | if ranked != nil { | 288 | if ranked != nil { | ||
| 277 | state.rankedRecent = ranked | 289 | state.rankedRecent = ranked | ||
| 278 | } else if d != nil { | 290 | } else if d != nil { | ||
| 279 | - | state.mru = d.recentSessions(cur) | 291 | + | state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur))) |
| 280 | } | 292 | } | ||
| 281 | return state | 293 | return state | ||
| 282 | } | 294 | } | ||
| 283 | 295 | ||||
| 284 | func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { | 296 | func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { | ||
| 285 | state := d.barStateBase(cur, statusFeedback) | 297 | state := d.barStateBase(cur, statusFeedback) | ||
| 286 | - | state.mru = d.recentSessions(cur) | 298 | + | state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur))) |
| 287 | return state | 299 | return state | ||
| 288 | } | 300 | } | ||
| 289 | 301 | ||||
| @@ -383,33 +395,45 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLab | |||||
| 383 | return labels | 395 | return labels | ||
| 384 | } | 396 | } | ||
| 385 | 397 | ||||
| 386 | - | func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent { | 398 | + | func recentRouteDisplayWidth(entry recentRouteDisplay) int { |
| 387 | - | reserve := 1 | 399 | + | width := 2 + statusTextWidth(entry.name) |
| 388 | - | if feedback != "" { | 400 | + | if entry.attention { |
| 389 | - | reserve = statusTextWidth(feedback) + 2 | 401 | + | width += 1 + renderer.RuneWidth(ui.AttentionGlyph) |
| 390 | } | 402 | } | ||
| 391 | - | budget := rowLen - leftUsed - reserve | 403 | + | return width |
| 392 | - | if budget <= 0 { | 404 | + | } |
| 405 | + | ||||
| 406 | + | func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T { | |||
| 407 | + | if budget <= 0 || len(entries) == 0 { | |||
| 393 | return nil | 408 | return nil | ||
| 394 | } | 409 | } | ||
| 395 | - | cost := func(e rankedRecent) int { | |||
| 396 | - | width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(e.name) | |||
| 397 | - | if e.attention { | |||
| 398 | - | width += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |||
| 399 | - | } | |||
| 400 | - | return width | |||
| 401 | - | } | |||
| 402 | used := 0 | 410 | used := 0 | ||
| 403 | for i, entry := range entries { | 411 | for i, entry := range entries { | ||
| 404 | - | if used+cost(entry) > budget { | |||
| 405 | - | return entries[:i] // whole entries only; ranks remain their original MRU ranks. | |||
| 406 | - | } | |||
| 407 | used += cost(entry) | 412 | used += cost(entry) | ||
| 413 | + | if used > budget { | |||
| 414 | + | return entries[:i] | |||
| 415 | + | } | |||
| 408 | } | 416 | } | ||
| 409 | return entries | 417 | return entries | ||
| 410 | } | 418 | } | ||
| 411 | 419 | ||||
| 412 | - | func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession { | 420 | + | func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent { |
| 421 | + | reserve := 1 | |||
| 422 | + | if feedback != "" { | |||
| 423 | + | reserve = statusTextWidth(feedback) + 2 | |||
| 424 | + | } | |||
| 425 | + | budget := rowLen - leftUsed - reserve | |||
| 426 | + | return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int { | |||
| 427 | + | return recentRouteDisplayWidth(recentRouteDisplay{ | |||
| 428 | + | name: entry.name, | |||
| 429 | + | kind: entry.kind, | |||
| 430 | + | ephemeral: entry.ephemeral, | |||
| 431 | + | attention: entry.attention, | |||
| 432 | + | }) + statusTextWidth(strconv.Itoa(entry.rank)+":") | |||
| 433 | + | }) | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay { | |||
| 413 | // With no feedback, keep one blank trailing cell; with feedback, reserve | 437 | // With no feedback, keep one blank trailing cell; with feedback, reserve | ||
| 414 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | 438 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | ||
| 415 | copyReserve := 1 | 439 | copyReserve := 1 | ||
| @@ -420,34 +444,17 @@ func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []re | |||||
| 420 | if physicalBudget <= 0 || len(entries) == 0 { | 444 | if physicalBudget <= 0 || len(entries) == 0 { | ||
| 421 | return nil | 445 | return nil | ||
| 422 | } | 446 | } | ||
| 423 | - | cost := func(e recentSession) int { | |||
| 424 | - | n := 2 + statusTextWidth(e.name) | |||
| 425 | - | if e.attention { | |||
| 426 | - | n += 1 + renderer.RuneWidth(ui.AttentionGlyph) | |||
| 427 | - | } | |||
| 428 | - | return n | |||
| 429 | - | } | |||
| 430 | 447 | ||||
| 431 | budget := physicalBudget | 448 | budget := physicalBudget | ||
| 432 | if feedback == "" { | 449 | if feedback == "" { | ||
| 433 | budget -= mruFutureRightReserve(rowLen) | 450 | budget -= mruFutureRightReserve(rowLen) | ||
| 434 | // Keep at least one recent session when it physically fits; the reserved | 451 | // Keep at least one recent session when it physically fits; the reserved | ||
| 435 | // right side is only a budget preference, not a reason to hide all recents. | 452 | // right side is only a budget preference, not a reason to hide all recents. | ||
| 436 | - | if firstCost := cost(entries[0]); firstCost <= physicalBudget && budget < firstCost { | 453 | + | if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost { |
| 437 | budget = firstCost | 454 | budget = firstCost | ||
| 438 | } | 455 | } | ||
| 439 | } | 456 | } | ||
| 440 | - | if budget <= 0 { | 457 | + | return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth) |
| 441 | - | return nil | |||
| 442 | - | } | |||
| 443 | - | used := 0 | |||
| 444 | - | for i, e := range entries { | |||
| 445 | - | used += cost(e) | |||
| 446 | - | if used > budget { | |||
| 447 | - | return entries[:i] | |||
| 448 | - | } | |||
| 449 | - | } | |||
| 450 | - | return entries | |||
| 451 | } | 458 | } | ||
| 452 | 459 | ||||
| 453 | const ( | 460 | const ( | ||
M internal/usecase/daemon/status_test.go +16 -16
| @@ -1111,7 +1111,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse( | |||
| 1111 | 1111 | drawStatusBarState(row, state.bars, resolveStyles(nil)) | |
| 1112 | 1112 | return rowText(row) | |
| 1113 | 1113 | } | |
| 1114 | - | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}} | |
| 1114 | + | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "vty"}, {name: "misc"}}} | |
| 1115 | 1115 | ac.sendMu.Lock() | |
| 1116 | 1116 | defer ac.sendMu.Unlock() | |
| 1117 | 1117 | ||
| @@ -1147,7 +1147,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||
| 1147 | 1147 | state := barState{ | |
| 1148 | 1148 | status: statusSnapshot{session: "cur"}, | |
| 1149 | 1149 | attentionFrame: 1, | |
| 1150 | - | mru: []recentSession{ | |
| 1150 | + | mru: []recentRouteDisplay{ | |
| 1151 | 1151 | {name: "fresh"}, | |
| 1152 | 1152 | {name: "tmp", attention: true}, | |
| 1153 | 1153 | }, | |
| @@ -1219,7 +1219,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |||
| 1219 | 1219 | theme.Palette[10] = theme.Palette[2] | |
| 1220 | 1220 | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 1221 | 1221 | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 1222 | - | state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1222 | + | state := barState{status: statusSnapshot{session: "c"}, mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1223 | 1223 | row := make([]renderer.Cell, 16) | |
| 1224 | 1224 | ||
| 1225 | 1225 | drawStatusBarState(row, state, resolved.Styles) | |
| @@ -1235,7 +1235,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |||
| 1235 | 1235 | } | |
| 1236 | 1236 | ||
| 1237 | 1237 | func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) { | |
| 1238 | - | state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}} | |
| 1238 | + | state := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}} | |
| 1239 | 1239 | row := make([]renderer.Cell, 21) | |
| 1240 | 1240 | ||
| 1241 | 1241 | drawStatusBarState(row, state, resolveStyles(nil)) | |
| @@ -1247,9 +1247,9 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) { | |||
| 1247 | 1247 | } | |
| 1248 | 1248 | ||
| 1249 | 1249 | func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | |
| 1250 | - | mru := make([]recentSession, 0, maxMRUSessions) | |
| 1250 | + | mru := make([]recentRouteDisplay, 0, maxMRUSessions) | |
| 1251 | 1251 | for i := 1; i <= maxMRUSessions; i++ { | |
| 1252 | - | mru = append(mru, recentSession{name: "recent" + strconv.Itoa(i)}) | |
| 1252 | + | mru = append(mru, recentRouteDisplay{name: "recent" + strconv.Itoa(i)}) | |
| 1253 | 1253 | } | |
| 1254 | 1254 | state := barState{status: statusSnapshot{session: "cur"}, mru: mru} | |
| 1255 | 1255 | ||
| @@ -1309,27 +1309,27 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 1309 | 1309 | { | |
| 1310 | 1310 | name: "script text and copy feedback", | |
| 1311 | 1311 | width: 32, | |
| 1312 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "a"}}}, | |
| 1312 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}}}, | |
| 1313 | 1313 | wantContains: []string{" a"}, | |
| 1314 | 1314 | wantSuffix: " main ↑3 * copied", | |
| 1315 | 1315 | }, | |
| 1316 | 1316 | { | |
| 1317 | 1317 | name: "hide on overlap", | |
| 1318 | 1318 | width: 16, | |
| 1319 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}}, | |
| 1319 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}}, | |
| 1320 | 1320 | want: " cur ", | |
| 1321 | 1321 | wantNotContains: []string{"main", "copied"}, | |
| 1322 | 1322 | }, | |
| 1323 | 1323 | { | |
| 1324 | 1324 | name: "empty script keeps copy feedback behavior", | |
| 1325 | 1325 | width: 12, | |
| 1326 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}}, | |
| 1326 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}}, | |
| 1327 | 1327 | want: " cur copied", | |
| 1328 | 1328 | }, | |
| 1329 | 1329 | { | |
| 1330 | 1330 | name: "mru whole entry fitting with script text", | |
| 1331 | 1331 | width: 24, | |
| 1332 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}, | |
| 1332 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}}, | |
| 1333 | 1333 | wantContains: []string{" fresh"}, | |
| 1334 | 1334 | wantNotContains: []string{"middle"}, | |
| 1335 | 1335 | wantSuffix: " git", | |
| @@ -1337,7 +1337,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 1337 | 1337 | { | |
| 1338 | 1338 | name: "mru fitting reserves wide right anchor width", | |
| 1339 | 1339 | width: 12, | |
| 1340 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentSession{{name: "a"}}}, | |
| 1340 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentRouteDisplay{{name: "a"}}}, | |
| 1341 | 1341 | wantNotContains: []string{" a "}, | |
| 1342 | 1342 | wantSuffix: " 界 界 ", | |
| 1343 | 1343 | continuationCells: []int{9, 11}, | |
| @@ -1345,7 +1345,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 1345 | 1345 | { | |
| 1346 | 1346 | name: "mru fitting counts wide session names", | |
| 1347 | 1347 | width: 20, | |
| 1348 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "界"}, {name: "界"}, {name: "b"}}}, | |
| 1348 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "界"}, {name: "界"}, {name: "b"}}}, | |
| 1349 | 1349 | wantContains: []string{" 界 "}, | |
| 1350 | 1350 | wantNotContains: []string{" b "}, | |
| 1351 | 1351 | wantSuffix: " git", | |
| @@ -1378,7 +1378,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 1378 | 1378 | } | |
| 1379 | 1379 | ||
| 1380 | 1380 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |
| 1381 | - | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1381 | + | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1382 | 1382 | row := make([]renderer.Cell, 20) | |
| 1383 | 1383 | ||
| 1384 | 1384 | drawStatusBarState(row, state, resolveStyles(nil)) | |
| @@ -1391,7 +1391,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |||
| 1391 | 1391 | } | |
| 1392 | 1392 | ||
| 1393 | 1393 | func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | |
| 1394 | - | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}} | |
| 1394 | + | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}} | |
| 1395 | 1395 | tests := []struct { | |
| 1396 | 1396 | name string | |
| 1397 | 1397 | cols int | |
| @@ -1448,7 +1448,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||
| 1448 | 1448 | state := barState{ | |
| 1449 | 1449 | status: statusSnapshot{session: "current"}, | |
| 1450 | 1450 | bottomRight: "script ok", | |
| 1451 | - | mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}}, | |
| 1451 | + | mru: []recentRouteDisplay{{name: "new"}, {name: "middle"}, {name: "old"}}, | |
| 1452 | 1452 | } | |
| 1453 | 1453 | drawStatusBarState(row, state, styles) | |
| 1454 | 1454 | for i := range len(" current ") { | |
| @@ -1485,7 +1485,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||
| 1485 | 1485 | } | |
| 1486 | 1486 | ||
| 1487 | 1487 | single := make([]renderer.Cell, 20) | |
| 1488 | - | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles) | |
| 1488 | + | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles) | |
| 1489 | 1489 | only := strings.Index(rowText(single), "only") | |
| 1490 | 1490 | require.GreaterOrEqual(t, only, 0) | |
| 1491 | 1491 | require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface") | |
| @@ -1111,7 +1111,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse( | |||||
| 1111 | drawStatusBarState(row, state.bars, resolveStyles(nil)) | 1111 | drawStatusBarState(row, state.bars, resolveStyles(nil)) | ||
| 1112 | return rowText(row) | 1112 | return rowText(row) | ||
| 1113 | } | 1113 | } | ||
| 1114 | - | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}} | 1114 | + | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "vty"}, {name: "misc"}}} |
| 1115 | ac.sendMu.Lock() | 1115 | ac.sendMu.Lock() | ||
| 1116 | defer ac.sendMu.Unlock() | 1116 | defer ac.sendMu.Unlock() | ||
| 1117 | 1117 | ||||
| @@ -1147,7 +1147,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||||
| 1147 | state := barState{ | 1147 | state := barState{ | ||
| 1148 | status: statusSnapshot{session: "cur"}, | 1148 | status: statusSnapshot{session: "cur"}, | ||
| 1149 | attentionFrame: 1, | 1149 | attentionFrame: 1, | ||
| 1150 | - | mru: []recentSession{ | 1150 | + | mru: []recentRouteDisplay{ |
| 1151 | {name: "fresh"}, | 1151 | {name: "fresh"}, | ||
| 1152 | {name: "tmp", attention: true}, | 1152 | {name: "tmp", attention: true}, | ||
| 1153 | }, | 1153 | }, | ||
| @@ -1219,7 +1219,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |||||
| 1219 | theme.Palette[10] = theme.Palette[2] | 1219 | theme.Palette[10] = theme.Palette[2] | ||
| 1220 | theme.PaletteKnown = 1<<2 | 1<<10 | 1220 | theme.PaletteKnown = 1<<2 | 1<<10 | ||
| 1221 | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | 1221 | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | ||
| 1222 | - | state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 1222 | + | state := barState{status: statusSnapshot{session: "c"}, mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}} |
| 1223 | row := make([]renderer.Cell, 16) | 1223 | row := make([]renderer.Cell, 16) | ||
| 1224 | 1224 | ||||
| 1225 | drawStatusBarState(row, state, resolved.Styles) | 1225 | drawStatusBarState(row, state, resolved.Styles) | ||
| @@ -1235,7 +1235,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |||||
| 1235 | } | 1235 | } | ||
| 1236 | 1236 | ||||
| 1237 | func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) { | 1237 | func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) { | ||
| 1238 | - | state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}} | 1238 | + | state := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}} |
| 1239 | row := make([]renderer.Cell, 21) | 1239 | row := make([]renderer.Cell, 21) | ||
| 1240 | 1240 | ||||
| 1241 | drawStatusBarState(row, state, resolveStyles(nil)) | 1241 | drawStatusBarState(row, state, resolveStyles(nil)) | ||
| @@ -1247,9 +1247,9 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) { | |||||
| 1247 | } | 1247 | } | ||
| 1248 | 1248 | ||||
| 1249 | func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | 1249 | func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | ||
| 1250 | - | mru := make([]recentSession, 0, maxMRUSessions) | 1250 | + | mru := make([]recentRouteDisplay, 0, maxMRUSessions) |
| 1251 | for i := 1; i <= maxMRUSessions; i++ { | 1251 | for i := 1; i <= maxMRUSessions; i++ { | ||
| 1252 | - | mru = append(mru, recentSession{name: "recent" + strconv.Itoa(i)}) | 1252 | + | mru = append(mru, recentRouteDisplay{name: "recent" + strconv.Itoa(i)}) |
| 1253 | } | 1253 | } | ||
| 1254 | state := barState{status: statusSnapshot{session: "cur"}, mru: mru} | 1254 | state := barState{status: statusSnapshot{session: "cur"}, mru: mru} | ||
| 1255 | 1255 | ||||
| @@ -1309,27 +1309,27 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||||
| 1309 | { | 1309 | { | ||
| 1310 | name: "script text and copy feedback", | 1310 | name: "script text and copy feedback", | ||
| 1311 | width: 32, | 1311 | width: 32, | ||
| 1312 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "a"}}}, | 1312 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}}}, |
| 1313 | wantContains: []string{" a"}, | 1313 | wantContains: []string{" a"}, | ||
| 1314 | wantSuffix: " main ↑3 * copied", | 1314 | wantSuffix: " main ↑3 * copied", | ||
| 1315 | }, | 1315 | }, | ||
| 1316 | { | 1316 | { | ||
| 1317 | name: "hide on overlap", | 1317 | name: "hide on overlap", | ||
| 1318 | width: 16, | 1318 | width: 16, | ||
| 1319 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}}, | 1319 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}}, |
| 1320 | want: " cur ", | 1320 | want: " cur ", | ||
| 1321 | wantNotContains: []string{"main", "copied"}, | 1321 | wantNotContains: []string{"main", "copied"}, | ||
| 1322 | }, | 1322 | }, | ||
| 1323 | { | 1323 | { | ||
| 1324 | name: "empty script keeps copy feedback behavior", | 1324 | name: "empty script keeps copy feedback behavior", | ||
| 1325 | width: 12, | 1325 | width: 12, | ||
| 1326 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}}, | 1326 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}}, |
| 1327 | want: " cur copied", | 1327 | want: " cur copied", | ||
| 1328 | }, | 1328 | }, | ||
| 1329 | { | 1329 | { | ||
| 1330 | name: "mru whole entry fitting with script text", | 1330 | name: "mru whole entry fitting with script text", | ||
| 1331 | width: 24, | 1331 | width: 24, | ||
| 1332 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}, | 1332 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}}, |
| 1333 | wantContains: []string{" fresh"}, | 1333 | wantContains: []string{" fresh"}, | ||
| 1334 | wantNotContains: []string{"middle"}, | 1334 | wantNotContains: []string{"middle"}, | ||
| 1335 | wantSuffix: " git", | 1335 | wantSuffix: " git", | ||
| @@ -1337,7 +1337,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||||
| 1337 | { | 1337 | { | ||
| 1338 | name: "mru fitting reserves wide right anchor width", | 1338 | name: "mru fitting reserves wide right anchor width", | ||
| 1339 | width: 12, | 1339 | width: 12, | ||
| 1340 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentSession{{name: "a"}}}, | 1340 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentRouteDisplay{{name: "a"}}}, |
| 1341 | wantNotContains: []string{" a "}, | 1341 | wantNotContains: []string{" a "}, | ||
| 1342 | wantSuffix: " 界 界 ", | 1342 | wantSuffix: " 界 界 ", | ||
| 1343 | continuationCells: []int{9, 11}, | 1343 | continuationCells: []int{9, 11}, | ||
| @@ -1345,7 +1345,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||||
| 1345 | { | 1345 | { | ||
| 1346 | name: "mru fitting counts wide session names", | 1346 | name: "mru fitting counts wide session names", | ||
| 1347 | width: 20, | 1347 | width: 20, | ||
| 1348 | - | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "界"}, {name: "界"}, {name: "b"}}}, | 1348 | + | state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "界"}, {name: "界"}, {name: "b"}}}, |
| 1349 | wantContains: []string{" 界 "}, | 1349 | wantContains: []string{" 界 "}, | ||
| 1350 | wantNotContains: []string{" b "}, | 1350 | wantNotContains: []string{" b "}, | ||
| 1351 | wantSuffix: " git", | 1351 | wantSuffix: " git", | ||
| @@ -1378,7 +1378,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||||
| 1378 | } | 1378 | } | ||
| 1379 | 1379 | ||||
| 1380 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | 1380 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | ||
| 1381 | - | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 1381 | + | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}} |
| 1382 | row := make([]renderer.Cell, 20) | 1382 | row := make([]renderer.Cell, 20) | ||
| 1383 | 1383 | ||||
| 1384 | drawStatusBarState(row, state, resolveStyles(nil)) | 1384 | drawStatusBarState(row, state, resolveStyles(nil)) | ||
| @@ -1391,7 +1391,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |||||
| 1391 | } | 1391 | } | ||
| 1392 | 1392 | ||||
| 1393 | func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | 1393 | func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | ||
| 1394 | - | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}} | 1394 | + | state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}} |
| 1395 | tests := []struct { | 1395 | tests := []struct { | ||
| 1396 | name string | 1396 | name string | ||
| 1397 | cols int | 1397 | cols int | ||
| @@ -1448,7 +1448,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||||
| 1448 | state := barState{ | 1448 | state := barState{ | ||
| 1449 | status: statusSnapshot{session: "current"}, | 1449 | status: statusSnapshot{session: "current"}, | ||
| 1450 | bottomRight: "script ok", | 1450 | bottomRight: "script ok", | ||
| 1451 | - | mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}}, | 1451 | + | mru: []recentRouteDisplay{{name: "new"}, {name: "middle"}, {name: "old"}}, |
| 1452 | } | 1452 | } | ||
| 1453 | drawStatusBarState(row, state, styles) | 1453 | drawStatusBarState(row, state, styles) | ||
| 1454 | for i := range len(" current ") { | 1454 | for i := range len(" current ") { | ||
| @@ -1485,7 +1485,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||||
| 1485 | } | 1485 | } | ||
| 1486 | 1486 | ||||
| 1487 | single := make([]renderer.Cell, 20) | 1487 | single := make([]renderer.Cell, 20) | ||
| 1488 | - | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles) | 1488 | + | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles) |
| 1489 | only := strings.Index(rowText(single), "only") | 1489 | only := strings.Index(rowText(single), "only") | ||
| 1490 | require.GreaterOrEqual(t, only, 0) | 1490 | require.GreaterOrEqual(t, only, 0) | ||
| 1491 | require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface") | 1491 | require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface") | ||