v0.7.0 4c16dd8

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

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