detached d7e6236

fix(core): a closed window leaves from where it was drawn, ordered by the two-pass rule

unmapWindow hands leaveFrom the snapshot's rectShot of the closed window,
so one closed mid-motion (its entrance, a slide) exits from its drawn rect
and fade with their speeds instead of jumping to its settled rect. Absent
leaving tiles take withLeaving's two-pass order (C6); a tile closed while
the stash hides is drawn before the leaving stash floats, the same on every
publish. configures.prune forgets a destroyed window once it left the
scene: no configure reaches it during or after the fade (tested).
commit
d7e6236ed7e8a27e7e027427065b1db38545fdb9
author
brice · · 2026-10-05 00:43 UTC
parent
df88f4a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +101 -18
M internal/core/transition.go +14 -6
@@ -427,10 +427,12 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
427427
428428 // unmapWindow handles a window's unmap: it leaves its workspace, the tiles
429429 // around it re-flow with rect motions, and a window that was drawn as a
430-// tile or an ordinary float keeps fading out from its last settled
431-// placement (leave). Like a map, an unmap is a client event that animates;
432-// the settled layout is the same with animations off. A fullscreen window,
433-// one under a session lock or with the overview open just goes.
430+// tile or an ordinary float keeps fading out from where it was drawn
431+// (leaveFrom with the snapshot's record of it: a window closed mid-motion
432+// leaves from its drawn rect, fade and speed, not from its settled rect).
433+// Like a map, an unmap is a client event that animates; the settled layout
434+// is the same with animations off. A fullscreen window, one under a
435+// session lock or with the overview open just goes.
434436 func (c *Core) unmapWindow(v ports.WindowUnmapped) {
435437 sc, w := c.screenOf(v.ID)
436438 if sc == nil {
@@ -443,7 +445,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
443445 }
444446 now := c.now()
445447 // The placement that leaves is the settled one of the last publish:
446- // where the window was going (its rect motion, if any, ends with it).
448+ // where the window was going.
447449 var left Placement
448450 if sc.mon.Current() == w {
449451 for _, p := range sc.settledLayout {
@@ -454,16 +456,22 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
454456 }
455457 }
456458 shots := c.snapshot(now)
459+ var shot *rectShot
457460 for i := range shots {
458461 if shots[i].overview {
459462 shots[i].ws = nil
460463 }
464+ if shots[i].sc == sc {
465+ if j := slices.IndexFunc(shots[i].rects, func(r rectShot) bool { return r.id == v.ID }); j >= 0 {
466+ shot = &shots[i].rects[j]
467+ }
468+ }
461469 }
462470 sc.mon.RemoveWindow(v.ID)
463471 delete(sc.rects, v.ID)
464472 c.transition(shots, now)
465473 if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek {
466- c.leave(sc, left, now)
474+ c.leaveFrom(sc, left, shot, now)
467475 }
468476 }
469477
@@ -427,10 +427,12 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
427 427
428 // unmapWindow handles a window's unmap: it leaves its workspace, the tiles428 // unmapWindow handles a window's unmap: it leaves its workspace, the tiles
429 // around it re-flow with rect motions, and a window that was drawn as a429 // around it re-flow with rect motions, and a window that was drawn as a
430-// tile or an ordinary float keeps fading out from its last settled430+// tile or an ordinary float keeps fading out from where it was drawn
431-// placement (leave). Like a map, an unmap is a client event that animates;431+// (leaveFrom with the snapshot's record of it: a window closed mid-motion
432-// the settled layout is the same with animations off. A fullscreen window,432+// leaves from its drawn rect, fade and speed, not from its settled rect).
433-// one under a session lock or with the overview open just goes.433+// Like a map, an unmap is a client event that animates; the settled layout
434+// is the same with animations off. A fullscreen window, one under a
435+// session lock or with the overview open just goes.
434 func (c *Core) unmapWindow(v ports.WindowUnmapped) {436 func (c *Core) unmapWindow(v ports.WindowUnmapped) {
435 sc, w := c.screenOf(v.ID)437 sc, w := c.screenOf(v.ID)
436 if sc == nil {438 if sc == nil {
@@ -443,7 +445,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
443 }445 }
444 now := c.now()446 now := c.now()
445 // The placement that leaves is the settled one of the last publish:447 // The placement that leaves is the settled one of the last publish:
446- // where the window was going (its rect motion, if any, ends with it).448+ // where the window was going.
447 var left Placement449 var left Placement
448 if sc.mon.Current() == w {450 if sc.mon.Current() == w {
449 for _, p := range sc.settledLayout {451 for _, p := range sc.settledLayout {
@@ -454,16 +456,22 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
454 }456 }
455 }457 }
456 shots := c.snapshot(now)458 shots := c.snapshot(now)
459+ var shot *rectShot
457 for i := range shots {460 for i := range shots {
458 if shots[i].overview {461 if shots[i].overview {
459 shots[i].ws = nil462 shots[i].ws = nil
460 }463 }
464+ if shots[i].sc == sc {
465+ if j := slices.IndexFunc(shots[i].rects, func(r rectShot) bool { return r.id == v.ID }); j >= 0 {
466+ shot = &shots[i].rects[j]
467+ }
468+ }
461 }469 }
462 sc.mon.RemoveWindow(v.ID)470 sc.mon.RemoveWindow(v.ID)
463 delete(sc.rects, v.ID)471 delete(sc.rects, v.ID)
464 c.transition(shots, now)472 c.transition(shots, now)
465 if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek {473 if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek {
466- c.leave(sc, left, now)474+ c.leaveFrom(sc, left, shot, now)
467 }475 }
468 }476 }
469 477
M internal/core/transition_test.go +6 -4
@@ -650,7 +650,7 @@ func TestDiscreteSwipeTransition(t *testing.T) {
650650
651651 // A window unmapped while its rect motion runs stops moving: with
652652 // animations on it stays in the scene only as a leaving entry (drawn where
653-// it was going, fading out, hidden to input), the others keep their
653+// it was, fading out and shrinking, hidden to input), the others keep their
654654 // motions, and once everything settles it is gone and no frame is wanted:
655655 // a motion kept for a gone window would ask for frames with nothing to
656656 // move. With animations off it leaves the scene at once.
@@ -672,15 +672,17 @@ func TestUnmapMidRectMotion(t *testing.T) {
672672 }
673673 }
674674 moving := rectNow(t, mid, 2).X
675+ drawn3 := rectNow(t, mid, 3)
675676 r.client <- ports.WindowUnmapped{ID: 3}
676677 s := scene(t, r.scenes)
677678 w3, shown3 := listedIn(s, 3)
678679 switch {
679680 case tt.edit != nil && shown3:
680681 t.Fatalf("the unmapped window is still shown with animations off: %+v", w3)
681- case tt.edit == nil && (!shown3 || w3.Fade != 0 || w3.Rect.X != 0 || w3.Rect.W != 400):
682- // It leaves from the settled rect it was moving to.
683- t.Fatalf("the unmapped window right after the unmap: %+v (shown %t), want it leaving from its settled slot", w3, shown3)
682+ case tt.edit == nil && (!shown3 || w3.Fade != 0 || w3.Rect != drawn3):
683+ // It leaves from where it was drawn, not from the settled
684+ // slot it was moving to.
685+ t.Fatalf("the unmapped window right after the unmap: %+v (shown %t), want it leaving from %+v", w3, shown3, drawn3)
684686 }
685687 // The neighbour's own motion carries on from where it was drawn.
686688 if got := rectNow(t, s, 2).X; got != moving {
@@ -650,7 +650,7 @@ func TestDiscreteSwipeTransition(t *testing.T) {
650 650
651 // A window unmapped while its rect motion runs stops moving: with651 // A window unmapped while its rect motion runs stops moving: with
652 // animations on it stays in the scene only as a leaving entry (drawn where652 // animations on it stays in the scene only as a leaving entry (drawn where
653-// it was going, fading out, hidden to input), the others keep their653+// it was, fading out and shrinking, hidden to input), the others keep their
654 // motions, and once everything settles it is gone and no frame is wanted:654 // motions, and once everything settles it is gone and no frame is wanted:
655 // a motion kept for a gone window would ask for frames with nothing to655 // a motion kept for a gone window would ask for frames with nothing to
656 // move. With animations off it leaves the scene at once.656 // move. With animations off it leaves the scene at once.
@@ -672,15 +672,17 @@ func TestUnmapMidRectMotion(t *testing.T) {
672 }672 }
673 }673 }
674 moving := rectNow(t, mid, 2).X674 moving := rectNow(t, mid, 2).X
675+ drawn3 := rectNow(t, mid, 3)
675 r.client <- ports.WindowUnmapped{ID: 3}676 r.client <- ports.WindowUnmapped{ID: 3}
676 s := scene(t, r.scenes)677 s := scene(t, r.scenes)
677 w3, shown3 := listedIn(s, 3)678 w3, shown3 := listedIn(s, 3)
678 switch {679 switch {
679 case tt.edit != nil && shown3:680 case tt.edit != nil && shown3:
680 t.Fatalf("the unmapped window is still shown with animations off: %+v", w3)681 t.Fatalf("the unmapped window is still shown with animations off: %+v", w3)
681- case tt.edit == nil && (!shown3 || w3.Fade != 0 || w3.Rect.X != 0 || w3.Rect.W != 400):682+ case tt.edit == nil && (!shown3 || w3.Fade != 0 || w3.Rect != drawn3):
682- // It leaves from the settled rect it was moving to.683+ // It leaves from where it was drawn, not from the settled
683- t.Fatalf("the unmapped window right after the unmap: %+v (shown %t), want it leaving from its settled slot", w3, shown3)684+ // slot it was moving to.
685+ t.Fatalf("the unmapped window right after the unmap: %+v (shown %t), want it leaving from %+v", w3, shown3, drawn3)
684 }686 }
685 // The neighbour's own motion carries on from where it was drawn.687 // The neighbour's own motion carries on from where it was drawn.
686 if got := rectNow(t, s, 2).X; got != moving {688 if got := rectNow(t, s, 2).X; got != moving {
M internal/core/transition_unmap_internal_test.go +81 -8
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "math"
45 "slices"
56 "testing"
67 "time"
@@ -86,6 +87,14 @@ func TestUnmapTiledLeavesAndReflows(t *testing.T) {
8687 if len(sc.rects) != 0 || c.animating() {
8788 t.Fatal("still animating after the settle")
8889 }
90+ // The destroyed window got no configure at any point, and its last one
91+ // is forgotten once it left the scene.
92+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
93+ t.Fatalf("the destroyed window was configured: %v", ids)
94+ }
95+ if _, ok := c.configures.sent[2]; ok {
96+ t.Fatal("configures still remember the destroyed window")
97+ }
8998 }
9099
91100 // A float leaves too, from its settled rect.
@@ -112,28 +121,92 @@ func TestUnmapFloatingLeaves(t *testing.T) {
112121 }
113122 }
114123
115-// A window closed while its own rect motion runs leaves from the rect it
116-// was going to, its motion replaced by the leaving one.
117-func TestUnmapMidMotionLeavesFromSettled(t *testing.T) {
124+// A window closed while its own rect motion runs (its entrance here)
125+// leaves from what was drawn: the first leaving frame shows the rect and
126+// fade it had, not its settled rect, and the leaving motion replaces the
127+// running one.
128+func TestUnmapMidMotionLeavesFromDrawn(t *testing.T) {
118129 c, ic, _, sc := twoSettled(t)
119130 c.mapWindow(ports.WindowMapped{ID: 3})
120131 indicatorScene(t, c)
121132 ic.now = ic.now.Add(30 * time.Millisecond)
122133 c.animate(ic.now, nil)
123- indicatorScene(t, c)
134+ before := sceneWindow(t, indicatorScene(t, c), 3)
124135 settled3 := settledOf(t, sc, 3)
125- if drawn := sceneWindow(t, indicatorScene(t, c), 3).Rect; drawn == settled3.Rect {
126- t.Fatal("window 3 is not in flight: the case checks nothing")
136+ if before.Rect == settled3.Rect || !(before.Fade > 0 && before.Fade < 1) {
137+ t.Fatalf("window 3 is not in flight (%+v): the case checks nothing", before)
127138 }
128139 c.unmapWindow(ports.WindowUnmapped{ID: 3})
129140 s := indicatorScene(t, c)
130141 w := sceneWindow(t, s, 3)
131- if w.Rect != settled3.Rect || w.Fade != 0 {
132- t.Fatalf("leaving window right after the unmap: %+v, want its settled %+v", w, settled3.Rect)
142+ if w.Rect != before.Rect || math.Abs(w.Fade-before.Fade) > 1e-9 {
143+ t.Fatalf("leaving window right after the unmap: %+v, want drawn as before %+v", w, before)
133144 }
134145 if rm, ok := sc.rects[3]; !ok || !rm.leaving {
135146 t.Fatalf("no leaving motion for window 3: %+v", rm)
136147 }
148+ ic.now = ic.now.Add(5 * time.Second)
149+ c.animate(ic.now, nil)
150+ if s := indicatorScene(t, c); slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 3 }) || len(sc.rects) != 0 {
151+ t.Fatalf("the closed window is still there: %+v", s.Windows)
152+ }
153+}
154+
155+// A tile closed while the stash hides: the absent leaving tile goes by
156+// withLeaving's two-pass rule (absent tiles by ID before the floats, the
157+// hidden stash windows replaced in place), the same on every refresh.
158+func TestUnmapWhileStashHidesOrdersDeterministically(t *testing.T) {
159+ c, ic, sc, cmds := stashRig(t, true)
160+ sc.mon.AddWindow(5)
161+ sc.mon.AddWindow(6)
162+ settleShown(t, c)
163+ drainConfigures(cmds)
164+ act(c, ic, ActionToggleStashVisible) // 2 and 3 hide, leaving in place
165+ c.unmapWindow(ports.WindowUnmapped{ID: 5})
166+ var want []WindowID
167+ for i := range 20 {
168+ s := indicatorScene(t, c)
169+ var ids []WindowID
170+ for _, w := range s.Windows {
171+ ids = append(ids, w.ID)
172+ }
173+ if i == 0 {
174+ want = ids
175+ k := slices.Index(sc.settledLayout, settledOf(t, sc, 5))
176+ if k < 0 || !sc.settledLayout[k].Leaving || !sc.shown[k].Leaving {
177+ t.Fatalf("the closed tile is not a leaving entry: %+v", sc.settledLayout)
178+ }
179+ for _, id := range []WindowID{2, 3} {
180+ if j := slices.Index(ids, id); j < 0 || !sc.settledLayout[j].Leaving {
181+ t.Fatalf("stash window %d not replaced in place: %v", id, ids)
182+ }
183+ }
184+ // The absent tile sits before any drawn float (the leaving
185+ // stash windows); a hidden, undrawn one may precede it.
186+ i5 := slices.Index(ids, 5)
187+ for j, w := range s.Windows {
188+ if w.Floating && !w.Hidden && j < i5 {
189+ t.Fatalf("order %v: float %d before the leaving tile 5", ids, w.ID)
190+ }
191+ }
192+ continue
193+ }
194+ if !slices.Equal(ids, want) {
195+ t.Fatalf("publish %d: order %v, first %v", i, ids, want)
196+ }
197+ }
198+ if ids := drainConfigures(cmds); slices.Contains(ids, 5) {
199+ t.Fatalf("the closed window was configured: %v", ids)
200+ }
201+ ic.now = ic.now.Add(5 * time.Second)
202+ c.animate(ic.now, nil)
203+ s := indicatorScene(t, c)
204+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 5 }) || len(sc.rects) != 0 {
205+ t.Fatalf("after the settle: %+v", s.Windows)
206+ }
207+ if ids := drainConfigures(cmds); slices.Contains(ids, 5) {
208+ t.Fatalf("the destroyed window was configured after the fade: %v", ids)
209+ }
137210 }
138211
139212 // With animations off an unmap is instant: the window is gone, no motion.
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "math"
4 "slices"5 "slices"
5 "testing"6 "testing"
6 "time"7 "time"
@@ -86,6 +87,14 @@ func TestUnmapTiledLeavesAndReflows(t *testing.T) {
86 if len(sc.rects) != 0 || c.animating() {87 if len(sc.rects) != 0 || c.animating() {
87 t.Fatal("still animating after the settle")88 t.Fatal("still animating after the settle")
88 }89 }
90+ // The destroyed window got no configure at any point, and its last one
91+ // is forgotten once it left the scene.
92+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
93+ t.Fatalf("the destroyed window was configured: %v", ids)
94+ }
95+ if _, ok := c.configures.sent[2]; ok {
96+ t.Fatal("configures still remember the destroyed window")
97+ }
89 }98 }
90 99
91 // A float leaves too, from its settled rect.100 // A float leaves too, from its settled rect.
@@ -112,28 +121,92 @@ func TestUnmapFloatingLeaves(t *testing.T) {
112 }121 }
113 }122 }
114 123
115-// A window closed while its own rect motion runs leaves from the rect it124+// A window closed while its own rect motion runs (its entrance here)
116-// was going to, its motion replaced by the leaving one.125+// leaves from what was drawn: the first leaving frame shows the rect and
117-func TestUnmapMidMotionLeavesFromSettled(t *testing.T) {126+// fade it had, not its settled rect, and the leaving motion replaces the
127+// running one.
128+func TestUnmapMidMotionLeavesFromDrawn(t *testing.T) {
118 c, ic, _, sc := twoSettled(t)129 c, ic, _, sc := twoSettled(t)
119 c.mapWindow(ports.WindowMapped{ID: 3})130 c.mapWindow(ports.WindowMapped{ID: 3})
120 indicatorScene(t, c)131 indicatorScene(t, c)
121 ic.now = ic.now.Add(30 * time.Millisecond)132 ic.now = ic.now.Add(30 * time.Millisecond)
122 c.animate(ic.now, nil)133 c.animate(ic.now, nil)
123- indicatorScene(t, c)134+ before := sceneWindow(t, indicatorScene(t, c), 3)
124 settled3 := settledOf(t, sc, 3)135 settled3 := settledOf(t, sc, 3)
125- if drawn := sceneWindow(t, indicatorScene(t, c), 3).Rect; drawn == settled3.Rect {136+ if before.Rect == settled3.Rect || !(before.Fade > 0 && before.Fade < 1) {
126- t.Fatal("window 3 is not in flight: the case checks nothing")137+ t.Fatalf("window 3 is not in flight (%+v): the case checks nothing", before)
127 }138 }
128 c.unmapWindow(ports.WindowUnmapped{ID: 3})139 c.unmapWindow(ports.WindowUnmapped{ID: 3})
129 s := indicatorScene(t, c)140 s := indicatorScene(t, c)
130 w := sceneWindow(t, s, 3)141 w := sceneWindow(t, s, 3)
131- if w.Rect != settled3.Rect || w.Fade != 0 {142+ if w.Rect != before.Rect || math.Abs(w.Fade-before.Fade) > 1e-9 {
132- t.Fatalf("leaving window right after the unmap: %+v, want its settled %+v", w, settled3.Rect)143+ t.Fatalf("leaving window right after the unmap: %+v, want drawn as before %+v", w, before)
133 }144 }
134 if rm, ok := sc.rects[3]; !ok || !rm.leaving {145 if rm, ok := sc.rects[3]; !ok || !rm.leaving {
135 t.Fatalf("no leaving motion for window 3: %+v", rm)146 t.Fatalf("no leaving motion for window 3: %+v", rm)
136 }147 }
148+ ic.now = ic.now.Add(5 * time.Second)
149+ c.animate(ic.now, nil)
150+ if s := indicatorScene(t, c); slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 3 }) || len(sc.rects) != 0 {
151+ t.Fatalf("the closed window is still there: %+v", s.Windows)
152+ }
153+}
154+
155+// A tile closed while the stash hides: the absent leaving tile goes by
156+// withLeaving's two-pass rule (absent tiles by ID before the floats, the
157+// hidden stash windows replaced in place), the same on every refresh.
158+func TestUnmapWhileStashHidesOrdersDeterministically(t *testing.T) {
159+ c, ic, sc, cmds := stashRig(t, true)
160+ sc.mon.AddWindow(5)
161+ sc.mon.AddWindow(6)
162+ settleShown(t, c)
163+ drainConfigures(cmds)
164+ act(c, ic, ActionToggleStashVisible) // 2 and 3 hide, leaving in place
165+ c.unmapWindow(ports.WindowUnmapped{ID: 5})
166+ var want []WindowID
167+ for i := range 20 {
168+ s := indicatorScene(t, c)
169+ var ids []WindowID
170+ for _, w := range s.Windows {
171+ ids = append(ids, w.ID)
172+ }
173+ if i == 0 {
174+ want = ids
175+ k := slices.Index(sc.settledLayout, settledOf(t, sc, 5))
176+ if k < 0 || !sc.settledLayout[k].Leaving || !sc.shown[k].Leaving {
177+ t.Fatalf("the closed tile is not a leaving entry: %+v", sc.settledLayout)
178+ }
179+ for _, id := range []WindowID{2, 3} {
180+ if j := slices.Index(ids, id); j < 0 || !sc.settledLayout[j].Leaving {
181+ t.Fatalf("stash window %d not replaced in place: %v", id, ids)
182+ }
183+ }
184+ // The absent tile sits before any drawn float (the leaving
185+ // stash windows); a hidden, undrawn one may precede it.
186+ i5 := slices.Index(ids, 5)
187+ for j, w := range s.Windows {
188+ if w.Floating && !w.Hidden && j < i5 {
189+ t.Fatalf("order %v: float %d before the leaving tile 5", ids, w.ID)
190+ }
191+ }
192+ continue
193+ }
194+ if !slices.Equal(ids, want) {
195+ t.Fatalf("publish %d: order %v, first %v", i, ids, want)
196+ }
197+ }
198+ if ids := drainConfigures(cmds); slices.Contains(ids, 5) {
199+ t.Fatalf("the closed window was configured: %v", ids)
200+ }
201+ ic.now = ic.now.Add(5 * time.Second)
202+ c.animate(ic.now, nil)
203+ s := indicatorScene(t, c)
204+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 5 }) || len(sc.rects) != 0 {
205+ t.Fatalf("after the settle: %+v", s.Windows)
206+ }
207+ if ids := drainConfigures(cmds); slices.Contains(ids, 5) {
208+ t.Fatalf("the destroyed window was configured after the fade: %v", ids)
209+ }
137 }210 }
138 211
139 // With animations off an unmap is instant: the window is gone, no motion.212 // With animations off an unmap is instant: the window is gone, no motion.

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 23

Tags 10

↑↓ moveenter openesc close