v0.2.0 80c61e3

overlay_runtime.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"sync"
6	"time"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/ports"
10	scopy "github.com/bnema/vev/internal/usecase/copy"
11	"github.com/bnema/vev/internal/usecase/notices"
12	"github.com/bnema/vev/internal/usecase/palette"
13	"github.com/bnema/vev/internal/usecase/picker"
14	promptui "github.com/bnema/vev/internal/usecase/prompt"
15	"github.com/bnema/vev/internal/usecase/visualsearch"
16)
17
18type overlayRuntime struct {
19	ac *attachedClient
20
21	pickerMu sync.Mutex
22	picker   *picker.Model
23	// pickerGeneration identifies one open lifecycle. It advances only when a
24	// picker is published, so delayed close and registration work can prove it
25	// still owns the exact lifecycle it captured.
26	pickerGeneration          uint64
27	pickerTitle               string
28	pickerIntent              pickerIntent
29	pickerSource              moveSourceLocator
30	pickerPreview             *tab
31	pickerPreviewSession      *session
32	pickerRemotePreview       picker.Preview
33	pickerRemotePreviewCancel context.CancelFunc
34	pickerPreviewGeneration   uint64
35	pickerPending             []byte
36	pickerESC                 pendingByteTimer
37
38	// Test-only, unsynchronized lifecycle seams. Assign them before picker
39	// publication or goroutine startup. Hooks run without pickerMu or
40	// remoteCatalog.mu held.
41	beforeRemotePickerRegistration func()
42	afterPickerRefreshBuild        func(*picker.Model)
43
44	paletteMu            sync.Mutex
45	palette              *palette.Model
46	paletteRouteSnapshot ports.RecentRouteSnapshot
47	paletteGeneration    uint64
48	paletteHints         palette.ContextualHints
49	paletteFeedback      string
50	palettePending       []byte
51
52	promptMu               sync.Mutex
53	prompt                 *promptui.Model
54	promptSubmit           func(string) error
55	promptTransitionSubmit func(string, attachmentConnectionToken) error
56	promptPending          []byte
57
58	resizeMu      sync.Mutex
59	resizeActive  bool
60	resizePending []byte
61	resizeESC     pendingByteTimer
62
63	copyMu            sync.Mutex
64	copyMode          *scopy.Mode
65	copyCandidate     *scopy.Mode
66	copyDocument      *scopy.Document
67	copyPane          *pane
68	copyPending       []byte
69	copyESC           pendingByteTimer
70	copySearch        *visualsearch.Model
71	copySearchPending []byte
72	statusFeedback    string
73	copyPointer       copyPointerState
74	copyClick         copyClickCandidate
75	copyPointerEpoch  uint64
76
77	// noticeMu is the innermost overlay lock: it guards the toast fields below
78	// and nothing is ever locked, sent, or rendered while it is held. The only
79	// permitted nesting is sendMu -> noticeMu (rendering reads the toasts).
80	// The notification history overlay's fields share this lock rather than
81	// adding one of their own: both concern notice presentation for this
82	// client and neither is ever held across a render call.
83	noticeMu       sync.Mutex
84	noticeToasts   []noticeToast
85	noticeOverflow int
86	// noticeSeq numbers toast entries so an already-fired expiry timer cannot
87	// dismiss the refreshed entry that replaced the one it belonged to.
88	noticeSeq uint64
89
90	// noticesOverlay is the `notifications` command's history modal. nil when
91	// closed.
92	noticesOverlay *notices.Model
93	noticesPending []byte
94	noticesESC     pendingByteTimer
95}
96
97type copyPointerState struct {
98	valid    bool
99	epoch    uint64
100	pane     *pane
101	document *scopy.Document
102	geometry copyMouseGeometry
103	press    scopy.Pos
104	dragging bool
105	wordDrag bool
106}
107
108type copyClickCandidate struct {
109	valid   bool
110	pane    *pane
111	pos     scopy.Pos
112	at      time.Time
113	dragged bool
114}
115
116func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
117	return &overlayRuntime{ac: ac}
118}
119
120func (rt *overlayRuntime) Active() bool {
121	if rt == nil || rt.ac == nil {
122		return false
123	}
124	return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive()
125}
126
127func (rt *overlayRuntime) promptActive() bool {
128	if rt == nil {
129		return false
130	}
131	rt.promptMu.Lock()
132	defer rt.promptMu.Unlock()
133	return rt.prompt != nil
134}
135
136func (rt *overlayRuntime) paletteActive() bool {
137	if rt == nil {
138		return false
139	}
140	rt.paletteMu.Lock()
141	defer rt.paletteMu.Unlock()
142	return rt.palette != nil
143}
144
145func (rt *overlayRuntime) pickerActive() bool {
146	if rt == nil {
147		return false
148	}
149	rt.pickerMu.Lock()
150	defer rt.pickerMu.Unlock()
151	return rt.picker != nil
152}
153
154func (rt *overlayRuntime) noticesActive() bool {
155	if rt == nil {
156		return false
157	}
158	rt.noticeMu.Lock()
159	defer rt.noticeMu.Unlock()
160	return rt.noticesOverlay != nil
161}
162
163func (rt *overlayRuntime) resizeModeActive() bool {
164	if rt == nil {
165		return false
166	}
167	rt.resizeMu.Lock()
168	defer rt.resizeMu.Unlock()
169	return rt.resizeActive
170}
171
172func (rt *overlayRuntime) copyActive() bool {
173	if rt == nil {
174		return false
175	}
176	rt.copyMu.Lock()
177	defer rt.copyMu.Unlock()
178	return rt.copyMode != nil
179}
180
181func (rt *overlayRuntime) copySearchActive() bool {
182	if rt == nil {
183		return false
184	}
185	rt.copyMu.Lock()
186	defer rt.copyMu.Unlock()
187	return rt.copySearch != nil
188}
189
190func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
191	rt.copyPointerEpoch++
192	pointer.epoch = rt.copyPointerEpoch
193	pointer.valid = true
194	rt.copyPointer = pointer
195}
196
197func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
198	rt.copyPointerEpoch++
199	rt.copyPointer = copyPointerState{}
200	if clearClick {
201		rt.copyClick = copyClickCandidate{}
202	}
203}
204
205// clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or
206// teardown occurring while publication revalidates can invalidate the transfer.
207func (rt *overlayRuntime) clearCopyPointerForTransferLocked() {
208	rt.copyPointer = copyPointerState{}
209	rt.copyClick = copyClickCandidate{}
210}
211
212func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) {
213	if rt.copyCandidate != candidate {
214		return
215	}
216	rt.copyCandidate = nil
217	rt.copyDocument = nil
218	rt.copyPane = nil
219	rt.copySearch = nil
220	rt.copySearchPending = nil
221}
222
223func (rt *overlayRuntime) clearCopyModeLocked() {
224	rt.copyMode = nil
225	rt.copyCandidate = nil
226	rt.copyDocument = nil
227	rt.copyPane = nil
228	rt.copySearch = nil
229	rt.copySearchPending = nil
230	rt.invalidateCopyPointerLocked(true)
231}
232
233func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
234	if rt == nil || p == nil {
235		return false
236	}
237	rt.copyMu.Lock()
238	defer rt.copyMu.Unlock()
239	active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil)
240	prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p
241	if active {
242		rt.clearCopyModeLocked()
243	} else if prePublication {
244		rt.invalidateCopyPointerLocked(true)
245	}
246	return active || prePublication
247}
248
249func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool {
250	if rt == nil || rt.ac == nil {
251		return false
252	}
253	ac := rt.ac
254	var effect *attachmentEffectTicket
255	if len(effects) != 0 {
256		effect = effects[0]
257	}
258	if rt.promptActive() {
259		d.handlePromptInput(ac, data, effect)
260		return true
261	}
262	if rt.paletteActive() {
263		d.handlePaletteInput(ac, data, effect)
264		return true
265	}
266	if rt.pickerActive() {
267		d.handlePickerInput(ac, data, effect)
268		return true
269	}
270	if rt.noticesActive() {
271		d.handleNoticesInput(ac, data)
272		return true
273	}
274	if rt.resizeModeActive() {
275		d.handleResizeInput(ac, data)
276		return true
277	}
278	if rt.copyActive() {
279		d.handleCopyInput(ac, data)
280		return true
281	}
282	return false
283}
284
285type overlayRenderSnapshot struct {
286	rt *overlayRuntime
287
288	copyActive      bool
289	copyMode        *scopy.Mode
290	copyPane        *pane
291	copySearchModel *visualsearch.Model
292	statusFeedback  string
293	resizeActive    bool
294
295	pickerActive  bool
296	pickerModel   *picker.Model
297	pickerTitle   string
298	previewTab    *tab
299	remotePreview picker.Preview
300
301	noticesOverlayActive bool
302	noticesOverlayModel  *notices.Model
303
304	paletteActive bool
305	paletteModel  *palette.Model
306	// paletteHints is a copy captured under paletteMu. Rendering must use this
307	// immutable interaction snapshot rather than consult live session state.
308	paletteHints         *palette.ContextualHints
309	paletteFeedback      string
310	paletteRouteSnapshot ports.RecentRouteSnapshot
311	paletteLocked        bool
312
313	promptActive bool
314	promptModel  *promptui.Model
315	promptLocked bool
316
317	notices        []domain.Notification
318	noticeOverflow int
319}
320
321// SnapshotForRender captures the overlay state needed by paint.
322//
323// The snapshot owns any prompt or palette locks it had to keep held so the
324// returned model pointers stay stable during composition. Callers must release
325// those locks with overlayRenderSnapshot.Unlock, usually with defer immediately
326// after acquisition and before any path that may re-enter rendering.
327func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
328	snap := &overlayRenderSnapshot{rt: rt}
329	if rt == nil {
330		return snap
331	}
332
333	// noticeMu is innermost and never held across render, so it is taken and
334	// released here rather than tracked like paletteMu/promptMu below.
335	rt.noticeMu.Lock()
336	if len(rt.noticeToasts) > 0 {
337		snap.notices = make([]domain.Notification, len(rt.noticeToasts))
338		for i, t := range rt.noticeToasts {
339			snap.notices[i] = t.n
340		}
341	}
342	snap.noticeOverflow = rt.noticeOverflow
343	snap.noticesOverlayActive = rt.noticesOverlay != nil
344	snap.noticesOverlayModel = rt.noticesOverlay.Clone()
345	rt.noticeMu.Unlock()
346
347	rt.copyMu.Lock()
348	snap.copyActive = rt.copyMode != nil
349	snap.copyPane = rt.copyPane
350	if rt.copyMode != nil {
351		copyModeValue := *rt.copyMode
352		copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
353		snap.copyMode = &copyModeValue
354	}
355	snap.copySearchModel = rt.copySearch.Clone()
356	snap.statusFeedback = rt.statusFeedback
357	if snap.statusFeedback != "" && !snap.copyActive {
358		rt.statusFeedback = ""
359	}
360	rt.copyMu.Unlock()
361
362	rt.resizeMu.Lock()
363	snap.resizeActive = rt.resizeActive
364	rt.resizeMu.Unlock()
365
366	rt.pickerMu.Lock()
367	snap.pickerActive = rt.picker != nil
368	snap.pickerModel = rt.picker.Clone()
369	snap.pickerTitle = rt.pickerTitle
370	snap.previewTab = rt.pickerPreview
371	snap.remotePreview = clonePickerPreview(rt.pickerRemotePreview)
372	rt.pickerMu.Unlock()
373
374	rt.paletteMu.Lock()
375	snap.paletteModel = rt.palette
376	snap.paletteActive = snap.paletteModel != nil
377	if snap.paletteActive {
378		hints := rt.paletteHints
379		hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
380		snap.paletteHints = &hints
381		snap.paletteFeedback = rt.paletteFeedback
382		snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
383		snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
384		snap.paletteLocked = true
385	} else {
386		rt.paletteMu.Unlock()
387	}
388
389	rt.promptMu.Lock()
390	snap.promptModel = rt.prompt
391	snap.promptActive = snap.promptModel != nil
392	if snap.promptActive {
393		snap.promptLocked = true
394	} else {
395		rt.promptMu.Unlock()
396	}
397
398	return snap
399}
400
401func (rt *overlayRuntime) UnlockRenderSnapshot(snap *overlayRenderSnapshot) {
402	if snap == nil {
403		return
404	}
405	snap.Unlock()
406}
407
408func (snap *overlayRenderSnapshot) Unlock() {
409	if snap == nil || snap.rt == nil {
410		return
411	}
412	if snap.promptLocked {
413		snap.rt.promptMu.Unlock()
414		snap.promptLocked = false
415	}
416	if snap.paletteLocked {
417		snap.rt.paletteMu.Unlock()
418		snap.paletteLocked = false
419	}
420}

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