v0.3.0 99dc266

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

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