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. Construction
142// performs no data queries: the initial load runs on the first Show so
143// on-demand instances (see the host ensure methods) never pay for a
144// discarded constructor generation before Show schedules its own Reload.
145// Reopening reloads through Show as before.
146func NewHistorySidebar(ctx context.Context, cfg HistorySidebarConfig) *HistorySidebar {
147 ctx, cancel := context.WithCancel(ctx)
148 log := logging.FromContext(ctx).With().Str("component", "history-sidebar").Logger()
149
150 hs := &HistorySidebar{
151 historyUC: cfg.HistoryUC,
152 onURL: func(callCtx context.Context, url string) {
153 if cfg.OnNavigate != nil {
154 if err := cfg.OnNavigate(callCtx, url); err != nil {
155 log.Error().Err(err).Str("url", url).Msg("history sidebar navigate failed")
156 }
157 }
158 },
159 onOpenInNewPane: func(callCtx context.Context, url string) error {
160 if cfg.OnOpenInNewPane == nil {
161 // Fall back to regular navigation when no new-pane handler set
162 if cfg.OnNavigate != nil {
163 return cfg.OnNavigate(callCtx, url)
164 }
165 return nil
166 }
167 return cfg.OnOpenInNewPane(callCtx, url)
168 },
169 onNavigateKeepOpen: func(callCtx context.Context, url string) {
170 if cfg.OnNavigateKeepOpen != nil {
171 if err := cfg.OnNavigateKeepOpen(callCtx, url); err != nil {
172 log.Error().Err(err).Str("url", url).Msg("history sidebar keep-open navigate failed")
173 }
174 } else if cfg.OnNavigate != nil {
175 // Fall back to the default navigate path when no dedicated
176 // keep-open callback is configured.
177 if err := cfg.OnNavigate(callCtx, url); err != nil {
178 log.Error().Err(err).Str("url", url).Msg("history sidebar keep-open fallback failed")
179 }
180 }
181 },
182 onClose: func() {
183 if cfg.OnClose != nil {
184 cfg.OnClose()
185 }
186 },
187 logger: log,
188 ctx: ctx,
189 cancel: cancel,
190 hasMore: cfg.HistoryUC != nil,
191 }
192
193 if err := hs.createWidgets(); err != nil {
194 log.Error().Err(err).Msg("failed to create history sidebar widgets")
195 cancel()
196 return nil
197 }
198
199 hs.setupSearchHandler()
200 hs.setupScrollLoadMore()
201 hs.setupKeyboardNavigation()
202
203 log.Debug().Msg("history sidebar created")
204
205 return hs
206}
207
208// Destroy cleans up the sidebar and releases resources.
209func (hs *HistorySidebar) Destroy() {
210 hs.mu.Lock()
211 if hs.destroyed {
212 hs.mu.Unlock()
213 return
214 }
215 hs.destroyed = true
216 timerID := hs.debounceTimer
217 hs.debounceTimer = 0
218 reloadTimerID := hs.cancelReloadDebounceLocked()
219 tickerID := hs.relativeTimeTicker
220 hs.relativeTimeTicker = 0
221 hs.clearRelativeTimeBindingsLocked()
222 hs.mu.Unlock()
223
224 if hs.cancel != nil {
225 hs.cancel()
226 }
227
228 if timerID != 0 {
229 hs.removeSource(timerID)
230 }
231 if reloadTimerID != 0 {
232 hs.removeSource(reloadTimerID)
233 }
234 if tickerID != 0 {
235 hs.removeSource(tickerID)
236 }
237}
238
239// WidgetAsLayout returns the sidebar's outer widget for embedding.
240func (hs *HistorySidebar) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
241 if hs.outerBox == nil {
242 return nil
243 }
244 return factory.WrapWidget(&hs.outerBox.Widget)
245}
246
247// Widget returns the raw GTK widget.
248func (hs *HistorySidebar) Widget() *gtk.Widget {
249 if hs.outerBox == nil {
250 return nil
251 }
252 return &hs.outerBox.Widget
253}
254
255// Show displays the sidebar and focuses the search entry.
256// History data is refreshed asynchronously so Ctrl+H always shows
257// current recent visits, not stale data from initialization.
258func (hs *HistorySidebar) Show() {
259 hs.mu.Lock()
260 if hs.outerBox == nil || hs.destroyed {
261 hs.mu.Unlock()
262 return
263 }
264
265 reloadTimerID := hs.reloadDebounceTimer
266 hs.reloadDebounceTimer = 0
267 hs.outerBox.SetVisible(true)
268 hs.visible = true
269 hs.mu.Unlock()
270
271 if reloadTimerID != 0 {
272 hs.removeSource(reloadTimerID)
273 }
274 hs.startRelativeTimeTicker()
275
276 // Schedule a background reload so the sidebar shows fresh data
277 // when it becomes visible, not stale data captured at init time.
278 reloadCb := glib.SourceFunc(func(uintptr) bool {
279 hs.mu.RLock()
280 destroyed := hs.destroyed
281 hs.mu.RUnlock()
282 if destroyed {
283 return false
284 }
285 hs.Reload()
286 return false
287 })
288 hs.scheduleIdle(reloadCb)
289
290 // Focus search entry via idle callback to ensure layout is stable.
291 cb := glib.SourceFunc(func(uintptr) bool {
292 hs.mu.RLock()
293 destroyed := hs.destroyed
294 entry := hs.searchEntry
295 hs.mu.RUnlock()
296 if destroyed || entry == nil {
297 return false
298 }
299 entry.GrabFocus()
300 return false
301 })
302 hs.scheduleIdle(cb)
303}
304
305// Hide hides the sidebar.
306func (hs *HistorySidebar) Hide() {
307 hs.mu.Lock()
308 if hs.destroyed {
309 hs.mu.Unlock()
310 return
311 }
312
313 reloadTimerID := hs.cancelReloadDebounceLocked()
314 tickerID := hs.relativeTimeTicker
315 hs.relativeTimeTicker = 0
316 hs.clearRelativeTimeBindingsLocked()
317 if hs.outerBox != nil {
318 hs.outerBox.SetVisible(false)
319 }
320 hs.visible = false
321 hs.mu.Unlock()
322
323 if reloadTimerID != 0 {
324 hs.removeSource(reloadTimerID)
325 }
326 if tickerID != 0 {
327 hs.removeSource(tickerID)
328 }
329}
330
331// IsVisible returns whether the sidebar is visible.
332func (hs *HistorySidebar) IsVisible() bool {
333 hs.mu.RLock()
334 defer hs.mu.RUnlock()
335 return hs.visible
336}
337
338// Reload triggers a fresh load of history data. Preserves the current search
339// query, scroll position, and selection.
340func (hs *HistorySidebar) Reload() {
341 hs.mu.Lock()
342 if hs.destroyed {
343 hs.mu.Unlock()
344 return
345 }
346 hs.preserveScrollAndSelection()
347 savedQuery := hs.currentQuery
348
349 // Reset all data state but keep the query preserved so the search entry
350 // text and internal state remain consistent.
351 hs.loadDone = false
352 hs.loadStarted = false
353 hs.totalLoaded = 0
354 hs.hasMore = hs.historyUC != nil
355 hs.isLoading = false
356 hs.allEntries = nil
357 hs.setDisplayGroupsLocked(nil)
358 hs.searchResults = nil
359 hs.searchDone = false
360 hs.searchErr = nil
361
362 if savedQuery == "" {
363 hs.currentQuery = ""
364 hs.mu.Unlock()
365 hs.startLoadHistory()
366 } else {
367 hs.currentQuery = savedQuery
368 hs.searchGen++ // Invalidate any in-flight search
369 gen := hs.searchGen
370 hs.mu.Unlock()
371 hs.scheduleClearList()
372 hs.doFTSearch(savedQuery, gen)
373 }
374}
375
376// SetSearchQuery externally sets the search text and triggers filtering.
377func (hs *HistorySidebar) SetSearchQuery(query string) {
378 hs.mu.RLock()
379 entry := hs.searchEntry
380 hs.mu.RUnlock()
381 if entry != nil {
382 entry.SetText(query)
383 }
384}
385
386// ClearSearch clears the search entry text.
387func (hs *HistorySidebar) ClearSearch() {
388 hs.SetSearchQuery("")
389}
390
391// =============================================================================
392// Helpers
393// =============================================================================
394
395func safeSidebarString(s, fallback string) string {
396 if s == "" {
397 return fallback
398 }
399 return s
400}