detached ade7c5a

feat(core): add consume-or-expel-window-left/right on Cmd+[ and Cmd+]

A lone window joins the neighbor column; a stacked one leaves for a new
column. A full fixed workspace stacks it into the neighbor column instead,
and past the edge onto the neighbor monitor (new edge column, or stacked
when that one is full too). Slots follow their window (ADR 016).
commit
ade7c5a513c0d296a58d337c8608d5bdd6972a78
author
brice · · 2026-09-26 14:44 UTC
parent
1ddb3e5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +350 -3
M examples/config +5 -0
@@ -97,6 +97,11 @@ bind.cmd+k = focus-window-up
9797 bind.cmd+j = focus-window-down
9898 bind.cmd+shift+h = move-column-left
9999 bind.cmd+shift+l = move-column-right
100+# A window alone in its column joins the neighbor column; a stacked one
101+# leaves for a new column. A full fixed workspace stacks it into the
102+# neighbor column instead, past the edge on the neighbor monitor.
103+bind.cmd+bracketleft = consume-or-expel-window-left
104+bind.cmd+bracketright = consume-or-expel-window-right
100105 bind.cmd+r = cycle-column-width
101106 bind.cmd+shift+f = toggle-fullscreen
102107 bind.cmd+q = close-window
@@ -97,6 +97,11 @@ bind.cmd+k = focus-window-up
97 bind.cmd+j = focus-window-down97 bind.cmd+j = focus-window-down
98 bind.cmd+shift+h = move-column-left98 bind.cmd+shift+h = move-column-left
99 bind.cmd+shift+l = move-column-right99 bind.cmd+shift+l = move-column-right
100+# A window alone in its column joins the neighbor column; a stacked one
101+# leaves for a new column. A full fixed workspace stacks it into the
102+# neighbor column instead, past the edge on the neighbor monitor.
103+bind.cmd+bracketleft = consume-or-expel-window-left
104+bind.cmd+bracketright = consume-or-expel-window-right
100 bind.cmd+r = cycle-column-width105 bind.cmd+r = cycle-column-width
101 bind.cmd+shift+f = toggle-fullscreen106 bind.cmd+shift+f = toggle-fullscreen
102 bind.cmd+q = close-window107 bind.cmd+q = close-window
M internal/adapters/config/config.go +3 -1
@@ -27,6 +27,8 @@ var defaultBinds = []struct{ combo, action string }{
2727 {"cmd+down", "focus-window-down"},
2828 {"cmd+shift+left", "move-column-left"},
2929 {"cmd+shift+right", "move-column-right"},
30+ {"cmd+bracketleft", "consume-or-expel-window-left"},
31+ {"cmd+bracketright", "consume-or-expel-window-right"},
3032 {"cmd+r", "cycle-column-width"},
3133 {"cmd+shift+f", "toggle-fullscreen"},
3234 {"cmd+q", "close-window"},
@@ -41,7 +43,7 @@ var defaultBinds = []struct{ combo, action string }{
4143 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
4244 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
4345 }
44-var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
46+var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
4547 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
4648 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
4749
@@ -27,6 +27,8 @@ var defaultBinds = []struct{ combo, action string }{
27 {"cmd+down", "focus-window-down"},27 {"cmd+down", "focus-window-down"},
28 {"cmd+shift+left", "move-column-left"},28 {"cmd+shift+left", "move-column-left"},
29 {"cmd+shift+right", "move-column-right"},29 {"cmd+shift+right", "move-column-right"},
30+ {"cmd+bracketleft", "consume-or-expel-window-left"},
31+ {"cmd+bracketright", "consume-or-expel-window-right"},
30 {"cmd+r", "cycle-column-width"},32 {"cmd+r", "cycle-column-width"},
31 {"cmd+shift+f", "toggle-fullscreen"},33 {"cmd+shift+f", "toggle-fullscreen"},
32 {"cmd+q", "close-window"},34 {"cmd+q", "close-window"},
@@ -41,7 +43,7 @@ var defaultBinds = []struct{ combo, action string }{
41 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},43 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
42 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},44 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
43 }45 }
44-var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}46+var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
45 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}47 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
46 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)48 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
47 49
M internal/adapters/config/config_test.go +1 -1
@@ -21,7 +21,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
2121
2222 func TestDefaultsAndLoad(t *testing.T) {
2323 d := Defaults()
24- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 50 || d.Layout.MaxColumns != 2 {
24+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 52 || d.Layout.MaxColumns != 2 {
2525 t.Fatalf("defaults: %+v", d)
2626 }
2727 if d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
@@ -21,7 +21,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
21 21
22 func TestDefaultsAndLoad(t *testing.T) {22 func TestDefaultsAndLoad(t *testing.T) {
23 d := Defaults()23 d := Defaults()
24- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 50 || d.Layout.MaxColumns != 2 {24+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 52 || d.Layout.MaxColumns != 2 {
25 t.Fatalf("defaults: %+v", d)25 t.Fatalf("defaults: %+v", d)
26 }26 }
27 if d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {27 if d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
M internal/core/action.go +28 -0
@@ -36,6 +36,9 @@ const (
3636 // Scale steps through the clean scales of the output (see CleanScales).
3737 ActionScaleUp Action = "scale-up"
3838 ActionScaleDown Action = "scale-down"
39+ // Consume or expel the focused window (ADR 016).
40+ ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left"
41+ ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
3942 )
4043
4144 // WorkspaceOp is what a numbered workspace action does.
@@ -130,6 +133,31 @@ func (c *Core) applyAction(a Action) Effect {
130133 c.focusScreen = i
131134 return Effect{}
132135 }
136+ case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
137+ dir = 1
138+ if a == ActionConsumeOrExpelLeft {
139+ dir = -1
140+ }
141+ from := c.cur()
142+ if from.mon.Current().ConsumeOrExpel(dir) {
143+ from.mon.normalize()
144+ return Effect{}
145+ }
146+ // Full fixed workspace, stacked edge column: one hop to the
147+ // neighbor monitor, if any; focus follows the window.
148+ i := c.neighbor(dir)
149+ if i < 0 {
150+ return Effect{}
151+ }
152+ id, ok := from.mon.Current().takeWindow()
153+ if !ok {
154+ return Effect{}
155+ }
156+ c.focusScreen = i
157+ c.cur().mon.Current().expelTo(id, dir)
158+ from.mon.normalize()
159+ c.cur().mon.normalize()
160+ return Effect{}
133161 case ActionMoveColumnLeft, ActionMoveColumnRight:
134162 // Past the edge the column moves to the neighbor screen, on the
135163 // side facing this one, and focus follows it.
@@ -36,6 +36,9 @@ const (
36 // Scale steps through the clean scales of the output (see CleanScales).36 // Scale steps through the clean scales of the output (see CleanScales).
37 ActionScaleUp Action = "scale-up"37 ActionScaleUp Action = "scale-up"
38 ActionScaleDown Action = "scale-down"38 ActionScaleDown Action = "scale-down"
39+ // Consume or expel the focused window (ADR 016).
40+ ActionConsumeOrExpelLeft Action = "consume-or-expel-window-left"
41+ ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
39 )42 )
40 43
41 // WorkspaceOp is what a numbered workspace action does.44 // WorkspaceOp is what a numbered workspace action does.
@@ -130,6 +133,31 @@ func (c *Core) applyAction(a Action) Effect {
130 c.focusScreen = i133 c.focusScreen = i
131 return Effect{}134 return Effect{}
132 }135 }
136+ case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
137+ dir = 1
138+ if a == ActionConsumeOrExpelLeft {
139+ dir = -1
140+ }
141+ from := c.cur()
142+ if from.mon.Current().ConsumeOrExpel(dir) {
143+ from.mon.normalize()
144+ return Effect{}
145+ }
146+ // Full fixed workspace, stacked edge column: one hop to the
147+ // neighbor monitor, if any; focus follows the window.
148+ i := c.neighbor(dir)
149+ if i < 0 {
150+ return Effect{}
151+ }
152+ id, ok := from.mon.Current().takeWindow()
153+ if !ok {
154+ return Effect{}
155+ }
156+ c.focusScreen = i
157+ c.cur().mon.Current().expelTo(id, dir)
158+ from.mon.normalize()
159+ c.cur().mon.normalize()
160+ return Effect{}
133 case ActionMoveColumnLeft, ActionMoveColumnRight:161 case ActionMoveColumnLeft, ActionMoveColumnRight:
134 // Past the edge the column moves to the neighbor screen, on the162 // Past the edge the column moves to the neighbor screen, on the
135 // side facing this one, and focus follows it.163 // side facing this one, and focus follows it.
M internal/core/core.go +1 -1
@@ -181,7 +181,7 @@ func (c *Core) apply(cfg ports.Config) error {
181181 continue
182182 }
183183 switch Action(a) {
184- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
184+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
185185 default:
186186 return fmt.Errorf("invalid action %q", a)
187187 }
@@ -181,7 +181,7 @@ func (c *Core) apply(cfg ports.Config) error {
181 continue181 continue
182 }182 }
183 switch Action(a) {183 switch Action(a) {
184- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:184+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
185 default:185 default:
186 return fmt.Errorf("invalid action %q", a)186 return fmt.Errorf("invalid action %q", a)
187 }187 }
A internal/core/expel.go +107 -0
@@ -0,0 +1,107 @@
1+package core
2+
3+import "slices"
4+
5+// Consume or expel (ADR 016): Cmd+[ and Cmd+] move the focused tiled window
6+// one column left or right. Alone in its column it joins the bottom of the
7+// neighbor column (consume); in a stacked column it leaves for a new column
8+// (expel). A full fixed workspace never gets a new column: the window stacks
9+// into the neighbor column instead, past the edge on the neighbor monitor.
10+//
11+// A slot column's slot window is its first window: windows only join a
12+// column at the bottom, so the slot follows Windows[0].
13+
14+// full reports whether a fixed-overflow workspace has no room for a column.
15+func (w *Workspace) full() bool {
16+ return w.Overflow == OverflowFixed && len(w.Columns) >= max(w.MaxColumns, 1)
17+}
18+
19+// ConsumeOrExpel moves the focused window in direction dir (-1 or 1). It
20+// returns false when the window must leave for the neighbor monitor: a full
21+// workspace with the window stacked in the edge column.
22+func (w *Workspace) ConsumeOrExpel(dir int) bool {
23+ if len(w.Columns) == 0 || w.floatFocus || (dir != -1 && dir != 1) {
24+ return true
25+ }
26+ // A fullscreen window hides the others (fixed) or its column (scroll):
27+ // moving would break its way back.
28+ if w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) {
29+ return true
30+ }
31+ src := w.Focus
32+ col := w.Columns[src]
33+ id := col.Windows[col.Focus]
34+ next := src + dir
35+ edge := next < 0 || next >= len(w.Columns)
36+ switch {
37+ case len(col.Windows) == 1 && edge:
38+ // Consume at the edge: move-column already crosses monitors.
39+ case len(col.Windows) == 1:
40+ // The column goes away, and a slot with it.
41+ w.stack(id, w.Columns[next].Windows[0])
42+ case !w.full():
43+ c := Column{Windows: []WindowID{id}, Width: col.Width}
44+ if col.Focus == 0 && col.Slot != 0 {
45+ // The slot and its width follow the slot window.
46+ c.Slot = col.Slot
47+ w.Columns[src].Slot, w.Columns[src].Width = 0, Width{}
48+ } else if col.Slot != 0 {
49+ c.Width = Width{}
50+ }
51+ w.RemoveWindow(id)
52+ w.insertColumn(src+max(dir, 0), c)
53+ case edge:
54+ return false
55+ default:
56+ w.stack(id, w.Columns[next].Windows[0])
57+ }
58+ return true
59+}
60+
61+// stack moves tiled window id to the bottom of the column holding target
62+// and focuses it. Leaving a slot column's first row releases the slot.
63+func (w *Workspace) stack(id, target WindowID) {
64+ w.dropSlotOf(id)
65+ w.RemoveWindow(id)
66+ i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) })
67+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
68+ w.FocusID(id)
69+}
70+
71+// dropSlotOf clears the slot of the column whose slot window is id.
72+func (w *Workspace) dropSlotOf(id WindowID) {
73+ for i := range w.Columns {
74+ if w.Columns[i].Slot != 0 && w.Columns[i].Windows[0] == id {
75+ w.Columns[i].Slot = 0
76+ }
77+ }
78+}
79+
80+// takeWindow removes the focused tiled window from its column for another
81+// workspace; its slot, if any, is released.
82+func (w *Workspace) takeWindow() (WindowID, bool) {
83+ id, ok := w.Focused()
84+ if !ok || w.floatFocus {
85+ return 0, false
86+ }
87+ w.dropSlotOf(id)
88+ w.RemoveWindow(id)
89+ return id, true
90+}
91+
92+// expelTo puts a window expelled from the neighbor monitor on the side
93+// facing it (dir > 0: it came from the left): as a new edge column, or at
94+// the bottom of the edge column when the workspace is full.
95+func (w *Workspace) expelTo(id WindowID, dir int) {
96+ at := 0
97+ if dir < 0 {
98+ at = len(w.Columns)
99+ }
100+ if !w.full() || w.origin != nil {
101+ w.receive(Column{Windows: []WindowID{id}}, at)
102+ return
103+ }
104+ i := min(at, len(w.Columns)-1)
105+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
106+ w.Activate(id)
107+}
@@ -0,0 +1,107 @@
1+package core
2+
3+import "slices"
4+
5+// Consume or expel (ADR 016): Cmd+[ and Cmd+] move the focused tiled window
6+// one column left or right. Alone in its column it joins the bottom of the
7+// neighbor column (consume); in a stacked column it leaves for a new column
8+// (expel). A full fixed workspace never gets a new column: the window stacks
9+// into the neighbor column instead, past the edge on the neighbor monitor.
10+//
11+// A slot column's slot window is its first window: windows only join a
12+// column at the bottom, so the slot follows Windows[0].
13+
14+// full reports whether a fixed-overflow workspace has no room for a column.
15+func (w *Workspace) full() bool {
16+ return w.Overflow == OverflowFixed && len(w.Columns) >= max(w.MaxColumns, 1)
17+}
18+
19+// ConsumeOrExpel moves the focused window in direction dir (-1 or 1). It
20+// returns false when the window must leave for the neighbor monitor: a full
21+// workspace with the window stacked in the edge column.
22+func (w *Workspace) ConsumeOrExpel(dir int) bool {
23+ if len(w.Columns) == 0 || w.floatFocus || (dir != -1 && dir != 1) {
24+ return true
25+ }
26+ // A fullscreen window hides the others (fixed) or its column (scroll):
27+ // moving would break its way back.
28+ if w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus)) {
29+ return true
30+ }
31+ src := w.Focus
32+ col := w.Columns[src]
33+ id := col.Windows[col.Focus]
34+ next := src + dir
35+ edge := next < 0 || next >= len(w.Columns)
36+ switch {
37+ case len(col.Windows) == 1 && edge:
38+ // Consume at the edge: move-column already crosses monitors.
39+ case len(col.Windows) == 1:
40+ // The column goes away, and a slot with it.
41+ w.stack(id, w.Columns[next].Windows[0])
42+ case !w.full():
43+ c := Column{Windows: []WindowID{id}, Width: col.Width}
44+ if col.Focus == 0 && col.Slot != 0 {
45+ // The slot and its width follow the slot window.
46+ c.Slot = col.Slot
47+ w.Columns[src].Slot, w.Columns[src].Width = 0, Width{}
48+ } else if col.Slot != 0 {
49+ c.Width = Width{}
50+ }
51+ w.RemoveWindow(id)
52+ w.insertColumn(src+max(dir, 0), c)
53+ case edge:
54+ return false
55+ default:
56+ w.stack(id, w.Columns[next].Windows[0])
57+ }
58+ return true
59+}
60+
61+// stack moves tiled window id to the bottom of the column holding target
62+// and focuses it. Leaving a slot column's first row releases the slot.
63+func (w *Workspace) stack(id, target WindowID) {
64+ w.dropSlotOf(id)
65+ w.RemoveWindow(id)
66+ i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) })
67+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
68+ w.FocusID(id)
69+}
70+
71+// dropSlotOf clears the slot of the column whose slot window is id.
72+func (w *Workspace) dropSlotOf(id WindowID) {
73+ for i := range w.Columns {
74+ if w.Columns[i].Slot != 0 && w.Columns[i].Windows[0] == id {
75+ w.Columns[i].Slot = 0
76+ }
77+ }
78+}
79+
80+// takeWindow removes the focused tiled window from its column for another
81+// workspace; its slot, if any, is released.
82+func (w *Workspace) takeWindow() (WindowID, bool) {
83+ id, ok := w.Focused()
84+ if !ok || w.floatFocus {
85+ return 0, false
86+ }
87+ w.dropSlotOf(id)
88+ w.RemoveWindow(id)
89+ return id, true
90+}
91+
92+// expelTo puts a window expelled from the neighbor monitor on the side
93+// facing it (dir > 0: it came from the left): as a new edge column, or at
94+// the bottom of the edge column when the workspace is full.
95+func (w *Workspace) expelTo(id WindowID, dir int) {
96+ at := 0
97+ if dir < 0 {
98+ at = len(w.Columns)
99+ }
100+ if !w.full() || w.origin != nil {
101+ w.receive(Column{Windows: []WindowID{id}}, at)
102+ return
103+ }
104+ i := min(at, len(w.Columns)-1)
105+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
106+ w.Activate(id)
107+}
A internal/core/expel_test.go +149 -0
@@ -0,0 +1,149 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+// cols builds a workspace with the given columns, focus on the last window
9+// of column focus.
10+func cols(overflow Overflow, max, focus int, columns ...[]WindowID) *Workspace {
11+ w := &Workspace{Overflow: overflow, MaxColumns: max}
12+ w.SetOutput(300, 100)
13+ for _, c := range columns {
14+ w.Columns = append(w.Columns, Column{Windows: c, Focus: len(c) - 1})
15+ }
16+ w.Focus = focus
17+ return w
18+}
19+
20+func ids(w *Workspace) [][]WindowID {
21+ var out [][]WindowID
22+ for _, c := range w.Columns {
23+ out = append(out, c.Windows)
24+ }
25+ return out
26+}
27+
28+func TestConsumeOrExpel(t *testing.T) {
29+ for _, tc := range []struct {
30+ name string
31+ w *Workspace
32+ dir int
33+ want [][]WindowID
34+ stays bool
35+ focusCol int
36+ }{
37+ {"consume right", cols(OverflowScroll, 2, 0, []WindowID{1}, []WindowID{2}), 1, [][]WindowID{{2, 1}}, true, 0},
38+ {"consume left", cols(OverflowScroll, 2, 1, []WindowID{1}, []WindowID{2}), -1, [][]WindowID{{1, 2}}, true, 0},
39+ {"consume at the edge does nothing", cols(OverflowFixed, 3, 1, []WindowID{1}, []WindowID{2}), 1, [][]WindowID{{1}, {2}}, true, 1},
40+ {"expel right in scroll", cols(OverflowScroll, 1, 0, []WindowID{1, 2}, []WindowID{3}), 1, [][]WindowID{{1}, {2}, {3}}, true, 1},
41+ {"expel left in scroll", cols(OverflowScroll, 1, 1, []WindowID{1}, []WindowID{2, 3}), -1, [][]WindowID{{1}, {3}, {2}}, true, 1},
42+ {"expel in fixed under the limit", cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}), 1, [][]WindowID{{1}, {2}, {3}}, true, 1},
43+ {"full fixed stacks into the next column", cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}), 1, [][]WindowID{{1}, {3, 2}, {4}}, true, 1},
44+ {"full fixed at the edge leaves", cols(OverflowFixed, 3, 2, []WindowID{1}, []WindowID{2}, []WindowID{3, 4}), 1, [][]WindowID{{1}, {2}, {3, 4}}, false, 2},
45+ {"past the limit counts as full", cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}), 1, [][]WindowID{{1}, {3, 2}, {4}}, true, 1},
46+ } {
47+ t.Run(tc.name, func(t *testing.T) {
48+ stays := tc.w.ConsumeOrExpel(tc.dir)
49+ if got := ids(tc.w); !reflect.DeepEqual(got, tc.want) || stays != tc.stays || tc.w.Focus != tc.focusCol {
50+ t.Fatal(got, stays, tc.w.Focus)
51+ }
52+
53+ })
54+ }
55+}
56+
57+func TestConsumeOrExpelFocusFollows(t *testing.T) {
58+ w := cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
59+ w.ConsumeOrExpel(1)
60+ if id, _ := w.Focused(); id != 2 {
61+ t.Fatal(id)
62+ }
63+}
64+
65+func TestConsumeOrExpelSkips(t *testing.T) {
66+ t.Run("floating focus", func(t *testing.T) {
67+ w := cols(OverflowScroll, 2, 0, []WindowID{1, 2})
68+ w.AddFloating(9, 10, 10)
69+ w.FocusID(9)
70+ w.ConsumeOrExpel(1)
71+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) {
72+ t.Fatal(got)
73+ }
74+ })
75+ t.Run("fullscreen", func(t *testing.T) {
76+ w := cols(OverflowFixed, 3, 0, []WindowID{1, 2})
77+ w.fullscreen = 2
78+ w.ConsumeOrExpel(1)
79+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) {
80+ t.Fatal(got)
81+ }
82+ })
83+}
84+
85+func TestConsumeOrExpelSlots(t *testing.T) {
86+ slots := func(w *Workspace) []int {
87+ var out []int
88+ for _, c := range w.Columns {
89+ out = append(out, c.Slot)
90+ }
91+ return out
92+ }
93+ t.Run("the slot follows its window", func(t *testing.T) {
94+ w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
95+ w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3}
96+ w.Columns[0].Focus = 0
97+ w.ConsumeOrExpel(1)
98+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Width != (Width{Num: 2, Den: 3}) || w.Columns[0].Width != (Width{}) {
99+ t.Fatal(got, slots(w), w.Columns)
100+ }
101+ })
102+ t.Run("another window leaves the slot", func(t *testing.T) {
103+ w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
104+ w.Columns[0].Slot = 1
105+ w.ConsumeOrExpel(1)
106+ if !reflect.DeepEqual(slots(w), []int{1, 0}) {
107+ t.Fatal(slots(w))
108+ }
109+ })
110+ t.Run("stacking the slot window releases it", func(t *testing.T) {
111+ w := cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3})
112+ w.Columns[0].Slot, w.Columns[0].Focus = 1, 0
113+ w.ConsumeOrExpel(1)
114+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {3, 1}}) || !reflect.DeepEqual(slots(w), []int{0, 0}) {
115+ t.Fatal(got, slots(w))
116+ }
117+ })
118+}
119+
120+func TestExpelTo(t *testing.T) {
121+ t.Run("new edge column", func(t *testing.T) {
122+ w := cols(OverflowFixed, 3, 0, []WindowID{1}, []WindowID{2})
123+ w.expelTo(9, 1)
124+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{9}, {1}, {2}}) || w.Focus != 0 {
125+ t.Fatal(got)
126+ }
127+ })
128+ t.Run("full stacks into the first column", func(t *testing.T) {
129+ w := cols(OverflowFixed, 2, 1, []WindowID{1}, []WindowID{2})
130+ w.expelTo(9, 1)
131+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1, 9}, {2}}) || id != 9 {
132+ t.Fatal(ids(w), id)
133+ }
134+ })
135+ t.Run("full from the right stacks into the last column", func(t *testing.T) {
136+ w := cols(OverflowFixed, 2, 0, []WindowID{1}, []WindowID{2}, []WindowID{3})
137+ w.expelTo(9, -1)
138+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1}, {2}, {3, 9}}) || id != 9 {
139+ t.Fatal(ids(w), id)
140+ }
141+ })
142+ t.Run("empty workspace", func(t *testing.T) {
143+ w := cols(OverflowFixed, 2, 0)
144+ w.expelTo(9, -1)
145+ if !reflect.DeepEqual(ids(w), [][]WindowID{{9}}) {
146+ t.Fatal(ids(w))
147+ }
148+ })
149+}
@@ -0,0 +1,149 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+// cols builds a workspace with the given columns, focus on the last window
9+// of column focus.
10+func cols(overflow Overflow, max, focus int, columns ...[]WindowID) *Workspace {
11+ w := &Workspace{Overflow: overflow, MaxColumns: max}
12+ w.SetOutput(300, 100)
13+ for _, c := range columns {
14+ w.Columns = append(w.Columns, Column{Windows: c, Focus: len(c) - 1})
15+ }
16+ w.Focus = focus
17+ return w
18+}
19+
20+func ids(w *Workspace) [][]WindowID {
21+ var out [][]WindowID
22+ for _, c := range w.Columns {
23+ out = append(out, c.Windows)
24+ }
25+ return out
26+}
27+
28+func TestConsumeOrExpel(t *testing.T) {
29+ for _, tc := range []struct {
30+ name string
31+ w *Workspace
32+ dir int
33+ want [][]WindowID
34+ stays bool
35+ focusCol int
36+ }{
37+ {"consume right", cols(OverflowScroll, 2, 0, []WindowID{1}, []WindowID{2}), 1, [][]WindowID{{2, 1}}, true, 0},
38+ {"consume left", cols(OverflowScroll, 2, 1, []WindowID{1}, []WindowID{2}), -1, [][]WindowID{{1, 2}}, true, 0},
39+ {"consume at the edge does nothing", cols(OverflowFixed, 3, 1, []WindowID{1}, []WindowID{2}), 1, [][]WindowID{{1}, {2}}, true, 1},
40+ {"expel right in scroll", cols(OverflowScroll, 1, 0, []WindowID{1, 2}, []WindowID{3}), 1, [][]WindowID{{1}, {2}, {3}}, true, 1},
41+ {"expel left in scroll", cols(OverflowScroll, 1, 1, []WindowID{1}, []WindowID{2, 3}), -1, [][]WindowID{{1}, {3}, {2}}, true, 1},
42+ {"expel in fixed under the limit", cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}), 1, [][]WindowID{{1}, {2}, {3}}, true, 1},
43+ {"full fixed stacks into the next column", cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}), 1, [][]WindowID{{1}, {3, 2}, {4}}, true, 1},
44+ {"full fixed at the edge leaves", cols(OverflowFixed, 3, 2, []WindowID{1}, []WindowID{2}, []WindowID{3, 4}), 1, [][]WindowID{{1}, {2}, {3, 4}}, false, 2},
45+ {"past the limit counts as full", cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}), 1, [][]WindowID{{1}, {3, 2}, {4}}, true, 1},
46+ } {
47+ t.Run(tc.name, func(t *testing.T) {
48+ stays := tc.w.ConsumeOrExpel(tc.dir)
49+ if got := ids(tc.w); !reflect.DeepEqual(got, tc.want) || stays != tc.stays || tc.w.Focus != tc.focusCol {
50+ t.Fatal(got, stays, tc.w.Focus)
51+ }
52+
53+ })
54+ }
55+}
56+
57+func TestConsumeOrExpelFocusFollows(t *testing.T) {
58+ w := cols(OverflowFixed, 3, 0, []WindowID{1, 2}, []WindowID{3}, []WindowID{4})
59+ w.ConsumeOrExpel(1)
60+ if id, _ := w.Focused(); id != 2 {
61+ t.Fatal(id)
62+ }
63+}
64+
65+func TestConsumeOrExpelSkips(t *testing.T) {
66+ t.Run("floating focus", func(t *testing.T) {
67+ w := cols(OverflowScroll, 2, 0, []WindowID{1, 2})
68+ w.AddFloating(9, 10, 10)
69+ w.FocusID(9)
70+ w.ConsumeOrExpel(1)
71+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) {
72+ t.Fatal(got)
73+ }
74+ })
75+ t.Run("fullscreen", func(t *testing.T) {
76+ w := cols(OverflowFixed, 3, 0, []WindowID{1, 2})
77+ w.fullscreen = 2
78+ w.ConsumeOrExpel(1)
79+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{1, 2}}) {
80+ t.Fatal(got)
81+ }
82+ })
83+}
84+
85+func TestConsumeOrExpelSlots(t *testing.T) {
86+ slots := func(w *Workspace) []int {
87+ var out []int
88+ for _, c := range w.Columns {
89+ out = append(out, c.Slot)
90+ }
91+ return out
92+ }
93+ t.Run("the slot follows its window", func(t *testing.T) {
94+ w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
95+ w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3}
96+ w.Columns[0].Focus = 0
97+ w.ConsumeOrExpel(1)
98+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Width != (Width{Num: 2, Den: 3}) || w.Columns[0].Width != (Width{}) {
99+ t.Fatal(got, slots(w), w.Columns)
100+ }
101+ })
102+ t.Run("another window leaves the slot", func(t *testing.T) {
103+ w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
104+ w.Columns[0].Slot = 1
105+ w.ConsumeOrExpel(1)
106+ if !reflect.DeepEqual(slots(w), []int{1, 0}) {
107+ t.Fatal(slots(w))
108+ }
109+ })
110+ t.Run("stacking the slot window releases it", func(t *testing.T) {
111+ w := cols(OverflowFixed, 2, 0, []WindowID{1, 2}, []WindowID{3})
112+ w.Columns[0].Slot, w.Columns[0].Focus = 1, 0
113+ w.ConsumeOrExpel(1)
114+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {3, 1}}) || !reflect.DeepEqual(slots(w), []int{0, 0}) {
115+ t.Fatal(got, slots(w))
116+ }
117+ })
118+}
119+
120+func TestExpelTo(t *testing.T) {
121+ t.Run("new edge column", func(t *testing.T) {
122+ w := cols(OverflowFixed, 3, 0, []WindowID{1}, []WindowID{2})
123+ w.expelTo(9, 1)
124+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{9}, {1}, {2}}) || w.Focus != 0 {
125+ t.Fatal(got)
126+ }
127+ })
128+ t.Run("full stacks into the first column", func(t *testing.T) {
129+ w := cols(OverflowFixed, 2, 1, []WindowID{1}, []WindowID{2})
130+ w.expelTo(9, 1)
131+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1, 9}, {2}}) || id != 9 {
132+ t.Fatal(ids(w), id)
133+ }
134+ })
135+ t.Run("full from the right stacks into the last column", func(t *testing.T) {
136+ w := cols(OverflowFixed, 2, 0, []WindowID{1}, []WindowID{2}, []WindowID{3})
137+ w.expelTo(9, -1)
138+ if id, _ := w.Focused(); !reflect.DeepEqual(ids(w), [][]WindowID{{1}, {2}, {3, 9}}) || id != 9 {
139+ t.Fatal(ids(w), id)
140+ }
141+ })
142+ t.Run("empty workspace", func(t *testing.T) {
143+ w := cols(OverflowFixed, 2, 0)
144+ w.expelTo(9, -1)
145+ if !reflect.DeepEqual(ids(w), [][]WindowID{{9}}) {
146+ t.Fatal(ids(w))
147+ }
148+ })
149+}
M internal/core/screens_test.go +56 -0
@@ -523,3 +523,59 @@ func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
523523 }
524524 }
525525 }
526+
527+// windowsOf lists the tiled windows of an output in scene order, with the
528+// focused one.
529+func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
530+ for _, s := range set {
531+ if s.Output != out {
532+ continue
533+ }
534+ for _, w := range s.Windows {
535+ if !w.Hidden {
536+ ids = append(ids, w.ID)
537+ }
538+ if w.Focused {
539+ focused = w.ID
540+ }
541+ }
542+ }
543+ return ids, focused
544+}
545+
546+func TestExpelCrossesToFullNeighbor(t *testing.T) {
547+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
548+ // DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
549+ // [1] [2 3], focus on 3 at the bottom of the edge column.
550+ r.mapWindow(t, 1)
551+ r.mapWindow(t, 2)
552+ r.mapWindow(t, 3)
553+ r.key(t, "bracketleft", ports.ModAlt)
554+ // DP-2: [4] [5], full too.
555+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
556+ r.mapWindow(t, 4)
557+ r.mapWindow(t, 5)
558+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
559+ // Expel right at the edge: 3 stacks under 4, the first column of DP-2.
560+ set := r.key(t, "bracketright", ports.ModAlt)
561+ if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
562+ t.Fatal(got)
563+ }
564+ got, focused := windowsOf(set, "DP-2")
565+ if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
566+ t.Fatal(got, focused)
567+ }
568+ if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
569+ t.Fatal(out.Focused)
570+ }
571+ // 3 is not in the edge column of DP-2: it stacks into [5].
572+ set = r.key(t, "bracketright", ports.ModAlt)
573+ if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
574+ t.Fatal(got, focused)
575+ }
576+ // Now at the edge with no right monitor: nothing moves.
577+ set = r.key(t, "bracketright", ports.ModAlt)
578+ if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
579+ t.Fatal(got, focused)
580+ }
581+}
@@ -523,3 +523,59 @@ func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
523 }523 }
524 }524 }
525 }525 }
526+
527+// windowsOf lists the tiled windows of an output in scene order, with the
528+// focused one.
529+func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
530+ for _, s := range set {
531+ if s.Output != out {
532+ continue
533+ }
534+ for _, w := range s.Windows {
535+ if !w.Hidden {
536+ ids = append(ids, w.ID)
537+ }
538+ if w.Focused {
539+ focused = w.ID
540+ }
541+ }
542+ }
543+ return ids, focused
544+}
545+
546+func TestExpelCrossesToFullNeighbor(t *testing.T) {
547+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
548+ // DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
549+ // [1] [2 3], focus on 3 at the bottom of the edge column.
550+ r.mapWindow(t, 1)
551+ r.mapWindow(t, 2)
552+ r.mapWindow(t, 3)
553+ r.key(t, "bracketleft", ports.ModAlt)
554+ // DP-2: [4] [5], full too.
555+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
556+ r.mapWindow(t, 4)
557+ r.mapWindow(t, 5)
558+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
559+ // Expel right at the edge: 3 stacks under 4, the first column of DP-2.
560+ set := r.key(t, "bracketright", ports.ModAlt)
561+ if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
562+ t.Fatal(got)
563+ }
564+ got, focused := windowsOf(set, "DP-2")
565+ if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
566+ t.Fatal(got, focused)
567+ }
568+ if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
569+ t.Fatal(out.Focused)
570+ }
571+ // 3 is not in the edge column of DP-2: it stacks into [5].
572+ set = r.key(t, "bracketright", ports.ModAlt)
573+ if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
574+ t.Fatal(got, focused)
575+ }
576+ // Now at the edge with no right monitor: nothing moves.
577+ set = r.key(t, "bracketright", ports.ModAlt)
578+ if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
579+ t.Fatal(got, focused)
580+ }
581+}

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