v0.31.0 bf0756b

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.
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}

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