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.geometry.claimLocked(c, ac, ac.geometrySnapshot()) == nil {
105		c.geometry.refreshLocked(c)
106	}
107	return true
108}
109
110func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
111	if entry == nil || entry.core() == nil {
112		return false
113	}
114	// Transition publication may already hold tab locks (move commit). Only
115	// publish collection membership here; target repair runs at the next view
116	// snapshot outside that critical section.
117	return entry.core().registerAttachmentLocked(ac)
118}
119
120func (s *session) registerAttachment(ac *attachedClient) bool {
121	if s == nil || ac == nil {
122		return false
123	}
124	s.dispatchMu.Lock()
125	defer s.dispatchMu.Unlock()
126	s.mu.Lock()
127	defer s.mu.Unlock()
128	return s.registerAttachmentLocked(ac)
129}
130
131// unregisterAttachmentLocked removes only ac and leaves every other
132// attachment's view intact. Caller holds s.mu.
133func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {
134	if s == nil {
135		return false
136	}
137	return s.sessionCore.unregisterAttachmentLocked(ac)
138}
139
140func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
141	if c == nil || ac == nil || c.attachments == nil {
142		return false
143	}
144	if _, exists := c.attachments[ac]; !exists {
145		return false
146	}
147	delete(c.attachments, ac)
148	delete(c.attachmentOrder, ac)
149	c.geometry.removeAttachmentLocked(c, ac)
150	return true
151}
152
153func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
154	if entry == nil || entry.core() == nil {
155		return false
156	}
157	return entry.core().unregisterAttachmentLocked(ac)
158}
159
160func (s *session) unregisterAttachment(ac *attachedClient) bool {
161	if s == nil || ac == nil {
162		return false
163	}
164	s.dispatchMu.Lock()
165	defer s.dispatchMu.Unlock()
166	s.mu.Lock()
167	defer s.mu.Unlock()
168	return s.unregisterAttachmentLocked(ac)
169}
170
171func (s *session) snapshotAttachmentsLocked() []*attachedClient {
172	if s == nil || len(s.attachments) == 0 {
173		return nil
174	}
175	out := make([]*attachedClient, 0, len(s.attachments))
176	for ac := range s.attachments {
177		out = append(out, ac)
178	}
179	slices.SortStableFunc(out, func(a, b *attachedClient) int {
180		// ClientID is the stable wire identity, so concurrent registration order
181		// cannot affect snapshots or invalidation order.
182		if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
183			return id
184		}
185		return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
186	})
187	return out
188}
189
190func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool {
191	if entry == nil || ac == nil || entry.core() == nil {
192		return false
193	}
194	_, ok := entry.core().attachments[ac]
195	return ok
196}
197
198func attachmentRegistered(entry *session, ac *attachedClient) bool {
199	if entry == nil || ac == nil || entry.core() == nil {
200		return false
201	}
202	core := entry.core()
203	core.mu.Lock()
204	defer core.mu.Unlock()
205	return attachmentRegisteredLocked(entry, ac)
206}
207
208func (s *session) attachmentRegistered(ac *attachedClient) bool {
209	return attachmentRegistered(s, ac)
210}
211
212func (s *session) attachmentRegisteredLocked(ac *attachedClient) bool {
213	return attachmentRegisteredLocked(s, ac)
214}
215
216func (s *session) attachmentViewsTabLocked(tb *tab) bool {
217	if s == nil || tb == nil {
218		return false
219	}
220	for ac := range s.attachments {
221		if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
222			return true
223		}
224	}
225	return false
226}
227
228func (s *session) snapshotAttachments() []*attachedClient {
229	if s == nil {
230		return nil
231	}
232	s.mu.Lock()
233	defer s.mu.Unlock()
234	return s.snapshotAttachmentsLocked()
235}
236
237// repairAttachmentViewLocked validates stable targets and chooses the nearest
238// surviving target when a tab or pane was removed. Caller holds s.mu.
239func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
240	_ = ac
241	if s == nil {
242		return attachmentView{}
243	}
244	var targetTab *tab
245	for _, tb := range s.tabs {
246		if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
247			targetTab = tb
248			break
249		}
250	}
251	tabRepaired := targetTab == nil
252	if targetTab == nil && len(s.tabs) != 0 {
253		targetTab = s.tabs[0]
254	}
255	if targetTab == nil {
256		view.tabID = ""
257		view.paneID = ""
258		view.windowTop = 0
259		view.windowRows = 0
260		view.windowSet = false
261		view.bookmark = 0
262		view.liveBottom = true
263		return view
264	}
265	view.tabID = domain.TabStableID(targetTab.stableID)
266
267	targetPane := (*pane)(nil)
268	targetTab.mu.Lock()
269	for _, candidate := range targetTab.panes {
270		if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
271			targetPane = candidate
272			break
273		}
274	}
275	paneRepaired := targetPane == nil
276	if targetPane == nil {
277		targetPane = targetTab.focusedPane()
278	}
279	if targetPane != nil {
280		view.paneID = domain.PaneStableID(targetPane.stableID)
281	} else {
282		view.paneID = ""
283	}
284	targetTab.mu.Unlock()
285	if view.windowRows <= 0 {
286		targetTab.mu.Lock()
287		view.windowRows = targetTab.size.Rows
288		targetTab.mu.Unlock()
289	}
290	if view.windowTop < 0 {
291		view.windowTop = 0
292	}
293	if tabRepaired || paneRepaired || targetPane == nil {
294		view.bookmark = 0
295		view.liveBottom = true
296	}
297	return view
298}
299
300// repairAttachmentView repairs ac and increments its revision only when the
301// stable target changed. It is deliberately separate from registration so
302// layout mutation code can validate every surviving attachment in one ordered
303// pass.
304func (s *session) repairAttachmentView(ac *attachedClient) bool {
305	if s == nil || ac == nil {
306		return false
307	}
308	before := ac.viewSnapshot()
309	s.mu.Lock()
310	if _, ok := s.attachments[ac]; !ok {
311		s.mu.Unlock()
312		return false
313	}
314	after := s.repairAttachmentViewLocked(ac, before)
315	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 {
316		s.mu.Unlock()
317		return false
318	}
319	after.revision++
320	s.mu.Unlock()
321	return ac.publishViewIfCurrent(before, after)
322}
323
324// prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
325// for attachments that were viewing removed. Caller holds s.mu before tabs is
326// mutated; the normal invalidation pass publishes the revised views.
327func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index int) {
328	if s == nil || removed == nil || len(s.tabs) <= 1 || index < 0 || index >= len(s.tabs) {
329		return
330	}
331	replacement := index + 1
332	if replacement >= len(s.tabs) {
333		replacement = index - 1
334	}
335	if replacement < 0 || replacement >= len(s.tabs) || s.tabs[replacement] == nil {
336		return
337	}
338	tabID := domain.TabStableID(s.tabs[replacement].stableID)
339	removedID := domain.TabStableID(removed.stableID)
340	for ac := range s.attachments {
341		view := ac.viewSnapshot()
342		if view.tabID == removedID {
343			view.tabID = tabID
344			view.paneID = ""
345			view.bookmark = 0
346			view.liveBottom = true
347			view.revision++
348			ac.publishView(view)
349		}
350	}
351}
352
353// invalidateViewsLocked orders all per-attachment invalidations behind the
354// shared session mutation. Caller holds s.mu and dispatchMu.
355func (s *session) invalidateViewsLocked() {
356	for _, ac := range s.snapshotAttachmentsLocked() {
357		view := ac.viewSnapshot()
358		repaired := s.repairAttachmentViewLocked(ac, view)
359		repaired.revision++
360		ac.publishView(repaired)
361	}
362}
363
364// runMutation is the one admission boundary for shared session mutations.
365// Callers perform their state mutation and view invalidation inside fn; the
366// dispatch lock is acquired before any session, tab, or daemon lock.
367func (s *session) runMutation(fn func() error) error {
368	if s == nil || fn == nil {
369		return nil
370	}
371	s.dispatchMu.Lock()
372	defer s.dispatchMu.Unlock()
373	return fn()
374}
375
376// tabForAttachment resolves the attachment's stable target after validating
377// it against current membership. It never changes another attachment's view.
378func (s *session) tabForAttachment(ac *attachedClient) *tab {
379	if s == nil || ac == nil {
380		return nil
381	}
382	for {
383		view := ac.viewSnapshot()
384		s.mu.Lock()
385		if _, ok := s.attachments[ac]; !ok {
386			s.mu.Unlock()
387			return nil
388		}
389		repaired := s.repairAttachmentViewLocked(ac, view)
390		var target *tab
391		for _, tb := range s.tabs {
392			if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
393				target = tb
394				break
395			}
396		}
397		s.mu.Unlock()
398
399		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 {
400			repaired.revision++
401		}
402		if !ac.publishViewIfCurrent(view, repaired) {
403			continue
404		}
405		return target
406	}
407}
408
409// paneForAttachment resolves both stable IDs and repairs a removed pane before
410// returning it. The tab remains shared; only the attachment's view moves.
411func (s *session) tabIndexForAttachment(ac *attachedClient) (int, int) {
412	if s == nil {
413		return -1, 0
414	}
415	s.mu.Lock()
416	tabs := append([]*tab(nil), s.tabs...)
417	s.mu.Unlock()
418	if len(tabs) == 0 {
419		return -1, 0
420	}
421	if ac == nil {
422		return 0, len(tabs)
423	}
424	view := ac.viewSnapshot()
425	for i, tb := range tabs {
426		if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
427			return i, len(tabs)
428		}
429	}
430	return 0, len(tabs)
431}
432
433// firstTab returns the first tab in the session's ordered tab collection.
434// It is a deterministic session-level default for headless work, never an
435// interactive attachment selection.
436func (s *session) firstTab() *tab {
437	if s == nil {
438		return nil
439	}
440	s.mu.Lock()
441	defer s.mu.Unlock()
442	if len(s.tabs) == 0 {
443		return nil
444	}
445	return s.tabs[0]
446}
447
448func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
449	tb := s.tabForAttachment(ac)
450	if tb == nil {
451		return nil, nil
452	}
453	view := ac.viewSnapshot()
454	tb.mu.Lock()
455	defer tb.mu.Unlock()
456	for _, p := range tb.panes {
457		if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
458			return tb, p
459		}
460	}
461	return tb, tb.focusedPane()
462}
463
464// setAttachmentPaneLocked publishes the initiating attachment's pane target
465// after a shared focus mutation. Caller holds s.mu; the structural tree focus
466// is not used to resolve any other attachment's target.
467func (s *session) setAttachmentPaneLocked(ac *attachedClient, tb *tab, p *pane) bool {
468	if s == nil || ac == nil || tb == nil || p == nil || !attachmentRegisteredLocked(s, ac) {
469		return false
470	}
471	view := ac.viewSnapshot()
472	before := view
473	view.tabID = domain.TabStableID(tb.stableID)
474	view.paneID = domain.PaneStableID(p.stableID)
475	view = s.repairAttachmentViewLocked(ac, view)
476	if view.tabID == before.tabID && view.paneID == before.paneID {
477		return false
478	}
479	view.revision++
480	ac.publishView(view)
481	return true
482}
483
484// updateAttachmentView applies one attachment-local view mutation at the
485// shared mutation boundary. The revision increments exactly once and target
486// IDs are repaired before publication.
487func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachmentView)) bool {
488	if s == nil || ac == nil || update == nil {
489		return false
490	}
491	changed := false
492	_ = s.runMutation(func() error {
493		s.mu.Lock()
494		defer s.mu.Unlock()
495		if _, ok := s.attachments[ac]; !ok {
496			return nil
497		}
498		before := ac.viewSnapshot()
499		next := before
500		update(&next)
501		next = s.repairAttachmentViewLocked(ac, next)
502		next.revision++
503		ac.publishView(next)
504		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
505		return nil
506	})
507	return changed
508}
509
510// selectAttachmentTab changes only one attachment's target. Shared tab order
511// and PTY ownership are untouched.
512func (s *session) activateAttachmentViewLocked(ac *attachedClient, idx int) bool {
513	if s == nil || ac == nil || idx < 0 || idx >= len(s.tabs) || !attachmentRegisteredLocked(s, ac) {
514		return false
515	}
516	view := ac.viewSnapshot()
517	view.tabID = domain.TabStableID(s.tabs[idx].stableID)
518	view.paneID = ""
519	view = s.repairAttachmentViewLocked(ac, view)
520	view.revision++
521	ac.publishView(view)
522	return true
523}
524
525func (s *session) selectAttachmentTabLocked(ac *attachedClient, tabID domain.TabStableID) bool {
526	if s == nil || ac == nil || !attachmentRegisteredLocked(s, ac) {
527		return false
528	}
529	view := ac.viewSnapshot()
530	view.tabID = tabID
531	view.paneID = ""
532	view = s.repairAttachmentViewLocked(ac, view)
533	view.revision++
534	ac.publishView(view)
535	s.invalidateViewsLocked()
536	return view.tabID == tabID
537}
538
539func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool {
540	if s == nil || ac == nil {
541		return false
542	}
543	var changed bool
544	_ = s.runMutation(func() error {
545		s.mu.Lock()
546		defer s.mu.Unlock()
547		changed = s.selectAttachmentTabLocked(ac, tabID)
548		return nil
549	})
550	return changed
551}
552
553func (s *session) switchAttachmentTabForDispatch(ac *attachedClient, idx int) bool {
554	if s == nil || ac == nil {
555		return false
556	}
557	s.mu.Lock()
558	defer s.mu.Unlock()
559	if idx < 0 || idx >= len(s.tabs) {
560		return false
561	}
562	return s.selectAttachmentTabLocked(ac, domain.TabStableID(s.tabs[idx].stableID))
563}
564
565func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
566	if s == nil || ac == nil {
567		return false
568	}
569	s.mu.Lock()
570	registered := attachmentRegisteredLocked(s, ac)
571	if registered && idx >= 0 && idx < len(s.tabs) {
572		tabID := domain.TabStableID(s.tabs[idx].stableID)
573		s.mu.Unlock()
574		return s.selectAttachmentTab(ac, tabID)
575	}
576	s.mu.Unlock()
577	return false
578}
579
580func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta int) bool {
581	position, count := s.tabIndexForAttachment(ac)
582	if count < 2 {
583		return false
584	}
585	return s.switchAttachmentTabForDispatch(ac, (position+delta+count)%count)
586}
587
588// repairAttachmentViews repairs all live attachment targets after a shared
589// topology mutation.
590func (s *session) repairAttachmentViews() {
591	if s == nil {
592		return
593	}
594	_ = s.runMutation(func() error {
595		s.mu.Lock()
596		s.invalidateViewsLocked()
597		s.mu.Unlock()
598		return nil
599	})
600}
601
602// repairAttachmentViewsLocked repairs all live attachment targets after a tab
603// or pane mutation. It is intended to run at the same mutation linearization
604// point as the shared membership change.
605func (s *session) repairAttachmentViewsLocked() {
606	if s == nil {
607		return
608	}
609	s.invalidateViewsLocked()
610}

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