transactional_resize.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5
6	vt "github.com/bnema/vev-vt"
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/domain/layout"
9	"github.com/bnema/vev/internal/ports"
10)
11
12type resizeOwnerPostEffect uint8
13
14const (
15	resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota
16	resizeOwnerPostRetrySchedule
17	resizeOwnerPostRenderInvalidation
18	resizeOwnerPostCommitPublication
19)
20
21// resizeMember is an unpublished layout decision. Prepare only reads guarded
22// state; apply performs the external PTY call; commit is the sole publisher.
23type resizeMember struct {
24	session    *session
25	tab        *tab
26	pane       *pane
27	owner      paneEffectLease
28	rect       domain.Rect
29	geometry   domain.Geometry
30	floating   floatingGeometry
31	isFloating bool
32	// floatingGeneration identifies the exact popup slot accepted by a failed
33	// resize. Floating panes are intentionally outside tb.panes, so retries
34	// must validate this generation and pointer rather than use tiled lookup.
35	floatingGeneration uint64
36	retry              bool
37	ok                 bool
38	err                error
39	screenResized      bool
40}
41
42func resizePTYGeometry(pty ports.PTY, geometry domain.Geometry) error {
43	return pty.Resize(geometry.NormalizePixels())
44}
45
46func setScreenGeometry(screen *vt.Screen, geometry domain.Geometry) {
47	if screen == nil {
48		return
49	}
50	screen.SetGeometry(vt.Geometry{
51		Cols: geometry.Cols, Rows: geometry.Rows,
52		PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
53	})
54}
55
56type preparedTabLayout struct {
57	tab          *tab
58	generation   uint64
59	size         domain.Size
60	previousSize domain.Size
61	members      []resizeMember
62}
63
64func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
65	return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
66}
67
68func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) preparedTabLayout {
69	plan := preparedTabLayout{tab: tb, generation: tb.layoutGeneration, size: size, previousSize: tb.size}
70	if tb.tree == nil || tb.tree.Root == nil || !size.Valid() {
71		return plan
72	}
73	area := domain.Rect{Width: size.Cols, Height: size.Rows}
74	placements, ok := layout.Solve(tb.tree.Root, area)
75	if !ok && tb.tree.Root.Kind == layout.Leaf {
76		placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}}
77		ok = true
78	}
79	if !ok {
80		return plan
81	}
82	for _, placement := range placements {
83		if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
84			continue
85		}
86		if p := tb.panes[placement.ID]; p != nil {
87			p.mu.Lock()
88			owner := p.effectLeaseLocked()
89			p.mu.Unlock()
90			paneSize := rectSize(placement.Content)
91			plan.members = append(plan.members, resizeMember{
92				session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content,
93				geometry: sess.geometry.paneGeometry(paneSize),
94			})
95		}
96	}
97	return plan
98}
99
100// applyPreparedTabMembers performs only the external PTY phase. A successful
101// resize leaves the parser gate closed until the complete plan is validated.
102func (g *sharedPTYGeometry) applyPreparedTabMembers(d *Daemon, plan *preparedTabLayout) {
103	for i := range plan.members {
104		member := &plan.members[i]
105		if !member.geometry.Valid() {
106			member.geometry = domain.Geometry{Size: rectSize(member.rect)}
107		}
108		p := member.pane
109		p.resizeMu.Lock()
110		p.mu.Lock()
111		if !resizeMemberOwnerCurrent(member) {
112			member.retry = p.owner.Load() != nil
113			p.resizeRetry = member.retry
114			p.mu.Unlock()
115			p.resizeMu.Unlock()
116			continue
117		}
118		old := p.rect
119		pty := p.pty
120		// A rejected earlier attempt deliberately leaves its parser gate open.
121		// Reapply even if the committed rectangle already matches: the PTY may
122		// still be at that rejected attempt's external size. An accepted failed
123		// attempt records resizeRetry for the same reason after its gate closes.
124		pixelGeometryChanged := (p.geometry.PixelsKnown() || member.geometry.PixelsKnown()) && p.geometry != member.geometry
125		needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height || pixelGeometryChanged
126		if needsApply && pty != nil {
127			p.resizeApplying = true
128		}
129		p.mu.Unlock()
130		if !needsApply || pty == nil {
131			member.ok = true
132		} else if err := resizePTYGeometry(pty, member.geometry); err != nil {
133			d.log.Warn("pty resize failed", "err", err)
134			p.mu.Lock()
135			current := resizeMemberOwnerCurrent(member)
136			p.resizeRetry = true
137			p.mu.Unlock()
138			d.replayResizePending(member.session, member.tab, p, false, member.rect)
139			member.retry = true
140			if current {
141				member.err = err
142			}
143		} else {
144			p.mu.Lock()
145			current := resizeMemberOwnerCurrent(member)
146			if !current && p.owner.Load() != nil {
147				p.resizeRetry = true
148				member.retry = true
149			}
150			p.mu.Unlock()
151			member.ok = current
152			member.screenResized = true // records that this member owns an open gate
153		}
154		p.resizeMu.Unlock()
155	}
156}
157
158func resizeMemberOwnerCurrent(member *resizeMember) bool {
159	if member == nil || member.pane == nil {
160		return false
161	}
162	if member.owner.owner == nil {
163		return true
164	}
165	owner := member.pane.owner.Load()
166	return owner == member.owner.owner && owner.session == member.session && owner.tab == member.tab
167}
168
169func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
170	if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize {
171		return false
172	}
173	for i := range plan.members {
174		member := &plan.members[i]
175		if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrent(member) {
176			return false
177		}
178	}
179	return true
180}
181
182func resizeMembersOwnerCurrent(members []resizeMember) bool {
183	for i := range members {
184		if !resizeMemberOwnerCurrent(&members[i]) {
185			return false
186		}
187	}
188	return true
189}
190
191func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
192	for i := range plan.members {
193		member := &plan.members[i]
194		member.pane.mu.Lock()
195		current := resizeMemberOwnerCurrent(member)
196		if !current {
197			member.pane.resizeRetry = member.pane.owner.Load() != nil
198			member.retry = member.pane.resizeRetry
199		}
200		member.pane.mu.Unlock()
201		if !current {
202			return false
203		}
204	}
205	for i := range plan.members {
206		member := &plan.members[i]
207		member.pane.mu.Lock()
208		member.pane.rect = member.rect
209		if member.ok {
210			member.pane.resizeRetry = false
211			member.pane.geometry = member.geometry
212			setScreenGeometry(member.pane.screen, member.geometry)
213		}
214		member.pane.mu.Unlock()
215	}
216	return true
217}
218
219// cancelStalePreparedGates releases gates for members which disappeared from
220// the next solved layout (for example a stack member collapsed by a focus
221// change). Members that remain are deliberately left gated for the retry.
222func (g *sharedPTYGeometry) cancelStalePreparedGates(d *Daemon, sess *session, tb *tab, plan *preparedTabLayout) {
223	tb.mu.Lock()
224	latest := prepareTabLayoutLocked(sess, tb)
225	tb.mu.Unlock()
226	current := make(map[*pane]struct{}, len(latest.members))
227	for i := range latest.members {
228		current[latest.members[i].pane] = struct{}{}
229	}
230	for i := range plan.members {
231		member := &plan.members[i]
232		if !member.screenResized {
233			continue
234		}
235		if _, ok := current[member.pane]; ok {
236			continue
237		}
238		member.pane.resizeMu.Lock()
239		d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
240		member.pane.resizeMu.Unlock()
241	}
242}
243
244func (g *sharedPTYGeometry) finishPreparedTabMembers(d *Daemon, plan *preparedTabLayout, accepted bool) {
245	if !accepted {
246		// cancelStalePreparedGates owns stale-plan cancellation. Keeping this
247		// path empty ensures a removed member's pending bytes are replayed once,
248		// after the fresh layout determines it cannot survive the retry.
249		return
250	}
251	for i := range plan.members {
252		member := &plan.members[i]
253		if !member.screenResized {
254			continue
255		}
256		member.pane.resizeMu.Lock()
257		d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
258		member.pane.resizeMu.Unlock()
259	}
260}
261
262// applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
263// one per-tab single-writer loop, but never holds tab or pane state locks while
264// invoking PTY.Resize.
265func (g *sharedPTYGeometry) applyTabLayoutTransaction(d *Daemon, sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) {
266	return g.applyTabLayoutTransactionWithNotice(d, sess, tb, true, current...)
267}
268
269func (g *sharedPTYGeometry) applyTabLayoutTransactionWithNotice(d *Daemon, sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) {
270	if tb == nil {
271		return nil, false
272	}
273	tb.layoutApplyMu.Lock()
274	defer tb.layoutApplyMu.Unlock()
275	for {
276		if tb.ctx != nil && tb.ctx.Err() != nil {
277			return nil, false
278		}
279		if len(current) != 0 && !current[0]() {
280			return nil, false
281		}
282		tb.mu.Lock()
283		plan := prepareTabLayoutLocked(sess, tb)
284		tb.mu.Unlock()
285		g.applyPreparedTabMembers(d, &plan)
286
287		tb.mu.Lock()
288		accepted := validatePreparedTabLayoutLocked(tb, &plan)
289		if len(current) != 0 && !current[0]() {
290			accepted = false
291		}
292		if accepted {
293			accepted = commitPreparedTabLayoutLocked(&plan)
294		}
295		tb.mu.Unlock()
296		g.finishPreparedTabMembers(d, &plan, accepted)
297		if !accepted {
298			g.cancelStalePreparedGates(d, sess, tb, &plan)
299			if len(current) != 0 && !current[0]() {
300				for i := range plan.members {
301					member := &plan.members[i]
302					if !member.screenResized {
303						continue
304					}
305					member.pane.resizeMu.Lock()
306					d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
307					member.pane.resizeMu.Unlock()
308				}
309				return nil, false
310			}
311			continue
312		}
313		failed := make([]resizeMember, 0)
314		for _, member := range plan.members {
315			if !member.ok {
316				failed = append(failed, member)
317			}
318		}
319		if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
320			d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
321				"pane resize failed; retrying in background", failed[len(failed)-1].err)
322		}
323		return failed, true
324	}
325}
326
327func (g *sharedPTYGeometry) applyTabLayout(d *Daemon, sess *session, tb *tab) bool {
328	failed, ok := g.applyTabLayoutTransaction(d, sess, tb)
329	if !ok {
330		return false
331	}
332	markSnapshotDirty(sess)
333	if len(failed) != 0 {
334		g.scheduleAcceptedTabLayoutRetry(d, sess, tb)
335	}
336	return true
337}
338
339const maxAcceptedTabLayoutRetries = 3
340
341// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
342// worker is deduplicated, derives cancellation from the tab lifecycle, and
343// suppresses repeat degradation notices after the accepted initial failure.
344func (g *sharedPTYGeometry) scheduleAcceptedTabLayoutRetry(d *Daemon, sess *session, tb *tab) {
345	if d.clock == nil || tb == nil || tb.ctx == nil {
346		return
347	}
348	tb.layoutRetryMu.Lock()
349	if tb.layoutRetryRunning {
350		tb.layoutRetryMu.Unlock()
351		return
352	}
353	ctx, cancel := context.WithCancel(tb.ctx)
354	tb.layoutRetryRunning = true
355	tb.layoutRetryCancel = cancel
356	tb.layoutRetryMu.Unlock()
357
358	go func() {
359		defer func() {
360			cancel()
361			tb.layoutRetryMu.Lock()
362			tb.layoutRetryRunning = false
363			tb.layoutRetryCancel = nil
364			tb.layoutRetryMu.Unlock()
365		}()
366		for range maxAcceptedTabLayoutRetries {
367			timer := d.clock.NewTimer(minOutputRenderDeadline)
368			if timer == nil {
369				return
370			}
371			timerC := timer.C()
372			if timerC == nil {
373				timer.Stop()
374				return
375			}
376			select {
377			case <-ctx.Done():
378				timer.Stop()
379				return
380			case <-timerC:
381			}
382			if ctx.Err() != nil {
383				return
384			}
385			failed, ok := g.applyTabLayoutTransactionWithNotice(d, sess, tb, false, func() bool { return ctx.Err() == nil })
386			if !ok || len(failed) == 0 {
387				return
388			}
389			markSnapshotDirty(sess)
390			for _, ac := range sess.snapshotAttachments() {
391				d.invalidateRender(sess, ac, true, "transactional_resize.go")
392			}
393		}
394	}()
395}
396
397// replayResizePending completes a pane's apply epoch. It keeps the gate set
398// while each buffered batch goes through the normal VT path, so reads arriving
399// during replay join the same ordered stream. Failure deliberately omits the
400// Screen.Resize, parsing every byte at the old dimensions.
401func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bool, size domain.Rect) {
402	first := true
403	for {
404		p.mu.Lock()
405		if first && resized {
406			p.screen.Resize(size.Width, size.Height)
407		}
408		first = false
409		data := append([]byte(nil), p.resizePending...)
410		p.resizePending = p.resizePending[:0]
411		if len(data) == 0 {
412			p.resizeApplying = false
413			p.mu.Unlock()
414			return
415		}
416		p.mu.Unlock()
417		if sess == nil {
418			p.mu.Lock()
419			writePaneScreenLocked(p, data)
420			p.refreshTerminalTitleLocked()
421			p.mu.Unlock()
422			continue
423		}
424		d.processPTYData(sess, tb, p, data, false)
425	}
426}
427
428// applyVisibleFloatingLayout retains floating panes' existing independent
429// lifecycle while routing their external resize through the same lock-free gate
430// used for retryable PTY work. Floating state is not part of a tiled layout
431// generation, so its slot generation and exact pane identity validate its
432// publication instead.
433func (g *sharedPTYGeometry) applyVisibleFloatingLayout(d *Daemon, sess *session, tb *tab, current func() bool) ([]resizeMember, bool) {
434	return g.applyVisibleFloatingLayoutForMember(d, sess, tb, current, nil)
435}
436
437// applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
438// exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
439// so accepting a replacement here would silently retry unrelated geometry.
440func (g *sharedPTYGeometry) applyVisibleFloatingLayoutForMember(d *Daemon, sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) {
441	if tb == nil {
442		return nil, true
443	}
444	tb.mu.Lock()
445	if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
446		(expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
447		tb.mu.Unlock()
448		return nil, true
449	}
450	p := tb.floating.pane
451	generation := tb.floating.generation
452	size := tb.size
453	geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
454	tb.mu.Unlock()
455	if !geometry.committable() {
456		return nil, true
457	}
458	if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
459		// A drawer can validly reserve only its separator row. Publish that
460		// presentation without inventing a physical PTY size; the last usable
461		// rectangle and screen remain committed until a later resize has content.
462		tb.mu.Lock()
463		currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
464			tb.floating.pane == p && tb.size == size
465		if current != nil && !current() {
466			currentSlot = false
467		}
468		if currentSlot {
469			p.mu.Lock()
470			p.popupGeometry = geometry
471			p.mu.Unlock()
472			tb.mu.Unlock()
473			return nil, true
474		}
475		tb.mu.Unlock()
476		if current != nil && !current() {
477			return nil, false
478		}
479		return nil, true
480	}
481
482	// This keeps successful PTY resizes gated until the
483	// floating slot and tab size are revalidated. A newer client resize may
484	// otherwise publish this obsolete popup geometry after its PTY call returns.
485	p.mu.Lock()
486	owner := p.effectLeaseLocked()
487	p.mu.Unlock()
488	ptyRect := geometry.ptyRect()
489	plan := preparedTabLayout{members: []resizeMember{{
490		session: sess, tab: tb, pane: p, owner: owner, rect: ptyRect,
491		geometry: sess.geometry.paneGeometry(rectSize(ptyRect)), floating: geometry,
492		isFloating: true, floatingGeneration: generation,
493	}}}
494	g.applyPreparedTabMembers(d, &plan)
495
496	// The PTY may have accepted an intermediate size, but a hidden, replaced,
497	// relaunched, or resized slot must never receive this attempt's geometry.
498	tb.mu.Lock()
499	currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && resizeMemberOwnerCurrent(&plan.members[0])
500	if current != nil && !current() {
501		currentSlot = false
502	}
503	if currentSlot && plan.members[0].ok {
504		p.mu.Lock()
505		currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
506		if currentSlot {
507			p.rect = plan.members[0].rect
508			p.geometry = plan.members[0].geometry
509			p.popupGeometry = geometry
510			p.resizeRetry = false
511			setScreenGeometry(p.screen, plan.members[0].geometry)
512		}
513		p.mu.Unlock()
514	}
515	tb.mu.Unlock()
516	if !currentSlot {
517		// This attempt has no tiled transaction loop to retain the gate for, so
518		// discard its buffered bytes at the old screen size before returning.
519		for i := range plan.members {
520			member := &plan.members[i]
521			if !member.screenResized {
522				continue
523			}
524			member.pane.resizeMu.Lock()
525			d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
526			member.pane.resizeMu.Unlock()
527		}
528		if current != nil && !current() {
529			return nil, false
530		}
531		return nil, true
532	}
533	if plan.members[0].ok {
534		g.finishPreparedTabMembers(d, &plan, true)
535		return nil, true
536	}
537	return plan.members, true
538}
539
540// releasePreparedSessionGates abandons a session attempt which cannot be
541// retried here (for example because a newer coordinator epoch won). Unlike a
542// stale per-tab retry, no succeeding attempt is owned by this call, so every
543// successful external resize must reopen its parser gate at the old screen
544// size.
545func (g *sharedPTYGeometry) releasePreparedSessionGates(d *Daemon, plans []*preparedTabLayout) {
546	for _, plan := range plans {
547		if plan == nil {
548			continue
549		}
550		for i := range plan.members {
551			member := &plan.members[i]
552			if !member.screenResized {
553				continue
554			}
555			member.pane.resizeMu.Lock()
556			d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
557			member.pane.resizeMu.Unlock()
558		}
559	}
560}
561
562// applySessionLayout keeps client resize publication as a two-phase session
563// transaction: all PTYs are applied and plans validated first, then the
564// coordinator admits the epoch before any tab size, rectangle, VT screen,
565// snapshot dirtiness, or resize telemetry becomes visible.
566func (g *sharedPTYGeometry) applySessionLayout(d *Daemon, sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
567	if sess == nil {
568		return nil, false
569	}
570	sess.layoutApplyMu.Lock()
571	defer sess.layoutApplyMu.Unlock()
572
573	target := contentSize(size)
574	for {
575		if sess.ctx != nil && sess.ctx.Err() != nil {
576			return nil, false
577		}
578		if current != nil && !current() {
579			return nil, false
580		}
581		sess.mu.Lock()
582		tabs := append([]*tab(nil), sess.tabs...)
583		sess.mu.Unlock()
584		plans := make([]*preparedTabLayout, 0, len(tabs))
585		for _, tb := range tabs {
586			tb.mu.Lock()
587			plan := prepareTabLayoutForSizeLocked(sess, tb, target)
588			tb.mu.Unlock()
589			plans = append(plans, &plan)
590		}
591		for _, plan := range plans {
592			g.applyPreparedTabMembers(d, plan)
593		}
594
595		// Hold every tab lock across final validation, epoch admission, and all
596		// publication. This makes the session commit indivisible to layout
597		// mutators while still keeping PTY.Resize outside every state lock.
598		for _, tb := range tabs {
599			tb.mu.Lock()
600		}
601		valid := true
602		for _, plan := range plans {
603			if !validatePreparedTabLayoutLocked(plan.tab, plan) {
604				valid = false
605				break
606			}
607		}
608		// This is the final external-apply validation boundary. Tests use the
609		// seam to install a newer epoch here; that epoch must reject admission
610		// before this attempt publishes any session geometry.
611		if valid && d.beforeSessionResizePublication != nil {
612			d.beforeSessionResizePublication()
613		}
614		// Claim publication and shared geometry commit use one fence. The hook
615		// above remains outside the fence so tests and lifecycle callbacks can
616		// publish a newer claim; the final validation then rejects this stale plan.
617		g.mu.Lock()
618		if valid && sess.ctx != nil && sess.ctx.Err() != nil {
619			valid = false
620		}
621		if valid && current != nil && !current() {
622			valid = false
623		}
624		if valid && admit != nil && !admit() {
625			valid = false
626		}
627		if valid {
628			for _, plan := range plans {
629				if !commitPreparedTabLayoutLocked(plan) {
630					valid = false
631					break
632				}
633				plan.tab.size = plan.size
634			}
635		}
636		g.mu.Unlock()
637		for i := len(tabs) - 1; i >= 0; i-- {
638			tabs[i].mu.Unlock()
639		}
640		if !valid {
641			g.releasePreparedSessionGates(d, plans)
642			// Headless requests have no attachment epoch. A live tab mutation
643			// still invalidates their plan, but must trigger a fresh prepare rather
644			// than being mistaken for a canceled request.
645			if current != nil && !current() {
646				return nil, false
647			}
648			// A layout mutation invalidated the plans while this epoch remains
649			// current. Reapply the fresh session geometry before admitting it.
650			continue
651		}
652
653		failed := make([]resizeMember, 0)
654		for _, plan := range plans {
655			g.finishPreparedTabMembers(d, plan, true)
656			for _, member := range plan.members {
657				if !member.ok {
658					failed = append(failed, member)
659				}
660			}
661		}
662		for _, tb := range tabs {
663			floatingFailed, ok := g.applyVisibleFloatingLayout(d, sess, tb, current)
664			if !ok {
665				return nil, false
666			}
667			failed = append(failed, floatingFailed...)
668		}
669		failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
670		if current != nil && !current() {
671			return nil, false
672		}
673		if len(failed) != 0 && failedCurrent {
674			d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
675				"pane resize failed; retrying in background", failed[len(failed)-1].err)
676		}
677		if len(failed) != 0 && !failedCurrent {
678			return nil, false
679		}
680		if current != nil {
681			d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty)
682			if !current() {
683				return nil, false
684			}
685		}
686		markSnapshotDirty(sess)
687		d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
688		return failed, true
689	}
690}

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