main f2d166b

action.go RawHistoryFolder

1package core
2
3import (
4	"slices"
5	"strconv"
6	"strings"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11type Action string
12
13const (
14	ActionSpawnTerminal      Action = "spawn-terminal"
15	ActionFocusColumnLeft    Action = "focus-column-left"
16	ActionFocusColumnRight   Action = "focus-column-right"
17	ActionFocusWindowUp      Action = "focus-window-up"
18	ActionFocusWindowDown    Action = "focus-window-down"
19	ActionMoveColumnLeft     Action = "move-column-left"
20	ActionMoveColumnRight    Action = "move-column-right"
21	ActionCycleColumnWidth   Action = "cycle-column-width"
22	ActionMaximizeColumn     Action = "maximize-column"
23	ActionToggleFullscreen   Action = "toggle-fullscreen"
24	ActionToggleWindowStash  Action = "toggle-window-stash"
25	ActionToggleStashVisible Action = "toggle-stash-visible"
26	ActionToggleOverview     Action = "toggle-overview"
27	ActionCloseWindow        Action = "close-window"
28	ActionQuit               Action = "quit"
29	// Workspaces stack vertically; prev/next stop at the ends.
30	ActionFocusWorkspacePrev Action = "focus-workspace-prev"
31	ActionFocusWorkspaceNext Action = "focus-workspace-next"
32	// Move the focused column, or only the focused window, to the previous
33	// or next workspace.
34	ActionMoveColumnToWorkspacePrev Action = "move-column-to-workspace-prev"
35	ActionMoveColumnToWorkspaceNext Action = "move-column-to-workspace-next"
36	ActionMoveWindowToWorkspacePrev Action = "move-window-to-workspace-prev"
37	ActionMoveWindowToWorkspaceNext Action = "move-window-to-workspace-next"
38	// Monitors act on the neighbor in that direction of the global layout
39	// (ADR 011).
40	ActionFocusMonitorLeft           Action = "focus-monitor-left"
41	ActionFocusMonitorRight          Action = "focus-monitor-right"
42	ActionFocusMonitorUp             Action = "focus-monitor-up"
43	ActionFocusMonitorDown           Action = "focus-monitor-down"
44	ActionMoveWorkspaceLeft          Action = "move-workspace-to-monitor-left"
45	ActionMoveWorkspaceRight         Action = "move-workspace-to-monitor-right"
46	ActionMoveWorkspaceToMonitorUp   Action = "move-workspace-to-monitor-up"
47	ActionMoveWorkspaceToMonitorDown Action = "move-workspace-to-monitor-down"
48	// Scale steps through the clean scales of the output (see CleanScales).
49	ActionScaleUp   Action = "scale-up"
50	ActionScaleDown Action = "scale-down"
51	// Consume or expel the focused window (ADR 016).
52	ActionConsumeOrExpelLeft  Action = "consume-or-expel-window-left"
53	ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
54	// Move the focused window up or down inside its column.
55	ActionMoveWindowUp   Action = "move-window-up"
56	ActionMoveWindowDown Action = "move-window-down"
57	// Swap the active numbered workspace with its neighbor.
58	ActionMoveWorkspacePrev Action = "move-workspace-prev"
59	ActionMoveWorkspaceNext Action = "move-workspace-next"
60	// Turn the focused tile into a free floating window, or back.
61	ActionToggleFloating Action = "toggle-floating"
62	// Switch to the column used before this one, further back while the
63	// command key is held (switcher.go).
64	ActionSwitchColumnNext Action = "switch-column-next"
65	ActionSwitchColumnPrev Action = "switch-column-prev"
66	// Show keyboard hint labels over the links and buttons of the focused
67	// output (hint.go).
68	ActionHint Action = "hint"
69)
70
71// monitorDirection returns the direction of the neighbor screen targeted by
72// a focus-monitor or move-workspace-to-monitor action, 0 for other actions.
73func monitorDirection(a Action) direction {
74	switch a {
75	case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
76		return dirLeft
77	case ActionFocusMonitorRight, ActionMoveWorkspaceRight:
78		return dirRight
79	case ActionFocusMonitorUp, ActionMoveWorkspaceToMonitorUp:
80		return dirUp
81	case ActionFocusMonitorDown, ActionMoveWorkspaceToMonitorDown:
82		return dirDown
83	}
84	return 0
85}
86
87// Resize axes of ResizeArg.
88const (
89	ResizeWidth  = 0
90	ResizeHeight = 1
91)
92
93var resizePrefixes = [...]string{
94	ResizeWidth:  "set-column-width ",
95	ResizeHeight: "set-window-height ",
96}
97
98// ResizeArg parses "set-column-width +N" (axis ResizeWidth) and
99// "set-window-height -N%" (axis ResizeHeight). The sign is required and the
100// unit depends on the axis: step is in cells for a width (N from 1 to 16,
101// no %), in percent for a height (N from 1 to 100, with %).
102func ResizeArg(a Action) (axis, step int, ok bool) {
103	for i, prefix := range resizePrefixes {
104		rest, found := strings.CutPrefix(string(a), prefix)
105		if !found {
106			continue
107		}
108		rest = strings.TrimSpace(rest)
109		if i == ResizeWidth {
110			if p, ok := parseResizeStep(rest, maxCells); ok {
111				return i, p, true
112			}
113			return 0, 0, false
114		}
115		if num, found := strings.CutSuffix(rest, "%"); found {
116			if p, ok := parseResizeStep(num, 100); ok {
117				return i, p, true
118			}
119		}
120		return 0, 0, false
121	}
122	return 0, 0, false
123}
124
125// parseResizeStep parses "+N" or "-N" with N from 1 to limit.
126func parseResizeStep(s string, limit int) (int, bool) {
127	if len(s) < 2 || (s[0] != '+' && s[0] != '-') {
128		return 0, false
129	}
130	n, err := strconv.Atoi(s[1:])
131	if err != nil || n < 1 || n > limit || s[1] == '+' || s[1] == '-' {
132		return 0, false
133	}
134	if s[0] == '-' {
135		n = -n
136	}
137	return n, true
138}
139
140// WorkspaceOp is what a numbered workspace action does.
141type WorkspaceOp int
142
143const (
144	FocusWorkspace WorkspaceOp = iota
145	MoveColumnToWorkspace
146	MoveWindowToWorkspace
147)
148
149var workspacePrefixes = [...]string{
150	FocusWorkspace:        "focus-workspace ",
151	MoveColumnToWorkspace: "move-column-to-workspace ",
152	MoveWindowToWorkspace: "move-window-to-workspace ",
153}
154
155// WorkspaceArg parses "focus-workspace N", "move-column-to-workspace N" and
156// "move-window-to-workspace N" (N from 1).
157func WorkspaceArg(a Action) (n int, op WorkspaceOp, ok bool) {
158	for i, prefix := range workspacePrefixes {
159		rest, found := strings.CutPrefix(string(a), prefix)
160		if !found {
161			continue
162		}
163		n, err := strconv.Atoi(strings.TrimSpace(rest))
164		if err != nil || n < 1 || n > 99 {
165			return 0, 0, false
166		}
167		return n, WorkspaceOp(i), true
168	}
169	return 0, 0, false
170}
171
172// namedPrefix toggles a named workspace: "workspace dev".
173const namedPrefix = "workspace "
174
175// NamedArg returns the name of a "workspace <name>" action.
176func NamedArg(a Action) (string, bool) {
177	rest, ok := strings.CutPrefix(string(a), namedPrefix)
178	name := strings.TrimSpace(rest)
179	return name, ok && name != "" && !strings.ContainsAny(name, " \t")
180}
181
182// spawnPrefix starts a bind action that runs commands: "spawn fuzzel --flag".
183// They are split by ports.SplitCommands; there is no shell.
184const spawnPrefix = "spawn "
185
186// SpawnCommands returns the commands of a "spawn <cmd>[; <cmd>...]" action.
187func SpawnCommands(a Action) ([][]string, bool) { return prefixedCommands(a, spawnPrefix) }
188
189// stashPrefix starts a bind action that runs commands and opens their
190// windows in the stash: "stash firefox".
191const stashPrefix = "stash "
192
193// StashCommands returns the commands of a "stash <cmd>[; <cmd>...]" action.
194func StashCommands(a Action) ([][]string, bool) { return prefixedCommands(a, stashPrefix) }
195
196func prefixedCommands(a Action, prefix string) ([][]string, bool) {
197	rest, ok := strings.CutPrefix(string(a), prefix)
198	if !ok {
199		return nil, false
200	}
201	commands := ports.SplitCommands(rest)
202	return commands, len(commands) > 0
203}
204
205type Effect struct {
206	Spawn    bool
207	Commands [][]string // commands to run; nil means the configured terminal
208	// Stash sends the spawned windows to the stash of the workspace the
209	// action applied to.
210	Stash bool
211	Close WindowID
212	Quit  bool
213}
214
215// applyAction runs a bind action. Focus past a content edge visits an occupied
216// workspace, then a neighbor screen, then a trailing empty workspace. Vertical
217// edges do this only with a vertical workspace axis; cascade stays within bands.
218// Other actions apply to the focused screen's monitor unless explicitly moving
219// between screens. Screen changes request a flash on the destination output.
220func (c *Core) applyAction(a Action) Effect {
221	if !c.flashOn() {
222		return c.dispatchAction(a)
223	}
224	switch a {
225	case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown,
226		ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating,
227		ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview,
228		ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight,
229		ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown,
230		ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
231	default:
232		return c.dispatchAction(a)
233	}
234	// Compare the action's visible result, not publication-time focus: pointer,
235	// activation and mapping changes must never request a flash.
236	beforeScreen := c.cur()
237	beforeWS := beforeScreen.mon.Current()
238	beforeID, _ := beforeScreen.mon.Focused()
239	before := beforeScreen.mon.Layout()
240	wasOverview := beforeScreen.mon.ov.open
241	wasStashed := beforeWS.stashIndex(beforeID) >= 0
242	wasShown := beforeWS.stashShown()
243	defer func() {
244		sc := c.cur()
245		id, _ := sc.mon.Focused()
246		if sc.mon.ov.open {
247			return
248		}
249		if wasOverview {
250			if a == ActionToggleOverview && id != 0 {
251				c.requestFlash(flashWindow, id)
252			}
253			return
254		}
255		// Crossing outputs marks the destination itself, even if it is empty.
256		if sc != beforeScreen {
257			c.requestFlash(flashMonitor, 0)
258			return
259		}
260		level := flashWindow
261		switch a {
262		case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown,
263			ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating:
264		case ActionToggleWindowStash:
265			if !wasStashed {
266				return
267			}
268		case ActionToggleStashVisible:
269			if wasShown || !sc.mon.Current().stashShown() {
270				return
271			}
272		case ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight:
273			level = flashColumn
274		case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown,
275			ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
276			return
277		default:
278			return
279		}
280		if sc.mon.Current() != beforeWS {
281			return
282		}
283		after := sc.mon.Layout()
284		changed := id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
285			return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating
286		})
287		if changed && id != 0 {
288			c.requestFlash(level, id)
289		}
290	}()
291	return c.dispatchAction(a)
292}
293
294func (c *Core) dispatchAction(a Action) Effect {
295	if e, ok := c.cur().mon.overviewAction(a); ok {
296		return e
297	}
298	// A bind acts on the window on screen: under a covering fullscreen
299	// window, that is the one Focused reports.
300	c.cur().mon.Current().focusCover()
301	// Before the edge moves below: a free float moves on its screen.
302	if c.cur().mon.Current().freeAction(a) {
303		return Effect{}
304	}
305	if name, ok := NamedArg(a); ok && c.bringNamed(name) {
306		return Effect{}
307	}
308	switch a {
309	case ActionHint:
310		c.hintRequest()
311		return Effect{}
312	case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
313		if i := c.neighbor(monitorDirection(a)); i >= 0 {
314			c.focusScreen = i
315		}
316		return Effect{}
317	case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown:
318		c.moveWorkspace(monitorDirection(a))
319		return Effect{}
320	case ActionFocusColumnLeft, ActionFocusColumnRight:
321		w := c.cur().mon.Current()
322		dir, d := 1, dirRight
323		if a == ActionFocusColumnLeft {
324			dir, d = -1, dirLeft
325		}
326		// A covering fullscreen window is left for the window on that side;
327		// with none (the workspace, band or stash edge), the move goes past
328		// the edge (see edgeFocus) and fullscreen stays.
329		if w.pinned() && !w.stashOverCover() {
330			if !w.FocusColumn(dir) {
331				c.edgeFocus(horizontalAxis, dir, d)
332			}
333			return Effect{}
334		}
335		// The first move from a float stays here unless nothing is under it:
336		// a native float leaves for the stash or the columns, and the stash
337		// keeps the focus at its ends.
338		edge := w.leavesBand(w.columnToward(dir)) && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat())
339		if edge && c.edgeFocus(horizontalAxis, dir, d) {
340			return Effect{}
341		}
342	case ActionFocusWindowUp, ActionFocusWindowDown:
343		m := c.cur().mon
344		// Cascade's up/down navigation stays within its bands. Let Monitor
345		// handle it unchanged; only vertical workspace axes cross screens.
346		if m.Current().policy().workspace == verticalAxis {
347			dir, d := 1, dirDown
348			if a == ActionFocusWindowUp {
349				dir, d = -1, dirUp
350			}
351			if !m.Current().FocusWindow(dir) {
352				c.edgeFocus(verticalAxis, dir, d)
353			}
354			return Effect{}
355		}
356	case ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight:
357		dir, d := 1, dirRight
358		if a == ActionConsumeOrExpelLeft {
359			dir, d = -1, dirLeft
360		}
361		from := c.cur()
362		if from.mon.Current().ConsumeOrExpel(dir) {
363			from.mon.normalize()
364			return Effect{}
365		}
366		// Full fixed workspace, stacked edge column: one hop to the
367		// neighbor monitor, if any; focus follows the window.
368		i := c.neighbor(d)
369		if i < 0 {
370			return Effect{}
371		}
372		id, ok := from.mon.Current().takeWindow()
373		if !ok {
374			return Effect{}
375		}
376		c.focusScreen = i
377		c.cur().mon.Current().expelTo(id, dir)
378		from.mon.normalize()
379		c.cur().mon.normalize()
380		return Effect{}
381	case ActionMoveColumnLeft, ActionMoveColumnRight:
382		// Past the edge of the workspace or cascade band the column moves to
383		// the neighbor screen, on the side facing this one, and focus
384		// follows it. With no screen there, a cascade column stays in its
385		// band.
386		w := c.cur().mon.Current()
387		dir, d := 1, dirRight
388		if a == ActionMoveColumnLeft {
389			dir, d = -1, dirLeft
390		}
391		if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
392			col, ok := w.takeColumn()
393			if !ok {
394				return Effect{}
395			}
396			from := c.cur()
397			c.focusScreen = i
398			to := c.cur().mon.Current()
399			at := 0
400			if d == dirLeft {
401				at = len(to.Columns)
402			}
403			to.receive(col, at)
404			from.mon.normalize()
405			c.cur().mon.normalize()
406			return Effect{}
407		}
408	}
409	return c.cur().mon.Apply(a)
410}
411
412// edgeFocus moves focus past a content edge along axis (dir -1/1, toward d):
413// first to an occupied workspace when axis is the workspace axis, then to the
414// neighbor monitor, then to the trailing empty workspace. It reports whether
415// focus moved. Callers retain responsibility for local content navigation and
416// for restricting which layout axes may cross monitors.
417func (c *Core) edgeFocus(axis layoutAxis, dir int, d direction) bool {
418	m := c.cur().mon
419	if to := m.occupiedStep(axis, dir); to >= 0 {
420		m.Focus(to)
421		return true
422	}
423	if i := c.neighbor(d); i >= 0 {
424		c.focusScreen = i
425		return true
426	}
427	return m.stepWorkspace(axis, dir)
428}
429
430// Apply runs a bind action on this monitor only. Vertical focus edges step
431// workspaces; Core dispatch adds neighboring-screen navigation for those edges.
432func (m *Monitor) Apply(a Action) Effect {
433	if e, ok := m.overviewAction(a); ok {
434		return e
435	}
436	if movesToWorkspace(a) {
437		if i, column, ok := m.moveDest(a); ok {
438			m.MoveToWorkspace(i, column)
439		}
440		return Effect{}
441	}
442	if n, _, ok := WorkspaceArg(a); ok {
443		before := m.Current()
444		m.FocusNumber(n)
445		if m.ov.open && m.Current() != before {
446			m.selectRow()
447		}
448		return Effect{}
449	}
450	if name, ok := NamedArg(a); ok {
451		before := m.Current()
452		m.ToggleNamed(name)
453		if m.ov.open && m.Current() != before {
454			m.selectRow()
455		}
456		return Effect{}
457	}
458	switch a {
459	case ActionFocusWindowUp, ActionFocusWindowDown:
460		// Past the column and any on-screen neighbor or demoted float,
461		// move to the next workspace if the layout's workspace axis is
462		// vertical. Cascade workspaces sit side by side: up/down stop at the
463		// first and last band.
464		dir := 1
465		if a == ActionFocusWindowUp {
466			dir = -1
467		}
468		if !m.Current().FocusWindow(dir) {
469			m.stepWorkspace(verticalAxis, dir)
470		}
471		return Effect{}
472	case ActionToggleFullscreen:
473		m.ToggleFullscreen()
474		return Effect{}
475	case ActionToggleWindowStash:
476		m.Current().ToggleWindowStash()
477		return Effect{}
478	case ActionToggleStashVisible:
479		w := m.Current()
480		// An empty stash has nothing to show: open the terminal in it.
481		if len(w.Stash) == 0 && m.stashTerminal {
482			return Effect{Spawn: true, Stash: true}
483		}
484		w.ToggleStashVisible()
485		return Effect{}
486	case ActionToggleOverview:
487		m.ToggleOverview()
488		return Effect{}
489	case ActionMaximizeColumn:
490		// A client that made itself fullscreen (Wine at monitor size) goes
491		// back to its column width; the next press maximizes the column.
492		if w := m.Current(); w.fullscreen != 0 {
493			if id, ok := w.Focused(); ok && id == w.fullscreen {
494				m.ToggleFullscreen()
495				return Effect{}
496			}
497		}
498	case ActionFocusWorkspacePrev, ActionFocusWorkspaceNext:
499		if m.shown == nil && a == ActionFocusWorkspacePrev {
500			m.Focus(m.Active - 1)
501		} else if m.shown == nil {
502			m.Focus(m.Active + 1)
503		}
504		return Effect{}
505	case ActionMoveWorkspacePrev:
506		m.MoveWorkspace(-1)
507		return Effect{}
508	case ActionMoveWorkspaceNext:
509		m.MoveWorkspace(1)
510		return Effect{}
511	}
512	return m.Current().Apply(a)
513}
514
515// movesToWorkspace reports a bind that moves a window to another workspace.
516func movesToWorkspace(a Action) bool {
517	if _, op, ok := WorkspaceArg(a); ok {
518		return op != FocusWorkspace
519	}
520	switch a {
521	case ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext,
522		ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext:
523		return true
524	}
525	return false
526}
527
528// moveDest resolves a move-to-workspace bind to the workspace index it
529// moves to and whether the whole column goes; ok is false when it has
530// nowhere to go. Up and down do nothing on a named workspace.
531func (m *Monitor) moveDest(a Action) (i int, column, ok bool) {
532	if n, op, isArg := WorkspaceArg(a); isArg {
533		return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true
534	}
535	if m.shown != nil {
536		return 0, false, false
537	}
538	switch a {
539	case ActionMoveColumnToWorkspacePrev, ActionMoveWindowToWorkspacePrev:
540		return m.Active - 1, a == ActionMoveColumnToWorkspacePrev, m.Active > 0
541	case ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspaceNext:
542		return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceNext, true
543	}
544	return 0, false, false
545}
546
547// Apply runs a bind action that only touches this workspace.
548func (w *Workspace) Apply(a Action) Effect {
549	if commands, ok := SpawnCommands(a); ok {
550		return Effect{Spawn: true, Commands: commands}
551	}
552	if commands, ok := StashCommands(a); ok {
553		return Effect{Spawn: true, Stash: true, Commands: commands}
554	}
555	if w.freeAction(a) {
556		return Effect{}
557	}
558	if axis, step, ok := ResizeArg(a); ok {
559		if axis == ResizeWidth {
560			for range max(step, -step) {
561				if step > 0 {
562					w.GrowColumn(false)
563				} else {
564					w.ShrinkColumn()
565				}
566			}
567		} else {
568			w.ResizeRow(step)
569		}
570		return Effect{}
571	}
572	switch a {
573	case ActionSpawnTerminal:
574		return Effect{Spawn: true}
575	case ActionFocusColumnLeft:
576		w.FocusColumn(-1)
577	case ActionFocusColumnRight:
578		w.FocusColumn(1)
579	case ActionFocusWindowUp:
580		w.FocusWindow(-1)
581	case ActionFocusWindowDown:
582		w.FocusWindow(1)
583	case ActionMoveColumnLeft:
584		w.MoveColumn(-1)
585	case ActionMoveColumnRight:
586		w.MoveColumn(1)
587	case ActionMoveWindowUp:
588		w.MoveWindow(-1)
589	case ActionMoveWindowDown:
590		w.MoveWindow(1)
591	case ActionCycleColumnWidth:
592		w.GrowColumn(true)
593	case ActionMaximizeColumn:
594		w.ToggleFullWidth()
595	case ActionToggleFullscreen:
596		w.ToggleFullscreen()
597	case ActionToggleWindowStash:
598		w.ToggleWindowStash()
599	case ActionToggleStashVisible:
600		w.ToggleStashVisible()
601	case ActionToggleFloating:
602		w.ToggleFloating()
603	case ActionCloseWindow:
604		id, _ := w.Focused()
605		return Effect{Close: id}
606	case ActionQuit:
607		return Effect{Quit: true}
608	}
609	return Effect{}
610}

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 25

Tags 10

↑↓ moveenter openesc close