pane_actions.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"fmt"
6	"slices"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/domain/layout"
10)
11
12type paneFocusChange struct {
13	tab           *tab
14	focused       *pane
15	span          domain.Rect
16	layoutChanged bool
17}
18
19func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
20	target := resolveDaemonActionTargetForAttachment(sess, ac)
21	change, err := d.splitPaneAt(sess, target.tab, target.pane, dir)
22	if err == nil && ac != nil {
23		publishPaneFocusForAttachment(sess, ac, change)
24		d.invalidateRender(sess, ac, true, "pane_actions.go")
25	}
26	return err
27}
28
29func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) {
30	after := dir == layout.Right || dir == layout.Down
31	return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
32		return tree.Split(oldFocus, dir, after, newID, area)
33	})
34}
35
36func (d *Daemon) spawnPaneOpAt(
37	sess *session,
38	tb *tab,
39	target *pane,
40	mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,
41) (paneFocusChange, error) {
42	if d.ptys == nil {
43		return paneFocusChange{}, nil
44	}
45	if tb == nil || target == nil {
46		return paneFocusChange{}, layout.ErrNotFound
47	}
48
49	sess.mu.Lock()
50	name, cwd := sess.name, sess.cwd
51	env := copyEnvironment(sess.env)
52	sess.mu.Unlock()
53
54	tb.mu.Lock()
55	if tb.tree == nil || tb.tree.Root == nil {
56		tb.mu.Unlock()
57		return paneFocusChange{}, layout.ErrNotFound
58	}
59	oldFocus := target.id
60	if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
61		tb.mu.Unlock()
62		return paneFocusChange{}, layout.ErrNotFound
63	}
64	newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
65	area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
66	candidate := tb.tree.Clone()
67	if err := mutate(candidate, oldFocus, newID, area); err != nil {
68		tb.mu.Unlock()
69		if errors.Is(err, layout.ErrTooSmall) {
70			return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err)
71		}
72		return paneFocusChange{}, err
73	}
74	tb.nextPaneID++
75	placements, ok := layout.Solve(candidate.Root, area)
76	if !ok {
77		tb.mu.Unlock()
78		return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall)
79	}
80	newRect := placementContent(placements, newID)
81	tabStableID := tb.stableID
82	generation := tb.layoutGeneration
83	tb.mu.Unlock()
84	initialGeometry := sess.geometry.paneGeometry(rectSize(newRect))
85
86	paneStableID, err := newStableID("p")
87	if err != nil {
88		return paneFocusChange{}, fmt.Errorf("daemon: generating pane identity: %w", err)
89	}
90	launch := d.shellLaunch(env)
91	lifetime := d.newPaneProcessLifetime(tb.ctx)
92	pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, initialGeometry)
93	if err != nil {
94		lifetime.abort()
95		if pty != nil {
96			_ = pty.Close()
97		}
98		d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
99		return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
100	}
101	p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title, d.currentHistoryConfig())
102	p.geometry = initialGeometry
103	setScreenGeometry(p.screen, initialGeometry)
104	p.rect = newRect
105
106	tb.mu.Lock()
107	if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) || tb.ctx != nil && tb.ctx.Err() != nil {
108		tb.mu.Unlock()
109		lifetime.abort()
110		_ = pty.Close()
111		return paneFocusChange{}, layout.ErrNotFound
112	}
113	if !lifetime.publish(p) {
114		tb.mu.Unlock()
115		_ = pty.Close()
116		return paneFocusChange{}, layout.ErrNotFound
117	}
118	// The tab lock excludes membership observers while pane.mu publishes the
119	// initial owner generation.
120	publishPaneOwner(p, sess, tb, 0)
121	tb.tree = candidate
122	tb.panes[newID] = p
123	tb.bumpLayoutGenerationLocked()
124	tb.mu.Unlock()
125
126	sess.geometry.applyTabLayout(d, sess, tb)
127	d.startPaneGoroutines(sess, tb, p)
128	return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil
129}
130
131func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect {
132	for _, pl := range placements {
133		if pl.ID == id {
134			return pl.Content
135		}
136	}
137	return domain.Rect{}
138}
139
140func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
141
142func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
143	target := resolveDaemonActionTargetForAttachment(sess, ac)
144	change, err := d.stackPaneAt(sess, target.tab, target.pane)
145	if err == nil && ac != nil {
146		publishPaneFocusForAttachment(sess, ac, change)
147		d.invalidateRender(sess, ac, true, "pane_actions.go")
148	}
149	return err
150}
151
152func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) (paneFocusChange, error) {
153	return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
154		return tree.StackNew(oldFocus, newID, area)
155	})
156}
157
158func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
159	target := resolveDaemonActionTargetForAttachment(sess, ac)
160	err := d.toggleStackAt(sess, target.tab, target.pane)
161	if err == nil && ac != nil {
162		d.invalidateRender(sess, ac, true, "pane_actions.go")
163	}
164	return err
165}
166
167func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
168	if d.ptys == nil {
169		return nil
170	}
171	if tb == nil || target == nil {
172		return layout.ErrNotFound
173	}
174	tb.mu.Lock()
175	if tb.tree == nil {
176		tb.mu.Unlock()
177		return layout.ErrNotFound
178	}
179	if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
180		tb.mu.Unlock()
181		return layout.ErrNotFound
182	}
183	candidate := tb.tree.Clone()
184	err := candidate.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
185	if err == nil {
186		tb.tree = candidate
187		tb.bumpLayoutGenerationLocked()
188	}
189	tb.mu.Unlock()
190	if err == nil {
191		sess.geometry.applyTabLayout(d, sess, tb)
192	}
193	return err
194}
195
196func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
197	if d.ptys == nil {
198		if ac != nil {
199			d.invalidateRender(sess, ac, true, "pane_actions.go")
200		}
201		return nil
202	}
203	target := resolveDaemonActionTargetForAttachment(sess, ac)
204	tb := target.tab
205	if tb == nil {
206		return layout.ErrNotFound
207	}
208	if target.pane == nil {
209		return layout.ErrNotFound
210	}
211	return d.closePane(sess, tb, target.pane.id, ac, true)
212}
213
214// reapPane retains the explicit-owner entry point for focused close tests. PTY
215// readers use reapPaneOwner so an owner publication cannot redirect exit to a
216// replacement that reused the same tab-local pane ID.
217func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {
218	if p == nil {
219		return
220	}
221	lease := p.effectLease()
222	if lease.owner == nil {
223		_ = d.closePane(sess, tb, p.id, nil, true)
224		return
225	}
226	if lease.owner.session != sess || lease.owner.tab != tb {
227		return
228	}
229	d.reapPaneOwner(p)
230}
231
232// reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries
233// the immutable owner pointer. Non-final removal revokes it with membership;
234// final-pane removal retains it until tab/session teardown commits, so stale
235// attachment snapshots cannot abandon the EOF before closing its resources.
236func (d *Daemon) reapPaneOwner(p *pane) {
237	for p != nil {
238		lease := p.effectLease()
239		if lease.owner == nil {
240			return
241		}
242		if d.reapTiledPaneLease(lease) {
243			return
244		}
245	}
246}
247
248func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
249	p, owner := lease.pane, lease.owner
250	if p == nil || owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration != 0 {
251		return true
252	}
253	sess, tb := owner.session, owner.tab
254	// EOF teardown is a shared mutation just like an explicit close. Hold the
255	// dispatch boundary before any session/tab lock, then use the locked close
256	// helpers below without re-entering it.
257	sess.dispatchMu.Lock()
258	defer sess.dispatchMu.Unlock()
259	sess.mu.Lock()
260	ownedTab := slices.Contains(sess.tabs, tb)
261	if p.owner.Load() != owner {
262		sess.mu.Unlock()
263		return false
264	}
265	if !ownedTab {
266		sess.mu.Unlock()
267		return true
268	}
269	attachments := sess.snapshotAttachmentsLocked()
270	sessionName := sess.name
271	tb.mu.Lock()
272	if p.owner.Load() != owner {
273		tb.mu.Unlock()
274		sess.mu.Unlock()
275		return false
276	}
277	if tb.panes[p.id] != p || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, p.id) {
278		tb.mu.Unlock()
279		sess.mu.Unlock()
280		return true
281	}
282	p.mu.Lock()
283	if p.owner.Load() != owner {
284		p.mu.Unlock()
285		tb.mu.Unlock()
286		sess.mu.Unlock()
287		return false
288	}
289	finalPane := len(layout.LeafIDs(tb.tree.Root)) <= 1
290	if !finalPane {
291		if err := tb.tree.Close(p.id); err != nil {
292			p.mu.Unlock()
293			tb.mu.Unlock()
294			sess.mu.Unlock()
295			return true
296		}
297		delete(tb.panes, p.id)
298		tb.bumpLayoutGenerationLocked()
299	}
300	if !finalPane {
301		p.clearOwnerLocked()
302	}
303	p.mu.Unlock()
304	tb.mu.Unlock()
305	sess.invalidateViewsLocked()
306	sess.mu.Unlock()
307	attachmentTransports := make(map[*attachedClient]transportSnapshot, len(attachments))
308	for _, ac := range attachments {
309		attachmentTransports[ac] = ac.transportSnapshot()
310	}
311
312	if finalPane {
313		for _, ac := range attachments {
314			if ac.overlays != nil {
315				ac.overlays.clearCopyModeForPane(p)
316			}
317		}
318		// A concurrent detach or token resume that invalidated the teardown's
319		// participant snapshot is itself observable progress: the next owner
320		// resolution consumes the fresh participant set, so retry instead of
321		// treating the abort as a completed close.
322		participantsChanged := false
323		if err := d.closeTabLocked(sess, tb, true); err != nil {
324			if !errors.Is(err, errSessionKillParticipantsChanged) {
325				return true
326			}
327			participantsChanged = true
328		}
329		if p.owner.Load() == nil {
330			return true
331		}
332		// A successful no-op teardown means participant, gate, or lifecycle
333		// validation rejected the mutation without changing either the tab list
334		// or the pane owner. That includes an explicit participants-changed
335		// sentinel: the kill aborted because a concurrent detach or transport
336		// replacement invalidated its snapshot, which the owner resolution below
337		// consumes. Retrying that exact state would spin forever; only retry when
338		// the teardown made observable progress that can be consumed by the next
339		// owner resolution.
340		d.mu.Lock()
341		sessionCurrent := d.sessions[sess.id] == sess
342		sess.mu.Lock()
343		tabCurrent := slices.Contains(sess.tabs, tb)
344		attachmentsChanged := len(sess.attachments) != len(attachmentTransports)
345		if !attachmentsChanged {
346			for ac, transport := range attachmentTransports {
347				if _, attached := sess.attachments[ac]; !attached || !ac.transportSnapshotCurrent(transport) {
348					attachmentsChanged = true
349					break
350				}
351			}
352		}
353		sess.mu.Unlock()
354		d.mu.Unlock()
355		if sessionCurrent && tabCurrent && !attachmentsChanged && !participantsChanged {
356			return true
357		}
358		return false
359	}
360	sess.geometry.applyTabLayout(d, sess, tb)
361	for _, ac := range attachments {
362		if ac.overlays != nil {
363			ac.overlays.clearCopyModeForPane(p)
364		}
365		ac.pruneCaptureFrames(p)
366	}
367	if rc := sess.renderCoordinator(); rc != nil {
368		rc.noteSyncPaneRemoved(p)
369	}
370	closePaneProcess(p)
371	d.log.Info("pane closed", "session", sessionName, "pane", p.id)
372	for _, ac := range attachments {
373		d.invalidateRender(sess, ac, true, "pane_actions.go")
374	}
375	return true
376}
377
378// closePane is the public close boundary for callers that are not already
379// inside session.runMutation. Mutation dispatchers use closePaneLocked.
380func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
381	if sess == nil {
382		return layout.ErrNotFound
383	}
384	sess.dispatchMu.Lock()
385	defer sess.dispatchMu.Unlock()
386	return d.closePaneLocked(sess, tb, id, ac, repaint)
387}
388
389// closePaneLocked requires sess.dispatchMu before entering any architecture
390// lock. It is used by existing runMutation callers to avoid nested dispatch
391// locking.
392func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
393	return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
394}
395
396func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffect) error {
397	if tb == nil {
398		return layout.ErrNotFound
399	}
400	// A nil attachment denotes a session-wide close with an explicit pane target.
401	attachments := sess.snapshotAttachments()
402	tb.mu.Lock()
403	p := tb.panes[id]
404	if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
405		tb.mu.Unlock()
406		return nil
407	}
408	if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
409		tb.mu.Unlock()
410		for _, viewer := range attachments {
411			if viewer.overlays != nil {
412				viewer.overlays.clearCopyModeForPane(p)
413			}
414			viewer.pruneCaptureFrames(p)
415		}
416		return d.closeTabLockedWithEffect(sess, tb, repaint, effect)
417	}
418	if err := tb.tree.Close(id); err != nil {
419		tb.mu.Unlock()
420		return err
421	}
422	delete(tb.panes, id)
423	tb.bumpLayoutGenerationLocked()
424	tb.mu.Unlock()
425	sess.mu.Lock()
426	sess.invalidateViewsLocked()
427	sess.mu.Unlock()
428	sess.geometry.applyTabLayout(d, sess, tb)
429
430	for _, viewer := range attachments {
431		if viewer.overlays != nil {
432			viewer.overlays.clearCopyModeForPane(p)
433		}
434		viewer.pruneCaptureFrames(p)
435	}
436
437	if rc := sess.renderCoordinator(); rc != nil {
438		rc.noteSyncPaneRemoved(p)
439	}
440	closePaneProcess(p)
441	d.log.Info("pane closed", "session", sess.name, "pane", id)
442	if repaint {
443		if ac != nil {
444			d.invalidateRender(sess, ac, true, "pane_actions.go")
445		} else {
446			for _, viewer := range attachments {
447				d.invalidateRender(sess, viewer, true, "pane_actions.go")
448			}
449		}
450	}
451	return nil
452}
453
454func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffect) error {
455	target := resolveDaemonActionTargetForAttachment(sess, ac)
456	oldFocus := layout.PaneID("")
457	if target.pane != nil {
458		oldFocus = target.pane.id
459	}
460	change, err := d.focusDirAt(sess, target.tab, target.pane, dir)
461	if err == nil {
462		if ac != nil {
463			publishPaneFocusForAttachment(sess, ac, change)
464			d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
465		}
466		return nil
467	}
468	span := change.span
469	if !errors.Is(err, errNoNeighbor) || target.tab == nil {
470		return err
471	}
472	if !overflowSourceEligible(sess, ac, target.tab) {
473		return errNoNeighbor
474	}
475
476	cfg := d.currentNavConfig()
477	if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
478		if ac == nil {
479			return errNoNeighbor
480		}
481		if effect != nil {
482			return d.switchToTargetForAttachment(effect, sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
483		}
484		return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
485	}
486
487	position, count := sess.tabIndexForAttachment(ac)
488	step := resolveOverflow(dir, cfg, position, count)
489	if step.kind != overflowTabs {
490		return err
491	}
492	candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
493	if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
494		return errNoNeighbor
495	}
496	if ac != nil {
497		sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
498		d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
499	}
500	return nil
501}
502
503func publishPaneFocusForAttachment(sess *session, ac *attachedClient, change paneFocusChange) bool {
504	if sess == nil || ac == nil || change.tab == nil || change.focused == nil {
505		return false
506	}
507	sess.mu.Lock()
508	changed := sess.setAttachmentPaneLocked(ac, change.tab, change.focused)
509	sess.mu.Unlock()
510	return changed
511}
512
513// finishPaneFocusForClient applies the attachment lifecycle shared by every
514// successful directional focus move. Keep copy-mode exit before the optional
515// stack title refresh, and both before render invalidation.
516func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
517	var newFocus layout.PaneID
518	var newPane *pane
519	var pl layout.Placement
520	var hasPlacement bool
521	if tb != nil {
522		tb.mu.Lock()
523		if ac != nil {
524			view := ac.viewSnapshot()
525			if domain.TabStableID(tb.stableID) == view.tabID {
526				for _, candidate := range tb.panes {
527					if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
528						newPane = candidate
529						break
530					}
531				}
532			}
533		} else {
534			newPane = tb.focusedPane()
535			if newPane != nil {
536				newFocus = newPane.id
537			}
538		}
539		if newPane != nil {
540			newFocus = newPane.id
541			pl, hasPlacement = panePlacementLocked(tb, newFocus)
542		}
543		tb.mu.Unlock()
544	}
545
546	if newFocus != oldFocus {
547		d.exitCopyMode(ac)
548		if hasPlacement && pl.InStack {
549			d.refreshPaneTitleOnFocus(sess, newFocus, tb)
550		}
551	}
552	d.invalidateRender(sess, ac, true, producer)
553}
554
555func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) {
556	placements, ok := solvedPlacementsLocked(tb)
557	if !ok {
558		return layout.Placement{}, false
559	}
560	for _, placement := range placements {
561		if placement.ID == id {
562			return placement, true
563		}
564	}
565	return layout.Placement{}, false
566}
567
568func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) {
569	change := paneFocusChange{tab: tb}
570	if tb == nil || target == nil {
571		return change, layout.ErrNotFound
572	}
573	tb.mu.Lock()
574	if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
575		tb.mu.Unlock()
576		return change, layout.ErrNotFound
577	}
578	candidate := tb.tree.Clone()
579	candidate.Focus = target.id
580	area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
581	change.span, _ = candidate.FocusSpan(area)
582	err := candidate.FocusDir(dir, area)
583	if err == nil {
584		change.focused = tb.panes[candidate.Focus]
585		change.layoutChanged = change.focused != nil && (candidate.Focus != tb.tree.Focus || layoutFingerprint(candidate.Root) != layoutFingerprint(tb.tree.Root))
586		if change.layoutChanged {
587			tb.tree = candidate
588			tb.bumpLayoutGenerationLocked()
589		}
590	}
591	tb.mu.Unlock()
592	if change.layoutChanged {
593		sess.geometry.applyTabLayout(d, sess, tb)
594	}
595	if errors.Is(err, layout.ErrNoPane) {
596		return change, errNoNeighbor
597	}
598	return change, err
599}

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