detached af6c204

refactor(daemon): share pane display titles

commit
af6c204b9dd1529ef4e51ca77509af5988c949df
author
brice · · 2026-07-09 23:31 UTC
committer
brice · · 2026-07-10 05:40 UTC
parent
c1b2985
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +158 -45
M internal/usecase/daemon/pane.go +1 -4
@@ -3,7 +3,6 @@ package daemon
33 import (
44 "context"
55 "sync"
6- "time"
76
87 "github.com/bnema/vev/internal/domain"
98 "github.com/bnema/vev/internal/ports"
@@ -27,9 +26,7 @@ type pane struct {
2726 flush chan struct{}
2827 syncGen uint64
2928 rect domain.Rect
30- title string
31- titleAt time.Time
32- titleValid bool
29+ title paneTitleState
3330 ctx context.Context
3431 cancel context.CancelFunc
3532 }
@@ -3,7 +3,6 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6- "time"
7 6
8 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
@@ -27,9 +26,7 @@ type pane struct {
27 flush chan struct{}26 flush chan struct{}
28 syncGen uint6427 syncGen uint64
29 rect domain.Rect28 rect domain.Rect
30- title string29+ title paneTitleState
31- titleAt time.Time
32- titleValid bool
33 ctx context.Context30 ctx context.Context
34 cancel context.CancelFunc31 cancel context.CancelFunc
35 }32 }
M internal/usecase/daemon/pane_titles.go +72 -14
@@ -10,6 +10,52 @@ import (
1010
1111 const paneTitleCacheTTL = time.Second
1212
13+// paneTitleState is guarded by pane.mu. generation changes whenever the
14+// displayed title can change, allowing cached composition to damage only a
15+// title row rather than the entire stacked layout.
16+type paneTitleState struct {
17+ processName string
18+ processNameAt time.Time
19+ processNameValid bool
20+ terminalTitle string
21+ generation uint64
22+}
23+
24+func formatPaneTitle(processName, terminalTitle, fallback string) string {
25+ if processName != "" && terminalTitle != "" {
26+ return processName + ": " + terminalTitle
27+ }
28+ if processName != "" {
29+ return processName
30+ }
31+ if terminalTitle != "" {
32+ return terminalTitle
33+ }
34+ return fallback
35+}
36+
37+func (p *pane) formattedTitleLocked(fallback string) string {
38+ return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback)
39+}
40+
41+// refreshTerminalTitleLocked synchronizes the title retained by the VT parser.
42+// It must be called while p.mu is held, including by ptyReader around Write.
43+func (p *pane) refreshTerminalTitleLocked() {
44+ title := p.screen.TerminalTitle()
45+ if p.title.terminalTitle != title {
46+ p.title.terminalTitle = title
47+ p.title.generation++
48+ }
49+}
50+
51+func (d *Daemon) paneTitleFallback() string {
52+ fallback := filepath.Base(d.shell)
53+ if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
54+ return d.shell
55+ }
56+ return fallback
57+}
58+
1359 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
1460 return d.refreshPaneTitleCached(sess, id, false)
1561 }
@@ -18,11 +64,9 @@ func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string
1864 return d.refreshPaneTitleCached(sess, id, true)
1965 }
2066
67+// refreshPaneTitleCached retains the ID lookup used by normal layout panes.
2168 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
22- fallback := filepath.Base(d.shell)
23- if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
24- fallback = d.shell
25- }
69+ fallback := d.paneTitleFallback()
2670 if sess == nil {
2771 return fallback
2872 }
@@ -36,29 +80,43 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
3680 if p == nil {
3781 return fallback
3882 }
83+ return d.refreshPaneDisplayTitle(sess, p, force)
84+}
85+
86+// refreshPaneDisplayTitle refreshes only the process-name portion of p's
87+// display title. It deliberately releases pane.mu before querying the PTY so
88+// callers that already render under pane locking cannot recurse on that lock.
89+func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string {
90+ fallback := d.paneTitleFallback()
91+ if p == nil {
92+ return fallback
93+ }
3994 now := d.clock.Now()
4095 p.mu.Lock()
41- if !force && p.titleValid && now.Sub(p.titleAt) < paneTitleCacheTTL {
42- title := p.title
96+ if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL {
97+ title := p.formattedTitleLocked(fallback)
4398 p.mu.Unlock()
44- if title == "" {
45- return fallback
46- }
4799 return title
48100 }
49101 p.mu.Unlock()
50- title := fallback
102+
103+ processName := fallback
51104 if d.procComm != nil && p.pty != nil {
52105 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
53106 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
54- title = strings.TrimSpace(comm)
107+ processName = strings.TrimSpace(comm)
55108 }
56109 }
57110 }
111+
58112 p.mu.Lock()
59- p.title = title
60- p.titleAt = now
61- p.titleValid = true
113+ if p.title.processName != processName {
114+ p.title.processName = processName
115+ p.title.generation++
116+ }
117+ p.title.processNameAt = now
118+ p.title.processNameValid = true
119+ title := p.formattedTitleLocked(fallback)
62120 p.mu.Unlock()
63121 return title
64122 }
@@ -10,6 +10,52 @@ import (
10 10
11 const paneTitleCacheTTL = time.Second11 const paneTitleCacheTTL = time.Second
12 12
13+// paneTitleState is guarded by pane.mu. generation changes whenever the
14+// displayed title can change, allowing cached composition to damage only a
15+// title row rather than the entire stacked layout.
16+type paneTitleState struct {
17+ processName string
18+ processNameAt time.Time
19+ processNameValid bool
20+ terminalTitle string
21+ generation uint64
22+}
23+
24+func formatPaneTitle(processName, terminalTitle, fallback string) string {
25+ if processName != "" && terminalTitle != "" {
26+ return processName + ": " + terminalTitle
27+ }
28+ if processName != "" {
29+ return processName
30+ }
31+ if terminalTitle != "" {
32+ return terminalTitle
33+ }
34+ return fallback
35+}
36+
37+func (p *pane) formattedTitleLocked(fallback string) string {
38+ return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback)
39+}
40+
41+// refreshTerminalTitleLocked synchronizes the title retained by the VT parser.
42+// It must be called while p.mu is held, including by ptyReader around Write.
43+func (p *pane) refreshTerminalTitleLocked() {
44+ title := p.screen.TerminalTitle()
45+ if p.title.terminalTitle != title {
46+ p.title.terminalTitle = title
47+ p.title.generation++
48+ }
49+}
50+
51+func (d *Daemon) paneTitleFallback() string {
52+ fallback := filepath.Base(d.shell)
53+ if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
54+ return d.shell
55+ }
56+ return fallback
57+}
58+
13 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {59 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
14 return d.refreshPaneTitleCached(sess, id, false)60 return d.refreshPaneTitleCached(sess, id, false)
15 }61 }
@@ -18,11 +64,9 @@ func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string
18 return d.refreshPaneTitleCached(sess, id, true)64 return d.refreshPaneTitleCached(sess, id, true)
19 }65 }
20 66
67+// refreshPaneTitleCached retains the ID lookup used by normal layout panes.
21 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {68 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
22- fallback := filepath.Base(d.shell)69+ fallback := d.paneTitleFallback()
23- if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
24- fallback = d.shell
25- }
26 if sess == nil {70 if sess == nil {
27 return fallback71 return fallback
28 }72 }
@@ -36,29 +80,43 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
36 if p == nil {80 if p == nil {
37 return fallback81 return fallback
38 }82 }
83+ return d.refreshPaneDisplayTitle(sess, p, force)
84+}
85+
86+// refreshPaneDisplayTitle refreshes only the process-name portion of p's
87+// display title. It deliberately releases pane.mu before querying the PTY so
88+// callers that already render under pane locking cannot recurse on that lock.
89+func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string {
90+ fallback := d.paneTitleFallback()
91+ if p == nil {
92+ return fallback
93+ }
39 now := d.clock.Now()94 now := d.clock.Now()
40 p.mu.Lock()95 p.mu.Lock()
41- if !force && p.titleValid && now.Sub(p.titleAt) < paneTitleCacheTTL {96+ if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL {
42- title := p.title97+ title := p.formattedTitleLocked(fallback)
43 p.mu.Unlock()98 p.mu.Unlock()
44- if title == "" {
45- return fallback
46- }
47 return title99 return title
48 }100 }
49 p.mu.Unlock()101 p.mu.Unlock()
50- title := fallback102+
103+ processName := fallback
51 if d.procComm != nil && p.pty != nil {104 if d.procComm != nil && p.pty != nil {
52 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {105 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
53 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {106 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
54- title = strings.TrimSpace(comm)107+ processName = strings.TrimSpace(comm)
55 }108 }
56 }109 }
57 }110 }
111+
58 p.mu.Lock()112 p.mu.Lock()
59- p.title = title113+ if p.title.processName != processName {
60- p.titleAt = now114+ p.title.processName = processName
61- p.titleValid = true115+ p.title.generation++
116+ }
117+ p.title.processNameAt = now
118+ p.title.processNameValid = true
119+ title := p.formattedTitleLocked(fallback)
62 p.mu.Unlock()120 p.mu.Unlock()
63 return title121 return title
64 }122 }
M internal/usecase/daemon/pane_titles_test.go +46 -4
@@ -8,12 +8,47 @@ import (
88
99 "github.com/stretchr/testify/require"
1010
11+ "github.com/bnema/vev/internal/domain"
1112 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1213 )
1314
15+func TestFormatPaneTitle(t *testing.T) {
16+ tests := []struct {
17+ name, process, terminal, fallback, want string
18+ }{
19+ {name: "process and terminal", process: "nvim", terminal: "project/main.go", fallback: "sh", want: "nvim: project/main.go"},
20+ {name: "process only", process: "nvim", fallback: "sh", want: "nvim"},
21+ {name: "terminal only", terminal: "project/main.go", fallback: "sh", want: "project/main.go"},
22+ {name: "fallback", fallback: "fish", want: "fish"},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ require.Equal(t, tt.want, formatPaneTitle(tt.process, tt.terminal, tt.fallback))
27+ })
28+ }
29+}
30+
31+func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) {
32+ p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5})
33+ p.mu.Lock()
34+ p.screen.Write([]byte("\x1b]2;project/main.go\a"))
35+ p.refreshTerminalTitleLocked()
36+ first := p.title.generation
37+ p.refreshTerminalTitleLocked()
38+ second := p.title.generation
39+ p.screen.Write([]byte("\x1b]0;other\a"))
40+ p.refreshTerminalTitleLocked()
41+ third := p.title.generation
42+ p.mu.Unlock()
43+
44+ require.Equal(t, uint64(1), first)
45+ require.Equal(t, first, second)
46+ require.Equal(t, first+1, third)
47+}
48+
1449 func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
1550 pty := portsmocks.NewMockPTY(t)
16- pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
51+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
1752 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
1853 clk := portsmocks.NewMockClock(t)
1954 clk.EXPECT().Now().Return(time.Time{}).Maybe()
@@ -26,8 +61,15 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
2661
2762 title := d.refreshPaneTitle(sess, "pane-1")
2863
29- require.Equal(t, "vim", title)
30- require.Equal(t, "vim", sess.activeTab().focusedPane().title)
64+ p := sess.activeTab().focusedPane()
65+ p.mu.Lock()
66+ p.screen.Write([]byte("\x1b]2;project/main.go\a"))
67+ p.refreshTerminalTitleLocked()
68+ p.mu.Unlock()
69+ title = d.refreshPaneDisplayTitle(sess, p, true)
70+
71+ require.Equal(t, "vim: project/main.go", title)
72+ require.Equal(t, "vim", p.title.processName)
3173 }
3274
3375 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
@@ -66,5 +108,5 @@ func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) {
66108 title := d.refreshPaneTitle(sess, "pane-1")
67109
68110 require.Equal(t, "fish", title)
69- require.Equal(t, "fish", sess.activeTab().focusedPane().title)
111+ require.Equal(t, "fish", sess.activeTab().focusedPane().title.processName)
70112 }
@@ -8,12 +8,47 @@ import (
8 8
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 10
11+ "github.com/bnema/vev/internal/domain"
11 portsmocks "github.com/bnema/vev/internal/ports/mocks"12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
12 )13 )
13 14
15+func TestFormatPaneTitle(t *testing.T) {
16+ tests := []struct {
17+ name, process, terminal, fallback, want string
18+ }{
19+ {name: "process and terminal", process: "nvim", terminal: "project/main.go", fallback: "sh", want: "nvim: project/main.go"},
20+ {name: "process only", process: "nvim", fallback: "sh", want: "nvim"},
21+ {name: "terminal only", terminal: "project/main.go", fallback: "sh", want: "project/main.go"},
22+ {name: "fallback", fallback: "fish", want: "fish"},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ require.Equal(t, tt.want, formatPaneTitle(tt.process, tt.terminal, tt.fallback))
27+ })
28+ }
29+}
30+
31+func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) {
32+ p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5})
33+ p.mu.Lock()
34+ p.screen.Write([]byte("\x1b]2;project/main.go\a"))
35+ p.refreshTerminalTitleLocked()
36+ first := p.title.generation
37+ p.refreshTerminalTitleLocked()
38+ second := p.title.generation
39+ p.screen.Write([]byte("\x1b]0;other\a"))
40+ p.refreshTerminalTitleLocked()
41+ third := p.title.generation
42+ p.mu.Unlock()
43+
44+ require.Equal(t, uint64(1), first)
45+ require.Equal(t, first, second)
46+ require.Equal(t, first+1, third)
47+}
48+
14 func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {49 func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
15 pty := portsmocks.NewMockPTY(t)50 pty := portsmocks.NewMockPTY(t)
16- pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()51+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
17 _, sess, _, _ := newManualSessionWithPTYs(t, pty)52 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
18 clk := portsmocks.NewMockClock(t)53 clk := portsmocks.NewMockClock(t)
19 clk.EXPECT().Now().Return(time.Time{}).Maybe()54 clk.EXPECT().Now().Return(time.Time{}).Maybe()
@@ -26,8 +61,15 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
26 61
27 title := d.refreshPaneTitle(sess, "pane-1")62 title := d.refreshPaneTitle(sess, "pane-1")
28 63
29- require.Equal(t, "vim", title)64+ p := sess.activeTab().focusedPane()
30- require.Equal(t, "vim", sess.activeTab().focusedPane().title)65+ p.mu.Lock()
66+ p.screen.Write([]byte("\x1b]2;project/main.go\a"))
67+ p.refreshTerminalTitleLocked()
68+ p.mu.Unlock()
69+ title = d.refreshPaneDisplayTitle(sess, p, true)
70+
71+ require.Equal(t, "vim: project/main.go", title)
72+ require.Equal(t, "vim", p.title.processName)
31 }73 }
32 74
33 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {75 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
@@ -66,5 +108,5 @@ func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) {
66 title := d.refreshPaneTitle(sess, "pane-1")108 title := d.refreshPaneTitle(sess, "pane-1")
67 109
68 require.Equal(t, "fish", title)110 require.Equal(t, "fish", title)
69- require.Equal(t, "fish", sess.activeTab().focusedPane().title)111+ require.Equal(t, "fish", sess.activeTab().focusedPane().title.processName)
70 }112 }
M internal/usecase/daemon/picker_test.go +3 -3
@@ -362,12 +362,12 @@ func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
362362 func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
363363 tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})
364364 left := tb.focusedPane()
365- left.title = "one"
365+ left.title.processName = "one"
366366 left.screen.Write([]byte("L"))
367367 rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
368- rightTop.title = "two"
368+ rightTop.title.processName = "two"
369369 rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})
370- rightBottom.title = "three"
370+ rightBottom.title.processName = "three"
371371 rightBottom.screen.Write([]byte("R"))
372372
373373 tb.mu.Lock()
@@ -362,12 +362,12 @@ func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
362 func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {362 func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
363 tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})363 tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})
364 left := tb.focusedPane()364 left := tb.focusedPane()
365- left.title = "one"365+ left.title.processName = "one"
366 left.screen.Write([]byte("L"))366 left.screen.Write([]byte("L"))
367 rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})367 rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
368- rightTop.title = "two"368+ rightTop.title.processName = "two"
369 rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})369 rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})
370- rightBottom.title = "three"370+ rightBottom.title.processName = "three"
371 rightBottom.screen.Write([]byte("R"))371 rightBottom.screen.Write([]byte("R"))
372 372
373 tb.mu.Lock()373 tb.mu.Lock()
M internal/usecase/daemon/render.go +27 -15
@@ -69,6 +69,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
6969 p.mu.Lock()
7070 wasSyncing := p.screen.SyncUpdateActive()
7171 p.screen.Write(data)
72+ p.refreshTerminalTitleLocked()
7273 isSyncing := p.screen.SyncUpdateActive()
7374 p.mu.Unlock()
7475 markSnapshotDirty(sess)
@@ -511,9 +512,10 @@ func (c *barCache) Reset() {
511512 }
512513
513514 type composedFrameCache struct {
514- frame renderer.Frame
515- valid bool
516- layoutSnap tabLayoutSnapshot
515+ frame renderer.Frame
516+ valid bool
517+ layoutSnap tabLayoutSnapshot
518+ titleGenerations map[layout.PaneID]uint64
517519 }
518520
519521 func (c *composedFrameCache) invalidate() {
@@ -580,7 +582,14 @@ func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool
580582 }
581583 topBar := frame.Row(0)
582584 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
583- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage)
585+ var titleGenerations map[layout.PaneID]uint64
586+ if composed != nil {
587+ if composed.titleGenerations == nil {
588+ composed.titleGenerations = make(map[layout.PaneID]uint64)
589+ }
590+ titleGenerations = composed.titleGenerations
591+ }
592+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
584593 bottomBar := frame.Row(screenRows + 1)
585594 drawStatusBarState(bottomBar, bars, styles)
586595 if composed != nil {
@@ -621,7 +630,11 @@ func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Re
621630 return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false)
622631 }
623632
624-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage {
633+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleCaches ...map[layout.PaneID]uint64) []renderer.Damage {
634+ var titleGenerations map[layout.PaneID]uint64
635+ if len(titleCaches) > 0 {
636+ titleGenerations = titleCaches[0]
637+ }
625638 contentArea := domain.Rect{Width: area.Width, Height: area.Height}
626639 root := tb.tree.Root
627640 placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
@@ -651,13 +664,16 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
651664 focused := tb.tree.Focus == pl.ID
652665 pl = offsetPlacement(pl, area.X, area.Y)
653666 if pl.TitleBar.Height > 0 {
654- drawPaneTitleBar(frame, pl, p, focused, theme, "")
655- if cacheValid {
667+ generation := drawPaneTitleBar(frame, pl, p, focused, theme, string(p.id))
668+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
656669 titleDamage := pl.TitleBar
657670 titleDamage.X -= area.X
658671 titleDamage.Y -= area.Y
659672 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
660673 }
674+ if titleGenerations != nil {
675+ titleGenerations[pl.ID] = generation
676+ }
661677 }
662678 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
663679 if consumeDamage {
@@ -721,7 +737,7 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
721737 }
722738 }
723739
724-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) {
740+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) uint64 {
725741 styles := newThemeStyles(theme)
726742 style := styles.border
727743 if focused {
@@ -733,15 +749,11 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse
733749 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
734750 }
735751 p.mu.Lock()
736- title := p.title
752+ title := p.formattedTitleLocked(fallback)
753+ generation := p.title.generation
737754 p.mu.Unlock()
738- if title == "" {
739- title = fallback
740- }
741- if title == "" {
742- title = string(p.id)
743- }
744755 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
756+ return generation
745757 }
746758
747759 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
@@ -69,6 +69,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
69 p.mu.Lock()69 p.mu.Lock()
70 wasSyncing := p.screen.SyncUpdateActive()70 wasSyncing := p.screen.SyncUpdateActive()
71 p.screen.Write(data)71 p.screen.Write(data)
72+ p.refreshTerminalTitleLocked()
72 isSyncing := p.screen.SyncUpdateActive()73 isSyncing := p.screen.SyncUpdateActive()
73 p.mu.Unlock()74 p.mu.Unlock()
74 markSnapshotDirty(sess)75 markSnapshotDirty(sess)
@@ -511,9 +512,10 @@ func (c *barCache) Reset() {
511 }512 }
512 513
513 type composedFrameCache struct {514 type composedFrameCache struct {
514- frame renderer.Frame515+ frame renderer.Frame
515- valid bool516+ valid bool
516- layoutSnap tabLayoutSnapshot517+ layoutSnap tabLayoutSnapshot
518+ titleGenerations map[layout.PaneID]uint64
517 }519 }
518 520
519 func (c *composedFrameCache) invalidate() {521 func (c *composedFrameCache) invalidate() {
@@ -580,7 +582,14 @@ func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool
580 }582 }
581 topBar := frame.Row(0)583 topBar := frame.Row(0)
582 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)584 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
583- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage)585+ var titleGenerations map[layout.PaneID]uint64
586+ if composed != nil {
587+ if composed.titleGenerations == nil {
588+ composed.titleGenerations = make(map[layout.PaneID]uint64)
589+ }
590+ titleGenerations = composed.titleGenerations
591+ }
592+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
584 bottomBar := frame.Row(screenRows + 1)593 bottomBar := frame.Row(screenRows + 1)
585 drawStatusBarState(bottomBar, bars, styles)594 drawStatusBarState(bottomBar, bars, styles)
586 if composed != nil {595 if composed != nil {
@@ -621,7 +630,11 @@ func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Re
621 return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false)630 return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false)
622 }631 }
623 632
624-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage {633+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleCaches ...map[layout.PaneID]uint64) []renderer.Damage {
634+ var titleGenerations map[layout.PaneID]uint64
635+ if len(titleCaches) > 0 {
636+ titleGenerations = titleCaches[0]
637+ }
625 contentArea := domain.Rect{Width: area.Width, Height: area.Height}638 contentArea := domain.Rect{Width: area.Width, Height: area.Height}
626 root := tb.tree.Root639 root := tb.tree.Root
627 placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea640 placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
@@ -651,13 +664,16 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
651 focused := tb.tree.Focus == pl.ID664 focused := tb.tree.Focus == pl.ID
652 pl = offsetPlacement(pl, area.X, area.Y)665 pl = offsetPlacement(pl, area.X, area.Y)
653 if pl.TitleBar.Height > 0 {666 if pl.TitleBar.Height > 0 {
654- drawPaneTitleBar(frame, pl, p, focused, theme, "")667+ generation := drawPaneTitleBar(frame, pl, p, focused, theme, string(p.id))
655- if cacheValid {668+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
656 titleDamage := pl.TitleBar669 titleDamage := pl.TitleBar
657 titleDamage.X -= area.X670 titleDamage.X -= area.X
658 titleDamage.Y -= area.Y671 titleDamage.Y -= area.Y
659 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})672 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
660 }673 }
674+ if titleGenerations != nil {
675+ titleGenerations[pl.ID] = generation
676+ }
661 }677 }
662 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {678 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
663 if consumeDamage {679 if consumeDamage {
@@ -721,7 +737,7 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
721 }737 }
722 }738 }
723 739
724-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) {740+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) uint64 {
725 styles := newThemeStyles(theme)741 styles := newThemeStyles(theme)
726 style := styles.border742 style := styles.border
727 if focused {743 if focused {
@@ -733,15 +749,11 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse
733 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})749 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
734 }750 }
735 p.mu.Lock()751 p.mu.Lock()
736- title := p.title752+ title := p.formattedTitleLocked(fallback)
753+ generation := p.title.generation
737 p.mu.Unlock()754 p.mu.Unlock()
738- if title == "" {
739- title = fallback
740- }
741- if title == "" {
742- title = string(p.id)
743- }
744 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)755 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
756+ return generation
745 }757 }
746 758
747 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {759 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
M internal/usecase/daemon/render_test.go +9 -5
@@ -664,10 +664,10 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
664664 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
665665 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
666666 p1 := win.focusedPane()
667- p1.title = "one"
667+ p1.title.processName = "one"
668668 p1.screen.ClearDamage()
669669 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
670- p2.title = "two"
670+ p2.title.processName = "two"
671671 p2.screen.Write([]byte("T"))
672672 p2.screen.ClearDamage()
673673
@@ -688,9 +688,9 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
688688 func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
689689 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
690690 p1 := win.focusedPane()
691- p1.title = "one"
691+ p1.title.processName = "one"
692692 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
693- p2.title = "two"
693+ p2.title.processName = "two"
694694 p2.screen.Write([]byte("T"))
695695 win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
696696 win.tree.Focus = p2.id
@@ -702,12 +702,16 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
702702 p1.screen.ClearDamage()
703703 p2.screen.ClearDamage()
704704
705- p1.title = "renamed"
705+ p1.title.processName = "renamed"
706+ p1.title.generation++
706707 frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
707708
708709 require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
709710 require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
710711 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
712+
713+ _, damage = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
714+ require.NotContains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}, "unchanged title must not redraw its row")
711715 }
712716
713717 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
@@ -664,10 +664,10 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
664 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {664 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
665 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})665 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
666 p1 := win.focusedPane()666 p1 := win.focusedPane()
667- p1.title = "one"667+ p1.title.processName = "one"
668 p1.screen.ClearDamage()668 p1.screen.ClearDamage()
669 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})669 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
670- p2.title = "two"670+ p2.title.processName = "two"
671 p2.screen.Write([]byte("T"))671 p2.screen.Write([]byte("T"))
672 p2.screen.ClearDamage()672 p2.screen.ClearDamage()
673 673
@@ -688,9 +688,9 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
688 func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {688 func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
689 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})689 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
690 p1 := win.focusedPane()690 p1 := win.focusedPane()
691- p1.title = "one"691+ p1.title.processName = "one"
692 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})692 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
693- p2.title = "two"693+ p2.title.processName = "two"
694 p2.screen.Write([]byte("T"))694 p2.screen.Write([]byte("T"))
695 win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}695 win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
696 win.tree.Focus = p2.id696 win.tree.Focus = p2.id
@@ -702,12 +702,16 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
702 p1.screen.ClearDamage()702 p1.screen.ClearDamage()
703 p2.screen.ClearDamage()703 p2.screen.ClearDamage()
704 704
705- p1.title = "renamed"705+ p1.title.processName = "renamed"
706+ p1.title.generation++
706 frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)707 frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
707 708
708 require.Equal(t, "renamed", rowText(frame.Row(1))[:7])709 require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
709 require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")710 require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
710 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})711 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
712+
713+ _, damage = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
714+ require.NotContains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}, "unchanged title must not redraw its row")
711 }715 }
712 716
713 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {717 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {

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