v0.2.0 80c61e3

attention.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"time"
6
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/ports"
9	"github.com/bnema/vev/internal/usecase/picker"
10)
11
12// noteAttention records attention for a tab. If the tab is currently visible,
13// it is kept long enough to render one pulse before paint acknowledges it. The
14// caller must not hold tab.mu.
15func (d *Daemon) noteAttention(sess *session, tb *tab) {
16	if sess == nil || tb == nil {
17		return
18	}
19
20	now := d.clock.Now()
21	if now.IsZero() {
22		now = time.Now()
23	}
24
25	sess.mu.Lock()
26	if !tb.attention {
27		tb.attentionAt = now
28	}
29	tb.attention = true
30	for _, attachment := range sess.snapshotAttachmentsLocked() {
31		view := attachment.viewSnapshot()
32		if domain.TabStableID(tb.stableID) == view.tabID {
33			tb.attentionVisiblePaint = true
34			break
35		}
36	}
37	sess.mu.Unlock()
38
39	// Do not repaint here: this runs on the PTY reader goroutine, and paint
40	// can block on a slow client's socket (Transport.Send has no deadline) —
41	// that would let one wedged client stall a different session's reader.
42	// The animator's first tick (<=120ms) delivers the repaint, and pulse
43	// frame 0 renders the bell as blank anyway, so nothing is visibly lost
44	// by not painting immediately.
45	d.pokeAttentionTicker()
46}
47
48// jumpAttention switches to the oldest pending attention target, local tab
49// first and then another session's. Returning nil never implies a switch
50// happened — it also covers "no target exists", which is routine and not an
51// error. Only a failure to reach a target that does exist is a genuine error.
52func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
53	return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{})
54}
55
56func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error {
57	if sess == nil || ac == nil {
58		return nil
59	}
60	if idx, ok := oldestAttentionTab(sess); ok {
61		if sess.switchAttachmentTab(ac, idx) {
62			d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, token.lease)
63			d.invalidateRender(sess, ac, true, "attention.go")
64		}
65		return nil
66	}
67
68	target, ok := d.oldestOtherSessionAttention(sess)
69	if !ok {
70		return nil
71	}
72	pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
73	if token.ac == nil {
74		return d.switchToTarget(sess, ac, pickerTarget)
75	}
76	return d.switchActiveTargetForAttachment(token, pickerTarget)
77}
78
79func oldestAttentionTab(sess *session) (int, bool) {
80	idx, _, ok := oldestAttentionTabWithTime(sess)
81	return idx, ok
82}
83
84type attentionTarget struct {
85	sessionID domain.SessionID
86	tabIndex  int
87	at        time.Time
88}
89
90func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
91	d.mu.Lock()
92	sessions := make([]*session, 0, len(d.sessions))
93	for _, sess := range d.sessions {
94		if sess != nil && sess != current {
95			sessions = append(sessions, sess)
96		}
97	}
98	d.mu.Unlock()
99
100	var target attentionTarget
101	found := false
102	for _, sess := range sessions {
103		idx, at, ok := oldestAttentionTabWithTime(sess)
104		if !ok {
105			continue
106		}
107		if !found || at.Before(target.at) {
108			target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at}
109			found = true
110		}
111	}
112	return target, found
113}
114
115func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
116	sess.mu.Lock()
117	defer sess.mu.Unlock()
118	idx := -1
119	var oldest time.Time
120	for i, tb := range sess.tabs {
121		if !tb.attention {
122			continue
123		}
124		if idx == -1 || tb.attentionAt.Before(oldest) {
125			idx = i
126			oldest = tb.attentionAt
127		}
128	}
129	return idx, oldest, idx != -1
130}
131
132func (d *Daemon) repaintAttachedClients(sess *session) {
133	for _, ac := range sess.snapshotAttachments() {
134		d.invalidateRender(sess, ac, false, "attention.go")
135	}
136}
137
138func (d *Daemon) repaintAllAttachedClients() {
139	d.mu.Lock()
140	sessions := sessionsSnapshot(d.sessions)
141	d.mu.Unlock()
142
143	for _, sess := range sessions {
144		d.repaintAttachedClients(sess)
145	}
146}
147
148func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
149	if tb == nil || ac == nil {
150		return false
151	}
152	s.mu.Lock()
153	defer s.mu.Unlock()
154	view := ac.viewSnapshot()
155	if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention {
156		return false
157	}
158	if tb.attentionVisiblePaint && !visible {
159		return false
160	}
161	tb.attention = false
162	tb.attentionAt = time.Time{}
163	tb.attentionVisiblePaint = false
164	return true
165}
166
167func (s *session) anyAttention() bool {
168	s.mu.Lock()
169	defer s.mu.Unlock()
170	for _, tb := range s.tabs {
171		if tb.attention {
172			return true
173		}
174	}
175	return false
176}
177
178func (d *Daemon) pokeAttentionTicker() {
179	if d == nil || d.animWake == nil {
180		return
181	}
182	select {
183	case d.animWake <- struct{}{}:
184	default:
185	}
186}
187
188func (d *Daemon) attentionFrame() int {
189	if d == nil {
190		return 0
191	}
192	d.attnMu.Lock()
193	defer d.attnMu.Unlock()
194	return d.animFrame
195}
196
197func (d *Daemon) attentionAnimator(ctx context.Context) {
198	active := false
199	var timer ports.Timer
200	for {
201		if !active {
202			if !d.anyAttention() {
203				d.setAttentionFrame(0)
204				select {
205				case <-ctx.Done():
206					return
207				case <-d.animWake:
208					continue
209				}
210			}
211			active = true
212		}
213
214		if timer == nil {
215			timer = d.clock.NewTimer(pulseFrameInterval)
216		} else {
217			timer.Reset(pulseFrameInterval)
218		}
219
220		select {
221		case <-ctx.Done():
222			if timer != nil {
223				timer.Stop()
224			}
225			return
226		case <-d.animWake:
227			if !timer.Stop() {
228				select {
229				case <-timer.C():
230				default:
231				}
232			}
233		case <-timer.C():
234			d.advanceAttentionFrame()
235			d.repaintAllAttachedClients()
236		}
237
238		if !d.anyAttention() {
239			d.setAttentionFrame(0)
240			d.repaintAllAttachedClients()
241			active = false
242		}
243	}
244}
245
246func (d *Daemon) anyAttention() bool {
247	d.mu.Lock()
248	sessions := sessionsSnapshot(d.sessions)
249	d.mu.Unlock()
250
251	for _, sess := range sessions {
252		if sess.anyAttention() {
253			return true
254		}
255	}
256	return false
257}
258
259func (d *Daemon) advanceAttentionFrame() {
260	d.attnMu.Lock()
261	d.animFrame = (d.animFrame + 1) % pulseFrameCount
262	d.attnMu.Unlock()
263}
264
265func (d *Daemon) setAttentionFrame(frame int) {
266	d.attnMu.Lock()
267	d.animFrame = frame
268	d.attnMu.Unlock()
269}

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