v0.31.0 bf0756b

favorites_sidebar.go RawHistoryFolder

1package component
2
3import (
4	"context"
5	"fmt"
6	"sync"
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/application/usecase"
13	"github.com/bnema/dumber/internal/domain/entity"
14)
15
16// FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar.
17type FavoritesSidebarConfig struct {
18	FavoritesUC        port.FavoritesSidebarFavorites
19	OnNavigate         func(ctx context.Context, url string) error
20	OnNavigateKeepOpen func(ctx context.Context, url string) error
21	OnOpenInNewPane    func(ctx context.Context, url string) error
22	OnClose            func()
23}
24
25// FavoritesSidebar is a read-only GTK sidebar component for browsing favorites.
26type FavoritesSidebar struct {
27	outerBox         *gtk.Box
28	searchEntry      *gtk.SearchEntry
29	tagBox           *gtk.Box
30	tagControls      []*gtk.Button
31	tagPromptBox     *gtk.Box
32	tagNameEntry     *gtk.Entry
33	tagPromptSaveBtn *gtk.Button
34	scrolledWin      *gtk.ScrolledWindow
35	listBox          *gtk.ListBox
36	formBox          *gtk.Box
37
38	formURLEntry      *gtk.SearchEntry
39	formTitleEntry    *gtk.SearchEntry
40	formTagSearch     *gtk.SearchEntry
41	formTagMatches    *gtk.Box
42	formShortcutEntry *gtk.SearchEntry
43	formSaveButton    *gtk.Button
44	formURL           string
45	formTitle         string
46	formShortcut      string
47	formTagIDs        map[entity.TagID]struct{}
48
49	favoritesUC        port.FavoritesSidebarFavorites
50	onNavigate         func(ctx context.Context, url string) error
51	onNavigateKeepOpen func(ctx context.Context, url string) error
52	onOpenInNewPane    func(ctx context.Context, url string) error
53	onClose            func()
54
55	allFavorites    []*entity.Favorite
56	allTags         []*entity.Tag
57	selectedTagIDs  map[entity.TagID]struct{}
58	displayRows     []favoriteSidebarDisplayRow
59	currentQuery    string
60	notice          string
61	noticeLoadGen   uint64
62	loadGen         uint64
63	destroyed       bool
64	visible         bool
65	focusZone       favoritesSidebarFocusZone
66	textInputWidget *gtk.Widget
67	mode            favoritesSidebarMode
68	editingID       entity.FavoriteID
69	confirmDelete   bool
70	confirmDeleteID entity.FavoriteID
71
72	retainedCallbacks     []any
73	tagCallbacks          []any
74	tagPromptCallbacks    []any
75	formCallbacks         []any
76	formTagMatchCallbacks []any
77	ctx                   context.Context
78	cancel                context.CancelFunc
79	mu                    sync.RWMutex
80
81	idleScheduler func(glib.SourceFunc)
82}
83
84// NewFavoritesSidebar creates a native favorites sidebar and starts loading data.
85func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar {
86	ctx, cancel := context.WithCancel(ctx)
87	fs := &FavoritesSidebar{
88		favoritesUC:        cfg.FavoritesUC,
89		onNavigate:         cfg.OnNavigate,
90		onNavigateKeepOpen: cfg.OnNavigateKeepOpen,
91		onOpenInNewPane:    cfg.OnOpenInNewPane,
92		onClose:            cfg.OnClose,
93		selectedTagIDs:     make(map[entity.TagID]struct{}),
94		ctx:                ctx,
95		cancel:             cancel,
96	}
97	if err := fs.createWidgets(); err != nil {
98		cancel()
99		return nil
100	}
101	fs.setupSearchHandler()
102	fs.setupKeyboardNavigation()
103	fs.startLoad()
104	return fs
105}
106
107// Widget returns the raw GTK widget.
108func (fs *FavoritesSidebar) Widget() *gtk.Widget {
109	if fs == nil || fs.outerBox == nil {
110		return nil
111	}
112	return &fs.outerBox.Widget
113}
114
115// Show displays the sidebar and focuses search. Nil-safe.
116func (fs *FavoritesSidebar) Show() {
117	if fs == nil {
118		return
119	}
120	fs.mu.Lock()
121	if fs.destroyed || fs.outerBox == nil {
122		fs.mu.Unlock()
123		return
124	}
125	fs.outerBox.SetVisible(true)
126	fs.visible = true
127	fs.mu.Unlock()
128	fs.startLoad()
129	fs.focusSearch()
130}
131
132// Hide hides the sidebar. Nil-safe.
133func (fs *FavoritesSidebar) Hide() {
134	if fs == nil {
135		return
136	}
137	fs.mu.Lock()
138	if fs.destroyed {
139		fs.mu.Unlock()
140		return
141	}
142	if fs.outerBox != nil {
143		fs.outerBox.SetVisible(false)
144	}
145	fs.visible = false
146	fs.mu.Unlock()
147}
148
149// Destroy cancels background work and marks the sidebar destroyed. Nil-safe.
150func (fs *FavoritesSidebar) Destroy() {
151	if fs == nil {
152		return
153	}
154	fs.mu.Lock()
155	if fs.destroyed {
156		fs.mu.Unlock()
157		return
158	}
159	fs.destroyed = true
160	fs.mu.Unlock()
161	if fs.cancel != nil {
162		fs.cancel()
163	}
164}
165
166func (fs *FavoritesSidebar) createWidgets() error {
167	if err := fs.createOuterAndSearch(); err != nil {
168		return err
169	}
170	if err := fs.createTagControls(); err != nil {
171		return err
172	}
173
174	fs.scrolledWin = gtk.NewScrolledWindow()
175	if fs.scrolledWin == nil {
176		return fmt.Errorf("favorites sidebar: scrolled window creation failed")
177	}
178	fs.scrolledWin.SetVexpand(true)
179	fs.scrolledWin.SetHexpand(true)
180	fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
181	fs.scrolledWin.AddCssClass("sidebar-list")
182
183	fs.listBox = gtk.NewListBox()
184	if fs.listBox == nil {
185		return fmt.Errorf("favorites sidebar: list box creation failed")
186	}
187	fs.listBox.AddCssClass("sidebar-list")
188	fs.listBox.AddCssClass("favorites-sidebar-list")
189	fs.trackListFocus(&fs.listBox.Widget)
190	fs.listBox.SetActivateOnSingleClick(true)
191	fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
192	rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
193		row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
194		fs.onRowActivated(row)
195	}
196	fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb)
197	fs.listBox.ConnectRowActivated(&rowActivatedCb)
198
199	fs.scrolledWin.SetChild(&fs.listBox.Widget)
200	fs.outerBox.Append(&fs.scrolledWin.Widget)
201
202	fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4)
203	if fs.formBox == nil {
204		return fmt.Errorf("favorites sidebar: form box creation failed")
205	}
206	fs.formBox.AddCssClass("favorites-sidebar-form")
207	fs.formBox.SetVisible(false)
208	fs.outerBox.Append(&fs.formBox.Widget)
209	return nil
210}
211
212func (fs *FavoritesSidebar) createOuterAndSearch() error {
213	fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
214	if fs.outerBox == nil {
215		return fmt.Errorf("favorites sidebar: outer box creation failed")
216	}
217	fs.outerBox.AddCssClass("sidebar-outer")
218	fs.outerBox.AddCssClass("favorites-sidebar-outer")
219	fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
220	fs.outerBox.SetHexpand(false)
221	fs.outerBox.SetVexpand(true)
222	fs.outerBox.SetVisible(false)
223
224	searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
225	if searchBox == nil {
226		return fmt.Errorf("favorites sidebar: search box creation failed")
227	}
228	searchBox.AddCssClass("sidebar-search-box")
229	searchBox.SetHexpand(true)
230	fs.searchEntry = gtk.NewSearchEntry()
231	if fs.searchEntry == nil {
232		return fmt.Errorf("favorites sidebar: search entry creation failed")
233	}
234	fs.searchEntry.AddCssClass("sidebar-search")
235	fs.searchEntry.AddCssClass("favorites-sidebar-search")
236	fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks)
237	fs.searchEntry.SetHexpand(true)
238	placeholder := "Search favorites..."
239	fs.searchEntry.SetPlaceholderText(&placeholder)
240	searchBox.Append(&fs.searchEntry.Widget)
241	fs.outerBox.Append(&searchBox.Widget)
242	return nil
243}
244
245func (fs *FavoritesSidebar) createTagControls() error {
246	fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
247	if fs.tagBox == nil {
248		return fmt.Errorf("favorites sidebar: tag box creation failed")
249	}
250	fs.tagBox.AddCssClass("favorites-sidebar-tags")
251	fs.tagBox.SetHexpand(true)
252	fs.outerBox.Append(&fs.tagBox.Widget)
253	fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
254	if fs.tagPromptBox == nil {
255		return fmt.Errorf("favorites sidebar: tag prompt creation failed")
256	}
257	fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt")
258	fs.tagPromptBox.SetVisible(false)
259	fs.outerBox.Append(&fs.tagPromptBox.Widget)
260	return nil
261}
262
263func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) {
264	if fs == nil || widget == nil || callbacks == nil {
265		return
266	}
267	controller := gtk.NewEventControllerFocus()
268	if controller == nil {
269		return
270	}
271	entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }
272	left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }
273	*callbacks = append(*callbacks, entered, left)
274	controller.ConnectEnter(&entered)
275	controller.ConnectLeave(&left)
276	widget.AddController(&controller.EventController)
277}
278
279func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) {
280	if fs == nil || widget == nil {
281		return
282	}
283	fs.mu.Lock()
284	if !fs.destroyed {
285		fs.textInputWidget = widget
286		if fs.searchEntry != nil && widget == &fs.searchEntry.Widget {
287			fs.focusZone = favoritesSidebarFocusSearch
288		} else {
289			fs.focusZone = favoritesSidebarFocusForm
290		}
291	}
292	fs.mu.Unlock()
293}
294
295func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) {
296	if fs == nil || widget == nil {
297		return
298	}
299	controller := gtk.NewEventControllerFocus()
300	if controller == nil {
301		return
302	}
303	entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) }
304	fs.retainedCallbacks = append(fs.retainedCallbacks, entered)
305	controller.ConnectEnter(&entered)
306	widget.AddController(&controller.EventController)
307}
308
309func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) {
310	if fs == nil {
311		return
312	}
313	fs.mu.Lock()
314	if !fs.destroyed {
315		fs.focusZone = zone
316	}
317	fs.mu.Unlock()
318}
319
320func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) {
321	if fs == nil || widget == nil {
322		return
323	}
324	fs.mu.Lock()
325	if !fs.destroyed && fs.textInputWidget == widget {
326		fs.textInputWidget = nil
327	}
328	fs.mu.Unlock()
329}
330
331func (fs *FavoritesSidebar) focusSearch() {
332	if fs == nil {
333		return
334	}
335	fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
336		fs.mu.RLock()
337		destroyed := fs.destroyed
338		entry := fs.searchEntry
339		fs.mu.RUnlock()
340		if !destroyed && entry != nil {
341			entry.GrabFocus()
342		}
343		return false
344	}))
345}
346
347// RequestReloadIfVisible refreshes sidebar data when the sidebar is currently visible.
348func (fs *FavoritesSidebar) RequestReloadIfVisible(_ string) {
349	if fs == nil {
350		return
351	}
352	fs.mu.RLock()
353	visible := fs.visible && !fs.destroyed
354	fs.mu.RUnlock()
355	if visible {
356		fs.startLoad()
357	}
358}
359
360func (fs *FavoritesSidebar) startLoad() {
361	if fs == nil {
362		return
363	}
364	fs.mu.Lock()
365	if fs.destroyed {
366		fs.mu.Unlock()
367		return
368	}
369	fs.loadGen++
370	gen := fs.loadGen
371	uc := fs.favoritesUC
372	ctx := fs.ctx
373	visible := fs.visible
374	fs.notice = "Loading favorites..."
375	fs.noticeLoadGen = gen
376	fs.mu.Unlock()
377	if visible {
378		fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
379			fs.mu.RLock()
380			destroyed := fs.destroyed
381			fs.mu.RUnlock()
382			if !destroyed {
383				fs.rebuildList()
384			}
385			return false
386		}))
387	}
388
389	if uc == nil || ctx == nil {
390		fs.applyLoadedData(nil, nil, gen, nil)
391		return
392	}
393	go func() {
394		favorites, favErr := uc.GetAll(ctx)
395		tags, tagErr := uc.GetAllTags(ctx)
396		err := favErr
397		if err == nil {
398			err = tagErr
399		}
400		fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
401			fs.applyLoadedData(favorites, tags, gen, err)
402			return false
403		}))
404	}()
405}
406
407func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool {
408	if fs == nil {
409		return false
410	}
411	fs.mu.Lock()
412	if fs.destroyed || gen != fs.loadGen {
413		fs.mu.Unlock()
414		return false
415	}
416	if favorites == nil {
417		favorites = []*entity.Favorite{}
418	}
419	if tags == nil {
420		tags = []*entity.Tag{}
421	}
422	fs.allFavorites = favorites
423	fs.allTags = tags
424	if fs.noticeLoadGen == gen {
425		fs.noticeLoadGen = 0
426		if err != nil {
427			fs.notice = err.Error()
428		} else {
429			fs.notice = ""
430		}
431	}
432	fs.rebuildDisplayRowsLocked()
433	fs.mu.Unlock()
434	fs.renderTags()
435	fs.rebuildList()
436	return true
437}
438
439func (fs *FavoritesSidebar) setupSearchHandler() {
440	if fs == nil || fs.searchEntry == nil {
441		return
442	}
443	changedCb := func(_ gtk.SearchEntry) {
444		fs.mu.RLock()
445		destroyed := fs.destroyed
446		fs.mu.RUnlock()
447		if destroyed {
448			return
449		}
450		fs.onSearchChanged()
451	}
452	fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb)
453	fs.searchEntry.ConnectSearchChanged(&changedCb)
454}
455
456func (fs *FavoritesSidebar) onSearchChanged() {
457	fs.mu.Lock()
458	if fs.destroyed {
459		fs.mu.Unlock()
460		return
461	}
462	if fs.searchEntry != nil {
463		fs.currentQuery = fs.searchEntry.GetText()
464	}
465	fs.rebuildDisplayRowsLocked()
466	fs.mu.Unlock()
467	fs.rebuildList()
468}
469
470func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
471	query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery}
472	for id := range fs.selectedTagIDs {
473		query.TagIDs = append(query.TagIDs, id)
474	}
475	filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query)
476	fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
477}
478
479// setNoticeLocked records a notice not owned by an in-flight load. fs.mu must be held.
480func (fs *FavoritesSidebar) setNoticeLocked(notice string) {
481	fs.notice = notice
482	fs.noticeLoadGen = 0
483}
484
485func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
486	if fs != nil && fs.idleScheduler != nil {
487		fs.idleScheduler(cb)
488		return
489	}
490	glib.IdleAdd(&cb, 0)
491}

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