notices_overlay.go RawHistoryFolder

1package daemon
2
3import (
4	"fmt"
5	"strings"
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/keys"
12	"github.com/bnema/vev/internal/usecase/notices"
13	"github.com/bnema/vev/internal/usecase/ui"
14)
15
16var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight: 8, Title: " Notifications ", Anchor: domain.AnchorCenter}
17
18// enterNotices opens the notification history overlay from the daemon's full
19// notice center history, newest first.
20func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
21	history := d.notices.history()
22	now := d.clock.Now()
23	ac.overlays.noticeMu.Lock()
24	ac.overlays.noticesOverlay = notices.New(history, now)
25	ac.overlays.noticesPending = nil
26	ac.overlays.noticeMu.Unlock()
27	d.invalidateRender(sess, ac, true, "notices_overlay.go")
28}
29
30// listInputState owns the common pending-ESC lifecycle for list overlays.
31// The caller holds the overlay lock while handleListInputLocked runs. The
32// timer callback takes the same lock before checking its identity, so a later
33// input chunk cannot let a stale lone-ESC close a reopened overlay.
34type listInputState struct {
35	pending     *[]byte
36	esc         *pendingByteTimer
37	moveUp      func()
38	moveDown    func()
39	lock        func()
40	unlock      func()
41	active      func() bool
42	closeLocked func()
43	afterClose  func()
44}
45
46type listInputResult struct {
47	changed bool
48	exit    bool
49	action  byte
50	stop    bool
51}
52
53func handleListInputLocked(clock ports.Clock, data []byte, state listInputState, action func(byte) listInputResult) listInputResult {
54	if len(*state.pending) > 0 {
55		state.esc.stop()
56		combined := make([]byte, 0, len(*state.pending)+len(data))
57		combined = append(combined, (*state.pending)...)
58		combined = append(combined, data...)
59		data = combined
60		*state.pending = nil
61	}
62
63	var result listInputResult
64	for i := 0; i < len(data); i++ {
65		switch data[i] {
66		case 'j':
67			state.moveDown()
68			result.changed = true
69		case 'k':
70			state.moveUp()
71			result.changed = true
72		case 'q', 0x03:
73			result.exit = true
74			return result
75		case keys.ESC:
76			tail := data[i:]
77			if consumed, move := routeListEscape(tail); consumed > 0 {
78				switch move {
79				case 'A':
80					state.moveUp()
81					result.changed = true
82				case 'B':
83					state.moveDown()
84					result.changed = true
85				}
86				i += consumed - 1
87				continue
88			}
89			if len(tail) == 1 {
90				retainListESCLocked(clock, state)
91				break
92			}
93			if isListEscapePrefix(tail) {
94				*state.pending = append((*state.pending)[:0], tail...)
95				i += len(tail) - 1
96				continue
97			}
98			result.exit = true
99			return result
100		default:
101			custom := action(data[i])
102			if custom.action != 0 {
103				result.action = custom.action
104			}
105			if custom.exit {
106				result.exit = true
107			}
108			if custom.stop {
109				result.stop = true
110				return result
111			}
112			if custom.exit {
113				return result
114			}
115		}
116	}
117	return result
118}
119
120// routeListEscape consumes one complete CSI or SS3 sequence. It returns its
121// byte length and the simple arrow final when it is an Up/Down sequence. Other
122// complete terminal sequences are deliberately ignored so their bytes cannot
123// trigger list actions or close the overlay.
124func routeListEscape(data []byte) (consumed int, move byte) {
125	if len(data) < 2 || data[0] != keys.ESC || (data[1] != '[' && data[1] != 'O') {
126		return 0, 0
127	}
128	for i := 2; i < len(data); i++ {
129		if data[i] < 0x40 || data[i] > 0x7e {
130			continue
131		}
132		if i == 2 && (data[i] == 'A' || data[i] == 'B') {
133			return i + 1, data[i]
134		}
135		return i + 1, 0
136	}
137	return 0, 0
138}
139
140func isListEscapePrefix(data []byte) bool {
141	return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
142}
143
144func retainListESCLocked(clock ports.Clock, state listInputState) {
145	*state.pending = append((*state.pending)[:0], keys.ESC)
146	state.esc.retain(clock, keys.ESCDelay, func(timer ports.Timer) {
147		state.lock()
148		if state.esc.timer != timer || len(*state.pending) != 1 || (*state.pending)[0] != keys.ESC || !state.active() {
149			state.unlock()
150			return
151		}
152		*state.pending = nil
153		state.esc.timer = nil
154		state.esc.done = nil
155		state.closeLocked()
156		state.unlock()
157		state.afterClose()
158	})
159}
160
161func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState {
162	rt := ac.overlays
163	return listInputState{
164		pending:  &rt.noticesPending,
165		esc:      &rt.noticesESC,
166		moveUp:   rt.noticesOverlay.Up,
167		moveDown: rt.noticesOverlay.Down,
168		lock:     rt.noticeMu.Lock,
169		unlock:   rt.noticeMu.Unlock,
170		active:   func() bool { return rt.noticesOverlay != nil },
171		closeLocked: func() {
172			rt.noticesOverlay = nil
173		},
174		afterClose: func() {
175			if entry := ac.currentAttachmentSession(); entry != nil {
176				d.invalidateRender(entry, ac, true, "notices_overlay.go")
177			}
178		},
179	}
180}
181
182func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
183	entry := ac.currentAttachmentSession()
184	if entry == nil {
185		return
186	}
187	rt := ac.overlays
188	rt.noticeMu.Lock()
189	if rt.noticesOverlay == nil {
190		rt.noticesPending = nil
191		rt.noticesESC.stop()
192		rt.noticeMu.Unlock()
193		return
194	}
195	var yankTarget domain.Notification
196	var yank bool
197	result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult {
198		if b == 'y' {
199			yankTarget, yank = rt.noticesOverlay.Selected()
200			return listInputResult{action: b}
201		}
202		return listInputResult{}
203	})
204	rt.noticeMu.Unlock()
205
206	if result.exit {
207		d.closeNotices(ac)
208	}
209	if yank {
210		// yankNotice sends over the wire and repaints itself, so it must run
211		// with noticeMu released; a quick yank doesn't close the overlay the
212		// way copy mode's own 'y' commits and exits.
213		d.yankNotice(entry, ac, yankTarget)
214	}
215	if result.exit || result.changed {
216		d.invalidateRender(entry, ac, true, "notices_overlay.go")
217	}
218}
219
220// yankNotice copies n's formatted details to the client's clipboard via
221// OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222// send each chunk, then leave a one-shot status-bar confirmation that the
223// next repaint clears.
224func (d *Daemon) yankNotice(entry *session, ac *attachedClient, n domain.Notification) {
225	chunks := scopy.OSC52(noticeYankPayload(n))
226	for _, chunk := range chunks {
227		failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228		if err != nil {
229			if entry != nil {
230				d.detachOnSendError(entry, ac, failed)
231			} else {
232				_ = ac.closeCapturedTransport(failed)
233			}
234			return
235		}
236	}
237	ac.overlays.copyMu.Lock()
238	if len(chunks) > 0 {
239		ac.overlays.statusFeedback = "copied notification details"
240	} else {
241		ac.overlays.statusFeedback = "notification too large to copy"
242	}
243	ac.overlays.copyMu.Unlock()
244	d.invalidateRender(entry, ac, true, "notices_overlay.go")
245}
246
247// noticeYankPayload formats n the way the yank commands put it on the
248// clipboard: a header line with timestamp, severity, code slug, and coalesce
249// count, then the message, then the full cause chain when present.
250func noticeYankPayload(n domain.Notification) string {
251	b := &strings.Builder{}
252	fmt.Fprintf(b, "[%s] %s %s", n.Time.Format(time.RFC3339), severityWord(n.Severity), n.Code.String())
253	if n.Count > 1 {
254		fmt.Fprintf(b, " ×%d", n.Count)
255	}
256	fmt.Fprintf(b, "\n%s\n", n.Message)
257	if n.Details != "" {
258		fmt.Fprintf(b, "details: %s\n", n.Details)
259	}
260	return b.String()
261}
262
263// severityWord renders sev as the word used in yank payloads, distinct from
264// the single-letter form the history list draws (notices.severityLetter).
265func severityWord(sev domain.NoticeSeverity) string {
266	switch sev {
267	case domain.NoticeWarn:
268		return "warn"
269	case domain.NoticeError:
270		return "error"
271	default:
272		return "info"
273	}
274}
275
276func (d *Daemon) closeNotices(ac *attachedClient) {
277	ac.overlays.noticeMu.Lock()
278	ac.overlays.noticesOverlay = nil
279	ac.overlays.noticesPending = nil
280	ac.overlays.noticesESC.stop()
281	ac.overlays.noticeMu.Unlock()
282}

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