detached 0ae8e2a

fix(renderer): synchronize floating pane composition

commit
0ae8e2a5cd28eb1064ae74b5faa90ea855f5fb59
author
brice · · 2026-07-10 06:10 UTC
parent
250429c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +41 -7
M internal/usecase/daemon/floating_render.go +10 -7
@@ -62,18 +62,25 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
6262 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
6363 geometry := calculateFloatingGeometry(content, cfg)
6464
65+ popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation
66+
67+ // The PTY reader mutates both the frame and its damage under p.mu. Keep
68+ // every read of them, including the blit, in the same critical section.
69+ // In particular, Frame is not safe to retain as an alias after unlocking.
6570 p.mu.Lock()
6671 title := p.displayTitleLocked()
6772 titleGeneration := p.title.generation
68- screen := p.screen.Frame
6973 screenDamage := p.screen.Damage()
74+ blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme)
75+ damage := append([]renderer.Damage(nil), baseDamage...)
76+ for _, d := range screenDamage {
77+ damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
78+ }
7079 p.screen.ClearDamage()
7180 p.mu.Unlock()
7281
73- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation
7482 titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
7583 drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
76- blitPaneFrame(frame, geometry.Inner, screen, false, theme)
7784 if cache != nil {
7885 cache.floating = p
7986 cache.floatingGeneration = generation
@@ -82,10 +89,6 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
8289 if full || popupChanged {
8390 return frame, []renderer.Damage{renderer.FullRedraw()}
8491 }
85- damage := append([]renderer.Damage(nil), baseDamage...)
86- for _, d := range screenDamage {
87- damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
88- }
8992 if titleChanged && geometry.Bounds.Height >= 3 {
9093 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
9194 }
@@ -62,18 +62,25 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
62 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)62 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
63 geometry := calculateFloatingGeometry(content, cfg)63 geometry := calculateFloatingGeometry(content, cfg)
64 64
65+ popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation
66+
67+ // The PTY reader mutates both the frame and its damage under p.mu. Keep
68+ // every read of them, including the blit, in the same critical section.
69+ // In particular, Frame is not safe to retain as an alias after unlocking.
65 p.mu.Lock()70 p.mu.Lock()
66 title := p.displayTitleLocked()71 title := p.displayTitleLocked()
67 titleGeneration := p.title.generation72 titleGeneration := p.title.generation
68- screen := p.screen.Frame
69 screenDamage := p.screen.Damage()73 screenDamage := p.screen.Damage()
74+ blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme)
75+ damage := append([]renderer.Damage(nil), baseDamage...)
76+ for _, d := range screenDamage {
77+ damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
78+ }
70 p.screen.ClearDamage()79 p.screen.ClearDamage()
71 p.mu.Unlock()80 p.mu.Unlock()
72 81
73- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation
74 titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration82 titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
75 drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)83 drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
76- blitPaneFrame(frame, geometry.Inner, screen, false, theme)
77 if cache != nil {84 if cache != nil {
78 cache.floating = p85 cache.floating = p
79 cache.floatingGeneration = generation86 cache.floatingGeneration = generation
@@ -82,10 +89,6 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
82 if full || popupChanged {89 if full || popupChanged {
83 return frame, []renderer.Damage{renderer.FullRedraw()}90 return frame, []renderer.Damage{renderer.FullRedraw()}
84 }91 }
85- damage := append([]renderer.Damage(nil), baseDamage...)
86- for _, d := range screenDamage {
87- damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
88- }
89 if titleChanged && geometry.Bounds.Height >= 3 {92 if titleChanged && geometry.Bounds.Height >= 3 {
90 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})93 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
91 }94 }
M internal/usecase/daemon/floating_render_test.go +31 -0
@@ -63,6 +63,37 @@ func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
6363 require.Equal(t, 1, damage[0].Height)
6464 }
6565
66+func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
67+ p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
68+ base := renderer.NewFrame(80, 24)
69+ content := domain.Rect{Width: 80, Height: 24}
70+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
71+
72+ start := make(chan struct{})
73+ var wg sync.WaitGroup
74+ wg.Add(2)
75+ go func() {
76+ defer wg.Done()
77+ <-start
78+ for range 500 {
79+ // This is the same pane lock used by ptyReader around Screen.Write.
80+ p.mu.Lock()
81+ p.screen.Write([]byte("\x1b[Hreader"))
82+ p.mu.Unlock()
83+ }
84+ }()
85+ go func() {
86+ defer wg.Done()
87+ <-start
88+ cache := &composedFrameCache{}
89+ for range 500 {
90+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
91+ }
92+ }()
93+ close(start)
94+ wg.Wait()
95+}
96+
6697 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
6798 frame := renderer.NewFrame(2, 1)
6899 drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{})
@@ -63,6 +63,37 @@ func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
63 require.Equal(t, 1, damage[0].Height)63 require.Equal(t, 1, damage[0].Height)
64 }64 }
65 65
66+func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
67+ p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
68+ base := renderer.NewFrame(80, 24)
69+ content := domain.Rect{Width: 80, Height: 24}
70+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
71+
72+ start := make(chan struct{})
73+ var wg sync.WaitGroup
74+ wg.Add(2)
75+ go func() {
76+ defer wg.Done()
77+ <-start
78+ for range 500 {
79+ // This is the same pane lock used by ptyReader around Screen.Write.
80+ p.mu.Lock()
81+ p.screen.Write([]byte("\x1b[Hreader"))
82+ p.mu.Unlock()
83+ }
84+ }()
85+ go func() {
86+ defer wg.Done()
87+ <-start
88+ cache := &composedFrameCache{}
89+ for range 500 {
90+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
91+ }
92+ }()
93+ close(start)
94+ wg.Wait()
95+}
96+
66 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {97 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
67 frame := renderer.NewFrame(2, 1)98 frame := renderer.NewFrame(2, 1)
68 drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{})99 drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{})

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