attention.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"time"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/ports"
10	"github.com/bnema/vev/internal/protocol"
11	"github.com/bnema/vev/internal/usecase/picker"
12)
13
14// noteAttention records attention for a tab. If the tab is currently visible,
15// it is kept long enough to render one pulse before paint acknowledges it. The
16// caller must not hold tab.mu.
17func (d *Daemon) noteAttention(sess *session, tb *tab) {
18	if sess == nil || tb == nil {
19		return
20	}
21
22	now := d.daemonNow()
23
24	sess.mu.Lock()
25	if !tb.attention {
26		tb.attentionAt = now
27	}
28	tb.attention = true
29	for _, attachment := range sess.snapshotAttachmentsLocked() {
30		view := attachment.viewSnapshot()
31		if domain.TabStableID(tb.stableID) == view.tabID && attachment.terminalFocus().MaySee() {
32			tb.attentionVisiblePaint = true
33			break
34		}
35	}
36	sess.mu.Unlock()
37
38	// Do not repaint here: this runs on the PTY reader goroutine, and paint
39	// can block on a slow client's socket (Transport.Send has no deadline) —
40	// that would let one wedged client stall a different session's reader.
41	// The animator's first tick (<=120ms) delivers the repaint, and pulse
42	// frame 0 renders the bell as blank anyway, so nothing is visibly lost
43	// by not painting immediately.
44	d.pokeAttentionTicker()
45}
46
47// jumpAttention switches to the oldest pending attention target: a tab of the
48// attached session first, then another session of this daemon, then the
49// oldest bell among the client's routes on any other daemon, which the client
50// navigates to. Returning nil never implies a switch happened —
51// it also covers "no target exists", which is routine and not an error. Only
52// a failure to reach a target that does exist is a genuine error.
53func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
54	return d.jumpAttentionForAttachment(sess, ac, nil)
55}
56
57func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, effect *attachmentEffect) error {
58	if sess == nil || ac == nil {
59		return nil
60	}
61	if idx, ok := oldestAttentionTab(sess); ok {
62		if sess.switchAttachmentTab(ac, idx) {
63			var lease *attachmentLease
64			if effect != nil {
65				lease = effect.lease
66			}
67			d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, lease)
68			d.invalidateRender(sess, ac, true, "attention.go")
69		}
70		return nil
71	}
72
73	target, ok := d.oldestOtherSessionAttention(sess)
74	if !ok {
75		return d.jumpRouteAttention(sess, ac, effect)
76	}
77	pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
78	if effect == nil {
79		return d.switchToTarget(sess, ac, pickerTarget)
80	}
81	return d.switchActiveTargetForAttachment(effect, pickerTarget)
82}
83
84// jumpRouteAttention asks the client to navigate to the oldest attention
85// route it published for another daemon. The serving daemon's own sessions
86// were already considered, so a local route of this daemon is skipped.
87func (d *Daemon) jumpRouteAttention(sess *session, ac *attachedClient, effect *attachmentEffect) error {
88	entry, ok := oldestRouteAttention(ac.routeSnapshotCopy(), d.ownsLifecycle)
89	if !ok {
90		return nil
91	}
92	exec := paletteExec{d: d, sess: sess, attachment: sess, ac: ac, effect: effect}
93	err := exec.NavigateRecentRoute(entry)
94	if errors.Is(err, errAttachmentTransition) {
95		return nil
96	}
97	return err
98}
99
100// oldestRouteAttention selects the route whose attention the client observed
101// first. owned reports lifecycles this daemon serves itself.
102func oldestRouteAttention(snapshot protocol.RecentRouteSnapshot, owned func(domain.SessionLifecycleID) bool) (protocol.RouteNavigationAction, bool) {
103	var best protocol.RecentRouteEntry
104	found := false
105	for _, entry := range snapshot.Entries {
106		if !entry.Attention || entry.AttentionSeq == 0 || owned(entry.Target.LifecycleID) {
107			continue
108		}
109		if !found || entry.AttentionSeq < best.AttentionSeq {
110			best, found = entry, true
111		}
112	}
113	if !found || snapshot.Generation == 0 {
114		return protocol.RouteNavigationAction{}, false
115	}
116	return protocol.RouteNavigationAction{SnapshotGeneration: snapshot.Generation, Key: best.Key, Generation: best.Generation}, true
117}
118
119// ownsLifecycle reports whether this daemon holds the lifecycle, live or
120// stopped.
121func (d *Daemon) ownsLifecycle(lifecycle domain.SessionLifecycleID) bool {
122	d.mu.Lock()
123	sessions := sessionsSnapshot(d.sessions)
124	stopped := false
125	for _, inactive := range d.inactive {
126		stopped = stopped || inactive.incarnation == lifecycle
127	}
128	d.mu.Unlock()
129	if stopped {
130		return true
131	}
132	for _, sess := range sessions {
133		sess.mu.Lock()
134		owned := sess.incarnation == lifecycle
135		sess.mu.Unlock()
136		if owned {
137			return true
138		}
139	}
140	return false
141}
142
143func oldestAttentionTab(sess *session) (int, bool) {
144	idx, _, ok := oldestAttentionTabWithTime(sess)
145	return idx, ok
146}
147
148type attentionTarget struct {
149	sessionID domain.SessionID
150	tabIndex  int
151	at        time.Time
152}
153
154func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
155	d.mu.Lock()
156	sessions := make([]*session, 0, len(d.sessions))
157	for _, sess := range d.sessions {
158		if sess != nil && sess != current {
159			sessions = append(sessions, sess)
160		}
161	}
162	d.mu.Unlock()
163
164	var target attentionTarget
165	found := false
166	for _, sess := range sessions {
167		idx, at, ok := oldestAttentionTabWithTime(sess)
168		if !ok {
169			continue
170		}
171		if !found || at.Before(target.at) {
172			target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at}
173			found = true
174		}
175	}
176	return target, found
177}
178
179func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
180	sess.mu.Lock()
181	defer sess.mu.Unlock()
182	idx := -1
183	var oldest time.Time
184	for i, tb := range sess.tabs {
185		if !tb.attention {
186			continue
187		}
188		if idx == -1 || tb.attentionAt.Before(oldest) {
189			idx = i
190			oldest = tb.attentionAt
191		}
192	}
193	return idx, oldest, idx != -1
194}
195
196func (d *Daemon) repaintAttachedClients(sess *session) {
197	for _, ac := range sess.snapshotAttachments() {
198		d.invalidateRender(sess, ac, false, "attention.go")
199	}
200}
201
202// refreshClientPickers republishes the picker source of every attachment whose
203// interaction the client presents. A bell raised or cleared while that modal is
204// open must reach it, and client-owned presentation suppresses the daemon's own
205// paints: only a newer source revision updates the client's rows. Unchanged
206// line sets publish nothing, so the attention pulse stays cheap.
207func (d *Daemon) refreshClientPickers() {
208	d.mu.Lock()
209	sessions := sessionsSnapshot(d.sessions)
210	d.mu.Unlock()
211
212	for _, sess := range sessions {
213		for _, ac := range sess.snapshotAttachments() {
214			if ac.overlays == nil || !ac.overlays.pickerClientActive() {
215				continue
216			}
217			d.refreshPickerSnapshot(ac)
218		}
219	}
220}
221
222func (d *Daemon) repaintAllAttachedClients() {
223	d.mu.Lock()
224	sessions := sessionsSnapshot(d.sessions)
225	d.mu.Unlock()
226
227	for _, sess := range sessions {
228		d.repaintAttachedClients(sess)
229	}
230}
231
232// ackAttention clears the bell of the tab ac shows. A client whose terminal
233// window reported losing focus is not looked at, so it never clears a bell:
234// the bell stays for every client until one that may be seen shows the tab.
235func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
236	if tb == nil || ac == nil || !ac.terminalFocus().MaySee() {
237		return false
238	}
239	s.mu.Lock()
240	defer s.mu.Unlock()
241	view := ac.viewSnapshot()
242	if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention {
243		return false
244	}
245	if tb.attentionVisiblePaint && !visible {
246		return false
247	}
248	tb.attention = false
249	tb.attentionAt = time.Time{}
250	tb.attentionVisiblePaint = false
251	return true
252}
253
254func (s *session) anyAttention() bool {
255	s.mu.Lock()
256	defer s.mu.Unlock()
257	for _, tb := range s.tabs {
258		if tb.attention {
259			return true
260		}
261	}
262	return false
263}
264
265func (d *Daemon) pokeAttentionTicker() {
266	if d == nil || d.animWake == nil {
267		return
268	}
269	select {
270	case d.animWake <- struct{}{}:
271	default:
272	}
273}
274
275func (d *Daemon) attentionFrame() int {
276	if d == nil {
277		return 0
278	}
279	d.attnMu.Lock()
280	defer d.attnMu.Unlock()
281	return d.animFrame
282}
283
284func (d *Daemon) attentionAnimator(ctx context.Context) {
285	active := false
286	var timer ports.Timer
287	for {
288		if !active {
289			if !d.anyAttention() {
290				d.setAttentionFrame(0)
291				select {
292				case <-ctx.Done():
293					return
294				case <-d.animWake:
295					continue
296				}
297			}
298			active = true
299		}
300
301		if timer == nil {
302			timer = d.clock.NewTimer(pulseFrameInterval)
303		} else {
304			timer.Reset(pulseFrameInterval)
305		}
306
307		select {
308		case <-ctx.Done():
309			if timer != nil {
310				timer.Stop()
311			}
312			return
313		case <-d.animWake:
314			if !timer.Stop() {
315				select {
316				case <-timer.C():
317				default:
318				}
319			}
320		case <-timer.C():
321			d.advanceAttentionFrame()
322			d.refreshClientPickers()
323			d.repaintAllAttachedClients()
324		}
325
326		if !d.anyAttention() {
327			d.setAttentionFrame(0)
328			d.repaintAllAttachedClients()
329			active = false
330		}
331	}
332}
333
334func (d *Daemon) anyAttention() bool {
335	d.mu.Lock()
336	sessions := sessionsSnapshot(d.sessions)
337	d.mu.Unlock()
338
339	for _, sess := range sessions {
340		if sess.anyAttention() {
341			return true
342		}
343		for _, ac := range sess.snapshotAttachments() {
344			snapshot := ac.routeSnapshotCopy()
345			for _, entry := range snapshot.Entries {
346				if entry.Attention {
347					return true
348				}
349			}
350		}
351	}
352	return false
353}
354
355func (d *Daemon) advanceAttentionFrame() {
356	d.attnMu.Lock()
357	d.animFrame = (d.animFrame + 1) % pulseFrameCount
358	d.attnMu.Unlock()
359}
360
361func (d *Daemon) setAttentionFrame(frame int) {
362	d.attnMu.Lock()
363	d.animFrame = frame
364	d.attnMu.Unlock()
365}

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