detached f9e1be4

test(navigation): strengthen overflow invariants

commit
f9e1be422bf2c89fc320ca3d6996fc51a63f548c
author
brice · · 2026-07-25 07:36 UTC
parent
05d4687
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +85 -54
M internal/usecase/daemon/navigation_overflow.go +11 -4
@@ -166,13 +166,14 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo
166166 }
167167
168168 target.mu.Lock()
169+ if target.tree == nil {
170+ target.mu.Unlock()
171+ return tabOverflowCandidate{}, false
172+ }
169173 area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}
170174 entry, err := target.tree.EntryPane(dir, span, area)
171175 entryPane := target.panes[entry]
172- oldFocus := layout.PaneID("")
173- if target.tree != nil {
174- oldFocus = target.tree.Focus
175- }
176+ oldFocus := target.tree.Focus
176177 valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)
177178 target.mu.Unlock()
178179 if !valid {
@@ -213,6 +214,12 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate
213214 return false
214215 }
215216 candidate.target.mu.Lock()
217+ if candidate.target.tree == nil {
218+ candidate.target.mu.Unlock()
219+ candidate.source.mu.Unlock()
220+ sess.mu.Unlock()
221+ return false
222+ }
216223 area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}
217224 entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)
218225 if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {
@@ -166,13 +166,14 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo
166 }166 }
167 167
168 target.mu.Lock()168 target.mu.Lock()
169+ if target.tree == nil {
170+ target.mu.Unlock()
171+ return tabOverflowCandidate{}, false
172+ }
169 area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}173 area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}
170 entry, err := target.tree.EntryPane(dir, span, area)174 entry, err := target.tree.EntryPane(dir, span, area)
171 entryPane := target.panes[entry]175 entryPane := target.panes[entry]
172- oldFocus := layout.PaneID("")176+ oldFocus := target.tree.Focus
173- if target.tree != nil {
174- oldFocus = target.tree.Focus
175- }
176 valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)177 valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)
177 target.mu.Unlock()178 target.mu.Unlock()
178 if !valid {179 if !valid {
@@ -213,6 +214,12 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate
213 return false214 return false
214 }215 }
215 candidate.target.mu.Lock()216 candidate.target.mu.Lock()
217+ if candidate.target.tree == nil {
218+ candidate.target.mu.Unlock()
219+ candidate.source.mu.Unlock()
220+ sess.mu.Unlock()
221+ return false
222+ }
216 area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}223 area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}
217 entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)224 entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)
218 if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {225 if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {
M internal/usecase/daemon/navigation_overflow_test.go +31 -5
@@ -31,6 +31,8 @@ func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
3131 {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}},
3232 {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
3333 {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1},
34+ {name: "negative position is out of range", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: -1, count: 2},
35+ {name: "position at count is out of range", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 2, count: 2},
3436 }
3537
3638 for _, tt := range tests {
@@ -298,7 +300,20 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
298300 for err := range renameErrors {
299301 require.NoError(t, err)
300302 }
301- require.NotNil(t, ac.currentSession())
303+
304+ current := ac.currentSession()
305+ owners := make([]*session, 0, 1)
306+ d.mu.Lock()
307+ for _, candidate := range d.sessions {
308+ candidate.mu.Lock()
309+ if candidate.client == ac {
310+ owners = append(owners, candidate)
311+ }
312+ candidate.mu.Unlock()
313+ }
314+ d.mu.Unlock()
315+ require.Len(t, owners, 1)
316+ require.Same(t, current, owners[0])
302317 }
303318
304319 func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
@@ -338,8 +353,19 @@ func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
338353 name string
339354 mutate func(sess *session, candidate tabOverflowCandidate)
340355 }{
341- {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { sess.active = 1 }},
342- {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { sess.tabs[1] = newTab(nil, candidate.target.size) }},
356+ {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) {
357+ sess.mu.Lock()
358+ sess.active = 1
359+ sess.mu.Unlock()
360+ }},
361+ {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) {
362+ sess.mu.Lock()
363+ sess.tabs[1] = newTab(nil, candidate.target.size)
364+ sess.mu.Unlock()
365+ }},
366+ {name: "source gains visible floating pane", mutate: func(_ *session, candidate tabOverflowCandidate) {
367+ installTestFloating(candidate.source, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
368+ }},
343369 }
344370
345371 for _, tt := range tests {
@@ -347,11 +373,11 @@ func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
347373 d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
348374 candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1)
349375 require.True(t, ok)
350- sess.mu.Lock()
351376 tt.mutate(sess, candidate)
352- sess.mu.Unlock()
377+ activeBeforeCommit := activeTabIndex(sess)
353378
354379 require.False(t, d.commitTabOverflow(sess, candidate))
380+ require.Equal(t, activeBeforeCommit, activeTabIndex(sess))
355381 })
356382 }
357383 }
@@ -31,6 +31,8 @@ func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
31 {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}},31 {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}},
32 {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},32 {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
33 {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1},33 {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1},
34+ {name: "negative position is out of range", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: -1, count: 2},
35+ {name: "position at count is out of range", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 2, count: 2},
34 }36 }
35 37
36 for _, tt := range tests {38 for _, tt := range tests {
@@ -298,7 +300,20 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
298 for err := range renameErrors {300 for err := range renameErrors {
299 require.NoError(t, err)301 require.NoError(t, err)
300 }302 }
301- require.NotNil(t, ac.currentSession())303+
304+ current := ac.currentSession()
305+ owners := make([]*session, 0, 1)
306+ d.mu.Lock()
307+ for _, candidate := range d.sessions {
308+ candidate.mu.Lock()
309+ if candidate.client == ac {
310+ owners = append(owners, candidate)
311+ }
312+ candidate.mu.Unlock()
313+ }
314+ d.mu.Unlock()
315+ require.Len(t, owners, 1)
316+ require.Same(t, current, owners[0])
302 }317 }
303 318
304 func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {319 func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
@@ -338,8 +353,19 @@ func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
338 name string353 name string
339 mutate func(sess *session, candidate tabOverflowCandidate)354 mutate func(sess *session, candidate tabOverflowCandidate)
340 }{355 }{
341- {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { sess.active = 1 }},356+ {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) {
342- {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { sess.tabs[1] = newTab(nil, candidate.target.size) }},357+ sess.mu.Lock()
358+ sess.active = 1
359+ sess.mu.Unlock()
360+ }},
361+ {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) {
362+ sess.mu.Lock()
363+ sess.tabs[1] = newTab(nil, candidate.target.size)
364+ sess.mu.Unlock()
365+ }},
366+ {name: "source gains visible floating pane", mutate: func(_ *session, candidate tabOverflowCandidate) {
367+ installTestFloating(candidate.source, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
368+ }},
343 }369 }
344 370
345 for _, tt := range tests {371 for _, tt := range tests {
@@ -347,11 +373,11 @@ func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
347 d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)373 d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
348 candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1)374 candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1)
349 require.True(t, ok)375 require.True(t, ok)
350- sess.mu.Lock()
351 tt.mutate(sess, candidate)376 tt.mutate(sess, candidate)
352- sess.mu.Unlock()377+ activeBeforeCommit := activeTabIndex(sess)
353 378
354 require.False(t, d.commitTabOverflow(sess, candidate))379 require.False(t, d.commitTabOverflow(sess, candidate))
380+ require.Equal(t, activeBeforeCommit, activeTabIndex(sess))
355 })381 })
356 }382 }
357 }383 }
M internal/usecase/layout/ops.go +43 -45
@@ -414,30 +414,7 @@ func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error)
414414 return "", ErrNoPane
415415 }
416416
417- type candidate struct {
418- id PaneID
419- gap int
420- overlap int
421- }
422- candidates := make([]candidate, 0, len(placements))
423- for _, placement := range placements {
424- if facing, gap, perpendicularOverlap := sideGapOverlap(outside, focusRect(placement), dir); facing {
425- candidates = append(candidates, candidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
426- }
427- }
428- if len(candidates) == 0 {
429- return "", ErrNoPane
430- }
431- sort.Slice(candidates, func(i, j int) bool {
432- if candidates[i].gap != candidates[j].gap {
433- return candidates[i].gap < candidates[j].gap
434- }
435- if candidates[i].overlap != candidates[j].overlap {
436- return candidates[i].overlap > candidates[j].overlap
437- }
438- return candidates[i].id < candidates[j].id
439- })
440- return candidates[0].id, nil
417+ return selectFacingCandidate(placements, outside, dir, facingGapFirst)
441418 }
442419
443420 // FocusEnter focuses the best pane on the facing edge and expands it when it
@@ -476,36 +453,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
476453 return ErrNotFound
477454 }
478455 current := focusRect(placements[currentIdx])
479- type candidate struct {
480- id PaneID
481- overlap int
482- gap int
456+ candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst)
457+ if err != nil {
458+ return err
483459 }
484- var candidates []candidate
485- for _, p := range placements {
486- if p.ID == t.Focus {
487- continue
488- }
489- r := focusRect(p)
490- if ok, gap, overlap := sideGapOverlap(current, r, dir); ok {
491- candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap})
460+ t.Focus = candidate
461+ setExpanded(t.Root, t.Focus)
462+ return nil
463+}
464+
465+type facingMetricPriority uint8
466+
467+const (
468+ facingGapFirst facingMetricPriority = iota
469+ facingOverlapFirst
470+)
471+
472+type facingCandidate struct {
473+ id PaneID
474+ gap int
475+ overlap int
476+}
477+
478+func selectFacingCandidate(placements []Placement, from domain.Rect, dir Direction, priority facingMetricPriority) (PaneID, error) {
479+ candidates := make([]facingCandidate, 0, len(placements))
480+ for _, placement := range placements {
481+ if facing, gap, perpendicularOverlap := sideGapOverlap(from, focusRect(placement), dir); facing {
482+ candidates = append(candidates, facingCandidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
492483 }
493484 }
494485 if len(candidates) == 0 {
495- return ErrNoPane
486+ return "", ErrNoPane
496487 }
497488 sort.Slice(candidates, func(i, j int) bool {
498- if candidates[i].overlap != candidates[j].overlap {
499- return candidates[i].overlap > candidates[j].overlap
500- }
501- if candidates[i].gap != candidates[j].gap {
502- return candidates[i].gap < candidates[j].gap
489+ if priority == facingGapFirst {
490+ if candidates[i].gap != candidates[j].gap {
491+ return candidates[i].gap < candidates[j].gap
492+ }
493+ if candidates[i].overlap != candidates[j].overlap {
494+ return candidates[i].overlap > candidates[j].overlap
495+ }
496+ } else {
497+ if candidates[i].overlap != candidates[j].overlap {
498+ return candidates[i].overlap > candidates[j].overlap
499+ }
500+ if candidates[i].gap != candidates[j].gap {
501+ return candidates[i].gap < candidates[j].gap
502+ }
503503 }
504504 return candidates[i].id < candidates[j].id
505505 })
506- t.Focus = candidates[0].id
507- setExpanded(t.Root, t.Focus)
508- return nil
506+ return candidates[0].id, nil
509507 }
510508
511509 func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {
@@ -414,30 +414,7 @@ func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error)
414 return "", ErrNoPane414 return "", ErrNoPane
415 }415 }
416 416
417- type candidate struct {417+ return selectFacingCandidate(placements, outside, dir, facingGapFirst)
418- id PaneID
419- gap int
420- overlap int
421- }
422- candidates := make([]candidate, 0, len(placements))
423- for _, placement := range placements {
424- if facing, gap, perpendicularOverlap := sideGapOverlap(outside, focusRect(placement), dir); facing {
425- candidates = append(candidates, candidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
426- }
427- }
428- if len(candidates) == 0 {
429- return "", ErrNoPane
430- }
431- sort.Slice(candidates, func(i, j int) bool {
432- if candidates[i].gap != candidates[j].gap {
433- return candidates[i].gap < candidates[j].gap
434- }
435- if candidates[i].overlap != candidates[j].overlap {
436- return candidates[i].overlap > candidates[j].overlap
437- }
438- return candidates[i].id < candidates[j].id
439- })
440- return candidates[0].id, nil
441 }418 }
442 419
443 // FocusEnter focuses the best pane on the facing edge and expands it when it420 // FocusEnter focuses the best pane on the facing edge and expands it when it
@@ -476,36 +453,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
476 return ErrNotFound453 return ErrNotFound
477 }454 }
478 current := focusRect(placements[currentIdx])455 current := focusRect(placements[currentIdx])
479- type candidate struct {456+ candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst)
480- id PaneID457+ if err != nil {
481- overlap int458+ return err
482- gap int
483 }459 }
484- var candidates []candidate460+ t.Focus = candidate
485- for _, p := range placements {461+ setExpanded(t.Root, t.Focus)
486- if p.ID == t.Focus {462+ return nil
487- continue463+}
488- }464+
489- r := focusRect(p)465+type facingMetricPriority uint8
490- if ok, gap, overlap := sideGapOverlap(current, r, dir); ok {466+
491- candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap})467+const (
468+ facingGapFirst facingMetricPriority = iota
469+ facingOverlapFirst
470+)
471+
472+type facingCandidate struct {
473+ id PaneID
474+ gap int
475+ overlap int
476+}
477+
478+func selectFacingCandidate(placements []Placement, from domain.Rect, dir Direction, priority facingMetricPriority) (PaneID, error) {
479+ candidates := make([]facingCandidate, 0, len(placements))
480+ for _, placement := range placements {
481+ if facing, gap, perpendicularOverlap := sideGapOverlap(from, focusRect(placement), dir); facing {
482+ candidates = append(candidates, facingCandidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
492 }483 }
493 }484 }
494 if len(candidates) == 0 {485 if len(candidates) == 0 {
495- return ErrNoPane486+ return "", ErrNoPane
496 }487 }
497 sort.Slice(candidates, func(i, j int) bool {488 sort.Slice(candidates, func(i, j int) bool {
498- if candidates[i].overlap != candidates[j].overlap {489+ if priority == facingGapFirst {
499- return candidates[i].overlap > candidates[j].overlap490+ if candidates[i].gap != candidates[j].gap {
500- }491+ return candidates[i].gap < candidates[j].gap
501- if candidates[i].gap != candidates[j].gap {492+ }
502- return candidates[i].gap < candidates[j].gap493+ if candidates[i].overlap != candidates[j].overlap {
494+ return candidates[i].overlap > candidates[j].overlap
495+ }
496+ } else {
497+ if candidates[i].overlap != candidates[j].overlap {
498+ return candidates[i].overlap > candidates[j].overlap
499+ }
500+ if candidates[i].gap != candidates[j].gap {
501+ return candidates[i].gap < candidates[j].gap
502+ }
503 }503 }
504 return candidates[i].id < candidates[j].id504 return candidates[i].id < candidates[j].id
505 })505 })
506- t.Focus = candidates[0].id506+ return candidates[0].id, nil
507- setExpanded(t.Root, t.Focus)
508- return nil
509 }507 }
510 508
511 func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {509 func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {

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