main 5041353

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

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