detached 88f2a08

Merge pull request #80 from bnema/fix/status-mru-after-jrs

fix(status): preserve recent sessions after JRS
commit
88f2a08c0c17b659b17ee27322406694b83aacf9
author
bnema · · 2026-07-13 09:06 UTC
committer
GitHub · · 2026-07-13 09:06 UTC
merge of
e5fa94f + fafeeae · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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
198198 bars.status.tabs = scratch.statusTabs
199199 scratch.mru = append(scratch.mru[:0], bars.mru...)
200200 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+ }
203205 if len(preview.Rows) > 0 {
204206 preview = clonePickerPreview(preview)
205207 } else {
@@ -282,6 +284,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
282284 return state, true
283285 }
284286
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+
285299 func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
286300 if src == nil {
287301 return nil
@@ -198,8 +198,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
198 bars.status.tabs = scratch.statusTabs198 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.mru200 bars.mru = scratch.mru
201- scratch.ranked = append(scratch.ranked[:0], bars.rankedRecent...)201+ if bars.rankedRecent != nil {
202- bars.rankedRecent = scratch.ranked202+ 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, true284 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 nil301 return nil
M internal/usecase/daemon/status_test.go +47 -0
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/vev/internal/usecase/command"
1717 "github.com/bnema/vev/internal/usecase/layout"
1818 "github.com/bnema/vev/internal/usecase/palette"
19+ "github.com/bnema/vev/internal/usecase/picker"
1920 themeui "github.com/bnema/vev/internal/usecase/theme"
2021 "github.com/bnema/vev/internal/usecase/ui"
2122 "github.com/bnema/vev/pkg/renderer"
@@ -1035,6 +1036,52 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
10351036 require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
10361037 }
10371038
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+
10381085 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
10391086 state := barState{
10401087 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"},

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