main 5041353

history_view.go RawHistoryFolder

1package systemviews
2
3import (
4	"fmt"
5	"net/url"
6	"strings"
7	"time"
8
9	"github.com/bnema/dumber/internal/domain/entity"
10	browserurl "github.com/bnema/dumber/internal/domain/url"
11)
12
13const historyURLDisplayMaxRunes = 180
14
15type historyRenderData struct {
16	Entries             []*entity.HistoryEntry
17	Stats               *entity.HistoryStats
18	Analytics           *entity.HistoryAnalytics
19	Domains             []*entity.DomainStat
20	Query               string
21	DomainFilter        string
22	Offset              int
23	Limit               int
24	WindowBefore        time.Time
25	WindowAfter         time.Time
26	HasMore             bool
27	AppendSkipFirstDate string
28	Notice              string
29	Error               string
30	Loading             bool
31}
32
33type historyTimelineGroup struct {
34	Date    string
35	Label   string
36	Entries []*entity.HistoryEntry
37}
38
39type historyCleanupItem struct {
40	Label   string
41	RangeID string
42	Confirm string
43	Notice  string
44}
45
46func historyHTML(data historyRenderData) string {
47	return mustRenderComponent(HistoryView(data))
48}
49
50func historyTimelineAppendHTML(data historyRenderData) string {
51	return mustRenderComponent(HistoryTimelineAppend(data))
52}
53
54func historyDocumentTitle(data historyRenderData) string {
55	return "History " + historyTitleDetail(data)
56}
57
58func historyTitleDetail(data historyRenderData) string {
59	if data.Loading {
60		return "- Loading"
61	}
62	entries, visits, days := historySummaryValues(data)
63	return fmt.Sprintf(
64		"- %s, %s, %s",
65		historyCountLabel(entries, "entry", "entries"),
66		historyCountLabel(visits, "visit", "visits"),
67		historyCountLabel(days, "day", "days"),
68	)
69}
70
71func historyCountLabel(count int64, singular, plural string) string {
72	label := plural
73	if count == 1 {
74		label = singular
75	}
76	return fmt.Sprintf("%d %s", count, label)
77}
78
79var historyCleanupRanges = []historyCleanupItem{
80	{Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"},
81	{Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"},
82	{Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"},
83	{Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"},
84	{Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},
85}
86
87func historyCleanupItems() []historyCleanupItem {
88	return historyCleanupRanges
89}
90
91func historyLimit(data historyRenderData) int {
92	if data.Limit <= 0 {
93		return 0
94	}
95	return data.Limit
96}
97
98func historyIsPaginated(data historyRenderData) bool {
99	return historyLimit(data) > 0
100}
101
102func disableHistoryNext(data historyRenderData) bool {
103	return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)
104}
105
106func showHistoryFilters(data historyRenderData) bool {
107	return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != ""
108}
109
110func historyShowingLabel(data historyRenderData) string {
111	if data.Loading {
112		return "Loading history…"
113	}
114	count := countHistoryEntries(data.Entries)
115	if strings.TrimSpace(data.Query) != "" {
116		return fmt.Sprintf("Showing %d matching item%s", count, pluralSuffix(count))
117	}
118	if strings.TrimSpace(data.DomainFilter) != "" {
119		return fmt.Sprintf("Loaded %d domain item%s", count, pluralSuffix(count))
120	}
121	return fmt.Sprintf("Loaded %d item%s", count, pluralSuffix(count))
122}
123
124func pluralSuffix(count int) string {
125	if count == 1 {
126		return ""
127	}
128	return "s"
129}
130
131func historyLoadMoreCursor(data historyRenderData) string {
132	if data.WindowAfter.IsZero() {
133		return ""
134	}
135	return data.WindowAfter.Format(time.RFC3339Nano)
136}
137
138func showHistoryLoadMore(data historyRenderData) bool {
139	return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore
140}
141
142func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {
143	if data.Stats != nil {
144		return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays
145	}
146	if data.Analytics != nil {
147		return data.Analytics.TotalEntries, data.Analytics.TotalVisits, data.Analytics.UniqueDays
148	}
149	entries = int64(countHistoryEntries(data.Entries))
150	visits = sumHistoryVisits(data.Entries)
151	uniqueDays = countUniqueHistoryDays(data.Entries)
152
153	return entries, visits, uniqueDays
154}
155
156func historyTimelineEmptyMessage(data historyRenderData) string {
157	if data.Loading {
158		return "Loading history…"
159	}
160	if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
161		return "No history entries match the current filters"
162	}
163	return "No history entries"
164}
165
166func lastHistoryDateKey(entries []*entity.HistoryEntry) string {
167	for i := len(entries) - 1; i >= 0; i-- {
168		if entries[i] == nil {
169			continue
170		}
171		dateKey, _ := historyDateKeyAndLabel(entries[i].LastVisited, time.Now().Local())
172		return dateKey
173	}
174	return ""
175}
176
177func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
178	groups := make([]historyTimelineGroup, 0)
179	indexByDate := map[string]int{}
180	now := time.Now().Local()
181	for _, entry := range entries {
182		if entry == nil {
183			continue
184		}
185		dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now)
186		idx, ok := indexByDate[dateKey]
187		if !ok {
188			idx = len(groups)
189			indexByDate[dateKey] = idx
190			groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label})
191		}
192		groups[idx].Entries = append(groups[idx].Entries, entry)
193	}
194	return groups
195}
196
197func historyDateKeyAndLabel(ts, now time.Time) (string, string) {
198	if ts.IsZero() {
199		return "unknown", "Unknown date"
200	}
201
202	local := ts.Local()
203	dateKey := local.Format("2006-01-02")
204	today := now.Local()
205	todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
206	yesterdayStart := todayStart.AddDate(0, 0, -1)
207	entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
208
209	switch {
210	case entryStart.Equal(todayStart):
211		return dateKey, "Today"
212	case entryStart.Equal(yesterdayStart):
213		return dateKey, "Yesterday"
214	default:
215		return dateKey, local.Format("Mon Jan 2")
216	}
217}
218
219func countHistoryEntries(entries []*entity.HistoryEntry) int {
220	count := 0
221	for _, entry := range entries {
222		if entry != nil {
223			count++
224		}
225	}
226	return count
227}
228
229func sumHistoryVisits(entries []*entity.HistoryEntry) int64 {
230	var visits int64
231	for _, entry := range entries {
232		if entry == nil {
233			continue
234		}
235		// Each HistoryEntry represents at least one visit, even when VisitCount
236		// is unset or non-positive in older persisted rows.
237		if entry.VisitCount <= 0 {
238			visits++
239			continue
240		}
241		visits += entry.VisitCount
242	}
243	return visits
244}
245
246func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
247	seen := map[string]struct{}{}
248	for _, entry := range entries {
249		if entry == nil || entry.LastVisited.IsZero() {
250			continue
251		}
252		seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{}
253	}
254	return int64(len(seen))
255}
256
257func displayHistoryDomain(raw string) string {
258	return browserurl.DisplayDomain(raw)
259}
260
261func historyDomainActionKey(domain *entity.DomainStat) string {
262	if domain == nil {
263		return ""
264	}
265	return browserurl.CanonicalDomain(domain.Domain)
266}
267
268func historyDomainDisplayLabel(domain *entity.DomainStat) string {
269	if domain == nil {
270		return ""
271	}
272	return displayHistoryDomain(domain.Domain)
273}
274
275func formatHistoryTime(ts time.Time) string {
276	if ts.IsZero() {
277		return ""
278	}
279	return ts.Local().Format("15:04")
280}
281
282func historyItemLabel(entry *entity.HistoryEntry) string {
283	if entry == nil {
284		return ""
285	}
286	label := strings.TrimSpace(entry.Title)
287	if label == "" {
288		label = displayHistoryDomain(entry.URL)
289	}
290	if label == "" {
291		label = entry.URL
292	}
293	return label
294}
295
296func historyItemURL(entry *entity.HistoryEntry) string {
297	if entry == nil {
298		return ""
299	}
300	return truncateHistoryURL(entry.URL)
301}
302
303func historyItemFaviconURL(entry *entity.HistoryEntry) string {
304	if entry == nil {
305		return ""
306	}
307	domain := browserurl.CanonicalDomain(entry.URL)
308	if domain == "" {
309		return ""
310	}
311	query := url.Values{}
312	query.Set("domain", domain)
313	query.Set("size", "32")
314	return "/api/favicon?" + query.Encode()
315}
316
317func historyFaviconClass(faviconURL string) string {
318	if strings.TrimSpace(faviconURL) == "" {
319		return "sv-history-favicon"
320	}
321	return "sv-history-favicon sv-history-favicon-has-image"
322}
323
324func historyFaviconFallback(entry *entity.HistoryEntry) string {
325	label := ""
326	if entry != nil {
327		label = displayHistoryDomain(entry.URL)
328	}
329	if label == "" {
330		return "•"
331	}
332	for _, r := range label {
333		return strings.ToUpper(string(r))
334	}
335	return "•"
336}
337
338func truncateHistoryURL(raw string) string {
339	raw = strings.TrimSpace(raw)
340	if raw == "" {
341		return ""
342	}
343	runes := []rune(raw)
344	if len(runes) <= historyURLDisplayMaxRunes {
345		return raw
346	}
347	return string(runes[:historyURLDisplayMaxRunes-1]) + "…"
348}
349
350func historyItemMeta(entry *entity.HistoryEntry) string {
351	if entry == nil {
352		return ""
353	}
354	meta := []string{displayHistoryDomain(entry.URL)}
355	if entry.VisitCount > 1 {
356		meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
357	}
358	if !entry.LastVisited.IsZero() {
359		meta = append(meta, formatHistoryTime(entry.LastVisited))
360	}
361	return strings.Join(nonEmptyStrings(meta), " · ")
362}
363
364func nonEmptyStrings(values []string) []string {
365	out := make([]string, 0, len(values))
366	for _, value := range values {
367		if strings.TrimSpace(value) != "" {
368			out = append(out, value)
369		}
370	}
371	return out
372}

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