fix(status): preserve contextual MRU mode
- commit
- fafeeaebd7a4a87fd7fb70bac4ee7c2cadabcc01
- author
- brice · · 2026-07-13 08:43 UTC
- parent
- e5fa94f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +63 -2
M internal/usecase/daemon/render_capture.go +16 -2
| @@ -198,8 +198,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 198 | 198 | bars.status.tabs = scratch.statusTabs | |
| 199 | 199 | scratch.mru = append(scratch.mru[:0], bars.mru...) | |
| 200 | 200 | bars.mru = scratch.mru | |
| 201 | - | scratch.ranked = append(scratch.ranked[:0], bars.rankedRecent...) | |
| 202 | - | bars.rankedRecent = scratch.ranked | |
| 201 | + | if bars.rankedRecent != nil { | |
| 202 | + | scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent) | |
| 203 | + | bars.rankedRecent = scratch.ranked | |
| 204 | + | } | |
| 203 | 205 | if len(preview.Rows) > 0 { | |
| 204 | 206 | preview = clonePickerPreview(preview) | |
| 205 | 207 | } else { | |
| @@ -282,6 +284,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||
| 282 | 284 | return state, true | |
| 283 | 285 | } | |
| 284 | 286 | ||
| 287 | + | // copyRankedRecentInto preserves the non-nil empty slice that selects | |
| 288 | + | // contextual recent-session mode while retaining scratch capacity for reuse. | |
| 289 | + | func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |
| 290 | + | if len(src) == 0 { | |
| 291 | + | if dst != nil { | |
| 292 | + | return dst[:0] | |
| 293 | + | } | |
| 294 | + | return []rankedRecent{} | |
| 295 | + | } | |
| 296 | + | return append(dst[:0], src...) | |
| 297 | + | } | |
| 298 | + | ||
| 285 | 299 | func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node { | |
| 286 | 300 | if src == nil { | |
| 287 | 301 | return nil | |
| @@ -198,8 +198,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 198 | bars.status.tabs = scratch.statusTabs | 198 | bars.status.tabs = scratch.statusTabs | ||
| 199 | scratch.mru = append(scratch.mru[:0], bars.mru...) | 199 | scratch.mru = append(scratch.mru[:0], bars.mru...) | ||
| 200 | bars.mru = scratch.mru | 200 | bars.mru = scratch.mru | ||
| 201 | - | scratch.ranked = append(scratch.ranked[:0], bars.rankedRecent...) | 201 | + | if bars.rankedRecent != nil { |
| 202 | - | bars.rankedRecent = scratch.ranked | 202 | + | scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent) |
| 203 | + | bars.rankedRecent = scratch.ranked | |||
| 204 | + | } | |||
| 203 | if len(preview.Rows) > 0 { | 205 | if len(preview.Rows) > 0 { | ||
| 204 | preview = clonePickerPreview(preview) | 206 | preview = clonePickerPreview(preview) | ||
| 205 | } else { | 207 | } else { | ||
| @@ -282,6 +284,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla | |||||
| 282 | return state, true | 284 | return state, true | ||
| 283 | } | 285 | } | ||
| 284 | 286 | ||||
| 287 | + | // copyRankedRecentInto preserves the non-nil empty slice that selects | |||
| 288 | + | // contextual recent-session mode while retaining scratch capacity for reuse. | |||
| 289 | + | func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |||
| 290 | + | if len(src) == 0 { | |||
| 291 | + | if dst != nil { | |||
| 292 | + | return dst[:0] | |||
| 293 | + | } | |||
| 294 | + | return []rankedRecent{} | |||
| 295 | + | } | |||
| 296 | + | return append(dst[:0], src...) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 285 | func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node { | 299 | func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node { | ||
| 286 | if src == nil { | 300 | if src == nil { | ||
| 287 | return nil | 301 | return nil | ||
M internal/usecase/daemon/status_test.go +47 -0
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/bnema/vev/internal/usecase/command" | |
| 17 | 17 | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | 18 | "github.com/bnema/vev/internal/usecase/palette" | |
| 19 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 19 | 20 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 20 | 21 | "github.com/bnema/vev/internal/usecase/ui" | |
| 21 | 22 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -1035,6 +1036,52 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) { | |||
| 1035 | 1036 | require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name}) | |
| 1036 | 1037 | } | |
| 1037 | 1038 | ||
| 1039 | + | func TestCaptureRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) { | |
| 1040 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 1041 | + | capture := func(bars barState) capturedRenderState { | |
| 1042 | + | t.Helper() | |
| 1043 | + | state, ok := captureRenderState(sess, ac, bars, capturedOverlayRenderState{}, picker.Preview{}, domain.FloatingConfig{}, false, damageCaptureConsume) | |
| 1044 | + | require.True(t, ok) | |
| 1045 | + | return *state | |
| 1046 | + | } | |
| 1047 | + | draw := func(state capturedRenderState) string { | |
| 1048 | + | t.Helper() | |
| 1049 | + | row := make([]renderer.Cell, 32) | |
| 1050 | + | drawStatusBarState(row, state.bars, resolveThemeStyles(nil)) | |
| 1051 | + | return rowText(row) | |
| 1052 | + | } | |
| 1053 | + | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}} | |
| 1054 | + | ac.sendMu.Lock() | |
| 1055 | + | defer ac.sendMu.Unlock() | |
| 1056 | + | ||
| 1057 | + | // An empty but non-nil list is JRS contextual mode, not normal mode. | |
| 1058 | + | emptyContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{}}) | |
| 1059 | + | require.NotNil(t, emptyContextual.bars.rankedRecent) | |
| 1060 | + | require.Empty(t, emptyContextual.bars.rankedRecent) | |
| 1061 | + | emptyRow := draw(emptyContextual) | |
| 1062 | + | require.NotContains(t, emptyRow, "vty") | |
| 1063 | + | require.NotContains(t, emptyRow, "misc") | |
| 1064 | + | ||
| 1065 | + | // A later normal frame must clear contextual mode even after scratch reuse. | |
| 1066 | + | normalAfterEmpty := capture(normal) | |
| 1067 | + | require.Nil(t, normalAfterEmpty.bars.rankedRecent) | |
| 1068 | + | normalRow := draw(normalAfterEmpty) | |
| 1069 | + | require.Contains(t, normalRow, "vty") | |
| 1070 | + | require.Contains(t, normalRow, "misc") | |
| 1071 | + | ||
| 1072 | + | // A populated contextual list reuses the same scratch and remains distinct. | |
| 1073 | + | populatedContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{{rank: 1, name: "jrs"}}}) | |
| 1074 | + | require.NotNil(t, populatedContextual.bars.rankedRecent) | |
| 1075 | + | require.Equal(t, []rankedRecent{{rank: 1, name: "jrs"}}, populatedContextual.bars.rankedRecent) | |
| 1076 | + | populatedRow := draw(populatedContextual) | |
| 1077 | + | require.Contains(t, populatedRow, "1:jrs") | |
| 1078 | + | require.NotContains(t, populatedRow, "vty") | |
| 1079 | + | ||
| 1080 | + | normalAfterPopulated := capture(normal) | |
| 1081 | + | require.Nil(t, normalAfterPopulated.bars.rankedRecent) | |
| 1082 | + | require.Contains(t, draw(normalAfterPopulated), "vty") | |
| 1083 | + | } | |
| 1084 | + | ||
| 1038 | 1085 | func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) { | |
| 1039 | 1086 | state := barState{ | |
| 1040 | 1087 | status: statusSnapshot{session: "cur"}, | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "github.com/bnema/vev/internal/usecase/command" | 16 | "github.com/bnema/vev/internal/usecase/command" | ||
| 17 | "github.com/bnema/vev/internal/usecase/layout" | 17 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 18 | "github.com/bnema/vev/internal/usecase/palette" | 18 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 19 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 19 | themeui "github.com/bnema/vev/internal/usecase/theme" | 20 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 20 | "github.com/bnema/vev/internal/usecase/ui" | 21 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 21 | "github.com/bnema/vev/pkg/renderer" | 22 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -1035,6 +1036,52 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) { | |||||
| 1035 | require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name}) | 1036 | require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name}) | ||
| 1036 | } | 1037 | } | ||
| 1037 | 1038 | ||||
| 1039 | + | func TestCaptureRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) { | |||
| 1040 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 1041 | + | capture := func(bars barState) capturedRenderState { | |||
| 1042 | + | t.Helper() | |||
| 1043 | + | state, ok := captureRenderState(sess, ac, bars, capturedOverlayRenderState{}, picker.Preview{}, domain.FloatingConfig{}, false, damageCaptureConsume) | |||
| 1044 | + | require.True(t, ok) | |||
| 1045 | + | return *state | |||
| 1046 | + | } | |||
| 1047 | + | draw := func(state capturedRenderState) string { | |||
| 1048 | + | t.Helper() | |||
| 1049 | + | row := make([]renderer.Cell, 32) | |||
| 1050 | + | drawStatusBarState(row, state.bars, resolveThemeStyles(nil)) | |||
| 1051 | + | return rowText(row) | |||
| 1052 | + | } | |||
| 1053 | + | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}} | |||
| 1054 | + | ac.sendMu.Lock() | |||
| 1055 | + | defer ac.sendMu.Unlock() | |||
| 1056 | + | ||||
| 1057 | + | // An empty but non-nil list is JRS contextual mode, not normal mode. | |||
| 1058 | + | emptyContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{}}) | |||
| 1059 | + | require.NotNil(t, emptyContextual.bars.rankedRecent) | |||
| 1060 | + | require.Empty(t, emptyContextual.bars.rankedRecent) | |||
| 1061 | + | emptyRow := draw(emptyContextual) | |||
| 1062 | + | require.NotContains(t, emptyRow, "vty") | |||
| 1063 | + | require.NotContains(t, emptyRow, "misc") | |||
| 1064 | + | ||||
| 1065 | + | // A later normal frame must clear contextual mode even after scratch reuse. | |||
| 1066 | + | normalAfterEmpty := capture(normal) | |||
| 1067 | + | require.Nil(t, normalAfterEmpty.bars.rankedRecent) | |||
| 1068 | + | normalRow := draw(normalAfterEmpty) | |||
| 1069 | + | require.Contains(t, normalRow, "vty") | |||
| 1070 | + | require.Contains(t, normalRow, "misc") | |||
| 1071 | + | ||||
| 1072 | + | // A populated contextual list reuses the same scratch and remains distinct. | |||
| 1073 | + | populatedContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{{rank: 1, name: "jrs"}}}) | |||
| 1074 | + | require.NotNil(t, populatedContextual.bars.rankedRecent) | |||
| 1075 | + | require.Equal(t, []rankedRecent{{rank: 1, name: "jrs"}}, populatedContextual.bars.rankedRecent) | |||
| 1076 | + | populatedRow := draw(populatedContextual) | |||
| 1077 | + | require.Contains(t, populatedRow, "1:jrs") | |||
| 1078 | + | require.NotContains(t, populatedRow, "vty") | |||
| 1079 | + | ||||
| 1080 | + | normalAfterPopulated := capture(normal) | |||
| 1081 | + | require.Nil(t, normalAfterPopulated.bars.rankedRecent) | |||
| 1082 | + | require.Contains(t, draw(normalAfterPopulated), "vty") | |||
| 1083 | + | } | |||
| 1084 | + | ||||
| 1038 | func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) { | 1085 | func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) { | ||
| 1039 | state := barState{ | 1086 | state := barState{ | ||
| 1040 | status: statusSnapshot{session: "cur"}, | 1087 | status: statusSnapshot{session: "cur"}, | ||