ui_actions.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"time"
6
7	"github.com/bnema/vev/internal/ports"
8	"github.com/bnema/vev/internal/protocol"
9)
10
11// bindForeground runs at the existing foreground ownership boundary.
12func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consumer uint64) uint64 {
13	u.mu.Lock()
14	defer u.mu.Unlock()
15	u.generation++
16	u.input = input
17	u.consumer = consumer
18	u.foreground = ctx
19	u.boundary = ports.UIActionResult{}
20	u.suppressed = ports.UIContext{}
21	if u.handoff != nil && u.handoff.destinationGeneration == 0 {
22		u.handoff.destinationGeneration = u.generation
23	} else if u.pending != 0 {
24		u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
25		u.pending = 0
26	}
27	u.signalLocked()
28	return u.generation
29}
30
31// follow retains the accepted input action across an exact daemon navigation.
32// It waits for the destination's first committed publication, not merely a
33// successful send on the source attachment.
34func (u *UI) follow(generation, actionID uint64) bool {
35	u.mu.Lock()
36	defer u.mu.Unlock()
37	record, ok := u.records[actionID]
38	if !ok || actionID == 0 || generation != u.generation || record.Context.Generation != generation ||
39		(record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed) {
40		return false
41	}
42	if u.pending != 0 && u.pending != actionID {
43		u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
44	}
45	record.Status = ports.UIActionPending
46	u.records[actionID] = record
47	u.pending = actionID
48	u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation}
49	u.signalLocked()
50	return true
51}
52
53// arriveInPlace marks the destination of a followed same-peer switch as
54// committed on the unchanged foreground generation. The switch keeps the
55// attachment, so no new foreground binding names the destination: the worker
56// calls this just before publishing the destination's first output, and that
57// publication settles the action.
58func (u *UI) arriveInPlace(generation uint64) {
59	u.mu.Lock()
60	defer u.mu.Unlock()
61	if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 {
62		return
63	}
64	u.handoff.destinationGeneration = generation
65}
66
67// failInPlace fails a followed same-peer switch the daemon refused: the source
68// attachment is unchanged and the destination never commits.
69func (u *UI) failInPlace(generation uint64) {
70	u.mu.Lock()
71	defer u.mu.Unlock()
72	if u.handoff == nil || generation != u.generation || u.handoff.sourceGeneration != generation || u.handoff.destinationGeneration != 0 {
73		return
74	}
75	u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{})
76}
77
78// releaseForeground retires exactly the binding installed for generation.
79// The supervisor owns the presentation fence after the foreground drains.
80func (u *UI) releaseForeground(generation uint64) {
81	u.mu.Lock()
82	if generation != u.generation {
83		u.mu.Unlock()
84		return
85	}
86	u.input = nil
87	u.consumer = 0
88	u.foreground = nil
89	u.boundary = ports.UIActionResult{}
90	if u.pending != 0 && (u.handoff == nil || u.handoff.actionID != u.pending) {
91		u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
92	}
93	u.signalLocked()
94	u.mu.Unlock()
95}
96
97func (u *UI) accept(id, generation uint64) bool {
98	u.mu.Lock()
99	defer u.mu.Unlock()
100	if u.pending != id || u.generation != generation {
101		return false
102	}
103	if len(u.order) == uiActionHistory {
104		evicted := u.order[0]
105		delete(u.records, evicted)
106		delete(u.dispatched, evicted)
107		if done, ok := u.completion[evicted]; ok {
108			close(done)
109			delete(u.completion, evicted)
110		}
111		u.order = u.order[1:]
112	}
113	u.order = append(u.order, id)
114	u.completion[id] = make(chan struct{})
115	u.records[id] = ports.UIActionResult{ActionID: id, Accepted: true, Status: ports.UIActionPending, Context: u.reservedContext}
116	u.signalLocked()
117	return true
118}
119
120func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports.UIActionResult) {
121	record, ok := u.records[id]
122	if !ok {
123		return
124	}
125	record.Status = status
126	if status == ports.UIActionProcessed {
127		u.dispatched[id] = true
128		record.Revision = boundary.Revision
129		record.Context = boundary.Context
130	}
131	u.records[id] = record
132	if done, ok := u.completion[id]; ok {
133		close(done)
134		delete(u.completion, id)
135	}
136	if u.handoff != nil && u.handoff.actionID == id {
137		u.handoff = nil
138	}
139	if u.pending == id {
140		u.pending = 0
141	}
142	if u.overlayAction == id {
143		u.overlayAction = 0
144	}
145	u.signalLocked()
146}
147
148// published remembers only a committed output boundary, not receipt receive
149// time or an unrelated status-only revision.
150func (u *UI) published(generation uint64) {
151	// Runs once per committed frame: read only the boundary, never the cells.
152	boundary, err := u.state.Boundary()
153	if err != nil {
154		return
155	}
156	u.mu.Lock()
157	defer u.mu.Unlock()
158	if generation != u.generation || boundary.Context.Generation != generation {
159		return
160	}
161	u.boundary = boundary
162	u.suppressed = ports.UIContext{}
163	if u.handoff != nil && u.handoff.destinationGeneration == generation && boundary.Context.Status == ports.UIStatusAttached {
164		u.handoff.boundary = u.boundary
165		if u.dispatched[u.handoff.actionID] {
166			u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary)
167		}
168	}
169	u.signalLocked()
170}
171
172func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) {
173	u.mu.Lock()
174	defer u.mu.Unlock()
175	if u.handoff != nil && u.handoff.actionID == receipt.ActionID {
176		return
177	}
178	record, ok := u.records[receipt.ActionID]
179	if !ok || record.Status != ports.UIActionPending || record.Context.Generation != generation || generation != u.generation || receipt.Validate() != nil {
180		return
181	}
182	if receipt.Outcome == protocol.UIReceiptUnavailable {
183		u.finishLocked(receipt.ActionID, ports.UIActionUnavailable, ports.UIActionResult{})
184		return
185	}
186	boundary := u.boundary
187	if boundary.Revision == 0 {
188		return
189	}
190	if receiptMatches(boundary.Context, receipt) {
191		u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary)
192		return
193	}
194	// The client picker overlay suppressed the output that reached this
195	// boundary: it was applied, not written. Its committed context is the
196	// boundary, and the last written revision keeps later waits eligible.
197	if u.suppressed.Generation == generation && receiptMatches(u.suppressed, receipt) {
198		u.finishLocked(receipt.ActionID, ports.UIActionProcessed, ports.UIActionResult{Revision: boundary.Revision, Context: u.suppressed})
199	}
200}
201
202func receiptMatches(context ports.UIContext, receipt protocol.UIReceipt) bool {
203	return context.OutputEpoch == receipt.Epoch && context.OutputState == receipt.State && context.ViewPublication == receipt.ViewPublication
204}
205
206// suppressedOutput records the committed context of attachment output the
207// client picker overlay applied without writing, so a daemon receipt fenced
208// on it still settles its action.
209func (u *UI) suppressedOutput(generation uint64, context ports.UIContext) {
210	u.mu.Lock()
211	defer u.mu.Unlock()
212	if generation == 0 || generation != u.generation || context.Generation != generation || context.Status != ports.UIStatusAttached {
213		return
214	}
215	u.suppressed = context
216}
217
218// OverlayContext is the attached context a composition publishes while the
219// client picker is composed over the live attachment. The attachment is still
220// the actionable generation, so the picker frame keeps its session identity and
221// generation instead of an unattached Picker presentation. It reports false when
222// no attached boundary of the current foreground exists.
223func (u *UI) OverlayContext() (ports.UIContext, bool) {
224	if u == nil {
225		return ports.UIContext{}, false
226	}
227	u.mu.Lock()
228	defer u.mu.Unlock()
229	context := u.boundary.Context
230	if u.suppressed.Generation != 0 {
231		context = u.suppressed
232	}
233	if u.input == nil || context.Generation == 0 || context.Generation != u.generation || context.Status != ports.UIStatusAttached {
234		return ports.UIContext{}, false
235	}
236	return context, true
237}
238
239// markOverlayInput records that the admitted action id is about to be consumed
240// by the client picker overlay. It runs before the picker sees the bytes, so a
241// commit the read decides can always find the action to follow.
242func (u *UI) markOverlayInput(generation, id uint64) {
243	u.mu.Lock()
244	defer u.mu.Unlock()
245	if id == 0 || generation != u.generation || u.pending != id {
246		return
247	}
248	u.overlayAction = id
249}
250
251// followOverlay retains the overlay-consumed action across the attachment swap
252// a picker commit started, so it settles on the destination's first committed
253// publication.
254func (u *UI) followOverlay(generation uint64) {
255	u.mu.Lock()
256	id := u.overlayAction
257	u.mu.Unlock()
258	if id != 0 {
259		u.follow(generation, id)
260	}
261}
262
263// settleOverlay completes one overlay-consumed action at the supervisor's
264// event-loop boundary: the picker applied its input and repainted. A followed
265// action is left to the destination publication.
266func (u *UI) settleOverlay(generation, id uint64) {
267	snapshot, err := u.state.Snapshot()
268	u.mu.Lock()
269	defer u.mu.Unlock()
270	if id == 0 || u.overlayAction != id || u.pending != id || generation != u.generation {
271		return
272	}
273	if u.handoff != nil && u.handoff.actionID == id {
274		return
275	}
276	boundary := u.boundary
277	if err == nil && snapshot.Context.Generation == generation && snapshot.Context.Status == ports.UIStatusAttached {
278		boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
279	}
280	u.finishLocked(id, ports.UIActionProcessed, boundary)
281}
282
283func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) {
284	timeout, err := uiTimeout(request.Timeout)
285	if err != nil {
286		return ports.UIActionResult{}, err
287	}
288	if request.Attachment != u.handle {
289		return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
290	}
291	u.mu.Lock()
292	available := u.input != nil
293	u.mu.Unlock()
294	if !available {
295		return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable}
296	}
297	snapshot, err := u.state.Snapshot()
298	if err != nil {
299		return ports.UIActionResult{}, err
300	}
301	if request.Generation == 0 || snapshot.Context.Generation != request.Generation {
302		return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
303	}
304	var data []byte
305	switch {
306	case len(request.Keys) > 0 && request.Text == "":
307		data, err = encodeUIKeys(request.Keys, snapshot.ApplicationCursor)
308	case len(request.Keys) == 0 && request.Text != "":
309		data, err = encodeUIText(request.Text)
310	default:
311		err = errUIInvalidInput
312	}
313	if err != nil {
314		return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest}
315	}
316	u.mu.Lock()
317	if u.input == nil {
318		u.mu.Unlock()
319		return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable}
320	}
321	if request.Generation != u.generation {
322		u.mu.Unlock()
323		return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
324	}
325	if u.pending != 0 {
326		u.mu.Unlock()
327		return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrBusy}
328	}
329	if u.foreground == nil || u.foreground.Err() != nil || snapshot.Context.Status != ports.UIStatusAttached {
330		u.mu.Unlock()
331		return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable}
332	}
333	u.nextAction++
334	id := u.nextAction
335	u.pending = id
336	u.reservedContext = snapshot.Context
337	input, consumer, foreground := u.input, u.consumer, u.foreground
338	u.mu.Unlock()
339	timer := u.clock.NewTimer(timeout)
340	defer timer.Stop()
341	admissionCtx, cancelAdmission := context.WithCancel(ctx)
342	defer cancelAdmission()
343	batch := terminalAutomationRequest{ctx: admissionCtx, owner: u, consumer: consumer, record: terminalReadResult{data: data, source: terminalInputAutomation, generation: request.Generation, actionID: id, endBatch: true, keys: request.Keys, text: request.Text}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)}
344	reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) {
345		u.mu.Lock()
346		delete(u.records, id)
347		if u.pending == id {
348			u.pending = 0
349		}
350		for i, entry := range u.order {
351			if entry == id {
352				u.order = append(u.order[:i], u.order[i+1:]...)
353				break
354			}
355		}
356		u.signalLocked()
357		u.mu.Unlock()
358		return ports.UIActionResult{}, &ports.UIError{Code: code}
359	}
360	select {
361	case input.automation <- batch:
362	case <-ctx.Done():
363		return reject(ports.UIErrTimeout)
364	case <-foreground.Done():
365		return reject(ports.UIErrUnavailable)
366	case <-timer.C():
367		return reject(ports.UIErrTimeout)
368	}
369	// Once the owner receives the request it returns admission without I/O. Learn
370	// that decision even if cancellation races it; never claim rollback of input.
371	admitted := <-batch.admitted
372	if !admitted {
373		return reject(ports.UIErrInputBusy)
374	}
375	go u.trackDispatch(foreground, id, batch.dispatched)
376	for {
377		u.mu.Lock()
378		record, exists := u.records[id]
379		changed := u.changed
380		u.mu.Unlock()
381		if !exists {
382			return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrActionExpired, Accepted: true, ActionID: id}
383		}
384		if record.Status == ports.UIActionProcessed {
385			return record, nil
386		}
387		if record.Status != ports.UIActionPending {
388			return record, &ports.UIError{Code: ports.UIErrorCode(record.Status), Accepted: true, ActionID: id}
389		}
390		select {
391		case <-changed:
392		case <-ctx.Done():
393			return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id}
394		case <-timer.C():
395			return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id}
396		}
397	}
398}
399
400func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan bool) {
401	timer := u.clock.NewTimer(30 * time.Second)
402	defer timer.Stop()
403	foregroundDone := ctx.Done()
404	for {
405		u.mu.Lock()
406		record, exists := u.records[id]
407		changed := u.changed
408		u.mu.Unlock()
409		if !exists || record.Status != ports.UIActionPending {
410			return
411		}
412		var status ports.UIActionStatus
413		select {
414		case ok := <-dispatched:
415			if !ok {
416				status = ports.UIActionOutcomeUnknown
417			} else {
418				u.mu.Lock()
419				if _, exists := u.records[id]; exists {
420					u.dispatched[id] = true
421					if u.handoff != nil && u.handoff.boundary.Revision != 0 {
422						u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary)
423					}
424				}
425				u.mu.Unlock()
426			}
427			dispatched = nil
428		case <-foregroundDone:
429			u.mu.Lock()
430			following := u.handoff != nil && u.handoff.actionID == id
431			u.mu.Unlock()
432			if following {
433				foregroundDone = nil
434			} else {
435				status = ports.UIActionOutcomeUnknown
436			}
437		case <-timer.C():
438			status = ports.UIActionUnavailable
439		case <-changed:
440		}
441		if status != "" {
442			u.mu.Lock()
443			if current := u.records[id]; current.Status == ports.UIActionPending {
444				u.finishLocked(id, status, ports.UIActionResult{})
445			}
446			u.mu.Unlock()
447			return
448		}
449	}
450}
451
452var _ ports.UIService = (*UI)(nil)

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