model.go RawHistoryFolder

1// Package notices renders the notification history overlay: a scrollable,
2// newest-first list of the daemon's recorded notifications.
3package notices
4
5import (
6	"fmt"
7	"time"
8
9	renderer "github.com/bnema/vev-vt"
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/usecase/ui"
12)
13
14// RenderStyles are the styles Render uses to draw list rows. The zero value
15// is never used directly by daemon composition, which always supplies a
16// theme-derived RenderStyles the way picker.RenderStyles is supplied.
17type RenderStyles struct {
18	Background     renderer.Style // fills unused interior rows
19	Base           renderer.Style // non-selected row fill
20	Selection      renderer.Style // selected row fill
21	Text           renderer.Style // non-selected message segment
22	SelectionText  renderer.Style // selected row message segment
23	Muted          renderer.Style // non-selected severity/time/slug segment
24	SelectionMuted renderer.Style // selected row severity/time/slug segment
25}
26
27// Model is the history overlay's list state: a snapshot of notifications,
28// newest first, with a navigable selection. now anchors the relative
29// timestamps rendered next to each row and is fixed at construction so the
30// overlay's times don't drift while it stays open.
31type Model struct {
32	rows     []domain.Notification
33	selected int
34	now      time.Time
35}
36
37// New builds a Model from entries, expected newest-first as returned by the
38// notice center's history(). now is the instant relative row times render
39// against.
40func New(entries []domain.Notification, now time.Time) *Model {
41	m := &Model{rows: append([]domain.Notification(nil), entries...), now: now, selected: -1}
42	if len(m.rows) > 0 {
43		m.selected = 0
44	}
45	return m
46}
47
48func (m *Model) Up() {
49	m.move(-1)
50}
51
52func (m *Model) Down() {
53	m.move(1)
54}
55
56func (m *Model) move(delta int) {
57	if m == nil || len(m.rows) == 0 {
58		return
59	}
60	next := m.selected + delta
61	if next < 0 {
62		next = 0
63	}
64	if next >= len(m.rows) {
65		next = len(m.rows) - 1
66	}
67	m.selected = next
68}
69
70// Selected returns the highlighted notification, or false when history is
71// empty.
72func (m *Model) Selected() (domain.Notification, bool) {
73	if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
74		return domain.Notification{}, false
75	}
76	return m.rows[m.selected], true
77}
78
79// Clone returns an independent copy safe to render outside the lock that
80// guards the live model.
81func (m *Model) Clone() *Model {
82	if m == nil {
83		return nil
84	}
85	clone := *m
86	clone.rows = append([]domain.Notification(nil), m.rows...)
87	return &clone
88}
89
90// Render draws the history list into inner, scrolling to keep the selection
91// visible. Degenerate dimensions draw nothing; an empty history draws a
92// centered placeholder instead of a blank box.
93func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
94	frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
95	if frame.Width <= 0 || frame.Height <= 0 {
96		return frame
97	}
98	ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Background})
99	if m == nil || len(m.rows) == 0 {
100		text := "No notifications"
101		x := max(0, (frame.Width-textWidth(text))/2)
102		ui.DrawText(frame, x, frame.Height/2, frame.Width, text, styles.Text)
103		return frame
104	}
105	offset := m.scrollOffset(frame.Height)
106	for y := 0; y < frame.Height; y++ {
107		idx := offset + y
108		if idx >= len(m.rows) {
109			break
110		}
111		n := m.rows[idx]
112		base, mutedStyle, textStyle := styles.Base, styles.Muted, styles.Text
113		if idx == m.selected {
114			base, mutedStyle, textStyle = styles.Selection, styles.SelectionMuted, styles.SelectionText
115		}
116		ui.FillRect(frame, domain.Rect{X: 0, Y: y, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
117		meta := fmt.Sprintf("[%c] %s  %s  ", severityLetter(n.Severity), relativeTime(m.now, n.Time), n.Code.String())
118		x := ui.DrawText(frame, 0, y, frame.Width, meta, mutedStyle)
119		ui.DrawText(frame, x, y, frame.Width, rowMessage(n), textStyle)
120	}
121	return frame
122}
123
124// rowMessage appends a "×N" coalesce count suffix. The format string matches
125// the toast overlay's own count formatting (internal/usecase/ui.ComposeNotices),
126// but the field it is appended to differs: the toast appends to Title, while
127// this row appends to Message, matching this overlay's row layout.
128func rowMessage(n domain.Notification) string {
129	if n.Count > 1 {
130		return fmt.Sprintf("%s ×%d", n.Message, n.Count)
131	}
132	return n.Message
133}
134
135func (m *Model) scrollOffset(visible int) int {
136	if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
137		return 0
138	}
139	if m.selected < visible {
140		return 0
141	}
142	offset := m.selected - visible + 1
143	return min(offset, len(m.rows)-visible)
144}
145
146func severityLetter(sev domain.NoticeSeverity) byte {
147	switch sev {
148	case domain.NoticeWarn:
149		return 'W'
150	case domain.NoticeError:
151		return 'E'
152	default:
153		return 'I'
154	}
155}
156
157// relativeTime renders how long ago t was, relative to now, in the coarsest
158// unit that keeps the row compact. A non-positive delta (including clock
159// skew that puts t in the future) reads as "just now" rather than negative.
160func relativeTime(now, t time.Time) string {
161	d := now.Sub(t)
162	switch {
163	case d < time.Minute:
164		return "just now"
165	case d < time.Hour:
166		return fmt.Sprintf("%dm ago", int(d/time.Minute))
167	case d < 24*time.Hour:
168		return fmt.Sprintf("%dh ago", int(d/time.Hour))
169	default:
170		return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
171	}
172}
173
174func textWidth(text string) int {
175	width := 0
176	for _, r := range text {
177		width += renderer.RuneWidth(r)
178	}
179	return width
180}

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