v0.7.0 4c16dd8

overlay_runtime.go RawHistoryFolder

1package daemon
2
3import (
4	"sync"
5	"time"
6
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/protocol"
9	scopy "github.com/bnema/vev/internal/usecase/copy"
10	"github.com/bnema/vev/internal/usecase/notices"
11	"github.com/bnema/vev/internal/usecase/palette"
12	"github.com/bnema/vev/internal/usecase/picker"
13	promptui "github.com/bnema/vev/internal/usecase/prompt"
14	"github.com/bnema/vev/internal/usecase/ui"
15	"github.com/bnema/vev/internal/usecase/visualsearch"
16)
17
18type overlayRuntime struct {
19	ac *attachedClient
20
21	pickerMu sync.Mutex
22	// pickerPublishMu serializes snapshot publication for this attachment. A
23	// publisher reserves the sole right to publish, builds, validates, and
24	// sends its snapshot, and only then commits the revision it announced. It
25	// is held across the transport send so two concurrent publishers cannot
26	// interleave or reorder authoritative revisions. pickerMu is still the
27	// state lock and is never held across the send; the lock order is
28	// pickerPublishMu -> pickerMu, and it is never taken while sendMu is held.
29	pickerPublishMu sync.Mutex
30	// picker* carries the serving-daemon side of the picker interaction,
31	// guarded by pickerMu. Open tracks the current interaction namespace;
32	// interaction is the latest admitted open ID (late selections for closed
33	// interactions reject); revisions are the per-source published versions
34	// (older or duplicate revisions discard); keys maps opaque row keys of the
35	// serving source to resolved targets; intent and moverSource are the
36	// daemon-side facts captured at open; requestID echoes a client client picker request.
37	pickerOpen        bool
38	pickerInteraction uint64
39	pickerRevisions   map[string]uint64
40	pickerKeys        map[string]picker.Target
41	// pickerSourcePublished distinguishes "never published" from "published an
42	// empty set" for the current interaction. pickerRecent and pickerGrouped
43	// are the exact projections last published: a refresh whose projections
44	// equal them is not authority-relevant, so it must not bump the revision
45	// nor send a snapshot the client never needed to see.
46	pickerSourcePublished bool
47	pickerRecent          protocol.PickerProjection
48	pickerGrouped         protocol.PickerProjection
49	pickerIntent          protocol.PickerIntent
50	pickerMoveSource      moveSourceLocator
51	pickerRequestID       uint64
52	// pickerOpenedAt is the daemon-clock instant the current interaction was
53	// offered. Raw input that still arrives after pickerAcquisitionGrace
54	// proves the client never took the interaction, so it is released instead
55	// of swallowing input forever.
56	pickerOpenedAt time.Time
57	// pickerNavigationOffers numbers the fire-and-forget navigation offers
58	// that ask the client to open its own session picker over this live
59	// attachment. They open no daemon interaction, so they never touch
60	// pickerInteraction.
61	pickerNavigationOffers uint64
62
63	paletteMu            sync.Mutex
64	palette              *palette.Model
65	paletteRouteSnapshot protocol.RecentRouteSnapshot
66	paletteGeneration    uint64
67	paletteHints         palette.ContextualHints
68	palettePreview       string
69	paletteFeedback      string
70	palettePending       []byte
71	// paletteInventory* carries the serving-daemon side of navigation
72	// inventory, guarded by paletteMu alongside the palette model. Open
73	// tracks the current interaction namespace; publication is the latest
74	// admitted publication generation (older/duplicates discard); groups
75	// holds the sanitized relayed rows; selected freezes the chosen row
76	// across select-close; demandSent records open-demand emission.
77	paletteInventoryOpen        bool
78	paletteInventoryInteraction uint64
79	paletteInventoryPublication uint64
80	paletteInventoryGroups      []protocol.NavigationInventorySourceGroup
81	paletteInventorySelected    *protocol.NavigationInventorySelection
82	paletteInventoryDemandSent  bool
83
84	promptMu               sync.Mutex
85	prompt                 *promptui.Model
86	promptSubmit           func(string) error
87	promptTransitionSubmit func(string, *attachmentEffect) error
88	promptPending          []byte
89
90	resizeMu      sync.Mutex
91	resizeActive  bool
92	resizePending []byte
93	resizeESC     pendingByteTimer
94
95	copyMu            sync.Mutex
96	copyMode          *scopy.Mode
97	copyCandidate     *scopy.Mode
98	copyDocument      *scopy.Document
99	copyPane          *pane
100	copyPending       []byte
101	copyESC           pendingByteTimer
102	copyScroll        copyScrollAnimation
103	copySearch        *visualsearch.Model
104	copySearchPending []byte
105	statusFeedback    string
106	copyPointer       copyPointerState
107	copyClick         copyClickCandidate
108	copyPointerEpoch  uint64
109
110	// noticeMu is the innermost overlay lock: it guards the toast fields below
111	// and nothing is ever locked, sent, or rendered while it is held. The only
112	// permitted nesting is sendMu -> noticeMu (rendering reads the toasts).
113	// The notification history overlay's fields share this lock rather than
114	// adding one of their own: both concern notice presentation for this
115	// client and neither is ever held across a render call.
116	noticeMu sync.Mutex
117	// noticeQueue is this client's toast stack and its waiting notices. nil
118	// until the first notice.
119	noticeQueue *ui.ToastQueue[domain.Notification]
120	// noticeTimer fires at the queue's next expiry. A stale fire is harmless:
121	// it only re-evaluates the queue at the current time.
122	noticeTimer pendingByteTimer
123	noticeDue   time.Time
124
125	// noticesOverlay is the `notifications` command's history modal. nil when
126	// closed.
127	noticesOverlay *notices.Model
128	noticesPending []byte
129	noticesESC     pendingByteTimer
130}
131
132type copyPointerState struct {
133	valid    bool
134	epoch    uint64
135	pane     *pane
136	document *scopy.Document
137	geometry copyMouseGeometry
138	press    scopy.Pos
139	dragging bool
140	wordDrag bool
141}
142
143type copyClickCandidate struct {
144	valid   bool
145	pane    *pane
146	pos     scopy.Pos
147	at      time.Time
148	dragged bool
149}
150
151func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
152	return &overlayRuntime{ac: ac}
153}
154
155func (rt *overlayRuntime) Active() bool {
156	if rt == nil || rt.ac == nil {
157		return false
158	}
159	return rt.promptActive() || rt.paletteActive() || rt.pickerClientActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive()
160}
161
162func (rt *overlayRuntime) promptActive() bool {
163	if rt == nil {
164		return false
165	}
166	rt.promptMu.Lock()
167	defer rt.promptMu.Unlock()
168	return rt.prompt != nil
169}
170
171func (rt *overlayRuntime) paletteActive() bool {
172	if rt == nil {
173		return false
174	}
175	rt.paletteMu.Lock()
176	defer rt.paletteMu.Unlock()
177	return rt.palette != nil
178}
179
180// pickerClientActive reports whether a picker interaction is open on this
181// attachment. Such an interaction owns the user input: the daemon must not
182// route raw key or mouse events to the session while it is open. Its typed
183// PickerSelection/PickerClose messages are the only effect it may have, and
184// they arrive on the control path, not here.
185func (rt *overlayRuntime) pickerClientActive() bool {
186	if rt == nil {
187		return false
188	}
189	rt.pickerMu.Lock()
190	defer rt.pickerMu.Unlock()
191	return rt.pickerOpen
192}
193
194func (rt *overlayRuntime) noticesActive() bool {
195	if rt == nil {
196		return false
197	}
198	rt.noticeMu.Lock()
199	defer rt.noticeMu.Unlock()
200	return rt.noticesOverlay != nil
201}
202
203func (rt *overlayRuntime) resizeModeActive() bool {
204	if rt == nil {
205		return false
206	}
207	rt.resizeMu.Lock()
208	defer rt.resizeMu.Unlock()
209	return rt.resizeActive
210}
211
212func (rt *overlayRuntime) copyActive() bool {
213	if rt == nil {
214		return false
215	}
216	rt.copyMu.Lock()
217	defer rt.copyMu.Unlock()
218	return rt.copyMode != nil
219}
220
221func (rt *overlayRuntime) copySearchActive() bool {
222	if rt == nil {
223		return false
224	}
225	rt.copyMu.Lock()
226	defer rt.copyMu.Unlock()
227	return rt.copySearch != nil
228}
229
230func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
231	rt.copyPointerEpoch++
232	pointer.epoch = rt.copyPointerEpoch
233	pointer.valid = true
234	rt.copyPointer = pointer
235}
236
237func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
238	rt.stopCopyScrollLocked()
239	rt.copyPointerEpoch++
240	rt.copyPointer = copyPointerState{}
241	if clearClick {
242		rt.copyClick = copyClickCandidate{}
243	}
244}
245
246// clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
247// teardown occurring while publication revalidates can invalidate the transfer.
248func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
249	rt.copyPointer = copyPointerState{}
250	rt.copyClick = copyClickCandidate{}
251}
252
253func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
254	if rt.copyCandidate != candidate {
255		return
256	}
257	rt.copyCandidate = nil
258	rt.copyDocument = nil
259	rt.copyPane = nil
260	rt.copySearch = nil
261	rt.copySearchPending = nil
262}
263
264func (rt *overlayRuntime) clearCopyModeLocked() {
265	rt.copyMode = nil
266	rt.copyCandidate = nil
267	rt.copyDocument = nil
268	rt.copyPane = nil
269	rt.copySearch = nil
270	rt.copySearchPending = nil
271	rt.invalidateCopyPointerLocked(true)
272}
273
274func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
275	if rt == nil || p == nil {
276		return false
277	}
278	rt.copyMu.Lock()
279	defer rt.copyMu.Unlock()
280	active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)
281	prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p
282	if active {
283		rt.clearCopyModeLocked()
284	} else if prePublication {
285		rt.invalidateCopyPointerLocked(true)
286	}
287	return active || prePublication
288}
289
290func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffect) bool {
291	if rt == nil || rt.ac == nil {
292		return false
293	}
294	ac := rt.ac
295	rt.copyMu.Lock()
296	rt.stopCopyScrollLocked()
297	rt.copyMu.Unlock()
298	var effect *attachmentEffect
299	if len(effects) != 0 {
300		effect = effects[0]
301	}
302	if rt.promptActive() {
303		d.handlePromptInput(ac, data, effect)
304		return true
305	}
306	if rt.paletteActive() {
307		d.handlePaletteInput(ac, data, effect)
308		return true
309	}
310	if rt.noticesActive() {
311		d.handleNoticesInput(ac, data)
312		return true
313	}
314	if rt.resizeModeActive() {
315		d.handleResizeInput(ac, data)
316		return true
317	}
318	if rt.copyActive() {
319		d.handleCopyInput(ac, data)
320		return true
321	}
322	return false
323}
324
325type overlayRenderSnapshot struct {
326	rt *overlayRuntime
327
328	copyActive      bool
329	copyMode        *scopy.Mode
330	copyPane        *pane
331	copySearchModel *visualsearch.Model
332	statusFeedback  string
333	resizeActive    bool
334
335	noticesOverlayActive bool
336	noticesOverlayModel  *notices.Model
337
338	paletteActive bool
339	paletteModel  *palette.Model
340	// paletteHints is a copy captured under paletteMu. Rendering must use this
341	// immutable interaction snapshot rather than consult live session state.
342	paletteHints         *palette.ContextualHints
343	palettePreview       string
344	paletteFeedback      string
345	paletteRouteSnapshot protocol.RecentRouteSnapshot
346	paletteLocked        bool
347
348	promptActive bool
349	promptModel  *promptui.Model
350	promptLocked bool
351
352	notices        []domain.Notification
353	noticeOverflow int
354}
355
356// SnapshotForRender captures the overlay state needed by paint.
357//
358// The snapshot owns any prompt or palette locks it had to keep held so the
359// returned model pointers stay stable during composition. Callers must release
360// those locks with overlayRenderSnapshot.Unlock, usually with defer immediately
361// after acquisition and before any path that may re-enter rendering.
362func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
363	snap := &overlayRenderSnapshot{rt: rt}
364	if rt == nil {
365		return snap
366	}
367
368	// noticeMu is innermost and never held across render, so it is taken and
369	// released here rather than tracked like paletteMu/promptMu below.
370	rt.noticeMu.Lock()
371	if rt.noticeQueue != nil {
372		for _, t := range rt.noticeQueue.Peek() {
373			snap.notices = append(snap.notices, t.Value)
374		}
375		snap.noticeOverflow = rt.noticeQueue.Pending()
376	}
377	snap.noticesOverlayActive = rt.noticesOverlay != nil
378	snap.noticesOverlayModel = rt.noticesOverlay.Clone()
379	rt.noticeMu.Unlock()
380
381	rt.copyMu.Lock()
382	snap.copyActive = rt.copyMode != nil
383	snap.copyPane = rt.copyPane
384	if rt.copyMode != nil {
385		copyModeValue := *rt.copyMode
386		copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
387		snap.copyMode = &copyModeValue
388	}
389	snap.copySearchModel = rt.copySearch.Clone()
390	snap.statusFeedback = rt.statusFeedback
391	if snap.statusFeedback != "" && !snap.copyActive {
392		rt.statusFeedback = ""
393	}
394	rt.copyMu.Unlock()
395
396	rt.resizeMu.Lock()
397	snap.resizeActive = rt.resizeActive
398	rt.resizeMu.Unlock()
399
400	rt.paletteMu.Lock()
401	snap.paletteModel = rt.palette
402	snap.paletteActive = snap.paletteModel != nil
403	if snap.paletteActive {
404		hints := rt.paletteHints
405		hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
406		snap.paletteHints = &hints
407		snap.palettePreview = rt.palettePreview
408		snap.paletteFeedback = rt.paletteFeedback
409		snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
410		snap.paletteRouteSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
411		snap.paletteLocked = true
412	} else {
413		rt.paletteMu.Unlock()
414	}
415
416	rt.promptMu.Lock()
417	snap.promptModel = rt.prompt
418	snap.promptActive = snap.promptModel != nil
419	if snap.promptActive {
420		snap.promptLocked = true
421	} else {
422		rt.promptMu.Unlock()
423	}
424
425	return snap
426}
427
428func (rt *overlayRuntime) UnlockRenderSnapshot(snap *overlayRenderSnapshot) {
429	if snap == nil {
430		return
431	}
432	snap.Unlock()
433}
434
435func (snap *overlayRenderSnapshot) Unlock() {
436	if snap == nil || snap.rt == nil {
437		return
438	}
439	if snap.promptLocked {
440		snap.rt.promptMu.Unlock()
441		snap.promptLocked = false
442	}
443	if snap.paletteLocked {
444		snap.rt.paletteMu.Unlock()
445		snap.paletteLocked = false
446	}
447}

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