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