v0.2.0 80c61e3

attachment_registry.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"cmp"
6	"slices"
7
8	vt "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10)
11
12// attachmentView is the mutable viewport owned by one attachment. Tab and
13// pane identity is stable across tab insertion, deletion, and reordering;
14// neither field is a session-local index or layout PaneID.
15type attachmentView struct {
16	tabID      domain.TabStableID
17	paneID     domain.PaneStableID
18	windowTop  int
19	windowRows int
20	windowSet  bool
21	bookmark   vt.RowID
22	liveBottom bool
23	revision   uint64
24}
25
26func (ac *attachedClient) viewSnapshot() attachmentView {
27	if ac == nil {
28		return attachmentView{}
29	}
30	ac.viewMu.Lock()
31	defer ac.viewMu.Unlock()
32	return ac.view
33}
34
35func (ac *attachedClient) publishViewLocked(view attachmentView) {
36	// Rebase while viewMu is held, before the new revision becomes visible.
37	// Output fences take the same lock, so no side effect can pair the new
38	// ViewRevision with the previous output epoch. Test/headless fixtures may
39	// leave output.attachment unset; use the stream-local lock for those.
40	if ac.output != nil && ac.view.revision != view.revision {
41		if ac.output.attachment == ac {
42			ac.output.rebaseLocked()
43		} else {
44			ac.output.stateMu.Lock()
45			ac.output.rebaseLocked()
46			ac.output.stateMu.Unlock()
47		}
48	}
49	ac.view = view
50}
51
52func (ac *attachedClient) publishView(view attachmentView) {
53	if ac == nil {
54		return
55	}
56	ac.viewMu.Lock()
57	defer ac.viewMu.Unlock()
58	ac.publishViewLocked(view)
59}
60
61func (ac *attachedClient) publishViewIfCurrent(before, view attachmentView) bool {
62	if ac == nil {
63		return false
64	}
65	ac.viewMu.Lock()
66	defer ac.viewMu.Unlock()
67	if ac.view != before {
68		return false
69	}
70	ac.publishViewLocked(view)
71	return true
72}
73
74// registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must
75// have acquired dispatchMu first when the operation came from a client.
76func (s *session) registerAttachmentLocked(ac *attachedClient) bool {
77	if s == nil || !s.sessionCore.registerAttachmentLocked(ac) {
78		return false
79	}
80	view := ac.viewSnapshot()
81	if view.tabID == "" || view.paneID == "" {
82		view = s.repairAttachmentViewLocked(ac, view)
83		ac.publishView(view)
84	}
85	return true
86}
87
88func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
89	if c == nil || ac == nil {
90		return false
91	}
92	if c.attachments == nil {
93		c.attachments = make(map[*attachedClient]struct{})
94	}
95	if _, exists := c.attachments[ac]; exists {
96		return false
97	}
98	if c.attachmentOrder == nil {
99		c.attachmentOrder = make(map[*attachedClient]uint64)
100	}
101	c.nextAttachmentID++
102	c.attachments[ac] = struct{}{}
103	c.attachmentOrder[ac] = c.nextAttachmentID
104	if c.claimGeometryOwnerLocked(ac, ac.sizeSnapshot()) == 0 {
105		c.refreshGeometryOwnerLocked()
106	}
107	return true
108}
109
110// latestValidGeometryOwnerLocked selects the most recent valid attachment,
111// excluding one claimant when a rejected request is being released. Caller
112// holds the owning session's mu.
113func (c *sessionCore) latestValidGeometryOwnerLocked(exclude *attachedClient) *attachedClient {
114	var owner *attachedClient
115	var ownerClaim, ownerOrder uint64
116	for candidate := range c.attachments {
117		if candidate == nil || candidate == exclude || !candidate.sizeSnapshot().Valid() || !candidate.geometryClaimSizeSnapshot().Valid() {
118			continue
119		}
120		claim := candidate.geometryClaim.Load()
121		order := c.attachmentOrder[candidate]
122		if owner == nil || claim > ownerClaim || (claim == ownerClaim && order > ownerOrder) {
123			owner = candidate
124			ownerClaim = claim
125			ownerOrder = order
126		}
127	}
128	return owner
129}
130
131// refreshGeometryOwnerLocked repairs a removed or invalid owner without
132// inventing a newer claim. Caller holds the owning session's mu.
133func (c *sessionCore) refreshGeometryOwnerLocked() {
134	if c == nil {
135		return
136	}
137	owner := c.geometryOwner.Load()
138	if owner != nil {
139		if _, ok := c.attachments[owner]; ok && owner.sizeSnapshot().Valid() && owner.geometryClaimSizeSnapshot().Valid() {
140			return
141		}
142	}
143	c.geometryMu.Lock()
144	c.geometryOwner.Store(c.latestValidGeometryOwnerLocked(nil))
145	c.geometryMu.Unlock()
146}
147
148// claimGeometryOwnerLocked records the latest attachment claim. Caller holds
149// the owning session's mu; the atomic publication lets resize transactions
150// validate the claim while tab locks are held.
151func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, size domain.Size) uint64 {
152	if c == nil || ac == nil || !size.Valid() {
153		return 0
154	}
155	c.geometryMu.Lock()
156	defer c.geometryMu.Unlock()
157	c.geometryClaimSeq++
158	claim := c.geometryClaimSeq
159	ac.publishGeometryClaim(size, claim)
160	c.geometryOwner.Store(ac)
161	return claim
162}
163
164func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
165	if entry == nil || entry.core() == nil {
166		return false
167	}
168	// Transition publication may already hold tab locks (move commit). Only
169	// publish collection membership here; target repair runs at the next view
170	// snapshot outside that critical section.
171	return entry.core().registerAttachmentLocked(ac)
172}
173
174func (s *session) registerAttachment(ac *attachedClient) bool {
175	if s == nil || ac == nil {
176		return false
177	}
178	s.dispatchMu.Lock()
179	defer s.dispatchMu.Unlock()
180	s.mu.Lock()
181	defer s.mu.Unlock()
182	return s.registerAttachmentLocked(ac)
183}
184
185// claimGeometryOwner records an explicit resize claim after the attachment
186// has already published its new local size. It is also used by callers that
187// bypass the wire resize path.
188func (s *session) claimGeometryOwner(ac *attachedClient) (uint64, bool) {
189	if ac == nil {
190		return 0, false
191	}
192	return s.claimGeometryOwnerForSize(ac, ac.sizeSnapshot())
193}
194
195// claimGeometryOwnerForSize records a requested shared geometry independently
196// of the attachment-local publication. Transactional resize commits use this
197// before the PTY phase so a stale request cannot publish after a newer claim.
198func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) {
199	if s == nil || ac == nil || !size.Valid() {
200		return 0, false
201	}
202	s.mu.Lock()
203	defer s.mu.Unlock()
204	if _, ok := s.attachments[ac]; !ok {
205		return 0, false
206	}
207	return s.claimGeometryOwnerLocked(ac, size), true
208}
209
210// releaseGeometryClaim drops a claim that never reached the coordinator's
211// resize request. It never creates a new claim; an older valid peer may become
212// the source again, or the session retains its headless geometry.
213func (s *session) releaseGeometryClaim(ac *attachedClient, claim uint64) {
214	if s == nil || ac == nil || claim == 0 {
215		return
216	}
217	s.mu.Lock()
218	defer s.mu.Unlock()
219	if s.geometryOwner.Load() != ac || ac.geometryClaim.Load() != claim {
220		return
221	}
222	ac.clearGeometryClaim()
223	s.geometryMu.Lock()
224	s.geometryOwner.Store(s.latestValidGeometryOwnerLocked(ac))
225	s.geometryMu.Unlock()
226}
227
228// unregisterAttachmentLocked removes only ac and leaves every other
229// attachment's view intact. Caller holds s.mu.
230func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {
231	if s == nil {
232		return false
233	}
234	return s.sessionCore.unregisterAttachmentLocked(ac)
235}
236
237func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
238	if c == nil || ac == nil || c.attachments == nil {
239		return false
240	}
241	if _, exists := c.attachments[ac]; !exists {
242		return false
243	}
244	delete(c.attachments, ac)
245	delete(c.attachmentOrder, ac)
246	c.refreshGeometryOwnerLocked()
247	return true
248}
249
250func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
251	if entry == nil || entry.core() == nil {
252		return false
253	}
254	return entry.core().unregisterAttachmentLocked(ac)
255}
256
257func (s *session) unregisterAttachment(ac *attachedClient) bool {
258	if s == nil || ac == nil {
259		return false
260	}
261	s.dispatchMu.Lock()
262	defer s.dispatchMu.Unlock()
263	s.mu.Lock()
264	defer s.mu.Unlock()
265	return s.unregisterAttachmentLocked(ac)
266}
267
268func (s *session) snapshotAttachmentsLocked() []*attachedClient {
269	if s == nil || len(s.attachments) == 0 {
270		return nil
271	}
272	out := make([]*attachedClient, 0, len(s.attachments))
273	for ac := range s.attachments {
274		out = append(out, ac)
275	}
276	slices.SortStableFunc(out, func(a, b *attachedClient) int {
277		// ClientID is the stable wire identity, so concurrent registration order
278		// cannot affect snapshots or invalidation order.
279		if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
280			return id
281		}
282		return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
283	})
284	return out
285}
286
287func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool {
288	if entry == nil || ac == nil || entry.core() == nil {
289		return false
290	}
291	_, ok := entry.core().attachments[ac]
292	return ok
293}
294
295func attachmentRegistered(entry *session, ac *attachedClient) bool {
296	if entry == nil || ac == nil || entry.core() == nil {
297		return false
298	}
299	core := entry.core()
300	core.mu.Lock()
301	defer core.mu.Unlock()
302	return attachmentRegisteredLocked(entry, ac)
303}
304
305func (s *session) attachmentRegistered(ac *attachedClient) bool {
306	return attachmentRegistered(s, ac)
307}
308
309func (s *session) attachmentRegisteredLocked(ac *attachedClient) bool {
310	return attachmentRegisteredLocked(s, ac)
311}
312
313func (s *session) attachmentViewsTabLocked(tb *tab) bool {
314	if s == nil || tb == nil {
315		return false
316	}
317	for ac := range s.attachments {
318		if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
319			return true
320		}
321	}
322	return false
323}
324
325func (s *session) snapshotAttachments() []*attachedClient {
326	if s == nil {
327		return nil
328	}
329	s.mu.Lock()
330	defer s.mu.Unlock()
331	return s.snapshotAttachmentsLocked()
332}
333
334// repairAttachmentViewLocked validates stable targets and chooses the nearest
335// surviving target when a tab or pane was removed. Caller holds s.mu.
336func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
337	_ = ac
338	if s == nil {
339		return attachmentView{}
340	}
341	var targetTab *tab
342	for _, tb := range s.tabs {
343		if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
344			targetTab = tb
345			break
346		}
347	}
348	tabRepaired := targetTab == nil
349	if targetTab == nil && len(s.tabs) != 0 {
350		targetTab = s.tabs[0]
351	}
352	if targetTab == nil {
353		view.tabID = ""
354		view.paneID = ""
355		view.windowTop = 0
356		view.windowRows = 0
357		view.windowSet = false
358		view.bookmark = 0
359		view.liveBottom = true
360		return view
361	}
362	view.tabID = domain.TabStableID(targetTab.stableID)
363
364	targetPane := (*pane)(nil)
365	targetTab.mu.Lock()
366	for _, candidate := range targetTab.panes {
367		if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
368			targetPane = candidate
369			break
370		}
371	}
372	paneRepaired := targetPane == nil
373	if targetPane == nil {
374		targetPane = targetTab.focusedPane()
375	}
376	if targetPane != nil {
377		view.paneID = domain.PaneStableID(targetPane.stableID)
378	} else {
379		view.paneID = ""
380	}
381	targetTab.mu.Unlock()
382	if view.windowRows <= 0 {
383		targetTab.mu.Lock()
384		view.windowRows = targetTab.size.Rows
385		targetTab.mu.Unlock()
386	}
387	if view.windowTop < 0 {
388		view.windowTop = 0
389	}
390	if tabRepaired || paneRepaired || targetPane == nil {
391		view.bookmark = 0
392		view.liveBottom = true
393	}
394	return view
395}
396
397// repairAttachmentView repairs ac and increments its revision only when the
398// stable target changed. It is deliberately separate from registration so
399// layout mutation code can validate every surviving attachment in one ordered
400// pass.
401func (s *session) repairAttachmentView(ac *attachedClient) bool {
402	if s == nil || ac == nil {
403		return false
404	}
405	before := ac.viewSnapshot()
406	s.mu.Lock()
407	if _, ok := s.attachments[ac]; !ok {
408		s.mu.Unlock()
409		return false
410	}
411	after := s.repairAttachmentViewLocked(ac, before)
412	if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
413		s.mu.Unlock()
414		return false
415	}
416	after.revision++
417	s.mu.Unlock()
418	return ac.publishViewIfCurrent(before, after)
419}
420
421// prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
422// for attachments that were viewing removed. Caller holds s.mu before tabs is
423// mutated; the normal invalidation pass publishes the revised views.
424func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index int) {
425	if s == nil || removed == nil || len(s.tabs) <= 1 || index < 0 || index >= len(s.tabs) {
426		return
427	}
428	replacement := index + 1
429	if replacement >= len(s.tabs) {
430		replacement = index - 1
431	}
432	if replacement < 0 || replacement >= len(s.tabs) || s.tabs[replacement] == nil {
433		return
434	}
435	tabID := domain.TabStableID(s.tabs[replacement].stableID)
436	removedID := domain.TabStableID(removed.stableID)
437	for ac := range s.attachments {
438		view := ac.viewSnapshot()
439		if view.tabID == removedID {
440			view.tabID = tabID
441			view.paneID = ""
442			view.bookmark = 0
443			view.liveBottom = true
444			view.revision++
445			ac.publishView(view)
446		}
447	}
448}
449
450// invalidateViewsLocked orders all per-attachment invalidations behind the
451// shared session mutation. Caller holds s.mu and dispatchMu.
452func (s *session) invalidateViewsLocked() {
453	for _, ac := range s.snapshotAttachmentsLocked() {
454		view := ac.viewSnapshot()
455		repaired := s.repairAttachmentViewLocked(ac, view)
456		repaired.revision++
457		ac.publishView(repaired)
458	}
459}
460
461// runMutation is the one admission boundary for shared session mutations.
462// Callers perform their state mutation and view invalidation inside fn; the
463// dispatch lock is acquired before any session, tab, or daemon lock.
464func (s *session) runMutation(fn func() error) error {
465	if s == nil || fn == nil {
466		return nil
467	}
468	s.dispatchMu.Lock()
469	defer s.dispatchMu.Unlock()
470	return fn()
471}
472
473// tabForAttachment resolves the attachment's stable target after validating
474// it against current membership. It never changes another attachment's view.
475func (s *session) tabForAttachment(ac *attachedClient) *tab {
476	if s == nil || ac == nil {
477		return nil
478	}
479	for {
480		view := ac.viewSnapshot()
481		s.mu.Lock()
482		if _, ok := s.attachments[ac]; !ok {
483			s.mu.Unlock()
484			return nil
485		}
486		repaired := s.repairAttachmentViewLocked(ac, view)
487		var target *tab
488		for _, tb := range s.tabs {
489			if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
490				target = tb
491				break
492			}
493		}
494		s.mu.Unlock()
495
496		if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
497			repaired.revision++
498		}
499		if !ac.publishViewIfCurrent(view, repaired) {
500			continue
501		}
502		return target
503	}
504}
505
506// paneForAttachment resolves both stable IDs and repairs a removed pane before
507// returning it. The tab remains shared; only the attachment's view moves.
508func (s *session) tabIndexForAttachment(ac *attachedClient) (int, int) {
509	if s == nil {
510		return -1, 0
511	}
512	s.mu.Lock()
513	tabs := append([]*tab(nil), s.tabs...)
514	s.mu.Unlock()
515	if len(tabs) == 0 {
516		return -1, 0
517	}
518	if ac == nil {
519		return 0, len(tabs)
520	}
521	view := ac.viewSnapshot()
522	for i, tb := range tabs {
523		if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
524			return i, len(tabs)
525		}
526	}
527	return 0, len(tabs)
528}
529
530// firstTab returns the first tab in the session's ordered tab collection.
531// It is a deterministic session-level default for headless work, never an
532// interactive attachment selection.
533func (s *session) firstTab() *tab {
534	if s == nil {
535		return nil
536	}
537	s.mu.Lock()
538	defer s.mu.Unlock()
539	if len(s.tabs) == 0 {
540		return nil
541	}
542	return s.tabs[0]
543}
544
545func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
546	tb := s.tabForAttachment(ac)
547	if tb == nil {
548		return nil, nil
549	}
550	view := ac.viewSnapshot()
551	tb.mu.Lock()
552	defer tb.mu.Unlock()
553	for _, p := range tb.panes {
554		if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
555			return tb, p
556		}
557	}
558	return tb, tb.focusedPane()
559}
560
561// setAttachmentPaneLocked publishes the initiating attachment's pane target
562// after a shared focus mutation. Caller holds s.mu; the structural tree focus
563// is not used to resolve any other attachment's target.
564func (s *session) setAttachmentPaneLocked(ac *attachedClient, tb *tab, p *pane) bool {
565	if s == nil || ac == nil || tb == nil || p == nil || !attachmentRegisteredLocked(s, ac) {
566		return false
567	}
568	view := ac.viewSnapshot()
569	before := view
570	view.tabID = domain.TabStableID(tb.stableID)
571	view.paneID = domain.PaneStableID(p.stableID)
572	view = s.repairAttachmentViewLocked(ac, view)
573	if view.tabID == before.tabID && view.paneID == before.paneID {
574		return false
575	}
576	view.revision++
577	ac.publishView(view)
578	return true
579}
580
581// updateAttachmentView applies one attachment-local view mutation at the
582// shared mutation boundary. The revision increments exactly once and target
583// IDs are repaired before publication.
584func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachmentView)) bool {
585	if s == nil || ac == nil || update == nil {
586		return false
587	}
588	changed := false
589	_ = s.runMutation(func() error {
590		s.mu.Lock()
591		defer s.mu.Unlock()
592		if _, ok := s.attachments[ac]; !ok {
593			return nil
594		}
595		before := ac.viewSnapshot()
596		next := before
597		update(&next)
598		next = s.repairAttachmentViewLocked(ac, next)
599		next.revision++
600		ac.publishView(next)
601		changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.windowSet != before.windowSet || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom
602		return nil
603	})
604	return changed
605}
606
607// selectAttachmentTab changes only one attachment's target. Shared tab order
608// and PTY ownership are untouched.
609func (s *session) activateAttachmentViewLocked(ac *attachedClient, idx int) bool {
610	if s == nil || ac == nil || idx < 0 || idx >= len(s.tabs) || !attachmentRegisteredLocked(s, ac) {
611		return false
612	}
613	view := ac.viewSnapshot()
614	view.tabID = domain.TabStableID(s.tabs[idx].stableID)
615	view.paneID = ""
616	view = s.repairAttachmentViewLocked(ac, view)
617	view.revision++
618	ac.publishView(view)
619	return true
620}
621
622func (s *session) selectAttachmentTabLocked(ac *attachedClient, tabID domain.TabStableID) bool {
623	if s == nil || ac == nil || !attachmentRegisteredLocked(s, ac) {
624		return false
625	}
626	view := ac.viewSnapshot()
627	view.tabID = tabID
628	view.paneID = ""
629	view = s.repairAttachmentViewLocked(ac, view)
630	view.revision++
631	ac.publishView(view)
632	s.invalidateViewsLocked()
633	return view.tabID == tabID
634}
635
636func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool {
637	if s == nil || ac == nil {
638		return false
639	}
640	var changed bool
641	_ = s.runMutation(func() error {
642		s.mu.Lock()
643		defer s.mu.Unlock()
644		changed = s.selectAttachmentTabLocked(ac, tabID)
645		return nil
646	})
647	return changed
648}
649
650func (s *session) switchAttachmentTabForDispatch(ac *attachedClient, idx int) bool {
651	if s == nil || ac == nil {
652		return false
653	}
654	s.mu.Lock()
655	defer s.mu.Unlock()
656	if idx < 0 || idx >= len(s.tabs) {
657		return false
658	}
659	return s.selectAttachmentTabLocked(ac, domain.TabStableID(s.tabs[idx].stableID))
660}
661
662func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
663	if s == nil || ac == nil {
664		return false
665	}
666	s.mu.Lock()
667	registered := attachmentRegisteredLocked(s, ac)
668	if registered && idx >= 0 && idx < len(s.tabs) {
669		tabID := domain.TabStableID(s.tabs[idx].stableID)
670		s.mu.Unlock()
671		return s.selectAttachmentTab(ac, tabID)
672	}
673	s.mu.Unlock()
674	return false
675}
676
677func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta int) bool {
678	position, count := s.tabIndexForAttachment(ac)
679	if count < 2 {
680		return false
681	}
682	return s.switchAttachmentTabForDispatch(ac, (position+delta+count)%count)
683}
684
685// repairAttachmentViews repairs all live attachment targets after a shared
686// topology mutation.
687func (s *session) repairAttachmentViews() {
688	if s == nil {
689		return
690	}
691	_ = s.runMutation(func() error {
692		s.mu.Lock()
693		s.invalidateViewsLocked()
694		s.mu.Unlock()
695		return nil
696	})
697}
698
699// repairAttachmentViewsLocked repairs all live attachment targets after a tab
700// or pane mutation. It is intended to run at the same mutation linearization
701// point as the shared membership change.
702func (s *session) repairAttachmentViewsLocked() {
703	if s == nil {
704		return
705	}
706	s.invalidateViewsLocked()
707}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close