v0.23.2 bfd5eb4

history.go RawHistoryFolder

1// Package model provides Bubble Tea models for CLI commands.
2package model
3
4import (
5	"context"
6	"net/url"
7	"os/exec"
8	"time"
9
10	"github.com/charmbracelet/bubbles/help"
11	"github.com/charmbracelet/bubbles/key"
12	"github.com/charmbracelet/bubbles/list"
13	"github.com/charmbracelet/bubbles/textinput"
14	tea "github.com/charmbracelet/bubbletea"
15	"github.com/charmbracelet/lipgloss"
16
17	"github.com/bnema/dumber/internal/application/usecase"
18	"github.com/bnema/dumber/internal/cli/styles"
19	"github.com/bnema/dumber/internal/domain/entity"
20	"github.com/bnema/dumber/internal/logging"
21)
22
23// HistoryModel is the Bubble Tea model for interactive history browser.
24type HistoryModel struct {
25	// UI components
26	tabs    styles.TabsModel
27	list    list.Model
28	search  textinput.Model
29	help    help.Model
30	keys    styles.HistoryKeyMap
31	cleanup *styles.CleanupModel
32	confirm *styles.ConfirmModel
33
34	// State
35	entries      [][]*entity.HistoryEntry // Entries grouped by tab (Today/Yesterday/Week/Older)
36	topDomains   []*entity.DomainStat
37	domainFilter string
38	searchMode   bool
39	showHelp     bool
40	width        int
41	height       int
42	err          error
43
44	// Dependencies
45	ctx       context.Context
46	historyUC *usecase.SearchHistoryUseCase
47	theme     *styles.Theme
48}
49
50// NewHistoryModel creates a new history browser model.
51func NewHistoryModel(ctx context.Context, theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) HistoryModel {
52	log := logging.FromContext(ctx)
53	log.Debug().Msg("creating history model")
54
55	// Create tabs for timeline grouping
56	tabs := styles.TimelineTabs(theme)
57
58	// Create search input
59	search := styles.NewSearchInput(theme)
60
61	// Create help
62	helpModel := styles.NewStyledHelp(theme)
63
64	// Create keybindings
65	keys := styles.DefaultHistoryKeyMap()
66
67	return HistoryModel{
68		tabs:      tabs,
69		search:    search,
70		help:      helpModel,
71		keys:      keys,
72		entries:   make([][]*entity.HistoryEntry, 4), // Today, Yesterday, This Week, Older
73		ctx:       ctx,
74		historyUC: historyUC,
75		theme:     theme,
76		width:     80,
77		height:    24,
78	}
79}
80
81// Init implements tea.Model.
82func (m HistoryModel) Init() tea.Cmd {
83	return tea.Batch(
84		m.loadHistory,
85		m.loadDomainStats,
86	)
87}
88
89// historyLoadedMsg is sent when history entries are loaded.
90type historyLoadedMsg struct {
91	entries [][]*entity.HistoryEntry
92	err     error
93}
94
95// domainStatsMsg is sent when domain stats are loaded.
96type domainStatsMsg struct {
97	domains []*entity.DomainStat
98	err     error
99}
100
101// historyDeletedMsg is sent when an entry is deleted.
102type historyDeletedMsg struct {
103	err error
104}
105
106// historyCleanedMsg is sent when history is cleaned up.
107type historyCleanedMsg struct {
108	err error
109}
110
111// loadHistory loads history entries grouped by time category.
112func (m HistoryModel) loadHistory() tea.Msg {
113	log := logging.FromContext(m.ctx)
114	log.Debug().Msg("loading history entries")
115
116	// Get recent entries (all)
117	entries, err := m.historyUC.GetRecent(m.ctx, 1000, 0)
118	if err != nil {
119		log.Error().Err(err).Msg("failed to load history")
120		return historyLoadedMsg{err: err}
121	}
122
123	log.Debug().Int("total_count", len(entries)).Msg("loaded history entries from database")
124
125	// Group by time category
126	grouped := make([][]*entity.HistoryEntry, 4)
127	for i := range grouped {
128		grouped[i] = make([]*entity.HistoryEntry, 0)
129	}
130
131	for _, entry := range entries {
132		cat := styles.GetTimeCategory(entry.LastVisited)
133		switch cat {
134		case styles.TimeCategoryToday:
135			grouped[0] = append(grouped[0], entry)
136		case styles.TimeCategoryYesterday:
137			grouped[1] = append(grouped[1], entry)
138		case styles.TimeCategoryThisWeek:
139			grouped[2] = append(grouped[2], entry)
140		case styles.TimeCategoryOlder:
141			grouped[3] = append(grouped[3], entry)
142		}
143	}
144
145	log.Debug().
146		Int("today", len(grouped[0])).
147		Int("yesterday", len(grouped[1])).
148		Int("this_week", len(grouped[2])).
149		Int("older", len(grouped[3])).
150		Msg("grouped history entries by time category")
151
152	return historyLoadedMsg{entries: grouped}
153}
154
155// loadDomainStats loads top domain statistics.
156func (m HistoryModel) loadDomainStats() tea.Msg {
157	log := logging.FromContext(m.ctx)
158	domains, err := m.historyUC.GetDomainStats(m.ctx, 10)
159	if err != nil {
160		log.Error().Err(err).Msg("failed to load domain stats")
161		return domainStatsMsg{err: err}
162	}
163	log.Debug().Int("domain_count", len(domains)).Msg("loaded domain statistics")
164	return domainStatsMsg{domains: domains}
165}
166
167// Update implements tea.Model.
168func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169	if m.cleanup != nil {
170		return m.handleCleanupModal(msg)
171	}
172	if m.confirm != nil {
173		return m.handleConfirmModal(msg)
174	}
175
176	switch msg := msg.(type) {
177	case tea.WindowSizeMsg:
178		return m.handleWindowSize(msg)
179	case tea.KeyMsg:
180		return m.handleKeyMsg(msg)
181
182	case historyLoadedMsg:
183		return m.handleHistoryLoaded(msg)
184
185	case domainStatsMsg:
186		return m.handleDomainStats(msg)
187
188	case historyDeletedMsg:
189		return m.handleHistoryDeleted(msg)
190
191	case historyCleanedMsg:
192		return m.handleHistoryCleaned(msg)
193	}
194
195	return m, nil
196}
197
198func (m HistoryModel) handleCleanupModal(msg tea.Msg) (tea.Model, tea.Cmd) {
199	cleanup, cmd := m.cleanup.Update(msg)
200	m.cleanup = &cleanup
201	if m.cleanup.Done() {
202		if m.cleanup.Confirmed {
203			cmd = m.performCleanup(m.cleanup.SelectedRange())
204		}
205		m.cleanup = nil
206	}
207	return m, cmd
208}
209
210func (m HistoryModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) {
211	confirm, cmd := m.confirm.Update(msg)
212	m.confirm = &confirm
213	if m.confirm.Done() {
214		if m.confirm.Result() {
215			cmd = m.deleteCurrentEntry()
216		}
217		m.confirm = nil
218	}
219	return m, cmd
220}
221
222func (m HistoryModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
223	m.width = msg.Width
224	m.height = msg.Height
225	m.help.Width = msg.Width
226	m.updateList()
227	return m, nil
228}
229
230func (m HistoryModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
231	if m.searchMode {
232		return m.handleSearchKey(msg)
233	}
234	return m.handleNormalKey(msg)
235}
236
237func (m HistoryModel) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
238	switch msg.String() {
239	case "esc":
240		m.searchMode = false
241		m.search.Blur()
242		return m, nil
243	case "enter":
244		m.searchMode = false
245		m.search.Blur()
246		return m, m.performSearch(m.search.Value())
247	default:
248		var cmd tea.Cmd
249		m.search, cmd = m.search.Update(msg)
250		return m, cmd
251	}
252}
253
254func (m HistoryModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
255	if updated, cmd, handled := m.handleGlobalKeys(msg); handled {
256		return updated, cmd
257	}
258	if updated, handled := m.handleTabSwitchKeys(msg); handled {
259		return updated, nil
260	}
261	if updated, cmd, handled := m.handleEntryActionKeys(msg); handled {
262		return updated, cmd
263	}
264
265	var cmd tea.Cmd
266	m.list, cmd = m.list.Update(msg)
267	return m, cmd
268}
269
270func (m HistoryModel) handleGlobalKeys(msg tea.KeyMsg) (HistoryModel, tea.Cmd, bool) {
271	switch {
272	case key.Matches(msg, m.keys.Quit):
273		return m, tea.Quit, true
274	case key.Matches(msg, m.keys.Help):
275		m.showHelp = !m.showHelp
276		return m, nil, true
277	case key.Matches(msg, m.keys.Search):
278		m.searchMode = true
279		m.search.Focus()
280		return m, textinput.Blink, true
281	default:
282		return m, nil, false
283	}
284}
285
286func (m HistoryModel) handleTabSwitchKeys(msg tea.KeyMsg) (HistoryModel, bool) {
287	switch {
288	case key.Matches(msg, m.keys.Tab1):
289		m.tabs.SetActive(0)
290	case key.Matches(msg, m.keys.Tab2):
291		m.tabs.SetActive(1)
292	case key.Matches(msg, m.keys.Tab3):
293		m.tabs.SetActive(2)
294	case key.Matches(msg, m.keys.Tab4):
295		m.tabs.SetActive(3)
296	case key.Matches(msg, m.keys.NextTab):
297		next := (m.tabs.Active + 1) % 4
298		m.tabs.SetActive(next)
299	case key.Matches(msg, m.keys.PrevTab):
300		prev := (m.tabs.Active + 3) % 4 // +3 is same as -1 mod 4
301		m.tabs.SetActive(prev)
302	default:
303		return m, false
304	}
305
306	m.updateList()
307	return m, true
308}
309
310func (m HistoryModel) handleEntryActionKeys(msg tea.KeyMsg) (HistoryModel, tea.Cmd, bool) {
311	switch {
312	case key.Matches(msg, m.keys.Open):
313		if item := m.list.SelectedItem(); item != nil {
314			if hi, ok := item.(styles.HistoryItem); ok {
315				return m, m.openURL(hi.URL), true
316			}
317		}
318		return m, nil, true
319	case key.Matches(msg, m.keys.Delete):
320		if m.list.SelectedItem() != nil {
321			confirm := styles.NewConfirm(m.theme, "Delete this entry?")
322			m.confirm = &confirm
323		}
324		return m, nil, true
325	case key.Matches(msg, m.keys.DeleteDomain):
326		if item := m.list.SelectedItem(); item != nil {
327			if hi, ok := item.(styles.HistoryItem); ok {
328				confirm := styles.NewConfirm(m.theme, "Delete all from "+hi.Domain+"?")
329				m.confirm = &confirm
330			}
331		}
332		return m, nil, true
333	case key.Matches(msg, m.keys.Cleanup):
334		cleanup := styles.NewCleanup(m.theme)
335		m.cleanup = &cleanup
336		return m, nil, true
337	case key.Matches(msg, m.keys.Filter):
338		m.cycleDomainFilter()
339		m.updateList()
340		return m, nil, true
341	default:
342		return m, nil, false
343	}
344}
345
346func (m HistoryModel) handleHistoryLoaded(msg historyLoadedMsg) (tea.Model, tea.Cmd) {
347	if msg.err != nil {
348		m.err = msg.err
349		return m, nil
350	}
351	m.entries = msg.entries
352	m.updateList()
353	return m, nil
354}
355
356func (m HistoryModel) handleDomainStats(msg domainStatsMsg) (tea.Model, tea.Cmd) {
357	if msg.err != nil {
358		m.err = msg.err
359		return m, nil
360	}
361	m.topDomains = msg.domains
362	return m, nil
363}
364
365func (m HistoryModel) handleHistoryDeleted(msg historyDeletedMsg) (tea.Model, tea.Cmd) {
366	if msg.err != nil {
367		m.err = msg.err
368		return m, nil
369	}
370	return m, m.loadHistory
371}
372
373func (m HistoryModel) handleHistoryCleaned(msg historyCleanedMsg) (tea.Model, tea.Cmd) {
374	if msg.err != nil {
375		m.err = msg.err
376		return m, nil
377	}
378	return m, m.loadHistory
379}
380
381// updateList updates the list with entries for the current tab.
382func (m *HistoryModel) updateList() {
383	activeTab := m.tabs.Active
384	if activeTab < 0 || activeTab >= len(m.entries) {
385		return
386	}
387
388	entries := m.entries[activeTab]
389
390	// Apply domain filter if set
391	if m.domainFilter != "" {
392		filtered := make([]*entity.HistoryEntry, 0)
393		for _, e := range entries {
394			if getDomain(e.URL) == m.domainFilter {
395				filtered = append(filtered, e)
396			}
397		}
398		entries = filtered
399	}
400
401	// Convert to list items
402	items := make([]styles.HistoryItem, len(entries))
403	for i, e := range entries {
404		items[i] = styles.HistoryItem{
405			ID:          e.ID,
406			URL:         e.URL,
407			Title:       e.Title,
408			Domain:      getDomain(e.URL),
409			VisitCount:  int(e.VisitCount),
410			LastVisited: e.LastVisited,
411		}
412	}
413
414	// Create new list
415	listHeight := m.height - 8 // Account for tabs, search, help
416	if listHeight < 5 {
417		listHeight = 5
418	}
419
420	m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
421}
422
423// cycleDomainFilter cycles through domain filters.
424func (m *HistoryModel) cycleDomainFilter() {
425	if len(m.topDomains) == 0 {
426		m.domainFilter = ""
427		return
428	}
429
430	// Find current filter in list
431	currentIdx := -1
432	for i, d := range m.topDomains {
433		if d.Domain == m.domainFilter {
434			currentIdx = i
435			break
436		}
437	}
438
439	// Move to next, or clear filter
440	if currentIdx == len(m.topDomains)-1 {
441		m.domainFilter = "" // Clear filter
442	} else {
443		m.domainFilter = m.topDomains[currentIdx+1].Domain
444	}
445}
446
447// performSearch executes a fuzzy search.
448func (m HistoryModel) performSearch(query string) tea.Cmd {
449	return func() tea.Msg {
450		log := logging.FromContext(m.ctx)
451		log.Debug().Str("query", query).Msg("performing search")
452
453		result, err := m.historyUC.Search(m.ctx, usecase.SearchInput{
454			Query: query,
455			Limit: 100,
456		})
457		if err != nil {
458			log.Error().Err(err).Msg("search failed")
459			return historyLoadedMsg{err: err}
460		}
461
462		log.Debug().Int("matches", len(result.Matches)).Msg("search completed")
463
464		// Convert matches to single list (all in "Today" tab for search results)
465		entries := make([]*entity.HistoryEntry, len(result.Matches))
466		for i, match := range result.Matches {
467			entries[i] = match.Entry
468		}
469
470		grouped := make([][]*entity.HistoryEntry, 4)
471		grouped[0] = entries // Put all results in first tab
472		for i := 1; i < 4; i++ {
473			grouped[i] = make([]*entity.HistoryEntry, 0)
474		}
475
476		return historyLoadedMsg{entries: grouped}
477	}
478}
479
480// deleteCurrentEntry deletes the currently selected entry.
481func (m HistoryModel) deleteCurrentEntry() tea.Cmd {
482	return func() tea.Msg {
483		if item := m.list.SelectedItem(); item != nil {
484			if hi, ok := item.(styles.HistoryItem); ok {
485				log := logging.FromContext(m.ctx)
486				log.Debug().Int64("id", hi.ID).Str("url", hi.URL).Msg("deleting history entry")
487				err := m.historyUC.Delete(m.ctx, hi.ID)
488				if err != nil {
489					log.Error().Err(err).Msg("failed to delete entry")
490				}
491				return historyDeletedMsg{err: err}
492			}
493		}
494		return historyDeletedMsg{}
495	}
496}
497
498// performCleanup cleans up history based on selected range.
499func (m HistoryModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd {
500	return func() tea.Msg {
501		log := logging.FromContext(m.ctx)
502		cutoff := cleanupRange.CutoffTime()
503
504		var err error
505		if cutoff.IsZero() {
506			log.Info().Msg("clearing all history")
507			err = m.historyUC.ClearAll(m.ctx)
508		} else {
509			log.Info().Time("cutoff", cutoff).Msg("clearing history older than cutoff")
510			err = m.historyUC.ClearOlderThan(m.ctx, cutoff)
511		}
512
513		if err != nil {
514			log.Error().Err(err).Msg("failed to cleanup history")
515		}
516		return historyCleanedMsg{err: err}
517	}
518}
519
520// openURL opens a URL in the default browser.
521func (m HistoryModel) openURL(urlStr string) tea.Cmd {
522	return func() tea.Msg {
523		log := logging.FromContext(m.ctx)
524		log.Debug().Str("url", urlStr).Msg("opening URL in browser")
525
526		// Use xdg-open on Linux
527		_ = exec.Command("xdg-open", urlStr).Start()
528		return nil
529	}
530}
531
532// View implements tea.Model.
533func (m HistoryModel) View() string {
534	// Handle modals
535	if m.cleanup != nil {
536		return m.cleanup.View()
537	}
538	if m.confirm != nil {
539		return m.confirm.View()
540	}
541
542	t := m.theme
543
544	// Build tab counts
545	counts := make([]int, 4)
546	for i, entries := range m.entries {
547		counts[i] = len(entries)
548	}
549
550	// Header with tabs
551	header := m.tabs.ViewWithCounts(counts)
552
553	// Search bar
554	var searchBar string
555	if m.searchMode {
556		searchBar = t.InputFocused.Render(m.search.View())
557	} else {
558		if m.domainFilter != "" {
559			searchBar = t.Subtle.Render("Filter: ") + t.Badge.Render(m.domainFilter) + t.Subtle.Render(" (f to cycle)")
560		} else {
561			searchBar = t.Subtle.Render("Press / to search, f to filter by domain, Tab/Shift+Tab to switch tabs")
562		}
563	}
564
565	// List
566	listView := m.list.View()
567
568	// Error display
569	if m.err != nil {
570		listView = t.ErrorStyle.Render("Error: " + m.err.Error())
571	}
572
573	// Help
574	var helpView string
575	if m.showHelp {
576		helpView = m.help.View(m.keys)
577	} else {
578		helpView = t.Subtle.Render("? for help • q to quit")
579	}
580
581	// Combine all parts
582	return lipgloss.JoinVertical(
583		lipgloss.Left,
584		header,
585		"",
586		searchBar,
587		"",
588		listView,
589		"",
590		helpView,
591	)
592}
593
594// getDomain extracts domain from URL.
595func getDomain(urlStr string) string {
596	u, err := url.Parse(urlStr)
597	if err != nil {
598		return ""
599	}
600	return u.Host
601}
602
603// HistoryListModel is a simpler non-interactive model for JSON output.
604type HistoryListModel struct {
605	entries []*entity.HistoryEntry
606	max     int
607	err     error
608
609	ctx       context.Context
610	historyUC *usecase.SearchHistoryUseCase
611}
612
613// NewHistoryListModel creates a model for list output.
614func NewHistoryListModel(ctx context.Context, historyUC *usecase.SearchHistoryUseCase, maxEntries int) HistoryListModel {
615	return HistoryListModel{
616		ctx:       ctx,
617		historyUC: historyUC,
618		max:       maxEntries,
619	}
620}
621
622// Init implements tea.Model.
623func (m HistoryListModel) Init() tea.Cmd {
624	return func() tea.Msg {
625		log := logging.FromContext(m.ctx)
626		log.Debug().Int("max", m.max).Msg("loading history for JSON output")
627
628		entries, err := m.historyUC.GetRecent(m.ctx, m.max, 0)
629		if err != nil {
630			log.Error().Err(err).Msg("failed to load history")
631			return historyLoadedMsg{err: err}
632		}
633
634		log.Debug().Int("count", len(entries)).Msg("loaded history entries")
635
636		grouped := make([][]*entity.HistoryEntry, 1)
637		grouped[0] = entries
638		return historyLoadedMsg{entries: grouped}
639	}
640}
641
642// Update implements tea.Model.
643func (m HistoryListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
644	switch msg := msg.(type) {
645	case historyLoadedMsg:
646		if msg.err != nil {
647			m.err = msg.err
648		} else if len(msg.entries) > 0 {
649			m.entries = msg.entries[0]
650		}
651		return m, tea.Quit
652	}
653	return m, nil
654}
655
656// View implements tea.Model.
657func (m HistoryListModel) View() string {
658	if m.err != nil {
659		return ""
660	}
661	return "" // Output handled externally
662}
663
664// Entries returns the loaded entries.
665func (m HistoryListModel) Entries() []*entity.HistoryEntry {
666	return m.entries
667}
668
669// Error returns any error that occurred.
670func (m HistoryListModel) Error() error {
671	return m.err
672}
673
674// Ensure interface compliance at compile time.
675var _ tea.Model = (*HistoryModel)(nil)
676var _ tea.Model = (*HistoryListModel)(nil)
677
678// Unexported helper to avoid exposing time package in imports when not needed.
679var _ = time.Now

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