detached fb3dc13

test(tabs): cover display and persistence lifecycle

commit
fb3dc1334f77ceb536583db868631c929d998305
author
brice · · 2026-07-09 18:18 UTC
parent
c8d167e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +67 -16
M internal/usecase/daemon/palette.go +1 -6
@@ -1,8 +1,6 @@
11 package daemon
22
33 import (
4- "strconv"
5-
64 "github.com/bnema/vev/internal/domain"
75 "github.com/bnema/vev/internal/usecase/command"
86 "github.com/bnema/vev/internal/usecase/palette"
@@ -155,10 +153,7 @@ func (e paletteExec) RenameTab() error {
155153 return nil
156154 }
157155 e.sess.mu.Lock()
158- currentName := tb.name
159- if currentName == "" {
160- currentName = strconv.Itoa(e.sess.active + 1)
161- }
156+ currentName := tabDisplayName(tb, e.sess.active)
162157 e.sess.mu.Unlock()
163158 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
164159 return e.d.renameTab(e.sess, tb, name)
@@ -1,8 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "strconv"
5-
6 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/usecase/command"5 "github.com/bnema/vev/internal/usecase/command"
8 "github.com/bnema/vev/internal/usecase/palette"6 "github.com/bnema/vev/internal/usecase/palette"
@@ -155,10 +153,7 @@ func (e paletteExec) RenameTab() error {
155 return nil153 return nil
156 }154 }
157 e.sess.mu.Lock()155 e.sess.mu.Lock()
158- currentName := tb.name156+ currentName := tabDisplayName(tb, e.sess.active)
159- if currentName == "" {
160- currentName = strconv.Itoa(e.sess.active + 1)
161- }
162 e.sess.mu.Unlock()157 e.sess.mu.Unlock()
163 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {158 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
164 return e.d.renameTab(e.sess, tb, name)159 return e.d.renameTab(e.sess, tb, name)
M internal/usecase/daemon/picker.go +1 -5
@@ -27,7 +27,6 @@ package daemon
2727
2828 import (
2929 "sort"
30- "strconv"
3130
3231 "github.com/bnema/vev/internal/domain"
3332 "github.com/bnema/vev/internal/platform"
@@ -71,10 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7170 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
7271 sessionAttention := false
7372 for i, tb := range s.tabs {
74- label := tb.name
75- if label == "" {
76- label = strconv.Itoa(i + 1)
77- }
73+ label := tabDisplayName(tb, i)
7874 if tb.attention {
7975 label = attentionSuffix(label)
8076 sessionAttention = true
@@ -27,7 +27,6 @@ package daemon
27 27
28 import (28 import (
29 "sort"29 "sort"
30- "strconv"
31 30
32 "github.com/bnema/vev/internal/domain"31 "github.com/bnema/vev/internal/domain"
33 "github.com/bnema/vev/internal/platform"32 "github.com/bnema/vev/internal/platform"
@@ -71,10 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
71 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}70 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
72 sessionAttention := false71 sessionAttention := false
73 for i, tb := range s.tabs {72 for i, tb := range s.tabs {
74- label := tb.name73+ label := tabDisplayName(tb, i)
75- if label == "" {
76- label = strconv.Itoa(i + 1)
77- }
78 if tb.attention {74 if tb.attention {
79 label = attentionSuffix(label)75 label = attentionSuffix(label)
80 sessionAttention = true76 sessionAttention = true
M internal/usecase/daemon/session_test.go +40 -0
@@ -701,6 +701,46 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
701701 require.Equal(t, []string{"shell"}, records[0].TabNames)
702702 }
703703
704+func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
705+ tests := []struct {
706+ name string
707+ tabNames []string
708+ closeIndex int
709+ want []string
710+ }{
711+ {name: "close before named tab", tabNames: []string{"shell", "", "logs"}, closeIndex: 0, want: []string{"", "logs"}},
712+ {name: "close after named tab", tabNames: []string{"shell", "logs", ""}, closeIndex: 2, want: []string{"shell", "logs"}},
713+ }
714+ for _, tt := range tests {
715+ t.Run(tt.name, func(t *testing.T) {
716+ ptys := make([]ports.PTY, 3)
717+ for i := range ptys {
718+ p, release := newBlockingPTY(t)
719+ ptys[i] = p
720+ t.Cleanup(release)
721+ }
722+ store, _ := newMockStore(t)
723+ d := newTestDaemon(t, newFactorySeq(t, ptys...), stubClock{})
724+ WithStore(store)(d)
725+ sz := domain.Size{Cols: 80, Rows: 24}
726+
727+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
728+ require.NoError(t, err)
729+ require.NoError(t, d.createTab(sess, sz))
730+ require.NoError(t, d.createTab(sess, sz))
731+ for i, name := range tt.tabNames {
732+ require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
733+ }
734+
735+ d.closeTab(sess, sess.tabs[tt.closeIndex], false)
736+ records, err := d.persist.LoadAll()
737+ require.NoError(t, err)
738+ require.Len(t, records, 1)
739+ require.Equal(t, tt.want, records[0].TabNames)
740+ })
741+ }
742+}
743+
704744 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
705745 sz := domain.Size{Cols: 80, Rows: 24}
706746 p, release := newBlockingPTY(t)
@@ -701,6 +701,46 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
701 require.Equal(t, []string{"shell"}, records[0].TabNames)701 require.Equal(t, []string{"shell"}, records[0].TabNames)
702 }702 }
703 703
704+func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
705+ tests := []struct {
706+ name string
707+ tabNames []string
708+ closeIndex int
709+ want []string
710+ }{
711+ {name: "close before named tab", tabNames: []string{"shell", "", "logs"}, closeIndex: 0, want: []string{"", "logs"}},
712+ {name: "close after named tab", tabNames: []string{"shell", "logs", ""}, closeIndex: 2, want: []string{"shell", "logs"}},
713+ }
714+ for _, tt := range tests {
715+ t.Run(tt.name, func(t *testing.T) {
716+ ptys := make([]ports.PTY, 3)
717+ for i := range ptys {
718+ p, release := newBlockingPTY(t)
719+ ptys[i] = p
720+ t.Cleanup(release)
721+ }
722+ store, _ := newMockStore(t)
723+ d := newTestDaemon(t, newFactorySeq(t, ptys...), stubClock{})
724+ WithStore(store)(d)
725+ sz := domain.Size{Cols: 80, Rows: 24}
726+
727+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
728+ require.NoError(t, err)
729+ require.NoError(t, d.createTab(sess, sz))
730+ require.NoError(t, d.createTab(sess, sz))
731+ for i, name := range tt.tabNames {
732+ require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
733+ }
734+
735+ d.closeTab(sess, sess.tabs[tt.closeIndex], false)
736+ records, err := d.persist.LoadAll()
737+ require.NoError(t, err)
738+ require.Len(t, records, 1)
739+ require.Equal(t, tt.want, records[0].TabNames)
740+ })
741+ }
742+}
743+
704 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {744 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
705 sz := domain.Size{Cols: 80, Rows: 24}745 sz := domain.Size{Cols: 80, Rows: 24}
706 p, release := newBlockingPTY(t)746 p, release := newBlockingPTY(t)
M internal/usecase/daemon/status.go +8 -5
@@ -141,15 +141,18 @@ func (s *session) statusSegments() statusSnapshot {
141141 }
142142 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
143143 for i, tb := range s.tabs {
144- name := tb.name
145- if name == "" {
146- name = strconv.Itoa(i + 1)
147- }
148- snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention}
144+ snap.tabs[i] = statusTab{name: tabDisplayName(tb, i), active: i == s.active, attention: tb.attention}
149145 }
150146 return snap
151147 }
152148
149+func tabDisplayName(tb *tab, index int) string {
150+ if tb.name != "" {
151+ return tb.name
152+ }
153+ return strconv.Itoa(index + 1)
154+}
155+
153156 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
154157 state := barState{copyFeedback: copyFeedback}
155158 if d != nil {
@@ -141,15 +141,18 @@ func (s *session) statusSegments() statusSnapshot {
141 }141 }
142 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}142 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
143 for i, tb := range s.tabs {143 for i, tb := range s.tabs {
144- name := tb.name144+ snap.tabs[i] = statusTab{name: tabDisplayName(tb, i), active: i == s.active, attention: tb.attention}
145- if name == "" {
146- name = strconv.Itoa(i + 1)
147- }
148- snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention}
149 }145 }
150 return snap146 return snap
151 }147 }
152 148
149+func tabDisplayName(tb *tab, index int) string {
150+ if tb.name != "" {
151+ return tb.name
152+ }
153+ return strconv.Itoa(index + 1)
154+}
155+
153 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {156 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
154 state := barState{copyFeedback: copyFeedback}157 state := barState{copyFeedback: copyFeedback}
155 if d != nil {158 if d != nil {
M internal/usecase/daemon/status_test.go +17 -0
@@ -49,6 +49,23 @@ func TestStatusCompositionGolden(t *testing.T) {
4949 }
5050 }
5151
52+func TestTabDisplayName(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ tab *tab
56+ index int
57+ want string
58+ }{
59+ {name: "custom name", tab: &tab{name: "logs"}, index: 3, want: "logs"},
60+ {name: "numeric fallback", tab: &tab{}, index: 3, want: "4"},
61+ }
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ require.Equal(t, tt.want, tabDisplayName(tt.tab, tt.index))
65+ })
66+ }
67+}
68+
5269 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
5370 p, release := newBlockingPTY(t)
5471 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -49,6 +49,23 @@ func TestStatusCompositionGolden(t *testing.T) {
49 }49 }
50 }50 }
51 51
52+func TestTabDisplayName(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ tab *tab
56+ index int
57+ want string
58+ }{
59+ {name: "custom name", tab: &tab{name: "logs"}, index: 3, want: "logs"},
60+ {name: "numeric fallback", tab: &tab{}, index: 3, want: "4"},
61+ }
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ require.Equal(t, tt.want, tabDisplayName(tt.tab, tt.index))
65+ })
66+ }
67+}
68+
52 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {69 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
53 p, release := newBlockingPTY(t)70 p, release := newBlockingPTY(t)
54 _, sess, ac, _ := newManualSessionWithPTYs(t, p)71 _, sess, ac, _ := newManualSessionWithPTYs(t, p)

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 2

Tags 13

↑↓ moveenter openesc close