v0.3.0 99dc266

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/usecase/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	term := sess.terminal
52	env := copyEnvironment(sess.env)
53	sess.mu.Unlock()
54
55	tb.mu.Lock()
56	if tb.tree == nil || tb.tree.Root == nil {
57		tb.mu.Unlock()
58		return paneFocusChange{}, layout.ErrNotFound
59	}
60	oldFocus := target.id
61	if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
62		tb.mu.Unlock()
63		return paneFocusChange{}, layout.ErrNotFound
64	}
65	newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
66	area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
67	candidate := tb.tree.Clone()
68	if err := mutate(candidate, oldFocus, newID, area); err != nil {
69		tb.mu.Unlock()
70		if errors.Is(err, layout.ErrTooSmall) {
71			return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err)
72		}
73		return paneFocusChange{}, err
74	}
75	tb.nextPaneID++
76	placements, ok := layout.Solve(candidate.Root, area)
77	if !ok {
78		tb.mu.Unlock()
79		return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall)
80	}
81	newRect := placementContent(placements, newID)
82	tabStableID := tb.stableID
83	generation := tb.layoutGeneration
84	tb.mu.Unlock()
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, term), cwd, rectSize(newRect))
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
102	p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)
103	p.rect = newRect
104
105	tb.mu.Lock()
106	if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) || tb.ctx != nil && tb.ctx.Err() != nil {
107		tb.mu.Unlock()
108		lifetime.abort()
109		_ = pty.Close()
110		return paneFocusChange{}, layout.ErrNotFound
111	}
112	if !lifetime.publish(p) {
113		tb.mu.Unlock()
114		_ = pty.Close()
115		return paneFocusChange{}, layout.ErrNotFound
116	}
117	// The tab lock excludes membership observers while pane.mu publishes the
118	// initial owner generation.
119	publishPaneOwner(p, sess, tb, 0)
120	tb.tree = candidate
121	tb.panes[newID] = p
122	tb.bumpLayoutGenerationLocked()
123	tb.mu.Unlock()
124
125	d.applyTabLayout(sess, tb)
126	d.startPaneGoroutines(sess, tb, p)
127	return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil
128}
129
130func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect {
131	for _, pl := range placements {
132		if pl.ID == id {
133			return pl.Content
134		}
135	}
136	return domain.Rect{}
137}
138
139func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
140
141func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
142	target := resolveDaemonActionTargetForAttachment(sess, ac)
143	change, err := d.stackPaneAt(sess, target.tab, target.pane)
144	if err == nil && ac != nil {
145		publishPaneFocusForAttachment(sess, ac, change)
146		d.invalidateRender(sess, ac, true, "pane_actions.go")
147	}
148	return err
149}
150
151func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) (paneFocusChange, error) {
152	return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
153		return tree.StackNew(oldFocus, newID, area)
154	})
155}
156
157func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
158	target := resolveDaemonActionTargetForAttachment(sess, ac)
159	err := d.toggleStackAt(sess, target.tab, target.pane)
160	if err == nil && ac != nil {
161		d.invalidateRender(sess, ac, true, "pane_actions.go")
162	}
163	return err
164}
165
166func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
167	if d.ptys == nil {
168		return nil
169	}
170	if tb == nil || target == nil {
171		return layout.ErrNotFound
172	}
173	tb.mu.Lock()
174	if tb.tree == nil {
175		tb.mu.Unlock()
176		return layout.ErrNotFound
177	}
178	if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
179		tb.mu.Unlock()
180		return layout.ErrNotFound
181	}
182	candidate := tb.tree.Clone()
183	err := candidate.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
184	if err == nil {
185		tb.tree = candidate
186		tb.bumpLayoutGenerationLocked()
187	}
188	tb.mu.Unlock()
189	if err == nil {
190		d.applyTabLayout(sess, tb)
191	}
192	return err
193}
194
195func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
196	if d.ptys == nil {
197		if ac != nil {
198			d.invalidateRender(sess, ac, true, "pane_actions.go")
199		}
200		return nil
201	}
202	target := resolveDaemonActionTargetForAttachment(sess, ac)
203	tb := target.tab
204	if tb == nil {
205		return layout.ErrNotFound
206	}
207	if target.pane == nil {
208		return layout.ErrNotFound
209	}
210	return d.closePane(sess, tb, target.pane.id, ac, true)
211}
212
213// reapPane retains the explicit-owner entry point for focused close tests. PTY
214// readers use reapPaneOwner so an owner publication cannot redirect exit to a
215// replacement that reused the same tab-local pane ID.
216func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {
217	if p == nil {
218		return
219	}
220	lease := p.effectLease()
221	if lease.owner == nil {
222		_ = d.closePane(sess, tb, p.id, nil, true)
223		return
224	}
225	if lease.owner.session != sess || lease.owner.tab != tb {
226		return
227	}
228	d.reapPaneOwner(p)
229}
230
231// reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries
232// the immutable owner pointer; a successful attempt revokes owner publication
233// under pane.mu before releasing membership locks, so a competing close or
234// move can never reap the pane from a second owner.
235func (d *Daemon) reapPaneOwner(p *pane) {
236	for p != nil {
237		lease := p.effectLease()
238		if lease.owner == nil {
239			return
240		}
241		if d.reapTiledPaneLease(lease) {
242			return
243		}
244	}
245}
246
247func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
248	p, owner := lease.pane, lease.owner
249	if p == nil || owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration != 0 {
250		return true
251	}
252	sess, tb := owner.session, owner.tab
253	// EOF teardown is a shared mutation just like an explicit close. Hold the
254	// dispatch boundary before any session/tab lock, then use the locked close
255	// helpers below without re-entering it.
256	sess.dispatchMu.Lock()
257	defer sess.dispatchMu.Unlock()
258	sess.mu.Lock()
259	ownedTab := slices.Contains(sess.tabs, tb)
260	if p.owner.Load() != owner {
261		sess.mu.Unlock()
262		return false
263	}
264	if !ownedTab {
265		sess.mu.Unlock()
266		return true
267	}
268	attachments := sess.snapshotAttachmentsLocked()
269	sessionName := sess.name
270	tb.mu.Lock()
271	if p.owner.Load() != owner {
272		tb.mu.Unlock()
273		sess.mu.Unlock()
274		return false
275	}
276	if tb.panes[p.id] != p || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, p.id) {
277		tb.mu.Unlock()
278		sess.mu.Unlock()
279		return true
280	}
281	p.mu.Lock()
282	if p.owner.Load() != owner {
283		p.mu.Unlock()
284		tb.mu.Unlock()
285		sess.mu.Unlock()
286		return false
287	}
288	finalPane := len(layout.LeafIDs(tb.tree.Root)) <= 1
289	if !finalPane {
290		if err := tb.tree.Close(p.id); err != nil {
291			p.mu.Unlock()
292			tb.mu.Unlock()
293			sess.mu.Unlock()
294			return true
295		}
296		delete(tb.panes, p.id)
297		tb.bumpLayoutGenerationLocked()
298	}
299	p.clearOwnerLocked()
300	p.mu.Unlock()
301	tb.mu.Unlock()
302	sess.invalidateViewsLocked()
303	sess.mu.Unlock()
304
305	if finalPane {
306		for _, ac := range attachments {
307			if ac.overlays != nil {
308				ac.overlays.clearCopyModeForPane(p)
309			}
310		}
311		_ = d.closeTabLocked(sess, tb, true)
312		return true
313	}
314	d.applyTabLayout(sess, tb)
315	for _, ac := range attachments {
316		if ac.overlays != nil {
317			ac.overlays.clearCopyModeForPane(p)
318		}
319		ac.pruneCaptureFrames(p)
320	}
321	if rc := sess.renderCoordinator(); rc != nil {
322		rc.noteSyncPaneRemoved(p)
323	}
324	closePaneProcess(p)
325	d.log.Info("pane closed", "session", sessionName, "pane", p.id)
326	for _, ac := range attachments {
327		d.invalidateRender(sess, ac, true, "pane_actions.go")
328	}
329	return true
330}
331
332// closePane is the public close boundary for callers that are not already
333// inside session.runMutation. Mutation dispatchers use closePaneLocked.
334func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
335	if sess == nil {
336		return layout.ErrNotFound
337	}
338	sess.dispatchMu.Lock()
339	defer sess.dispatchMu.Unlock()
340	return d.closePaneLocked(sess, tb, id, ac, repaint)
341}
342
343// closePaneLocked requires sess.dispatchMu before entering any architecture
344// lock. It is used by existing runMutation callers to avoid nested dispatch
345// locking.
346func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
347	return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
348}
349
350func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffectTicket) error {
351	if tb == nil {
352		return layout.ErrNotFound
353	}
354	// A nil attachment denotes a session-wide close with an explicit pane target.
355	attachments := sess.snapshotAttachments()
356	tb.mu.Lock()
357	p := tb.panes[id]
358	if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
359		tb.mu.Unlock()
360		return nil
361	}
362	if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
363		tb.mu.Unlock()
364		for _, viewer := range attachments {
365			if viewer.overlays != nil {
366				viewer.overlays.clearCopyModeForPane(p)
367			}
368			viewer.pruneCaptureFrames(p)
369		}
370		return d.closeTabLockedWithEffect(sess, tb, repaint, effect)
371	}
372	if err := tb.tree.Close(id); err != nil {
373		tb.mu.Unlock()
374		return err
375	}
376	delete(tb.panes, id)
377	tb.bumpLayoutGenerationLocked()
378	tb.mu.Unlock()
379	sess.mu.Lock()
380	sess.invalidateViewsLocked()
381	sess.mu.Unlock()
382	d.applyTabLayout(sess, tb)
383
384	for _, viewer := range attachments {
385		if viewer.overlays != nil {
386			viewer.overlays.clearCopyModeForPane(p)
387		}
388		viewer.pruneCaptureFrames(p)
389	}
390
391	if rc := sess.renderCoordinator(); rc != nil {
392		rc.noteSyncPaneRemoved(p)
393	}
394	closePaneProcess(p)
395	d.log.Info("pane closed", "session", sess.name, "pane", id)
396	if repaint {
397		if ac != nil {
398			d.invalidateRender(sess, ac, true, "pane_actions.go")
399		} else {
400			for _, viewer := range attachments {
401				d.invalidateRender(sess, viewer, true, "pane_actions.go")
402			}
403		}
404	}
405	return nil
406}
407
408func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffectTicket) error {
409	target := resolveDaemonActionTargetForAttachment(sess, ac)
410	oldFocus := layout.PaneID("")
411	if target.pane != nil {
412		oldFocus = target.pane.id
413	}
414	change, err := d.focusDirAt(sess, target.tab, target.pane, dir)
415	if err == nil {
416		if ac != nil {
417			publishPaneFocusForAttachment(sess, ac, change)
418			d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
419		}
420		return nil
421	}
422	span := change.span
423	if !errors.Is(err, errNoNeighbor) || target.tab == nil {
424		return err
425	}
426	if !overflowSourceEligible(sess, ac, target.tab) {
427		return errNoNeighbor
428	}
429
430	cfg := d.currentNavConfig()
431	if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
432		if ac == nil {
433			return errNoNeighbor
434		}
435		if effect != nil {
436			return d.switchToTargetForAttachment(effect.connectionToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
437		}
438		return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
439	}
440
441	position, count := sess.tabIndexForAttachment(ac)
442	step := resolveOverflow(dir, cfg, position, count)
443	if step.kind != overflowTabs {
444		return err
445	}
446	candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
447	if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
448		return errNoNeighbor
449	}
450	if ac != nil {
451		sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
452		d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
453	}
454	return nil
455}
456
457func publishPaneFocusForAttachment(sess *session, ac *attachedClient, change paneFocusChange) bool {
458	if sess == nil || ac == nil || change.tab == nil || change.focused == nil {
459		return false
460	}
461	sess.mu.Lock()
462	changed := sess.setAttachmentPaneLocked(ac, change.tab, change.focused)
463	sess.mu.Unlock()
464	return changed
465}
466
467// finishPaneFocusForClient applies the attachment lifecycle shared by every
468// successful directional focus move. Keep copy-mode exit before the optional
469// stack title refresh, and both before render invalidation.
470func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
471	var newFocus layout.PaneID
472	var newPane *pane
473	var pl layout.Placement
474	var hasPlacement bool
475	if tb != nil {
476		tb.mu.Lock()
477		if ac != nil {
478			view := ac.viewSnapshot()
479			if domain.TabStableID(tb.stableID) == view.tabID {
480				for _, candidate := range tb.panes {
481					if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
482						newPane = candidate
483						break
484					}
485				}
486			}
487		} else {
488			newPane = tb.focusedPane()
489			if newPane != nil {
490				newFocus = newPane.id
491			}
492		}
493		if newPane != nil {
494			newFocus = newPane.id
495			pl, hasPlacement = panePlacementLocked(tb, newFocus)
496		}
497		tb.mu.Unlock()
498	}
499
500	if newFocus != oldFocus {
501		d.exitCopyMode(ac)
502		if hasPlacement && pl.InStack {
503			d.refreshPaneTitleOnFocus(sess, newFocus, tb)
504		}
505	}
506	d.invalidateRender(sess, ac, true, producer)
507}
508
509func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) {
510	placements, ok := solvedPlacementsLocked(tb)
511	if !ok {
512		return layout.Placement{}, false
513	}
514	for _, placement := range placements {
515		if placement.ID == id {
516			return placement, true
517		}
518	}
519	return layout.Placement{}, false
520}
521
522func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) {
523	change := paneFocusChange{tab: tb}
524	if tb == nil || target == nil {
525		return change, layout.ErrNotFound
526	}
527	tb.mu.Lock()
528	if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
529		tb.mu.Unlock()
530		return change, layout.ErrNotFound
531	}
532	candidate := tb.tree.Clone()
533	candidate.Focus = target.id
534	area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
535	change.span, _ = candidate.FocusSpan(area)
536	err := candidate.FocusDir(dir, area)
537	if err == nil {
538		change.focused = tb.panes[candidate.Focus]
539		change.layoutChanged = change.focused != nil && (candidate.Focus != tb.tree.Focus || layoutFingerprint(candidate.Root) != layoutFingerprint(tb.tree.Root))
540		if change.layoutChanged {
541			tb.tree = candidate
542			tb.bumpLayoutGenerationLocked()
543		}
544	}
545	tb.mu.Unlock()
546	if change.layoutChanged {
547		d.applyTabLayout(sess, tb)
548	}
549	if errors.Is(err, layout.ErrNoPane) {
550		return change, errNoNeighbor
551	}
552	return change, err
553}

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