navigation_overflow.go RawHistoryFolder

1package daemon
2
3import (
4	"sort"
5
6	"github.com/bnema/vev/internal/domain"
7	"github.com/bnema/vev/internal/domain/layout"
8	"github.com/bnema/vev/internal/usecase/picker"
9)
10
11type overflowKind uint8
12
13const (
14	overflowNone overflowKind = iota
15	overflowTabs
16	overflowSessions
17)
18
19type overflowStep struct {
20	kind  overflowKind
21	delta int
22}
23
24// resolveOverflow is the pure escalation policy for directional navigation.
25// The caller supplies its position on the selected axis, so disabled axes and
26// either hard wall all resolve to the zero step.
27func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep {
28	var step overflowStep
29	switch dir {
30	case layout.Left:
31		if cfg.OverflowTabs {
32			step = overflowStep{kind: overflowTabs, delta: -1}
33		}
34	case layout.Right:
35		if cfg.OverflowTabs {
36			step = overflowStep{kind: overflowTabs, delta: 1}
37		}
38	case layout.Up:
39		if cfg.OverflowSessions {
40			step = overflowStep{kind: overflowSessions, delta: -1}
41		}
42	case layout.Down:
43		if cfg.OverflowSessions {
44			step = overflowStep{kind: overflowSessions, delta: 1}
45		}
46	}
47	if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count {
48		return overflowStep{}
49	}
50	return step
51}
52
53// overflowSourceEligible validates that directional overflow still originates
54// from the active tiled tab. The session-to-tab lock order matches tab overflow
55// commit and leaves no lock held across a session handoff.
56func overflowSourceEligible(sess *session, ac *attachedClient, expectedSource *tab) bool {
57	if sess == nil || expectedSource == nil {
58		return false
59	}
60	sess.mu.Lock()
61	defer sess.mu.Unlock()
62	if ac == nil || !attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID {
63		return false
64	}
65	expectedSource.mu.Lock()
66	defer expectedSource.mu.Unlock()
67	return expectedSource.floating.state != floatingVisible
68}
69
70type sessionOverflowSnapshot struct {
71	session *session
72	id      domain.SessionID
73	name    string
74}
75
76// prepareSessionOverflow snapshots only the live registry entries, then sorts
77// immutable names exactly like the picker. No daemon or session lock survives
78// the snapshot, sorting, or the later switchToTarget call.
79func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, cfg domain.NavConfig) (picker.Target, bool) {
80	if current == nil || !cfg.OverflowSessions || (dir != layout.Up && dir != layout.Down) {
81		return picker.Target{}, false
82	}
83	current.mu.Lock()
84	currentID := current.id
85	current.mu.Unlock()
86
87	d.mu.Lock()
88	live := sessionsSnapshot(d.sessions)
89	d.mu.Unlock()
90
91	snapshots := make([]sessionOverflowSnapshot, 0, len(live))
92	for _, sess := range live {
93		sess.mu.Lock()
94		snapshot := sessionOverflowSnapshot{session: sess, id: sess.id, name: sess.name}
95		sess.mu.Unlock()
96		snapshots = append(snapshots, snapshot)
97	}
98	sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].name < snapshots[j].name })
99
100	position := -1
101	for i, snapshot := range snapshots {
102		if snapshot.session == current && snapshot.id == currentID {
103			position = i
104			break
105		}
106	}
107	step := resolveOverflow(dir, cfg, position, len(snapshots))
108	if step.kind != overflowSessions {
109		return picker.Target{}, false
110	}
111	target := snapshots[position+step.delta]
112	return picker.Target{Session: target.id, TabIndex: -1}, true
113}
114
115// commitSessionOverflow carries its source constraint into the handoff so tab
116// identity and floating visibility are revalidated while source ownership is
117// transferred under the daemon -> routing -> session -> tab lock order.
118func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {
119	if from == nil || ac == nil || expectedSource == nil {
120		return errNoNeighbor
121	}
122	return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{expectedSource: expectedSource})
123}
124
125type tabOverflowCandidate struct {
126	source         *tab
127	target         *tab
128	sourceIndex    int
129	delta          int
130	direction      layout.Direction
131	span           domain.Rect
132	entry          layout.PaneID
133	entryPane      *pane
134	targetOldFocus layout.PaneID
135}
136
137func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
138	if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
139		return tabOverflowCandidate{}, false
140	}
141	sess.mu.Lock()
142	if ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID) {
143		sess.mu.Unlock()
144		return tabOverflowCandidate{}, false
145	}
146	sourceIndex := -1
147	for i, tb := range sess.tabs {
148		if tb == expectedSource {
149			sourceIndex = i
150			break
151		}
152	}
153	if sourceIndex < 0 {
154		sess.mu.Unlock()
155		return tabOverflowCandidate{}, false
156	}
157	targetIndex := sourceIndex + delta
158	if targetIndex < 0 || targetIndex >= len(sess.tabs) {
159		sess.mu.Unlock()
160		return tabOverflowCandidate{}, false
161	}
162	source := sess.tabs[sourceIndex]
163	target := sess.tabs[targetIndex]
164	sess.mu.Unlock()
165
166	source.mu.Lock()
167	floatingVisible := source.floating.state == floatingVisible
168	source.mu.Unlock()
169	if floatingVisible {
170		return tabOverflowCandidate{}, false
171	}
172
173	target.mu.Lock()
174	if target.tree == nil {
175		target.mu.Unlock()
176		return tabOverflowCandidate{}, false
177	}
178	area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}
179	entry, err := target.tree.EntryPane(dir, span, area)
180	entryPane := target.panes[entry]
181	oldFocus := target.tree.Focus
182	valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)
183	target.mu.Unlock()
184	if !valid {
185		return tabOverflowCandidate{}, false
186	}
187	return tabOverflowCandidate{
188		source:         source,
189		target:         target,
190		sourceIndex:    sourceIndex,
191		delta:          delta,
192		direction:      dir,
193		span:           span,
194		entry:          entry,
195		entryPane:      entryPane,
196		targetOldFocus: oldFocus,
197	}, true
198}
199
200func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {
201	if sess == nil || candidate.source == nil || candidate.target == nil {
202		return false
203	}
204	sess.mu.Lock()
205	targetIndex := candidate.sourceIndex + candidate.delta
206	if (ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(candidate.source.stableID) != ac.viewSnapshot().tabID)) || candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
207		sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
208		sess.mu.Unlock()
209		return false
210	}
211
212	candidate.source.mu.Lock()
213	if candidate.source.floating.state == floatingVisible {
214		candidate.source.mu.Unlock()
215		sess.mu.Unlock()
216		return false
217	}
218	candidate.target.mu.Lock()
219	if candidate.target.tree == nil {
220		candidate.target.mu.Unlock()
221		candidate.source.mu.Unlock()
222		sess.mu.Unlock()
223		return false
224	}
225	area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}
226	entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)
227	if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {
228		candidate.target.mu.Unlock()
229		candidate.source.mu.Unlock()
230		sess.mu.Unlock()
231		return false
232	}
233	targetTree := candidate.target.tree.Clone()
234	if err := targetTree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
235		candidate.target.mu.Unlock()
236		candidate.source.mu.Unlock()
237		sess.mu.Unlock()
238		return false
239	}
240	candidate.target.tree = targetTree
241	candidate.target.bumpLayoutGenerationLocked()
242	candidate.target.mu.Unlock()
243	candidate.source.mu.Unlock()
244	sess.mu.Unlock()
245	sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
246	sess.geometry.applyTabLayout(d, sess, candidate.target)
247	return true
248}

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