1package component
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/puregotk/v4/glib"
9 "github.com/bnema/puregotk/v4/gtk"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/dumber/internal/ui/layout"
15 "github.com/rs/zerolog"
16)
17
18// sidebar constants
19const (
20 sidebarMinWidth = 280
21 sidebarDefaultWidth = 320
22 sidebarSearchDebounceMs = 150
23 sidebarReloadDebounceMs = 200
24 sidebarRelativeTickMs = 60 * 1000
25 sidebarPageSize = 100 // entries fetched per page
26 sidebarSearchLimit = 100 // max FTS search results
27)
28
29// HistorySidebarKeyboardAction enumerates the possible activation actions
30// triggered by keyboard Enter variants.
31type HistorySidebarKeyboardAction int
32
33const (
34 // SidebarActionCloseOnActivate is the default activation path.
35 // The host currently keeps the sidebar visible for default activation.
36 SidebarActionCloseOnActivate HistorySidebarKeyboardAction = iota
37 // SidebarActionKeepOpenOnActivate navigates but leaves the sidebar visible.
38 SidebarActionKeepOpenOnActivate
39 // SidebarActionNewPaneOnActivate navigates by opening the URL in a new pane/split.
40 SidebarActionNewPaneOnActivate
41)
42
43// HistorySidebar is a GTK sidebar component that displays browsing history
44// grouped by day, with search/filter support and keyboard navigation.
45// History is loaded page by page with background goroutines to avoid
46// blocking the GTK main thread.
47type HistorySidebar struct {
48 // GTK widgets
49 outerBox *gtk.Box
50 searchBox *gtk.Box
51 searchEntry *gtk.SearchEntry
52 scrolledWin *gtk.ScrolledWindow
53 listBox *gtk.ListBox
54
55 // Dependencies
56 historyUC port.HistorySidebarHistory
57 onURL func(ctx context.Context, url string)
58 onOpenInNewPane func(ctx context.Context, url string) error
59 onNavigateKeepOpen func(ctx context.Context, url string)
60 onClose func()
61
62 // Data
63 allEntries []*entity.HistoryEntry // Flat list, most-recent-first
64 groups []historyGroup // Current display groups (filtered)
65 displayRows []historyDisplayRow // Explicit rows currently rendered
66
67 // Paging state (browse mode only)
68 totalLoaded int // how many entries have been fetched so far
69 hasMore bool
70 isLoading bool
71 loadGen uint64 // incremented each new browse load; used for stale-result protection
72
73 // State
74 visible bool
75 currentQuery string
76 loadStarted bool
77 loadDone bool
78 destroyed bool
79 mu sync.RWMutex
80 logger zerolog.Logger
81
82 // Search state
83 searchResults []*entity.HistoryEntry // non-nil when a search has completed
84 searchGen uint64 // incremented each search; used for stale-result protection
85 searchDone bool // true when the last search completed
86 searchErr error // last search error (if any)
87
88 // Scroll/selection preservation
89 prevScrollValue float64
90 prevSelectedURL string
91
92 // Search/reload/relative-time timers. GLib source IDs are owned and
93 // created/removed on the GTK main context.
94 debounceTimer uint
95 reloadDebounceTimer uint
96 relativeTimeTicker uint
97 relativeTimeLabelBinds []relativeTimeLabelBinding
98 relativeTimeDayKey dayKey
99 relativeTimeDayKeySet bool
100
101 // Retained callbacks
102 retainedCallbacks []any
103
104 // idleScheduler dispatches work onto the GTK main thread.
105 // Tests may override it to exercise scheduled callbacks deterministically.
106 idleScheduler func(glib.SourceFunc)
107 timeoutAdd func(uint, glib.SourceFunc) uint
108 sourceRemove func(uint)
109 now func() time.Time
110
111 // Context
112 ctx context.Context
113 cancel context.CancelFunc
114}
115
116// HistorySidebarConfig holds configuration for creating a HistorySidebar.
117type HistorySidebarConfig struct {
118 // HistoryUC provides history query and delete operations.
119 HistoryUC port.HistorySidebarHistory
120
121 // OnNavigate is called when the user activates a history entry.
122 // The default Enter / click behavior keeps the sidebar open after navigating.
123 OnNavigate func(ctx context.Context, url string) error
124
125 // OnOpenInNewPane is called when Shift+Enter activates a URL.
126 // Should open the URL in a new pane/split. If nil, Shift+Enter
127 // falls back to the default plain-Enter behavior.
128 OnOpenInNewPane func(ctx context.Context, url string) error
129
130 // OnNavigateKeepOpen is called when Ctrl+Enter activates a URL.
131 // Unlike OnNavigate, the sidebar must NOT be closed after this
132 // callback returns. If nil, Ctrl+Enter falls back to OnNavigate.
133 OnNavigateKeepOpen func(ctx context.Context, url string) error
134
135 // OnClose is called when the sidebar should close itself (e.g. Escape
136 // with empty search). The host should hide the sidebar and restore
137 // focus to the active content pane/webview.
138 OnClose func()
139}
140
141// NewHistorySidebar creates a new HistorySidebar component.
142func NewHistorySidebar(ctx context.Context, cfg HistorySidebarConfig) *HistorySidebar {
143 ctx, cancel := context.WithCancel(ctx)
144 log := logging.FromContext(ctx).With().Str("component", "history-sidebar").Logger()
145
146 hs := &HistorySidebar{
147 historyUC: cfg.HistoryUC,
148 onURL: func(callCtx context.Context, url string) {
149 if cfg.OnNavigate != nil {
150 if err := cfg.OnNavigate(callCtx, url); err != nil {
151 log.Error().Err(err).Str("url", url).Msg("history sidebar navigate failed")
152 }
153 }
154 },
155 onOpenInNewPane: func(callCtx context.Context, url string) error {
156 if cfg.OnOpenInNewPane == nil {
157 // Fall back to regular navigation when no new-pane handler set
158 if cfg.OnNavigate != nil {
159 return cfg.OnNavigate(callCtx, url)
160 }
161 return nil
162 }
163 return cfg.OnOpenInNewPane(callCtx, url)
164 },
165 onNavigateKeepOpen: func(callCtx context.Context, url string) {
166 if cfg.OnNavigateKeepOpen != nil {
167 if err := cfg.OnNavigateKeepOpen(callCtx, url); err != nil {
168 log.Error().Err(err).Str("url", url).Msg("history sidebar keep-open navigate failed")
169 }
170 } else if cfg.OnNavigate != nil {
171 // Fall back to the default navigate path when no dedicated
172 // keep-open callback is configured.
173 if err := cfg.OnNavigate(callCtx, url); err != nil {
174 log.Error().Err(err).Str("url", url).Msg("history sidebar keep-open fallback failed")
175 }
176 }
177 },
178 onClose: func() {
179 if cfg.OnClose != nil {
180 cfg.OnClose()
181 }
182 },
183 logger: log,
184 ctx: ctx,
185 cancel: cancel,
186 hasMore: cfg.HistoryUC != nil,
187 }
188
189 if err := hs.createWidgets(); err != nil {
190 log.Error().Err(err).Msg("failed to create history sidebar widgets")
191 cancel()
192 return nil
193 }
194
195 hs.setupSearchHandler()
196 hs.setupScrollLoadMore()
197 hs.setupKeyboardNavigation()
198
199 log.Debug().Msg("history sidebar created")
200
201 // Start loading history asynchronously (background goroutine)
202 hs.startLoadHistory()
203
204 return hs
205}
206
207// Destroy cleans up the sidebar and releases resources.
208func (hs *HistorySidebar) Destroy() {
209 hs.mu.Lock()
210 if hs.destroyed {
211 hs.mu.Unlock()
212 return
213 }
214 hs.destroyed = true
215 timerID := hs.debounceTimer
216 hs.debounceTimer = 0
217 reloadTimerID := hs.cancelReloadDebounceLocked()
218 tickerID := hs.relativeTimeTicker
219 hs.relativeTimeTicker = 0
220 hs.clearRelativeTimeBindingsLocked()
221 hs.mu.Unlock()
222
223 if hs.cancel != nil {
224 hs.cancel()
225 }
226
227 if timerID != 0 {
228 hs.removeSource(timerID)
229 }
230 if reloadTimerID != 0 {
231 hs.removeSource(reloadTimerID)
232 }
233 if tickerID != 0 {
234 hs.removeSource(tickerID)
235 }
236}
237
238// WidgetAsLayout returns the sidebar's outer widget for embedding.
239func (hs *HistorySidebar) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
240 if hs.outerBox == nil {
241 return nil
242 }
243 return factory.WrapWidget(&hs.outerBox.Widget)
244}
245
246// Widget returns the raw GTK widget.
247func (hs *HistorySidebar) Widget() *gtk.Widget {
248 if hs.outerBox == nil {
249 return nil
250 }
251 return &hs.outerBox.Widget
252}
253
254// Show displays the sidebar and focuses the search entry.
255// History data is refreshed asynchronously so Ctrl+H always shows
256// current recent visits, not stale data from initialization.
257func (hs *HistorySidebar) Show() {
258 hs.mu.Lock()
259 if hs.outerBox == nil || hs.destroyed {
260 hs.mu.Unlock()
261 return
262 }
263
264 reloadTimerID := hs.reloadDebounceTimer
265 hs.reloadDebounceTimer = 0
266 hs.outerBox.SetVisible(true)
267 hs.visible = true
268 hs.mu.Unlock()
269
270 if reloadTimerID != 0 {
271 hs.removeSource(reloadTimerID)
272 }
273 hs.startRelativeTimeTicker()
274
275 // Schedule a background reload so the sidebar shows fresh data
276 // when it becomes visible, not stale data captured at init time.
277 reloadCb := glib.SourceFunc(func(uintptr) bool {
278 hs.mu.RLock()
279 destroyed := hs.destroyed
280 hs.mu.RUnlock()
281 if destroyed {
282 return false
283 }
284 hs.Reload()
285 return false
286 })
287 hs.scheduleIdle(reloadCb)
288
289 // Focus search entry via idle callback to ensure layout is stable.
290 cb := glib.SourceFunc(func(uintptr) bool {
291 hs.mu.RLock()
292 destroyed := hs.destroyed
293 entry := hs.searchEntry
294 hs.mu.RUnlock()
295 if destroyed || entry == nil {
296 return false
297 }
298 entry.GrabFocus()
299 return false
300 })
301 hs.scheduleIdle(cb)
302}
303
304// Hide hides the sidebar.
305func (hs *HistorySidebar) Hide() {
306 hs.mu.Lock()
307 if hs.destroyed {
308 hs.mu.Unlock()
309 return
310 }
311
312 reloadTimerID := hs.cancelReloadDebounceLocked()
313 tickerID := hs.relativeTimeTicker
314 hs.relativeTimeTicker = 0
315 hs.clearRelativeTimeBindingsLocked()
316 if hs.outerBox != nil {
317 hs.outerBox.SetVisible(false)
318 }
319 hs.visible = false
320 hs.mu.Unlock()
321
322 if reloadTimerID != 0 {
323 hs.removeSource(reloadTimerID)
324 }
325 if tickerID != 0 {
326 hs.removeSource(tickerID)
327 }
328}
329
330// IsVisible returns whether the sidebar is visible.
331func (hs *HistorySidebar) IsVisible() bool {
332 hs.mu.RLock()
333 defer hs.mu.RUnlock()
334 return hs.visible
335}
336
337// Reload triggers a fresh load of history data. Preserves the current search
338// query, scroll position, and selection.
339func (hs *HistorySidebar) Reload() {
340 hs.mu.Lock()
341 if hs.destroyed {
342 hs.mu.Unlock()
343 return
344 }
345 hs.preserveScrollAndSelection()
346 savedQuery := hs.currentQuery
347
348 // Reset all data state but keep the query preserved so the search entry
349 // text and internal state remain consistent.
350 hs.loadDone = false
351 hs.loadStarted = false
352 hs.totalLoaded = 0
353 hs.hasMore = hs.historyUC != nil
354 hs.isLoading = false
355 hs.allEntries = nil
356 hs.setDisplayGroupsLocked(nil)
357 hs.searchResults = nil
358 hs.searchDone = false
359 hs.searchErr = nil
360
361 if savedQuery == "" {
362 hs.currentQuery = ""
363 hs.mu.Unlock()
364 hs.startLoadHistory()
365 } else {
366 hs.currentQuery = savedQuery
367 hs.searchGen++ // Invalidate any in-flight search
368 gen := hs.searchGen
369 hs.mu.Unlock()
370 hs.scheduleClearList()
371 hs.doFTSearch(savedQuery, gen)
372 }
373}
374
375// SetSearchQuery externally sets the search text and triggers filtering.
376func (hs *HistorySidebar) SetSearchQuery(query string) {
377 hs.mu.RLock()
378 entry := hs.searchEntry
379 hs.mu.RUnlock()
380 if entry != nil {
381 entry.SetText(query)
382 }
383}
384
385// ClearSearch clears the search entry text.
386func (hs *HistorySidebar) ClearSearch() {
387 hs.SetSearchQuery("")
388}
389
390// =============================================================================
391// Helpers
392// =============================================================================
393
394func safeSidebarString(s, fallback string) string {
395 if s == "" {
396 return fallback
397 }
398 return s
399}