feat/data-dnd e05cb15

monitor.go RawHistoryFolder

1package core
2
3import (
4	"iter"
5	"slices"
6
7	"github.com/bnema/neferwl/internal/ports"
8)
9
10// Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
11// are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
12// below the last one, and an empty unnamed workspace is removed once left.
13// Output-wide settings apply to every workspace.
14//
15// Named workspaces come from config and are never removed while configured.
16// Named workspaces are outside normal numbered up/down navigation. Their
17// `workspace <name>` binds and the overview can show them.
18type Monitor struct {
19	// Name is the connector; Key identifies the physical monitor (make,
20	// model and serial, else the connector). Workspaces remember the Key of
21	// their home monitor.
22	Name, Key  string
23	Workspaces []*Workspace
24	Active     int
25	hidden     []*Workspace
26	shown      *Workspace // hidden workspace on screen, nil for Workspaces[Active]
27	back       *Workspace // where a named workspace toggle returns to
28	template   Workspace
29	// slideBuf and slideShown are slideLayout's scratch (the neighbor's
30	// layout and its placements by window), reused and never handed out.
31	slideBuf   []Placement
32	slideShown map[WindowID]Placement
33	nextID     *uint64 // shared by the monitors of one Core; owner goroutine only
34	named      []NamedWorkspace
35	// followMove shows the target workspace after a move to it.
36	followMove bool
37	// stashCapture puts new windows in the shown stash (stash.capture).
38	stashCapture bool
39	// stashTerminal makes toggle-stash-visible on an empty stash open the
40	// terminal in it (stash.spawn-terminal, with a terminal command).
41	stashTerminal bool
42	// switchView slides the view between numbered workspaces: the view is
43	// at Active+switchView.off, in workspaces.
44	switchView slide
45	// switchAxis is frozen from the source workspace for each transition.
46	switchAxis layoutAxis
47	// switchList is the numbered list a landing slide measures from (the
48	// one its swipe began on); switchView.off is then from the current
49	// workspace's place in it.
50	switchList []*Workspace
51	// ovNamed, ovList and ovDividers are overviewWorkspaces' and
52	// overviewRows' scratch (the ordered named rows, the rows and the
53	// dividers), reused and never handed out past the call.
54	ovNamed    []*Workspace
55	ovList     []*Workspace
56	ovDividers [2]ports.Separator
57	// ov holds the overview selection and its Escape snapshot.
58	ov overviewState
59	// sw is the column switcher (switcher.go).
60	sw switcherState
61	// overviewOpens counts the overview's openings: a swipe sliding when
62	// one happens is dropped (gesture.go).
63	overviewOpens int
64}
65
66// NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
67// Overflow follow the monitor defaults.
68type NamedWorkspace struct {
69	Name string
70	// Monitor is the home monitor (connector or key); "" follows the focus.
71	Monitor    string
72	MaxColumns int
73	Overflow   Overflow
74	// Size overrides the logical width and height; a zero side inherits
75	// the monitor and the scale is always the monitor's.
76	Size [2]int
77}
78
79func NewMonitor() *Monitor { return newMonitor("", "") }
80
81func newMonitor(name, key string) *Monitor {
82	return newMonitorWithIDs(name, key, new(uint64))
83}
84
85func newMonitorWithIDs(name, key string, nextID *uint64) *Monitor {
86	m := &Monitor{Name: name, Key: key, nextID: nextID}
87	m.normalize()
88	return m
89}
90
91// matches reports whether home designates this monitor, by key or connector.
92func (m *Monitor) matches(home string) bool {
93	return home != "" && (home == m.Key || home == m.Name)
94}
95
96// all yields every workspace, numbered then hidden, without copying.
97// Callers must not add or remove workspaces while ranging.
98func (m *Monitor) all() iter.Seq[*Workspace] {
99	return func(yield func(*Workspace) bool) {
100		for _, w := range m.Workspaces {
101			if !yield(w) {
102				return
103			}
104		}
105		for _, w := range m.hidden {
106			if !yield(w) {
107				return
108			}
109		}
110	}
111}
112
113// isHidden reports whether w is in the hidden list.
114func (m *Monitor) isHidden(w *Workspace) bool { return indexOf(m.hidden, w) >= 0 }
115
116// take removes w from the monitor. The monitor keeps a workspace on screen:
117// the one at the same position, or the numbered active one.
118func (m *Monitor) take(w *Workspace) {
119	if m.back == w {
120		m.back = nil
121	}
122	if i := indexOf(m.hidden, w); i >= 0 {
123		m.hidden = append(m.hidden[:i], m.hidden[i+1:]...)
124		if m.shown == w {
125			m.shown = nil
126		}
127		m.normalize()
128		return
129	}
130	if i := indexOf(m.Workspaces, w); i >= 0 {
131		m.removeNumbered(i)
132		m.normalize()
133	}
134}
135
136// adopt adds a workspace from another monitor: hidden, or numbered at pos
137// (clamped above the trailing empty workspace). It takes this monitor's
138// output-wide settings and does not change what is on screen.
139func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
140	w.Gaps = m.template.Gaps
141	w.border = m.template.border
142	w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
143	w.SetOutput(m.template.Output.W, m.template.Output.H)
144	w.SetUsable(m.template.Usable)
145	if hidden {
146		m.hidden = append(m.hidden, w)
147	} else {
148		// normalize keeps a trailing empty workspace: stay above it.
149		at := min(max(pos, 0), len(m.Workspaces)-1)
150		m.Workspaces = slices.Insert(m.Workspaces, at, w)
151		if at <= m.Active {
152			m.Active++
153		}
154	}
155	m.applyNamed()
156	m.normalize()
157}
158
159// useSpecs sets the overrides of every named workspace, including those
160// that live on other monitors and may move here.
161func (m *Monitor) useSpecs(all []NamedWorkspace) {
162	m.named = append([]NamedWorkspace(nil), all...)
163	m.applyNamed()
164}
165
166// Current is the workspace on screen.
167func (m *Monitor) Current() *Workspace {
168	if m.shown != nil {
169		return m.shown
170	}
171	return m.Workspaces[m.Active]
172}
173
174func (m *Monitor) newWorkspace() *Workspace {
175	w := m.template
176	w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
177	w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
178	w.overviewAfter = nil
179	w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
180	(*m.nextID)++
181	w.ID = *m.nextID
182	return &w
183}
184
185// normalize keeps one trailing empty unnamed workspace and drops other empty
186// unnamed ones that are not active. The active workspace never changes.
187func (m *Monitor) normalize() {
188	spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
189	for i := len(m.Workspaces) - 2; i >= 0; i-- {
190		if spare(i) && i != m.Active {
191			m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
192			if i < m.Active {
193				m.Active--
194			}
195		}
196	}
197	for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- {
198		m.Workspaces = m.Workspaces[:n-1]
199	}
200	if n := len(m.Workspaces); n == 0 || !spare(n-1) {
201		m.Workspaces = append(m.Workspaces, m.newWorkspace())
202	}
203	for _, w := range m.hidden {
204		if indexOf(m.Workspaces, w.overviewAfter) < 0 {
205			w.overviewAfter = nil
206		}
207	}
208}
209
210// Focus switches to the numbered workspace at index i, clamped to the list.
211func (m *Monitor) Focus(i int) {
212	i = min(max(i, 0), len(m.Workspaces)-1)
213	m.stopSwitch()
214	m.shown = nil
215	m.Active = i
216	m.normalize()
217}
218
219// stepWorkspace switches to the previous or next workspace (dir -1/1) when
220// axis is the current layout's workspace axis, and reports whether it did;
221// Focus clamps past the first or the trailing workspace. A hidden named
222// workspace is outside the numbered list: it never steps.
223func (m *Monitor) stepWorkspace(axis layoutAxis, dir int) bool {
224	step := m.Current().policy().workspaceStep(axis, dir)
225	if m.shown != nil || step == 0 {
226		return false
227	}
228	m.Focus(m.Active + step)
229	return true
230}
231
232// occupiedStep is the numbered workspace dir (-1/1) away along axis when
233// it is the layout's workspace axis and that workspace is not the trailing
234// empty one, or -1.
235func (m *Monitor) occupiedStep(axis layoutAxis, dir int) int {
236	step := m.Current().policy().workspaceStep(axis, dir)
237	to := m.Active + step
238	if m.shown != nil || step == 0 || to < 0 || to >= len(m.Workspaces) {
239		return -1
240	}
241	if w := m.Workspaces[to]; w.empty() && w.Name == "" {
242		return -1
243	}
244	return to
245}
246
247// MoveWorkspace swaps the active numbered workspace with its neighbor up
248// (dir -1) or down (dir 1); the view follows it. It never passes the
249// trailing empty workspace.
250func (m *Monitor) MoveWorkspace(dir int) {
251	to := m.Active + dir
252	if m.shown != nil || (dir != -1 && dir != 1) || to < 0 || to >= len(m.Workspaces)-1 || m.Active >= len(m.Workspaces)-1 {
253		return
254	}
255	m.Workspaces[m.Active], m.Workspaces[to] = m.Workspaces[to], m.Workspaces[m.Active]
256	m.stopSwitch()
257	m.Active = to
258	m.normalize()
259}
260
261// FocusNumber switches to workspace n (1-based); past the end means the last.
262func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) }
263
264// show puts w on screen, numbered or hidden.
265func (m *Monitor) show(w *Workspace) {
266	if i := indexOf(m.Workspaces, w); i >= 0 {
267		m.Focus(i)
268		return
269	}
270	m.stopSwitch()
271	m.shown = w
272	m.normalize()
273}
274
275// ToggleNamed shows the named workspace, or returns to the previous one when
276// it is already on screen. Unknown names do nothing.
277func (m *Monitor) ToggleNamed(name string) {
278	target := m.byName(name)
279	switch cur := m.Current(); {
280	case target == nil:
281	case target != cur:
282		anchor := cur
283		if m.isHidden(cur) {
284			anchor = cur.overviewAfter
285		}
286		if indexOf(m.Workspaces, anchor) < 0 {
287			anchor = m.Workspaces[m.Active]
288		}
289		target.overviewAfter = anchor
290		m.back = cur
291		m.show(target)
292	case m.back != nil && m.has(m.back):
293		back := m.back
294		m.back = nil
295		m.show(back)
296	case m.shown != nil:
297		m.back = nil
298		m.shown = nil
299		m.normalize()
300	}
301}
302
303// byName returns the configured workspace with that name, or nil.
304func (m *Monitor) byName(name string) *Workspace {
305	for w := range m.all() {
306		if w.Name == name {
307			return w
308		}
309	}
310	return nil
311}
312
313func indexOf(list []*Workspace, w *Workspace) int {
314	for i, v := range list {
315		if v == w {
316			return i
317		}
318	}
319	return -1
320}
321
322// has reports whether w still belongs to the monitor.
323func (m *Monitor) has(w *Workspace) bool {
324	return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0
325}
326
327// find returns the workspace holding the window; i is -1 for hidden ones.
328func (m *Monitor) find(id WindowID) (*Workspace, int) {
329	for i, w := range m.Workspaces {
330		if w.has(id) {
331			return w, i
332		}
333	}
334	for _, w := range m.hidden {
335		if w.has(id) {
336			return w, -1
337		}
338	}
339	return nil, -1
340}
341
342// AddWindow places a new window on the workspace on screen; with
343// stash.capture, in its stash when shown.
344func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, 0) }
345
346// addWindow is AddWindow with the width in cells of the new column (zero:
347// automatic).
348func (m *Monitor) addWindow(id WindowID, cells int) {
349	if w, _ := m.find(id); w != nil {
350		return
351	}
352	if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
353		w.addWindow(id, cells)
354	}
355	m.normalize()
356}
357
358// AddFloating adds a floating window to the active workspace.
359func (m *Monitor) AddFloating(id WindowID, width, height int) {
360	if w, _ := m.find(id); w != nil {
361		return
362	}
363	m.Current().AddFloating(id, width, height)
364	m.normalize()
365}
366
367// RemoveWindow drops the window wherever it is; focus stays on the active
368// workspace.
369func (m *Monitor) RemoveWindow(id WindowID) {
370	heir := m.sw.heir(id)
371	if w, _ := m.find(id); w != nil {
372		w.RemoveWindow(id)
373		m.normalize()
374	}
375	m.sw.remove(id, heir)
376}
377
378// SetFullscreen applies a client request, in place. A client request never
379// moves the user (ADR 011): the view and the focus stay where they are.
380func (m *Monitor) SetFullscreen(id WindowID, on bool) {
381	if w, _ := m.find(id); w != nil {
382		w.SetFullscreen(id, on)
383	}
384}
385
386// ToggleFullscreen is the bind: the focused window goes fullscreen in place,
387// or leaves it. Focus moves leave it too (Workspace.leaveCover).
388func (m *Monitor) ToggleFullscreen() { m.Current().ToggleFullscreen() }
389
390// MoveToWorkspace moves the focused column (or only the focused window) to
391// numbered workspace index i. A floating window always moves alone. Focus
392// stays here unless followMove is set (ADR 017).
393func (m *Monitor) MoveToWorkspace(i int, column bool) {
394	i = min(max(i, 0), len(m.Workspaces)-1)
395	cur, to := m.Current(), m.Workspaces[i]
396	id, ok := cur.Focused()
397	if !ok || to == cur {
398		return
399	}
400	if f := cur.floatIndex(id); f >= 0 {
401		fl := cur.Floats[f]
402		if fl.free && fl.back != nil {
403			// Its column place is on this workspace.
404			fl = fl.rehome()
405		}
406		cur.RemoveWindow(id)
407		to.AddFloating(id, fl.W, fl.H)
408		to.Floats[len(to.Floats)-1] = fl
409		// As receive: a fullscreen window would hide it.
410		to.Activate(id)
411	} else if f := cur.stashIndex(id); f >= 0 {
412		// It joins the end of the stash there, shown and selected.
413		fl := cur.Stash[f].rehome()
414		cur.RemoveWindow(id)
415		to.addStash(fl)
416		to.Activate(id)
417	} else {
418		col := Column{Windows: []WindowID{id}}
419		ok := true
420		if column {
421			col, ok = cur.takeColumn()
422		} else {
423			_, ok = cur.takeWindow()
424		}
425		if !ok {
426			return
427		}
428		to.receive(col, -1)
429	}
430	if m.followMove {
431		m.Active = i
432		m.shown = nil
433	}
434	m.normalize()
435}
436
437// receive adds a column moved in from another workspace and focuses it: at
438// index at, or where a new window goes when at < 0. A fullscreen window
439// there is left, so the moved window is seen.
440func (w *Workspace) receive(col Column, at int) {
441	id := col.Windows[col.Focus]
442	switch {
443	case at < 0:
444		w.addColumn(col)
445	default:
446		w.insertColumn(at, col)
447	}
448	w.Activate(id)
449}
450
451// Focused is the focused window of the workspace on screen; in the
452// overview, the selected stash card when it is one.
453func (m *Monitor) Focused() (WindowID, bool) {
454	if id := m.card(); id != 0 {
455		return id, true
456	}
457	return m.Current().Focused()
458}
459
460// Layout places every window: the workspace on screen laid out, others hidden.
461// The slice is fresh, the caller's to keep; the per-frame paths use layoutInto.
462func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
463
464// layoutInto is Layout built in dst[:0]: the result aliases dst's storage,
465// so a reused dst's previous result is overwritten and only a caller done
466// with it may pass it. It is never the monitor's own scratch.
467func (m *Monitor) layoutInto(dst []Placement) []Placement {
468	if m.switcherShown() {
469		return m.switcherLayout(dst[:0])
470	}
471	if m.ov.open {
472		return m.overviewLayout(dst[:0])
473	}
474	result := dst[:0]
475	cur := m.Current()
476	for w := range m.all() {
477		if w == cur {
478			result = w.appendLayout(result)
479			continue
480		}
481		result = w.appendHidden(result, true)
482	}
483	return m.slideLayout(result)
484}
485
486// Output is the whole logical monitor, whatever the workspaces' sizes.
487func (m *Monitor) Output() Rect { return m.template.Output }
488
489// Frame is where the workspace on screen draws and takes input, in monitor
490// coordinates: its viewport, or the whole monitor in a preview (the
491// overview or the column switcher). Slides
492// involving a sized workspace remain settled inside the current frame.
493func (m *Monitor) Frame() Rect {
494	if m.previewing() {
495		return m.Output()
496	}
497	return m.Current().Output
498}
499
500// viewport is where the workspace on screen is seen: Frame, and the
501// usable area its tiles live in.
502func (m *Monitor) viewport() viewport {
503	return viewport{frame: m.Frame(), usable: m.Current().Usable}
504}
505
506// frameHas reports whether the output-local point is inside the frame.
507func (m *Monitor) frameHas(x, y float64) bool {
508	f := m.Frame()
509	return x >= float64(f.X) && x < float64(f.X+f.W) && y >= float64(f.Y) && y < float64(f.Y+f.H)
510}
511
512func (m *Monitor) each(f func(*Workspace)) {
513	f(&m.template)
514	for _, w := range m.Workspaces {
515		f(w)
516	}
517	for _, w := range m.hidden {
518		f(w)
519	}
520}
521func (m *Monitor) SetOutput(width, height int) {
522	m.each(func(w *Workspace) { w.SetOutput(width, height) })
523}
524func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
525func (m *Monitor) SetGaps(g int)    { m.each(func(w *Workspace) { w.SetGaps(g) }) }
526func (m *Monitor) SetBorder(b int) {
527	m.each(func(w *Workspace) { w.border = max(b, 0); w.reconcileFloats() })
528}
529
530// SetStash sets the width of a stashed window (stash.width; 0 is the
531// default) and the space between it and its neighbors (stash.gap), in
532// percent of the usable width.
533func (m *Monitor) SetStash(width, gap int) {
534	if width != 0 {
535		width = min(max(width, 10), 90)
536	}
537	m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) })
538}
539
540// SetStashCapture makes new windows join the shown stash (stash.capture).
541func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
542
543// SetStashTerminal makes toggle-stash-visible on an empty stash ask for a
544// terminal in it (stash.spawn-terminal).
545func (m *Monitor) SetStashTerminal(on bool) { m.stashTerminal = on }
546
547// SetFollowMove makes moves to another workspace show it (focus.follow-move).
548func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
549
550// SetMaxColumns sets the default; named workspaces may override it.
551func (m *Monitor) SetMaxColumns(n int) {
552	m.template.MaxColumns = n
553	m.applyNamed()
554}
555
556// SetOverflow sets the default; named workspaces may override it.
557func (m *Monitor) SetOverflow(o Overflow) {
558	m.template.Overflow = o
559	m.applyNamed()
560}
561
562// applyNamed gives every workspace the defaults or its named overrides.
563func (m *Monitor) applyNamed() {
564	specs := map[string]NamedWorkspace{}
565	for _, s := range m.named {
566		specs[s.Name] = s
567	}
568	m.each(func(w *Workspace) {
569		s := specs[w.Name]
570		before, axis := w.Overflow, w.policy().content
571		w.Overflow = m.template.Overflow
572		if s.Overflow != "" {
573			w.Overflow = s.Overflow
574		}
575		if before != w.Overflow && w.policy().equalCells {
576			w.clearMaximized()
577		}
578		if before != w.Overflow {
579			// Cascade bands start over from the column order.
580			w.forgetBands()
581		}
582		// The view offset belongs to the old content axis: SetMaxColumns
583		// scrolls back to the focus along the new one.
584		if w.policy().content != axis {
585			w.view.stop()
586			w.View = 0
587		}
588		// One call with the effective value: a passing smaller one would
589		// split cascade bands for good.
590		n := m.template.MaxColumns
591		if s.MaxColumns > 0 {
592			n = s.MaxColumns
593		}
594		w.SetMaxColumns(n)
595		// Named workspaces take their configured size; any other keeps its own.
596		if w.Name != "" {
597			w.SetSize(s.Size[0], s.Size[1])
598		}
599	})
600}
601
602// SetNamed applies bind-only named workspaces from config. Removed named
603// workspaces hand their windows to the active numbered workspace.
604func (m *Monitor) SetNamed(specs []NamedWorkspace) {
605	cur := m.Current()
606	want := map[string]NamedWorkspace{}
607	for _, s := range specs {
608		want[s.Name] = s
609	}
610	byName := map[string]*Workspace{}
611	for _, w := range m.Workspaces {
612		if _, ok := want[w.Name]; w.Name != "" && !ok {
613			w.Name = ""
614			w.SetSize(0, 0)
615		}
616		if w.Name != "" {
617			byName[w.Name] = w
618		}
619	}
620	var hidden []*Workspace
621	for _, w := range m.hidden {
622		if _, ok := want[w.Name]; ok {
623			byName[w.Name] = w
624			hidden = append(hidden, w)
625			continue
626		}
627		if w == cur {
628			cur = nil
629		}
630		for _, c := range w.Columns {
631			for _, id := range c.Windows {
632				m.Workspaces[m.Active].AddWindow(id)
633			}
634		}
635		for _, f := range w.Floats {
636			if f.free && f.back != nil {
637				f = f.rehome()
638			}
639			m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
640			m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
641		}
642		m.Workspaces[m.Active].reconcileFloats()
643		for _, f := range w.Stash {
644			m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome())
645		}
646	}
647	m.hidden = hidden
648	m.named = append([]NamedWorkspace(nil), specs...)
649	var nextHidden []*Workspace
650	for _, s := range specs {
651		w := byName[s.Name]
652		if w == nil {
653			w = m.newWorkspace()
654			w.Name = s.Name
655		}
656		if i := indexOf(m.Workspaces, w); i >= 0 {
657			m.removeNumbered(i)
658		}
659		nextHidden = append(nextHidden, w)
660	}
661	m.hidden = nextHidden
662	if m.back != nil && !m.has(m.back) {
663		m.back = nil
664	}
665	m.shown = nil
666	switch {
667	case cur == nil:
668	case indexOf(m.hidden, cur) >= 0:
669		m.shown = cur
670	case indexOf(m.Workspaces, cur) >= 0:
671		m.Active = indexOf(m.Workspaces, cur)
672	}
673	m.applyNamed()
674	m.normalize()
675}
676
677// removeNumbered drops Workspaces[i] and keeps Active on the same workspace
678// when possible.
679func (m *Monitor) removeNumbered(i int) {
680	m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...)
681	if i < m.Active || m.Active >= len(m.Workspaces) {
682		m.Active = max(m.Active-1, 0)
683	}
684	if len(m.Workspaces) == 0 {
685		m.Workspaces = append(m.Workspaces, m.newWorkspace())
686	}
687}

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