main 5041353

history_sidebar.go RawHistoryFolder

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}

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