main 5041353

history_model.go RawHistoryFolder

1// Package component provides UI components for the browser.
2package component
3
4import (
5	"sort"
6	"strconv"
7	"strings"
8	"time"
9
10	"github.com/bnema/dumber/internal/domain/entity"
11)
12
13// historyGroup represents a day-grouped section of history entries.
14// Groups are ordered newest-first; entries within a group are newest-first.
15type historyGroup struct {
16	Label   string
17	Entries []*entity.HistoryEntry
18}
19
20// dayKey identifies a unique calendar day for grouping.
21type dayKey struct {
22	year  int
23	month time.Month
24	day   int
25}
26
27const (
28	dayLabelToday           = "Today"
29	dayLabelYesterday       = "Yesterday"
30	dayLabelOtherYearFormat = "January 2, 2006"
31	hoursPerDay             = 24
32)
33
34// groupHistoryByDay groups entries by calendar day, newest-first.
35// Days are labeled: "Today", "Yesterday", "Monday, January 2" (current year),
36// "January 2, 2006" (other years). Within each group entries remain in
37// the order they arrived (assumed most-recent-first).
38func groupHistoryByDay(entries []*entity.HistoryEntry) []historyGroup {
39	return groupHistoryByDayAt(entries, time.Now())
40}
41
42func groupHistoryByDayAt(entries []*entity.HistoryEntry, now time.Time) []historyGroup {
43	if len(entries) == 0 {
44		return nil
45	}
46
47	local := now.Location()
48	todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, local)
49
50	groupsMap := make(map[dayKey]*historyGroup)
51	var keys []dayKey
52
53	for _, entry := range entries {
54		t := entry.LastVisited.In(local)
55		key := dayKey{t.Year(), t.Month(), t.Day()}
56
57		if _, ok := groupsMap[key]; !ok {
58			label := dayLabelForKey(key, todayStart, now)
59			groupsMap[key] = &historyGroup{Label: label}
60			keys = append(keys, key)
61		}
62		groupsMap[key].Entries = append(groupsMap[key].Entries, entry)
63	}
64
65	// Sort keys newest-first
66	sort.Slice(keys, func(i, j int) bool {
67		ki, kj := keys[i], keys[j]
68		ti := time.Date(ki.year, ki.month, ki.day, 0, 0, 0, 0, local)
69		tj := time.Date(kj.year, kj.month, kj.day, 0, 0, 0, 0, local)
70		return ti.After(tj)
71	})
72
73	groups := make([]historyGroup, len(keys))
74	for i, k := range keys {
75		groups[i] = *groupsMap[k]
76	}
77	return groups
78}
79
80func dayLabelForKey(key dayKey, todayStart, now time.Time) string {
81	dayStart := time.Date(key.year, key.month, key.day, 0, 0, 0, 0, now.Location())
82	switch {
83	case dayStart.Equal(todayStart):
84		return dayLabelToday
85	case dayStart.Equal(todayStart.AddDate(0, 0, -1)):
86		return dayLabelYesterday
87	case key.year == now.Year():
88		return dayStart.Format("Monday, January 2")
89	default:
90		return dayStart.Format(dayLabelOtherYearFormat)
91	}
92}
93
94// readableURL strips the protocol prefix and optional www. for display.
95func readableURL(rawURL string) string {
96	u := rawURL
97	for _, prefix := range []string{"https://", "http://", "ftp://"} {
98		if strings.HasPrefix(u, prefix) {
99			u = u[len(prefix):]
100			break
101		}
102	}
103	u = strings.TrimPrefix(u, "www.")
104	// Remove trailing slash when there's no path beyond it
105	if slashIdx := strings.IndexByte(u, '/'); slashIdx == -1 || slashIdx == len(u)-1 {
106		u = strings.TrimSuffix(u, "/")
107	}
108	return u
109}
110
111// relativeTime returns a compact relative time label for a timestamp.
112// Examples: "2m ago", "1h ago", "3d ago", "Jan 2".
113func relativeTime(t time.Time) string {
114	return relativeTimeAt(t, time.Now())
115}
116
117func relativeTimeAt(t, now time.Time) string {
118	d := now.Sub(t)
119
120	switch {
121	case d < time.Minute:
122		return "now"
123	case d < time.Hour:
124		m := int(d.Minutes())
125		if m < 2 {
126			return "1m ago"
127		}
128		return strconv.Itoa(m) + "m ago"
129	case d < hoursPerDay*time.Hour:
130		h := int(d.Hours())
131		if h < 2 {
132			return "1h ago"
133		}
134		return strconv.Itoa(h) + "h ago"
135	case d < 7*hoursPerDay*time.Hour:
136		days := int(d.Hours() / hoursPerDay)
137		if days < 2 {
138			return "1d ago"
139		}
140		return strconv.Itoa(days) + "d ago"
141	default:
142		if t.Year() == now.Year() {
143			return t.Format("Jan 2")
144		}
145		return t.Format("Jan 2, 2006")
146	}
147}
148
149// historyDisplayRowKind identifies the type of row rendered in the sidebar.
150type historyDisplayRowKind int
151
152const (
153	historyDisplayRowHeader historyDisplayRowKind = iota
154	historyDisplayRowEntry
155)
156
157// historyDisplayRow is the explicit row model rendered by the history sidebar.
158// It is the only source used for row lookup, selection, activation, and delete.
159type historyDisplayRow struct {
160	Kind       historyDisplayRowKind
161	Label      string
162	Entry      *entity.HistoryEntry
163	GroupIndex int
164}
165
166func buildHistoryDisplayRows(groups []historyGroup) []historyDisplayRow {
167	if len(groups) == 0 {
168		return nil
169	}
170	rows := make([]historyDisplayRow, 0)
171	for gi, group := range groups {
172		rows = append(rows, historyDisplayRow{Kind: historyDisplayRowHeader, Label: group.Label, GroupIndex: gi})
173		for _, entry := range group.Entries {
174			rows = append(rows, historyDisplayRow{Kind: historyDisplayRowEntry, Entry: entry, GroupIndex: gi})
175		}
176	}
177	return rows
178}
179
180// keyboardNavModel provides pure functions for keyboard navigation over the
181// explicit display rows. It has no GTK dependencies and can be tested directly.
182type keyboardNavModel struct {
183	rows []historyDisplayRow
184}
185
186// newKeyboardNavModel creates a keyboardNavModel over day-grouped history.
187func newKeyboardNavModel(groups []historyGroup) keyboardNavModel {
188	return newKeyboardNavModelFromRows(buildHistoryDisplayRows(groups))
189}
190
191func newKeyboardNavModelFromRows(rows []historyDisplayRow) keyboardNavModel {
192	return keyboardNavModel{rows: rows}
193}
194
195// totalRows returns the number of explicit display rows.
196func (m keyboardNavModel) totalRows() int { return len(m.rows) }
197
198// isSelectable returns true when the display row corresponds to an entry.
199func (m keyboardNavModel) isSelectable(index int) bool {
200	return index >= 0 && index < len(m.rows) && m.rows[index].Kind == historyDisplayRowEntry && m.rows[index].Entry != nil
201}
202
203func (m keyboardNavModel) firstSelectableIndex() int {
204	for i := 0; i < m.totalRows(); i++ {
205		if m.isSelectable(i) {
206			return i
207		}
208	}
209	return -1
210}
211
212func (m keyboardNavModel) lastSelectableIndex() int {
213	for i := m.totalRows() - 1; i >= 0; i-- {
214		if m.isSelectable(i) {
215			return i
216		}
217	}
218	return -1
219}
220
221func (m keyboardNavModel) nextSelectableIndex(from, dir int) int {
222	if dir != -1 && dir != +1 {
223		return -1
224	}
225	for i := from + dir; i >= 0 && i < m.totalRows(); i += dir {
226		if m.isSelectable(i) {
227			return i
228		}
229	}
230	return -1
231}
232
233func (m keyboardNavModel) groupIndexAt(index int) int {
234	if index < 0 || index >= len(m.rows) {
235		return -1
236	}
237	return m.rows[index].GroupIndex
238}
239
240func (m keyboardNavModel) maxGroupIndex() int {
241	maxIndex := -1
242	for _, row := range m.rows {
243		if row.GroupIndex > maxIndex {
244			maxIndex = row.GroupIndex
245		}
246	}
247	return maxIndex
248}
249
250func (m keyboardNavModel) cumulativeOffsetAtGroup(gi int) int {
251	if gi < 0 {
252		return -1
253	}
254	for i, row := range m.rows {
255		if row.GroupIndex == gi && row.Kind == historyDisplayRowHeader {
256			return i
257		}
258	}
259	return -1
260}
261
262func (m keyboardNavModel) firstEntryOfGroup(gi int) int {
263	for i, row := range m.rows {
264		if row.GroupIndex == gi && row.Kind == historyDisplayRowEntry && row.Entry != nil {
265			return i
266		}
267	}
268	return -1
269}
270
271func (m keyboardNavModel) previousDayBoundary(from int) int {
272	gi := m.groupIndexAt(from)
273	if gi <= 0 {
274		return -1
275	}
276	return m.firstEntryOfGroup(gi - 1)
277}
278
279func (m keyboardNavModel) nextDayBoundary(from int) int {
280	gi := m.groupIndexAt(from)
281	if gi < 0 || gi >= m.maxGroupIndex() {
282		return -1
283	}
284	return m.firstEntryOfGroup(gi + 1)
285}
286
287func (m keyboardNavModel) entryAt(index int) *entity.HistoryEntry {
288	if !m.isSelectable(index) {
289		return nil
290	}
291	return m.rows[index].Entry
292}
293
294func (m keyboardNavModel) entryURLAt(index int) string {
295	e := m.entryAt(index)
296	if e == nil {
297		return ""
298	}
299	return e.URL
300}
301
302func (m keyboardNavModel) entryCount() int {
303	n := 0
304	for i := range m.rows {
305		if m.isSelectable(i) {
306			n++
307		}
308	}
309	return n
310}

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 8

Tags 42

↑↓ moveenter openesc close