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