main 5041353

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