detached 5b8fa27

feat(favorites): add native sidebar browser

commit
5b8fa27461a7183de792694928ef4acc46a7b7c4
author
brice · · 2026-06-28 15:32 UTC
committer
brice · · 2026-06-28 15:44 UTC
parent
4a7ea51
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +1124 -0
A internal/ui/component/favorites_sidebar.go +320 -0
@@ -0,0 +1,320 @@
1+package component
2+
3+import (
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.
17+type 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.
26+type FavoritesSidebar struct {
27+ outerBox *gtk.Box
28+ searchEntry *gtk.SearchEntry
29+ tagBox *gtk.Box
30+ scrolledWin *gtk.ScrolledWindow
31+ listBox *gtk.ListBox
32+
33+ favoritesUC port.FavoritesSidebarFavorites
34+ onNavigate func(ctx context.Context, url string) error
35+ onNavigateKeepOpen func(ctx context.Context, url string) error
36+ onOpenInNewPane func(ctx context.Context, url string) error
37+ onClose func()
38+
39+ allFavorites []*entity.Favorite
40+ allTags []*entity.Tag
41+ selectedTagIDs map[entity.TagID]struct{}
42+ displayRows []favoriteSidebarDisplayRow
43+ currentQuery string
44+ notice string
45+ loadGen uint64
46+ destroyed bool
47+ visible bool
48+
49+ retainedCallbacks []interface{}
50+ tagCallbacks []interface{}
51+ ctx context.Context
52+ cancel context.CancelFunc
53+ mu sync.RWMutex
54+
55+ idleScheduler func(glib.SourceFunc)
56+}
57+
58+// NewFavoritesSidebar creates a native favorites sidebar and starts loading data.
59+func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar {
60+ ctx, cancel := context.WithCancel(ctx)
61+ fs := &FavoritesSidebar{
62+ favoritesUC: cfg.FavoritesUC,
63+ onNavigate: cfg.OnNavigate,
64+ onNavigateKeepOpen: cfg.OnNavigateKeepOpen,
65+ onOpenInNewPane: cfg.OnOpenInNewPane,
66+ onClose: cfg.OnClose,
67+ selectedTagIDs: make(map[entity.TagID]struct{}),
68+ ctx: ctx,
69+ cancel: cancel,
70+ }
71+ if err := fs.createWidgets(); err != nil {
72+ cancel()
73+ return nil
74+ }
75+ fs.setupSearchHandler()
76+ fs.setupKeyboardNavigation()
77+ fs.startLoad()
78+ return fs
79+}
80+
81+// Widget returns the raw GTK widget.
82+func (fs *FavoritesSidebar) Widget() *gtk.Widget {
83+ if fs == nil || fs.outerBox == nil {
84+ return nil
85+ }
86+ return &fs.outerBox.Widget
87+}
88+
89+// Show displays the sidebar and focuses search. Nil-safe.
90+func (fs *FavoritesSidebar) Show() {
91+ if fs == nil {
92+ return
93+ }
94+ fs.mu.Lock()
95+ if fs.destroyed || fs.outerBox == nil {
96+ fs.mu.Unlock()
97+ return
98+ }
99+ fs.outerBox.SetVisible(true)
100+ fs.visible = true
101+ fs.mu.Unlock()
102+ fs.focusSearch()
103+}
104+
105+// Hide hides the sidebar. Nil-safe.
106+func (fs *FavoritesSidebar) Hide() {
107+ if fs == nil {
108+ return
109+ }
110+ fs.mu.Lock()
111+ if fs.destroyed {
112+ fs.mu.Unlock()
113+ return
114+ }
115+ if fs.outerBox != nil {
116+ fs.outerBox.SetVisible(false)
117+ }
118+ fs.visible = false
119+ fs.mu.Unlock()
120+}
121+
122+// Destroy cancels background work and marks the sidebar destroyed. Nil-safe.
123+func (fs *FavoritesSidebar) Destroy() {
124+ if fs == nil {
125+ return
126+ }
127+ fs.mu.Lock()
128+ if fs.destroyed {
129+ fs.mu.Unlock()
130+ return
131+ }
132+ fs.destroyed = true
133+ fs.mu.Unlock()
134+ if fs.cancel != nil {
135+ fs.cancel()
136+ }
137+}
138+
139+func (fs *FavoritesSidebar) createWidgets() error {
140+ fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 6)
141+ if fs.outerBox == nil {
142+ return fmt.Errorf("favorites sidebar: outer box creation failed")
143+ }
144+ fs.outerBox.AddCssClass("favorites-sidebar-outer")
145+ fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
146+ fs.outerBox.SetHexpand(false)
147+ fs.outerBox.SetVexpand(true)
148+ fs.outerBox.SetVisible(false)
149+
150+ fs.searchEntry = gtk.NewSearchEntry()
151+ if fs.searchEntry == nil {
152+ return fmt.Errorf("favorites sidebar: search entry creation failed")
153+ }
154+ fs.searchEntry.AddCssClass("favorites-sidebar-search")
155+ fs.searchEntry.SetHexpand(true)
156+ placeholder := "Search favorites"
157+ fs.searchEntry.SetPlaceholderText(&placeholder)
158+ fs.outerBox.Append(&fs.searchEntry.Widget)
159+
160+ fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
161+ if fs.tagBox == nil {
162+ return fmt.Errorf("favorites sidebar: tag box creation failed")
163+ }
164+ fs.tagBox.AddCssClass("favorites-sidebar-tags")
165+ fs.tagBox.SetHexpand(true)
166+ fs.outerBox.Append(&fs.tagBox.Widget)
167+
168+ fs.scrolledWin = gtk.NewScrolledWindow()
169+ if fs.scrolledWin == nil {
170+ return fmt.Errorf("favorites sidebar: scrolled window creation failed")
171+ }
172+ fs.scrolledWin.SetVexpand(true)
173+ fs.scrolledWin.SetHexpand(true)
174+ fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
175+
176+ fs.listBox = gtk.NewListBox()
177+ if fs.listBox == nil {
178+ return fmt.Errorf("favorites sidebar: list box creation failed")
179+ }
180+ fs.listBox.AddCssClass("favorites-sidebar-list")
181+ fs.listBox.SetActivateOnSingleClick(true)
182+ fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
183+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
184+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
185+ fs.onRowActivated(row)
186+ }
187+ fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb)
188+ fs.listBox.ConnectRowActivated(&rowActivatedCb)
189+
190+ fs.scrolledWin.SetChild(&fs.listBox.Widget)
191+ fs.outerBox.Append(&fs.scrolledWin.Widget)
192+ return nil
193+}
194+
195+func (fs *FavoritesSidebar) focusSearch() {
196+ if fs == nil {
197+ return
198+ }
199+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
200+ fs.mu.RLock()
201+ destroyed := fs.destroyed
202+ entry := fs.searchEntry
203+ fs.mu.RUnlock()
204+ if !destroyed && entry != nil {
205+ entry.GrabFocus()
206+ }
207+ return false
208+ }))
209+}
210+
211+func (fs *FavoritesSidebar) startLoad() {
212+ if fs == nil {
213+ return
214+ }
215+ fs.mu.Lock()
216+ if fs.destroyed {
217+ fs.mu.Unlock()
218+ return
219+ }
220+ fs.loadGen++
221+ gen := fs.loadGen
222+ uc := fs.favoritesUC
223+ ctx := fs.ctx
224+ fs.notice = "Loading favorites..."
225+ fs.mu.Unlock()
226+
227+ if uc == nil || ctx == nil {
228+ fs.applyLoadedData(nil, nil, gen, nil)
229+ return
230+ }
231+ go func() {
232+ favorites, favErr := uc.GetAll(ctx)
233+ tags, tagErr := uc.GetAllTags(ctx)
234+ err := favErr
235+ if err == nil {
236+ err = tagErr
237+ }
238+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
239+ fs.applyLoadedData(favorites, tags, gen, err)
240+ return false
241+ }))
242+ }()
243+}
244+
245+func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool {
246+ if fs == nil {
247+ return false
248+ }
249+ fs.mu.Lock()
250+ if fs.destroyed || gen != fs.loadGen {
251+ fs.mu.Unlock()
252+ return false
253+ }
254+ if favorites == nil {
255+ favorites = []*entity.Favorite{}
256+ }
257+ if tags == nil {
258+ tags = []*entity.Tag{}
259+ }
260+ fs.allFavorites = favorites
261+ fs.allTags = tags
262+ if err != nil {
263+ fs.notice = err.Error()
264+ } else {
265+ fs.notice = ""
266+ }
267+ fs.rebuildDisplayRowsLocked()
268+ fs.mu.Unlock()
269+ fs.renderTags()
270+ fs.rebuildList()
271+ return true
272+}
273+
274+func (fs *FavoritesSidebar) setupSearchHandler() {
275+ if fs == nil || fs.searchEntry == nil {
276+ return
277+ }
278+ changedCb := func(_ gtk.SearchEntry) {
279+ fs.mu.RLock()
280+ destroyed := fs.destroyed
281+ fs.mu.RUnlock()
282+ if destroyed {
283+ return
284+ }
285+ fs.onSearchChanged()
286+ }
287+ fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb)
288+ fs.searchEntry.ConnectSearchChanged(&changedCb)
289+}
290+
291+func (fs *FavoritesSidebar) onSearchChanged() {
292+ fs.mu.Lock()
293+ if fs.destroyed {
294+ fs.mu.Unlock()
295+ return
296+ }
297+ if fs.searchEntry != nil {
298+ fs.currentQuery = fs.searchEntry.GetText()
299+ }
300+ fs.rebuildDisplayRowsLocked()
301+ fs.mu.Unlock()
302+ fs.rebuildList()
303+}
304+
305+func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
306+ query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery}
307+ for id := range fs.selectedTagIDs {
308+ query.TagIDs = append(query.TagIDs, id)
309+ }
310+ filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query)
311+ fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
312+}
313+
314+func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
315+ if fs != nil && fs.idleScheduler != nil {
316+ fs.idleScheduler(cb)
317+ return
318+ }
319+ glib.IdleAdd(&cb, 0)
320+}
@@ -0,0 +1,320 @@
1+package component
2+
3+import (
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.
17+type 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.
26+type FavoritesSidebar struct {
27+ outerBox *gtk.Box
28+ searchEntry *gtk.SearchEntry
29+ tagBox *gtk.Box
30+ scrolledWin *gtk.ScrolledWindow
31+ listBox *gtk.ListBox
32+
33+ favoritesUC port.FavoritesSidebarFavorites
34+ onNavigate func(ctx context.Context, url string) error
35+ onNavigateKeepOpen func(ctx context.Context, url string) error
36+ onOpenInNewPane func(ctx context.Context, url string) error
37+ onClose func()
38+
39+ allFavorites []*entity.Favorite
40+ allTags []*entity.Tag
41+ selectedTagIDs map[entity.TagID]struct{}
42+ displayRows []favoriteSidebarDisplayRow
43+ currentQuery string
44+ notice string
45+ loadGen uint64
46+ destroyed bool
47+ visible bool
48+
49+ retainedCallbacks []interface{}
50+ tagCallbacks []interface{}
51+ ctx context.Context
52+ cancel context.CancelFunc
53+ mu sync.RWMutex
54+
55+ idleScheduler func(glib.SourceFunc)
56+}
57+
58+// NewFavoritesSidebar creates a native favorites sidebar and starts loading data.
59+func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar {
60+ ctx, cancel := context.WithCancel(ctx)
61+ fs := &FavoritesSidebar{
62+ favoritesUC: cfg.FavoritesUC,
63+ onNavigate: cfg.OnNavigate,
64+ onNavigateKeepOpen: cfg.OnNavigateKeepOpen,
65+ onOpenInNewPane: cfg.OnOpenInNewPane,
66+ onClose: cfg.OnClose,
67+ selectedTagIDs: make(map[entity.TagID]struct{}),
68+ ctx: ctx,
69+ cancel: cancel,
70+ }
71+ if err := fs.createWidgets(); err != nil {
72+ cancel()
73+ return nil
74+ }
75+ fs.setupSearchHandler()
76+ fs.setupKeyboardNavigation()
77+ fs.startLoad()
78+ return fs
79+}
80+
81+// Widget returns the raw GTK widget.
82+func (fs *FavoritesSidebar) Widget() *gtk.Widget {
83+ if fs == nil || fs.outerBox == nil {
84+ return nil
85+ }
86+ return &fs.outerBox.Widget
87+}
88+
89+// Show displays the sidebar and focuses search. Nil-safe.
90+func (fs *FavoritesSidebar) Show() {
91+ if fs == nil {
92+ return
93+ }
94+ fs.mu.Lock()
95+ if fs.destroyed || fs.outerBox == nil {
96+ fs.mu.Unlock()
97+ return
98+ }
99+ fs.outerBox.SetVisible(true)
100+ fs.visible = true
101+ fs.mu.Unlock()
102+ fs.focusSearch()
103+}
104+
105+// Hide hides the sidebar. Nil-safe.
106+func (fs *FavoritesSidebar) Hide() {
107+ if fs == nil {
108+ return
109+ }
110+ fs.mu.Lock()
111+ if fs.destroyed {
112+ fs.mu.Unlock()
113+ return
114+ }
115+ if fs.outerBox != nil {
116+ fs.outerBox.SetVisible(false)
117+ }
118+ fs.visible = false
119+ fs.mu.Unlock()
120+}
121+
122+// Destroy cancels background work and marks the sidebar destroyed. Nil-safe.
123+func (fs *FavoritesSidebar) Destroy() {
124+ if fs == nil {
125+ return
126+ }
127+ fs.mu.Lock()
128+ if fs.destroyed {
129+ fs.mu.Unlock()
130+ return
131+ }
132+ fs.destroyed = true
133+ fs.mu.Unlock()
134+ if fs.cancel != nil {
135+ fs.cancel()
136+ }
137+}
138+
139+func (fs *FavoritesSidebar) createWidgets() error {
140+ fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 6)
141+ if fs.outerBox == nil {
142+ return fmt.Errorf("favorites sidebar: outer box creation failed")
143+ }
144+ fs.outerBox.AddCssClass("favorites-sidebar-outer")
145+ fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
146+ fs.outerBox.SetHexpand(false)
147+ fs.outerBox.SetVexpand(true)
148+ fs.outerBox.SetVisible(false)
149+
150+ fs.searchEntry = gtk.NewSearchEntry()
151+ if fs.searchEntry == nil {
152+ return fmt.Errorf("favorites sidebar: search entry creation failed")
153+ }
154+ fs.searchEntry.AddCssClass("favorites-sidebar-search")
155+ fs.searchEntry.SetHexpand(true)
156+ placeholder := "Search favorites"
157+ fs.searchEntry.SetPlaceholderText(&placeholder)
158+ fs.outerBox.Append(&fs.searchEntry.Widget)
159+
160+ fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
161+ if fs.tagBox == nil {
162+ return fmt.Errorf("favorites sidebar: tag box creation failed")
163+ }
164+ fs.tagBox.AddCssClass("favorites-sidebar-tags")
165+ fs.tagBox.SetHexpand(true)
166+ fs.outerBox.Append(&fs.tagBox.Widget)
167+
168+ fs.scrolledWin = gtk.NewScrolledWindow()
169+ if fs.scrolledWin == nil {
170+ return fmt.Errorf("favorites sidebar: scrolled window creation failed")
171+ }
172+ fs.scrolledWin.SetVexpand(true)
173+ fs.scrolledWin.SetHexpand(true)
174+ fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
175+
176+ fs.listBox = gtk.NewListBox()
177+ if fs.listBox == nil {
178+ return fmt.Errorf("favorites sidebar: list box creation failed")
179+ }
180+ fs.listBox.AddCssClass("favorites-sidebar-list")
181+ fs.listBox.SetActivateOnSingleClick(true)
182+ fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
183+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
184+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
185+ fs.onRowActivated(row)
186+ }
187+ fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb)
188+ fs.listBox.ConnectRowActivated(&rowActivatedCb)
189+
190+ fs.scrolledWin.SetChild(&fs.listBox.Widget)
191+ fs.outerBox.Append(&fs.scrolledWin.Widget)
192+ return nil
193+}
194+
195+func (fs *FavoritesSidebar) focusSearch() {
196+ if fs == nil {
197+ return
198+ }
199+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
200+ fs.mu.RLock()
201+ destroyed := fs.destroyed
202+ entry := fs.searchEntry
203+ fs.mu.RUnlock()
204+ if !destroyed && entry != nil {
205+ entry.GrabFocus()
206+ }
207+ return false
208+ }))
209+}
210+
211+func (fs *FavoritesSidebar) startLoad() {
212+ if fs == nil {
213+ return
214+ }
215+ fs.mu.Lock()
216+ if fs.destroyed {
217+ fs.mu.Unlock()
218+ return
219+ }
220+ fs.loadGen++
221+ gen := fs.loadGen
222+ uc := fs.favoritesUC
223+ ctx := fs.ctx
224+ fs.notice = "Loading favorites..."
225+ fs.mu.Unlock()
226+
227+ if uc == nil || ctx == nil {
228+ fs.applyLoadedData(nil, nil, gen, nil)
229+ return
230+ }
231+ go func() {
232+ favorites, favErr := uc.GetAll(ctx)
233+ tags, tagErr := uc.GetAllTags(ctx)
234+ err := favErr
235+ if err == nil {
236+ err = tagErr
237+ }
238+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
239+ fs.applyLoadedData(favorites, tags, gen, err)
240+ return false
241+ }))
242+ }()
243+}
244+
245+func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool {
246+ if fs == nil {
247+ return false
248+ }
249+ fs.mu.Lock()
250+ if fs.destroyed || gen != fs.loadGen {
251+ fs.mu.Unlock()
252+ return false
253+ }
254+ if favorites == nil {
255+ favorites = []*entity.Favorite{}
256+ }
257+ if tags == nil {
258+ tags = []*entity.Tag{}
259+ }
260+ fs.allFavorites = favorites
261+ fs.allTags = tags
262+ if err != nil {
263+ fs.notice = err.Error()
264+ } else {
265+ fs.notice = ""
266+ }
267+ fs.rebuildDisplayRowsLocked()
268+ fs.mu.Unlock()
269+ fs.renderTags()
270+ fs.rebuildList()
271+ return true
272+}
273+
274+func (fs *FavoritesSidebar) setupSearchHandler() {
275+ if fs == nil || fs.searchEntry == nil {
276+ return
277+ }
278+ changedCb := func(_ gtk.SearchEntry) {
279+ fs.mu.RLock()
280+ destroyed := fs.destroyed
281+ fs.mu.RUnlock()
282+ if destroyed {
283+ return
284+ }
285+ fs.onSearchChanged()
286+ }
287+ fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb)
288+ fs.searchEntry.ConnectSearchChanged(&changedCb)
289+}
290+
291+func (fs *FavoritesSidebar) onSearchChanged() {
292+ fs.mu.Lock()
293+ if fs.destroyed {
294+ fs.mu.Unlock()
295+ return
296+ }
297+ if fs.searchEntry != nil {
298+ fs.currentQuery = fs.searchEntry.GetText()
299+ }
300+ fs.rebuildDisplayRowsLocked()
301+ fs.mu.Unlock()
302+ fs.rebuildList()
303+}
304+
305+func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
306+ query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery}
307+ for id := range fs.selectedTagIDs {
308+ query.TagIDs = append(query.TagIDs, id)
309+ }
310+ filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query)
311+ fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
312+}
313+
314+func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
315+ if fs != nil && fs.idleScheduler != nil {
316+ fs.idleScheduler(cb)
317+ return
318+ }
319+ glib.IdleAdd(&cb, 0)
320+}
A internal/ui/component/favorites_sidebar_keyboard.go +267 -0
@@ -0,0 +1,267 @@
1+package component
2+
3+import (
4+ "github.com/bnema/puregotk/v4/gdk"
5+ "github.com/bnema/puregotk/v4/glib"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+func (fs *FavoritesSidebar) setupKeyboardNavigation() {
10+ if fs == nil || fs.outerBox == nil {
11+ return
12+ }
13+ keyController := gtk.NewEventControllerKey()
14+ if keyController == nil {
15+ return
16+ }
17+ keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
18+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
19+ fs.mu.RLock()
20+ if fs.destroyed {
21+ fs.mu.RUnlock()
22+ return false
23+ }
24+ searchEntry := fs.searchEntry
25+ onClose := fs.onClose
26+ fs.mu.RUnlock()
27+
28+ switch keyval {
29+ case uint(gdk.KEY_Escape):
30+ if searchEntry != nil && searchEntry.GetText() != "" {
31+ searchEntry.SetText("")
32+ return true
33+ }
34+ if onClose != nil {
35+ onClose()
36+ }
37+ return true
38+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
39+ return fs.handleEnterKey(state)
40+ case uint(gdk.KEY_Page_Up):
41+ fs.selectAdjacentRow(-5)
42+ return true
43+ case uint(gdk.KEY_Page_Down):
44+ fs.selectAdjacentRow(5)
45+ return true
46+ case uint(gdk.KEY_Home):
47+ fs.mu.RLock()
48+ index := firstSelectableIndex(fs.displayRows)
49+ fs.mu.RUnlock()
50+ fs.selectIndex(index)
51+ return true
52+ case uint(gdk.KEY_End):
53+ fs.mu.RLock()
54+ index := lastSelectableIndex(fs.displayRows)
55+ fs.mu.RUnlock()
56+ fs.selectIndex(index)
57+ return true
58+ case uint(gdk.KEY_Up):
59+ fs.selectAdjacentRow(-1)
60+ return true
61+ case uint(gdk.KEY_Down):
62+ fs.selectAdjacentRow(1)
63+ return true
64+ case uint(gdk.KEY_slash):
65+ searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus()
66+ if !shouldFocusSearchForSlash(searchFocused) {
67+ return false
68+ }
69+ fs.focusSearch()
70+ return true
71+ }
72+ return false
73+ }
74+ fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
75+ keyController.ConnectKeyPressed(&keyPressedCb)
76+ fs.outerBox.AddController(&keyController.EventController)
77+}
78+
79+func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool {
80+ if fs == nil {
81+ return false
82+ }
83+ url := fs.selectedEnterURL()
84+ if url == "" {
85+ return false
86+ }
87+ return fs.dispatchEnterURL(state, url)
88+}
89+
90+func (fs *FavoritesSidebar) selectedEnterURL() string {
91+ if fs == nil {
92+ return ""
93+ }
94+ if fs.listBox == nil {
95+ fs.mu.RLock()
96+ defer fs.mu.RUnlock()
97+ if fs.destroyed || len(fs.displayRows) != 1 {
98+ return ""
99+ }
100+ return fs.rowURLAt(0)
101+ }
102+ row := fs.listBox.GetSelectedRow()
103+ if row == nil || !row.GetSelectable() {
104+ return ""
105+ }
106+ fs.mu.RLock()
107+ defer fs.mu.RUnlock()
108+ if fs.destroyed {
109+ return ""
110+ }
111+ return fs.rowURLAt(row.GetIndex())
112+}
113+
114+func (fs *FavoritesSidebar) onRowActivated(row *gtk.ListBoxRow) {
115+ if row == nil || !row.GetSelectable() {
116+ return
117+ }
118+ fs.mu.RLock()
119+ if fs.destroyed {
120+ fs.mu.RUnlock()
121+ return
122+ }
123+ url := fs.rowURLAt(row.GetIndex())
124+ fs.mu.RUnlock()
125+ fs.navigateToURL(url)
126+}
127+
128+func (fs *FavoritesSidebar) selectAdjacentRow(delta int) {
129+ if fs == nil || fs.listBox == nil {
130+ return
131+ }
132+ current := -1
133+ if row := fs.listBox.GetSelectedRow(); row != nil {
134+ current = row.GetIndex()
135+ }
136+ fs.mu.RLock()
137+ if fs.destroyed {
138+ fs.mu.RUnlock()
139+ return
140+ }
141+ target := current
142+ step := 1
143+ if delta < 0 {
144+ step = -1
145+ }
146+ for i := 0; i < abs(delta); i++ {
147+ next := nextSelectableIndex(fs.displayRows, target, step)
148+ if next == -1 {
149+ break
150+ }
151+ target = next
152+ }
153+ if current < 0 {
154+ if step > 0 {
155+ target = firstSelectableIndex(fs.displayRows)
156+ } else {
157+ target = lastSelectableIndex(fs.displayRows)
158+ }
159+ }
160+ fs.mu.RUnlock()
161+ fs.selectIndex(target)
162+}
163+
164+func (fs *FavoritesSidebar) selectIndex(index int) {
165+ if fs == nil || index < 0 {
166+ return
167+ }
168+ fs.mu.RLock()
169+ destroyed := fs.destroyed
170+ listBox := fs.listBox
171+ fs.mu.RUnlock()
172+ if destroyed || listBox == nil {
173+ return
174+ }
175+ if row := listBox.GetRowAtIndex(index); row != nil && row.GetSelectable() {
176+ listBox.SelectRow(row)
177+ }
178+}
179+
180+func (fs *FavoritesSidebar) navigateToURL(url string) {
181+ if fs == nil || fs.onNavigate == nil || url == "" {
182+ return
183+ }
184+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
185+ fs.mu.RLock()
186+ destroyed := fs.destroyed
187+ fs.mu.RUnlock()
188+ if !destroyed {
189+ fs.handleNavigationError(fs.onNavigate(fs.ctx, url))
190+ }
191+ return false
192+ }))
193+}
194+
195+func (fs *FavoritesSidebar) navigateWithoutClosing(url string) {
196+ if fs == nil || url == "" {
197+ return
198+ }
199+ cb := fs.onNavigateKeepOpen
200+ if cb == nil {
201+ cb = fs.onNavigate
202+ }
203+ if cb == nil {
204+ return
205+ }
206+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
207+ fs.mu.RLock()
208+ destroyed := fs.destroyed
209+ fs.mu.RUnlock()
210+ if !destroyed {
211+ fs.handleNavigationError(cb(fs.ctx, url))
212+ }
213+ return false
214+ }))
215+}
216+
217+func (fs *FavoritesSidebar) navigateToNewPane(url string) {
218+ if fs == nil || fs.onOpenInNewPane == nil || url == "" {
219+ return
220+ }
221+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
222+ fs.mu.RLock()
223+ destroyed := fs.destroyed
224+ fs.mu.RUnlock()
225+ if !destroyed {
226+ fs.handleNavigationError(fs.onOpenInNewPane(fs.ctx, url))
227+ }
228+ return false
229+ }))
230+}
231+
232+func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
233+
234+func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool {
235+ if fs == nil || url == "" {
236+ return false
237+ }
238+ if state&gdk.ShiftMaskValue != 0 {
239+ fs.navigateToNewPane(url)
240+ } else if state&gdk.ControlMaskValue != 0 {
241+ fs.navigateWithoutClosing(url)
242+ } else {
243+ fs.navigateToURL(url)
244+ }
245+ return true
246+}
247+
248+func (fs *FavoritesSidebar) handleNavigationError(err error) {
249+ if fs == nil || err == nil {
250+ return
251+ }
252+ fs.mu.Lock()
253+ if fs.destroyed {
254+ fs.mu.Unlock()
255+ return
256+ }
257+ fs.notice = err.Error()
258+ fs.mu.Unlock()
259+ fs.rebuildList()
260+}
261+
262+func abs(v int) int {
263+ if v < 0 {
264+ return -v
265+ }
266+ return v
267+}
@@ -0,0 +1,267 @@
1+package component
2+
3+import (
4+ "github.com/bnema/puregotk/v4/gdk"
5+ "github.com/bnema/puregotk/v4/glib"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+func (fs *FavoritesSidebar) setupKeyboardNavigation() {
10+ if fs == nil || fs.outerBox == nil {
11+ return
12+ }
13+ keyController := gtk.NewEventControllerKey()
14+ if keyController == nil {
15+ return
16+ }
17+ keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
18+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
19+ fs.mu.RLock()
20+ if fs.destroyed {
21+ fs.mu.RUnlock()
22+ return false
23+ }
24+ searchEntry := fs.searchEntry
25+ onClose := fs.onClose
26+ fs.mu.RUnlock()
27+
28+ switch keyval {
29+ case uint(gdk.KEY_Escape):
30+ if searchEntry != nil && searchEntry.GetText() != "" {
31+ searchEntry.SetText("")
32+ return true
33+ }
34+ if onClose != nil {
35+ onClose()
36+ }
37+ return true
38+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
39+ return fs.handleEnterKey(state)
40+ case uint(gdk.KEY_Page_Up):
41+ fs.selectAdjacentRow(-5)
42+ return true
43+ case uint(gdk.KEY_Page_Down):
44+ fs.selectAdjacentRow(5)
45+ return true
46+ case uint(gdk.KEY_Home):
47+ fs.mu.RLock()
48+ index := firstSelectableIndex(fs.displayRows)
49+ fs.mu.RUnlock()
50+ fs.selectIndex(index)
51+ return true
52+ case uint(gdk.KEY_End):
53+ fs.mu.RLock()
54+ index := lastSelectableIndex(fs.displayRows)
55+ fs.mu.RUnlock()
56+ fs.selectIndex(index)
57+ return true
58+ case uint(gdk.KEY_Up):
59+ fs.selectAdjacentRow(-1)
60+ return true
61+ case uint(gdk.KEY_Down):
62+ fs.selectAdjacentRow(1)
63+ return true
64+ case uint(gdk.KEY_slash):
65+ searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus()
66+ if !shouldFocusSearchForSlash(searchFocused) {
67+ return false
68+ }
69+ fs.focusSearch()
70+ return true
71+ }
72+ return false
73+ }
74+ fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
75+ keyController.ConnectKeyPressed(&keyPressedCb)
76+ fs.outerBox.AddController(&keyController.EventController)
77+}
78+
79+func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool {
80+ if fs == nil {
81+ return false
82+ }
83+ url := fs.selectedEnterURL()
84+ if url == "" {
85+ return false
86+ }
87+ return fs.dispatchEnterURL(state, url)
88+}
89+
90+func (fs *FavoritesSidebar) selectedEnterURL() string {
91+ if fs == nil {
92+ return ""
93+ }
94+ if fs.listBox == nil {
95+ fs.mu.RLock()
96+ defer fs.mu.RUnlock()
97+ if fs.destroyed || len(fs.displayRows) != 1 {
98+ return ""
99+ }
100+ return fs.rowURLAt(0)
101+ }
102+ row := fs.listBox.GetSelectedRow()
103+ if row == nil || !row.GetSelectable() {
104+ return ""
105+ }
106+ fs.mu.RLock()
107+ defer fs.mu.RUnlock()
108+ if fs.destroyed {
109+ return ""
110+ }
111+ return fs.rowURLAt(row.GetIndex())
112+}
113+
114+func (fs *FavoritesSidebar) onRowActivated(row *gtk.ListBoxRow) {
115+ if row == nil || !row.GetSelectable() {
116+ return
117+ }
118+ fs.mu.RLock()
119+ if fs.destroyed {
120+ fs.mu.RUnlock()
121+ return
122+ }
123+ url := fs.rowURLAt(row.GetIndex())
124+ fs.mu.RUnlock()
125+ fs.navigateToURL(url)
126+}
127+
128+func (fs *FavoritesSidebar) selectAdjacentRow(delta int) {
129+ if fs == nil || fs.listBox == nil {
130+ return
131+ }
132+ current := -1
133+ if row := fs.listBox.GetSelectedRow(); row != nil {
134+ current = row.GetIndex()
135+ }
136+ fs.mu.RLock()
137+ if fs.destroyed {
138+ fs.mu.RUnlock()
139+ return
140+ }
141+ target := current
142+ step := 1
143+ if delta < 0 {
144+ step = -1
145+ }
146+ for i := 0; i < abs(delta); i++ {
147+ next := nextSelectableIndex(fs.displayRows, target, step)
148+ if next == -1 {
149+ break
150+ }
151+ target = next
152+ }
153+ if current < 0 {
154+ if step > 0 {
155+ target = firstSelectableIndex(fs.displayRows)
156+ } else {
157+ target = lastSelectableIndex(fs.displayRows)
158+ }
159+ }
160+ fs.mu.RUnlock()
161+ fs.selectIndex(target)
162+}
163+
164+func (fs *FavoritesSidebar) selectIndex(index int) {
165+ if fs == nil || index < 0 {
166+ return
167+ }
168+ fs.mu.RLock()
169+ destroyed := fs.destroyed
170+ listBox := fs.listBox
171+ fs.mu.RUnlock()
172+ if destroyed || listBox == nil {
173+ return
174+ }
175+ if row := listBox.GetRowAtIndex(index); row != nil && row.GetSelectable() {
176+ listBox.SelectRow(row)
177+ }
178+}
179+
180+func (fs *FavoritesSidebar) navigateToURL(url string) {
181+ if fs == nil || fs.onNavigate == nil || url == "" {
182+ return
183+ }
184+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
185+ fs.mu.RLock()
186+ destroyed := fs.destroyed
187+ fs.mu.RUnlock()
188+ if !destroyed {
189+ fs.handleNavigationError(fs.onNavigate(fs.ctx, url))
190+ }
191+ return false
192+ }))
193+}
194+
195+func (fs *FavoritesSidebar) navigateWithoutClosing(url string) {
196+ if fs == nil || url == "" {
197+ return
198+ }
199+ cb := fs.onNavigateKeepOpen
200+ if cb == nil {
201+ cb = fs.onNavigate
202+ }
203+ if cb == nil {
204+ return
205+ }
206+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
207+ fs.mu.RLock()
208+ destroyed := fs.destroyed
209+ fs.mu.RUnlock()
210+ if !destroyed {
211+ fs.handleNavigationError(cb(fs.ctx, url))
212+ }
213+ return false
214+ }))
215+}
216+
217+func (fs *FavoritesSidebar) navigateToNewPane(url string) {
218+ if fs == nil || fs.onOpenInNewPane == nil || url == "" {
219+ return
220+ }
221+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
222+ fs.mu.RLock()
223+ destroyed := fs.destroyed
224+ fs.mu.RUnlock()
225+ if !destroyed {
226+ fs.handleNavigationError(fs.onOpenInNewPane(fs.ctx, url))
227+ }
228+ return false
229+ }))
230+}
231+
232+func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
233+
234+func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool {
235+ if fs == nil || url == "" {
236+ return false
237+ }
238+ if state&gdk.ShiftMaskValue != 0 {
239+ fs.navigateToNewPane(url)
240+ } else if state&gdk.ControlMaskValue != 0 {
241+ fs.navigateWithoutClosing(url)
242+ } else {
243+ fs.navigateToURL(url)
244+ }
245+ return true
246+}
247+
248+func (fs *FavoritesSidebar) handleNavigationError(err error) {
249+ if fs == nil || err == nil {
250+ return
251+ }
252+ fs.mu.Lock()
253+ if fs.destroyed {
254+ fs.mu.Unlock()
255+ return
256+ }
257+ fs.notice = err.Error()
258+ fs.mu.Unlock()
259+ fs.rebuildList()
260+}
261+
262+func abs(v int) int {
263+ if v < 0 {
264+ return -v
265+ }
266+ return v
267+}
A internal/ui/component/favorites_sidebar_rows.go +307 -0
@@ -0,0 +1,307 @@
1+package component
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/bnema/puregotk/v4/pango"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+type favoriteSidebarDisplayRowKind int
13+
14+const (
15+ favoriteSidebarDisplayRowFavorite favoriteSidebarDisplayRowKind = iota
16+)
17+
18+type favoriteSidebarDisplayRow struct {
19+ Kind favoriteSidebarDisplayRowKind
20+ FavoriteID entity.FavoriteID
21+ TagID entity.TagID
22+ URL string
23+ Favorite *entity.Favorite
24+ Selectable bool
25+}
26+
27+func buildFavoriteSidebarDisplayRows(favorites []*entity.Favorite) []favoriteSidebarDisplayRow {
28+ rows := make([]favoriteSidebarDisplayRow, 0, len(favorites))
29+ for _, fav := range favorites {
30+ if fav == nil {
31+ continue
32+ }
33+ rows = append(rows, favoriteSidebarDisplayRow{
34+ Kind: favoriteSidebarDisplayRowFavorite,
35+ FavoriteID: fav.ID,
36+ URL: fav.URL,
37+ Favorite: fav,
38+ Selectable: fav.URL != "",
39+ })
40+ }
41+ return rows
42+}
43+
44+func firstSelectableIndex(rows []favoriteSidebarDisplayRow) int {
45+ for i := range rows {
46+ if rows[i].Selectable {
47+ return i
48+ }
49+ }
50+ return -1
51+}
52+
53+func lastSelectableIndex(rows []favoriteSidebarDisplayRow) int {
54+ for i := len(rows) - 1; i >= 0; i-- {
55+ if rows[i].Selectable {
56+ return i
57+ }
58+ }
59+ return -1
60+}
61+
62+func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction int) int {
63+ if direction != -1 && direction != 1 {
64+ return -1
65+ }
66+ for i := current + direction; i >= 0 && i < len(rows); i += direction {
67+ if rows[i].Selectable {
68+ return i
69+ }
70+ }
71+ return -1
72+}
73+
74+func (fs *FavoritesSidebar) renderTags() {
75+ if fs == nil || fs.tagBox == nil {
76+ return
77+ }
78+ fs.mu.RLock()
79+ if fs.destroyed {
80+ fs.mu.RUnlock()
81+ return
82+ }
83+ tags := append([]*entity.Tag(nil), fs.allTags...)
84+ selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
85+ for id := range fs.selectedTagIDs {
86+ selectedTagIDs[id] = struct{}{}
87+ }
88+ tagBox := fs.tagBox
89+ fs.mu.RUnlock()
90+
91+ clearBoxChildren(tagBox)
92+ callbacks := make([]interface{}, 0, len(tags)+1)
93+
94+ allButton := gtk.NewButtonWithLabel("All")
95+ if allButton != nil {
96+ if len(selectedTagIDs) == 0 {
97+ allButton.AddCssClass("suggested-action")
98+ }
99+ cb := func(_ gtk.Button) {
100+ fs.mu.Lock()
101+ if fs.destroyed {
102+ fs.mu.Unlock()
103+ return
104+ }
105+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
106+ fs.rebuildDisplayRowsLocked()
107+ fs.mu.Unlock()
108+ fs.renderTags()
109+ fs.rebuildList()
110+ }
111+ callbacks = append(callbacks, cb)
112+ allButton.ConnectClicked(&cb)
113+ tagBox.Append(&allButton.Widget)
114+ }
115+
116+ for _, tag := range tags {
117+ if tag == nil {
118+ continue
119+ }
120+ t := tag
121+ button := gtk.NewButtonWithLabel(t.Name)
122+ if button == nil {
123+ continue
124+ }
125+ if _, ok := selectedTagIDs[t.ID]; ok {
126+ button.AddCssClass("suggested-action")
127+ }
128+ cb := func(_ gtk.Button) {
129+ fs.toggleTag(t.ID)
130+ }
131+ callbacks = append(callbacks, cb)
132+ button.ConnectClicked(&cb)
133+ tagBox.Append(&button.Widget)
134+ }
135+
136+ fs.mu.Lock()
137+ if !fs.destroyed {
138+ fs.tagCallbacks = callbacks
139+ }
140+ fs.mu.Unlock()
141+}
142+
143+func (fs *FavoritesSidebar) toggleTag(tagID entity.TagID) {
144+ if fs == nil {
145+ return
146+ }
147+ fs.mu.Lock()
148+ if fs.destroyed {
149+ fs.mu.Unlock()
150+ return
151+ }
152+ if fs.selectedTagIDs == nil {
153+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
154+ }
155+ if _, ok := fs.selectedTagIDs[tagID]; ok {
156+ delete(fs.selectedTagIDs, tagID)
157+ } else {
158+ fs.selectedTagIDs[tagID] = struct{}{}
159+ }
160+ fs.rebuildDisplayRowsLocked()
161+ fs.mu.Unlock()
162+ fs.renderTags()
163+ fs.rebuildList()
164+}
165+
166+func (fs *FavoritesSidebar) rebuildList() {
167+ if fs == nil {
168+ return
169+ }
170+ fs.mu.RLock()
171+ if fs.destroyed || fs.listBox == nil {
172+ fs.mu.RUnlock()
173+ return
174+ }
175+ listBox := fs.listBox
176+ rows := append([]favoriteSidebarDisplayRow(nil), fs.displayRows...)
177+ notice := fs.notice
178+ query := fs.currentQuery
179+ fs.mu.RUnlock()
180+
181+ listBox.RemoveAll()
182+ if len(rows) == 0 {
183+ text := "No favorites"
184+ if notice != "" {
185+ text = notice
186+ } else if query != "" {
187+ text = noResultsText(query)
188+ }
189+ fs.appendNoticeRow(listBox, text)
190+ return
191+ }
192+ for _, row := range rows {
193+ fs.appendFavoriteRow(listBox, row.Favorite)
194+ }
195+ fs.ensureAtLeastOneSelectionInListBox(listBox)
196+}
197+
198+func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
199+ label := gtk.NewLabel(&text)
200+ if label == nil {
201+ return
202+ }
203+ label.AddCssClass("favorites-sidebar-empty")
204+ label.SetXalign(0.0)
205+ row := gtk.NewListBoxRow()
206+ if row == nil {
207+ return
208+ }
209+ row.SetSelectable(false)
210+ row.SetCanFocus(false)
211+ row.SetActivatable(false)
212+ row.SetChild(&label.Widget)
213+ listBox.Append(&row.Widget)
214+}
215+
216+func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, fav *entity.Favorite) {
217+ if fav == nil {
218+ return
219+ }
220+ rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
221+ if rowBox == nil {
222+ return
223+ }
224+ rowBox.SetHexpand(true)
225+
226+ title := gtk.NewLabel(nil)
227+ if title == nil {
228+ return
229+ }
230+ title.AddCssClass("favorites-sidebar-row-title")
231+ title.SetText(safeSidebarString(fav.Title, fav.URL))
232+ title.SetXalign(0.0)
233+ title.SetHexpand(true)
234+ title.SetEllipsize(pango.EllipsizeEndValue)
235+ rowBox.Append(&title.Widget)
236+
237+ sub := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
238+ if sub == nil {
239+ return
240+ }
241+ sub.SetHexpand(true)
242+ url := gtk.NewLabel(nil)
243+ if url == nil {
244+ return
245+ }
246+ url.AddCssClass("favorites-sidebar-row-subtitle")
247+ url.SetText(readableURL(fav.URL))
248+ url.SetXalign(0.0)
249+ url.SetHexpand(true)
250+ url.SetEllipsize(pango.EllipsizeEndValue)
251+ sub.Append(&url.Widget)
252+ if fav.ShortcutKey != nil {
253+ badge := fmt.Sprintf("Shortcut %d", *fav.ShortcutKey)
254+ badgeLabel := gtk.NewLabel(&badge)
255+ if badgeLabel != nil {
256+ badgeLabel.AddCssClass("favorites-sidebar-shortcut-badge")
257+ sub.Append(&badgeLabel.Widget)
258+ }
259+ }
260+ rowBox.Append(&sub.Widget)
261+
262+ if len(fav.Tags) > 0 {
263+ tags := gtk.NewBox(gtk.OrientationHorizontalValue, 3)
264+ if tags != nil {
265+ for _, tag := range fav.Tags {
266+ name := tag.Name
267+ label := gtk.NewLabel(&name)
268+ if label != nil {
269+ label.AddCssClass("favorites-sidebar-tag-chip")
270+ tags.Append(&label.Widget)
271+ }
272+ }
273+ rowBox.Append(&tags.Widget)
274+ }
275+ }
276+
277+ row := gtk.NewListBoxRow()
278+ if row == nil {
279+ return
280+ }
281+ row.AddCssClass("favorites-sidebar-row")
282+ row.SetSelectable(true)
283+ row.SetActivatable(true)
284+ row.SetCanFocus(true)
285+ row.SetFocusOnClick(true)
286+ row.SetChild(&rowBox.Widget)
287+ listBox.Append(&row.Widget)
288+}
289+
290+func (fs *FavoritesSidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
291+ if listBox == nil || listBox.GetSelectedRow() != nil {
292+ return
293+ }
294+ fs.mu.RLock()
295+ index := firstSelectableIndex(fs.displayRows)
296+ fs.mu.RUnlock()
297+ if row := listBox.GetRowAtIndex(index); row != nil {
298+ listBox.SelectRow(row)
299+ }
300+}
301+
302+func (fs *FavoritesSidebar) rowURLAt(index int) string {
303+ if index < 0 || index >= len(fs.displayRows) || !fs.displayRows[index].Selectable {
304+ return ""
305+ }
306+ return fs.displayRows[index].URL
307+}
@@ -0,0 +1,307 @@
1+package component
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/bnema/puregotk/v4/pango"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+type favoriteSidebarDisplayRowKind int
13+
14+const (
15+ favoriteSidebarDisplayRowFavorite favoriteSidebarDisplayRowKind = iota
16+)
17+
18+type favoriteSidebarDisplayRow struct {
19+ Kind favoriteSidebarDisplayRowKind
20+ FavoriteID entity.FavoriteID
21+ TagID entity.TagID
22+ URL string
23+ Favorite *entity.Favorite
24+ Selectable bool
25+}
26+
27+func buildFavoriteSidebarDisplayRows(favorites []*entity.Favorite) []favoriteSidebarDisplayRow {
28+ rows := make([]favoriteSidebarDisplayRow, 0, len(favorites))
29+ for _, fav := range favorites {
30+ if fav == nil {
31+ continue
32+ }
33+ rows = append(rows, favoriteSidebarDisplayRow{
34+ Kind: favoriteSidebarDisplayRowFavorite,
35+ FavoriteID: fav.ID,
36+ URL: fav.URL,
37+ Favorite: fav,
38+ Selectable: fav.URL != "",
39+ })
40+ }
41+ return rows
42+}
43+
44+func firstSelectableIndex(rows []favoriteSidebarDisplayRow) int {
45+ for i := range rows {
46+ if rows[i].Selectable {
47+ return i
48+ }
49+ }
50+ return -1
51+}
52+
53+func lastSelectableIndex(rows []favoriteSidebarDisplayRow) int {
54+ for i := len(rows) - 1; i >= 0; i-- {
55+ if rows[i].Selectable {
56+ return i
57+ }
58+ }
59+ return -1
60+}
61+
62+func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction int) int {
63+ if direction != -1 && direction != 1 {
64+ return -1
65+ }
66+ for i := current + direction; i >= 0 && i < len(rows); i += direction {
67+ if rows[i].Selectable {
68+ return i
69+ }
70+ }
71+ return -1
72+}
73+
74+func (fs *FavoritesSidebar) renderTags() {
75+ if fs == nil || fs.tagBox == nil {
76+ return
77+ }
78+ fs.mu.RLock()
79+ if fs.destroyed {
80+ fs.mu.RUnlock()
81+ return
82+ }
83+ tags := append([]*entity.Tag(nil), fs.allTags...)
84+ selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
85+ for id := range fs.selectedTagIDs {
86+ selectedTagIDs[id] = struct{}{}
87+ }
88+ tagBox := fs.tagBox
89+ fs.mu.RUnlock()
90+
91+ clearBoxChildren(tagBox)
92+ callbacks := make([]interface{}, 0, len(tags)+1)
93+
94+ allButton := gtk.NewButtonWithLabel("All")
95+ if allButton != nil {
96+ if len(selectedTagIDs) == 0 {
97+ allButton.AddCssClass("suggested-action")
98+ }
99+ cb := func(_ gtk.Button) {
100+ fs.mu.Lock()
101+ if fs.destroyed {
102+ fs.mu.Unlock()
103+ return
104+ }
105+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
106+ fs.rebuildDisplayRowsLocked()
107+ fs.mu.Unlock()
108+ fs.renderTags()
109+ fs.rebuildList()
110+ }
111+ callbacks = append(callbacks, cb)
112+ allButton.ConnectClicked(&cb)
113+ tagBox.Append(&allButton.Widget)
114+ }
115+
116+ for _, tag := range tags {
117+ if tag == nil {
118+ continue
119+ }
120+ t := tag
121+ button := gtk.NewButtonWithLabel(t.Name)
122+ if button == nil {
123+ continue
124+ }
125+ if _, ok := selectedTagIDs[t.ID]; ok {
126+ button.AddCssClass("suggested-action")
127+ }
128+ cb := func(_ gtk.Button) {
129+ fs.toggleTag(t.ID)
130+ }
131+ callbacks = append(callbacks, cb)
132+ button.ConnectClicked(&cb)
133+ tagBox.Append(&button.Widget)
134+ }
135+
136+ fs.mu.Lock()
137+ if !fs.destroyed {
138+ fs.tagCallbacks = callbacks
139+ }
140+ fs.mu.Unlock()
141+}
142+
143+func (fs *FavoritesSidebar) toggleTag(tagID entity.TagID) {
144+ if fs == nil {
145+ return
146+ }
147+ fs.mu.Lock()
148+ if fs.destroyed {
149+ fs.mu.Unlock()
150+ return
151+ }
152+ if fs.selectedTagIDs == nil {
153+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
154+ }
155+ if _, ok := fs.selectedTagIDs[tagID]; ok {
156+ delete(fs.selectedTagIDs, tagID)
157+ } else {
158+ fs.selectedTagIDs[tagID] = struct{}{}
159+ }
160+ fs.rebuildDisplayRowsLocked()
161+ fs.mu.Unlock()
162+ fs.renderTags()
163+ fs.rebuildList()
164+}
165+
166+func (fs *FavoritesSidebar) rebuildList() {
167+ if fs == nil {
168+ return
169+ }
170+ fs.mu.RLock()
171+ if fs.destroyed || fs.listBox == nil {
172+ fs.mu.RUnlock()
173+ return
174+ }
175+ listBox := fs.listBox
176+ rows := append([]favoriteSidebarDisplayRow(nil), fs.displayRows...)
177+ notice := fs.notice
178+ query := fs.currentQuery
179+ fs.mu.RUnlock()
180+
181+ listBox.RemoveAll()
182+ if len(rows) == 0 {
183+ text := "No favorites"
184+ if notice != "" {
185+ text = notice
186+ } else if query != "" {
187+ text = noResultsText(query)
188+ }
189+ fs.appendNoticeRow(listBox, text)
190+ return
191+ }
192+ for _, row := range rows {
193+ fs.appendFavoriteRow(listBox, row.Favorite)
194+ }
195+ fs.ensureAtLeastOneSelectionInListBox(listBox)
196+}
197+
198+func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
199+ label := gtk.NewLabel(&text)
200+ if label == nil {
201+ return
202+ }
203+ label.AddCssClass("favorites-sidebar-empty")
204+ label.SetXalign(0.0)
205+ row := gtk.NewListBoxRow()
206+ if row == nil {
207+ return
208+ }
209+ row.SetSelectable(false)
210+ row.SetCanFocus(false)
211+ row.SetActivatable(false)
212+ row.SetChild(&label.Widget)
213+ listBox.Append(&row.Widget)
214+}
215+
216+func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, fav *entity.Favorite) {
217+ if fav == nil {
218+ return
219+ }
220+ rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
221+ if rowBox == nil {
222+ return
223+ }
224+ rowBox.SetHexpand(true)
225+
226+ title := gtk.NewLabel(nil)
227+ if title == nil {
228+ return
229+ }
230+ title.AddCssClass("favorites-sidebar-row-title")
231+ title.SetText(safeSidebarString(fav.Title, fav.URL))
232+ title.SetXalign(0.0)
233+ title.SetHexpand(true)
234+ title.SetEllipsize(pango.EllipsizeEndValue)
235+ rowBox.Append(&title.Widget)
236+
237+ sub := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
238+ if sub == nil {
239+ return
240+ }
241+ sub.SetHexpand(true)
242+ url := gtk.NewLabel(nil)
243+ if url == nil {
244+ return
245+ }
246+ url.AddCssClass("favorites-sidebar-row-subtitle")
247+ url.SetText(readableURL(fav.URL))
248+ url.SetXalign(0.0)
249+ url.SetHexpand(true)
250+ url.SetEllipsize(pango.EllipsizeEndValue)
251+ sub.Append(&url.Widget)
252+ if fav.ShortcutKey != nil {
253+ badge := fmt.Sprintf("Shortcut %d", *fav.ShortcutKey)
254+ badgeLabel := gtk.NewLabel(&badge)
255+ if badgeLabel != nil {
256+ badgeLabel.AddCssClass("favorites-sidebar-shortcut-badge")
257+ sub.Append(&badgeLabel.Widget)
258+ }
259+ }
260+ rowBox.Append(&sub.Widget)
261+
262+ if len(fav.Tags) > 0 {
263+ tags := gtk.NewBox(gtk.OrientationHorizontalValue, 3)
264+ if tags != nil {
265+ for _, tag := range fav.Tags {
266+ name := tag.Name
267+ label := gtk.NewLabel(&name)
268+ if label != nil {
269+ label.AddCssClass("favorites-sidebar-tag-chip")
270+ tags.Append(&label.Widget)
271+ }
272+ }
273+ rowBox.Append(&tags.Widget)
274+ }
275+ }
276+
277+ row := gtk.NewListBoxRow()
278+ if row == nil {
279+ return
280+ }
281+ row.AddCssClass("favorites-sidebar-row")
282+ row.SetSelectable(true)
283+ row.SetActivatable(true)
284+ row.SetCanFocus(true)
285+ row.SetFocusOnClick(true)
286+ row.SetChild(&rowBox.Widget)
287+ listBox.Append(&row.Widget)
288+}
289+
290+func (fs *FavoritesSidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
291+ if listBox == nil || listBox.GetSelectedRow() != nil {
292+ return
293+ }
294+ fs.mu.RLock()
295+ index := firstSelectableIndex(fs.displayRows)
296+ fs.mu.RUnlock()
297+ if row := listBox.GetRowAtIndex(index); row != nil {
298+ listBox.SelectRow(row)
299+ }
300+}
301+
302+func (fs *FavoritesSidebar) rowURLAt(index int) string {
303+ if index < 0 || index >= len(fs.displayRows) || !fs.displayRows[index].Selectable {
304+ return ""
305+ }
306+ return fs.displayRows[index].URL
307+}
A internal/ui/component/favorites_sidebar_test.go +230 -0
@@ -0,0 +1,230 @@
1+package component
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/puregotk/v4/gdk"
12+ "github.com/bnema/puregotk/v4/glib"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+type fakeFavoritesSidebarUC struct {
18+ favorites []*entity.Favorite
19+ tags []*entity.Tag
20+}
21+
22+func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
23+ return f.favorites, nil
24+}
25+func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
26+ return f.tags, nil
27+}
28+func (f *fakeFavoritesSidebarUC) AddFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) {
29+ return nil, nil
30+}
31+func (f *fakeFavoritesSidebarUC) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) {
32+ return nil, nil
33+}
34+func (f *fakeFavoritesSidebarUC) DeleteFavorite(context.Context, entity.FavoriteID) error { return nil }
35+func (f *fakeFavoritesSidebarUC) SetShortcut(context.Context, entity.FavoriteID, *int) error {
36+ return nil
37+}
38+func (f *fakeFavoritesSidebarUC) TagFavorite(context.Context, entity.FavoriteID, entity.TagID) error {
39+ return nil
40+}
41+func (f *fakeFavoritesSidebarUC) UntagFavorite(context.Context, entity.FavoriteID, entity.TagID) error {
42+ return nil
43+}
44+
45+func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
46+ fs := &FavoritesSidebar{
47+ favoritesUC: &fakeFavoritesSidebarUC{favorites: favorites, tags: tags},
48+ selectedTagIDs: make(map[entity.TagID]struct{}),
49+ ctx: context.Background(),
50+ idleScheduler: func(cb glib.SourceFunc) {
51+ cb(0)
52+ },
53+ }
54+ return fs
55+}
56+
57+func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) {
58+ fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2}
59+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}})
60+ fs.loadGen = 1
61+
62+ applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil)
63+
64+ require.True(t, applied)
65+ assert.Len(t, fs.allFavorites, 1)
66+ assert.Len(t, fs.allTags, 1)
67+ require.Len(t, fs.displayRows, 1)
68+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
69+ assert.Equal(t, "https://go.dev", fs.displayRows[0].URL)
70+}
71+
72+func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) {
73+ devTag := entity.Tag{ID: 1, Name: "Dev"}
74+ newsTag := entity.Tag{ID: 2, Name: "News"}
75+ fs := newFavoritesSidebarHarness([]*entity.Favorite{
76+ {ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1},
77+ {ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2},
78+ }, nil)
79+ fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
80+
81+ fs.currentQuery = "golang"
82+ fs.rebuildDisplayRowsLocked()
83+ require.Len(t, fs.displayRows, 1)
84+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
85+
86+ fs.currentQuery = "example.com"
87+ fs.rebuildDisplayRowsLocked()
88+ require.Len(t, fs.displayRows, 1)
89+ assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID)
90+
91+ fs.currentQuery = "dev"
92+ fs.rebuildDisplayRowsLocked()
93+ require.Len(t, fs.displayRows, 1)
94+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
95+}
96+
97+func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) {
98+ fs := newFavoritesSidebarHarness([]*entity.Favorite{
99+ {ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}},
100+ {ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}},
101+ {ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}},
102+ }, nil)
103+ fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
104+ fs.selectedTagIDs[1] = struct{}{}
105+ fs.selectedTagIDs[2] = struct{}{}
106+
107+ fs.rebuildDisplayRowsLocked()
108+
109+ require.Len(t, fs.displayRows, 2)
110+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
111+ assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID)
112+}
113+
114+func TestFavoritesSidebarActivationCallbacks(t *testing.T) {
115+ fs := newFavoritesSidebarHarness(nil, nil)
116+ fs.ctx = context.Background()
117+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
118+ var mu sync.Mutex
119+ called := []string{}
120+ fs.onNavigate = func(_ context.Context, url string) error {
121+ mu.Lock()
122+ defer mu.Unlock()
123+ called = append(called, "nav:"+url)
124+ return nil
125+ }
126+ fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
127+ mu.Lock()
128+ defer mu.Unlock()
129+ called = append(called, "keep:"+url)
130+ return nil
131+ }
132+ fs.onOpenInNewPane = func(_ context.Context, url string) error {
133+ mu.Lock()
134+ defer mu.Unlock()
135+ called = append(called, "pane:"+url)
136+ return nil
137+ }
138+
139+ fs.navigateToURL("https://a.test")
140+ fs.navigateWithoutClosing("https://b.test")
141+ fs.navigateToNewPane("https://c.test")
142+
143+ assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called)
144+}
145+
146+func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {
147+ fs := newFavoritesSidebarHarness(nil, nil)
148+ fs.loadGen = 2
149+
150+ applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil)
151+
152+ assert.False(t, applied)
153+ assert.Empty(t, fs.allFavorites)
154+ assert.Empty(t, fs.displayRows)
155+}
156+
157+func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) {
158+ fs := newFavoritesSidebarHarness(nil, nil)
159+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
160+ called := false
161+ fs.onNavigate = func(context.Context, string) error {
162+ called = true
163+ return nil
164+ }
165+ fs.destroyed = true
166+
167+ fs.navigateToURL("https://example.test")
168+ fs.toggleTag(1)
169+ fs.onSearchChanged()
170+
171+ assert.False(t, called)
172+ assert.Empty(t, fs.selectedTagIDs)
173+}
174+
175+func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) {
176+ fs := newFavoritesSidebarHarness(nil, nil)
177+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
178+ fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") }
179+
180+ fs.navigateToURL("https://example.test")
181+
182+ assert.Equal(t, "navigation failed", fs.notice)
183+}
184+
185+func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) {
186+ fs := newFavoritesSidebarHarness(nil, nil)
187+ fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}}
188+ fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{
189+ {ID: 1, URL: "https://go.dev", Title: "Go"},
190+ {ID: 2, URL: "", Title: "Missing URL"},
191+ })
192+
193+ assert.Equal(t, 2, len(fs.allTags))
194+ require.Len(t, fs.displayRows, 2)
195+ assert.True(t, fs.displayRows[0].Selectable)
196+ assert.False(t, fs.displayRows[1].Selectable)
197+ fs.tagCallbacks = []interface{}{func() {}, func() {}}
198+ fs.tagCallbacks = nil
199+ assert.Empty(t, fs.tagCallbacks)
200+}
201+
202+func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) {
203+ assert.True(t, shouldFocusSearchForSlash(false))
204+ assert.False(t, shouldFocusSearchForSlash(true))
205+}
206+
207+func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) {
208+ fs := newFavoritesSidebarHarness(nil, nil)
209+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
210+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}}
211+ var called []string
212+ fs.onNavigate = func(_ context.Context, url string) error {
213+ called = append(called, "nav:"+url)
214+ return nil
215+ }
216+ fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
217+ called = append(called, "keep:"+url)
218+ return nil
219+ }
220+ fs.onOpenInNewPane = func(_ context.Context, url string) error {
221+ called = append(called, "pane:"+url)
222+ return nil
223+ }
224+
225+ assert.True(t, fs.handleEnterKey(0))
226+ assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue))
227+ assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue))
228+
229+ assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)
230+}
@@ -0,0 +1,230 @@
1+package component
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/puregotk/v4/gdk"
12+ "github.com/bnema/puregotk/v4/glib"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+type fakeFavoritesSidebarUC struct {
18+ favorites []*entity.Favorite
19+ tags []*entity.Tag
20+}
21+
22+func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
23+ return f.favorites, nil
24+}
25+func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
26+ return f.tags, nil
27+}
28+func (f *fakeFavoritesSidebarUC) AddFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) {
29+ return nil, nil
30+}
31+func (f *fakeFavoritesSidebarUC) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) {
32+ return nil, nil
33+}
34+func (f *fakeFavoritesSidebarUC) DeleteFavorite(context.Context, entity.FavoriteID) error { return nil }
35+func (f *fakeFavoritesSidebarUC) SetShortcut(context.Context, entity.FavoriteID, *int) error {
36+ return nil
37+}
38+func (f *fakeFavoritesSidebarUC) TagFavorite(context.Context, entity.FavoriteID, entity.TagID) error {
39+ return nil
40+}
41+func (f *fakeFavoritesSidebarUC) UntagFavorite(context.Context, entity.FavoriteID, entity.TagID) error {
42+ return nil
43+}
44+
45+func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
46+ fs := &FavoritesSidebar{
47+ favoritesUC: &fakeFavoritesSidebarUC{favorites: favorites, tags: tags},
48+ selectedTagIDs: make(map[entity.TagID]struct{}),
49+ ctx: context.Background(),
50+ idleScheduler: func(cb glib.SourceFunc) {
51+ cb(0)
52+ },
53+ }
54+ return fs
55+}
56+
57+func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) {
58+ fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2}
59+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}})
60+ fs.loadGen = 1
61+
62+ applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil)
63+
64+ require.True(t, applied)
65+ assert.Len(t, fs.allFavorites, 1)
66+ assert.Len(t, fs.allTags, 1)
67+ require.Len(t, fs.displayRows, 1)
68+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
69+ assert.Equal(t, "https://go.dev", fs.displayRows[0].URL)
70+}
71+
72+func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) {
73+ devTag := entity.Tag{ID: 1, Name: "Dev"}
74+ newsTag := entity.Tag{ID: 2, Name: "News"}
75+ fs := newFavoritesSidebarHarness([]*entity.Favorite{
76+ {ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1},
77+ {ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2},
78+ }, nil)
79+ fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
80+
81+ fs.currentQuery = "golang"
82+ fs.rebuildDisplayRowsLocked()
83+ require.Len(t, fs.displayRows, 1)
84+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
85+
86+ fs.currentQuery = "example.com"
87+ fs.rebuildDisplayRowsLocked()
88+ require.Len(t, fs.displayRows, 1)
89+ assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID)
90+
91+ fs.currentQuery = "dev"
92+ fs.rebuildDisplayRowsLocked()
93+ require.Len(t, fs.displayRows, 1)
94+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
95+}
96+
97+func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) {
98+ fs := newFavoritesSidebarHarness([]*entity.Favorite{
99+ {ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}},
100+ {ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}},
101+ {ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}},
102+ }, nil)
103+ fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
104+ fs.selectedTagIDs[1] = struct{}{}
105+ fs.selectedTagIDs[2] = struct{}{}
106+
107+ fs.rebuildDisplayRowsLocked()
108+
109+ require.Len(t, fs.displayRows, 2)
110+ assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
111+ assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID)
112+}
113+
114+func TestFavoritesSidebarActivationCallbacks(t *testing.T) {
115+ fs := newFavoritesSidebarHarness(nil, nil)
116+ fs.ctx = context.Background()
117+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
118+ var mu sync.Mutex
119+ called := []string{}
120+ fs.onNavigate = func(_ context.Context, url string) error {
121+ mu.Lock()
122+ defer mu.Unlock()
123+ called = append(called, "nav:"+url)
124+ return nil
125+ }
126+ fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
127+ mu.Lock()
128+ defer mu.Unlock()
129+ called = append(called, "keep:"+url)
130+ return nil
131+ }
132+ fs.onOpenInNewPane = func(_ context.Context, url string) error {
133+ mu.Lock()
134+ defer mu.Unlock()
135+ called = append(called, "pane:"+url)
136+ return nil
137+ }
138+
139+ fs.navigateToURL("https://a.test")
140+ fs.navigateWithoutClosing("https://b.test")
141+ fs.navigateToNewPane("https://c.test")
142+
143+ assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called)
144+}
145+
146+func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {
147+ fs := newFavoritesSidebarHarness(nil, nil)
148+ fs.loadGen = 2
149+
150+ applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil)
151+
152+ assert.False(t, applied)
153+ assert.Empty(t, fs.allFavorites)
154+ assert.Empty(t, fs.displayRows)
155+}
156+
157+func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) {
158+ fs := newFavoritesSidebarHarness(nil, nil)
159+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
160+ called := false
161+ fs.onNavigate = func(context.Context, string) error {
162+ called = true
163+ return nil
164+ }
165+ fs.destroyed = true
166+
167+ fs.navigateToURL("https://example.test")
168+ fs.toggleTag(1)
169+ fs.onSearchChanged()
170+
171+ assert.False(t, called)
172+ assert.Empty(t, fs.selectedTagIDs)
173+}
174+
175+func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) {
176+ fs := newFavoritesSidebarHarness(nil, nil)
177+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
178+ fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") }
179+
180+ fs.navigateToURL("https://example.test")
181+
182+ assert.Equal(t, "navigation failed", fs.notice)
183+}
184+
185+func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) {
186+ fs := newFavoritesSidebarHarness(nil, nil)
187+ fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}}
188+ fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{
189+ {ID: 1, URL: "https://go.dev", Title: "Go"},
190+ {ID: 2, URL: "", Title: "Missing URL"},
191+ })
192+
193+ assert.Equal(t, 2, len(fs.allTags))
194+ require.Len(t, fs.displayRows, 2)
195+ assert.True(t, fs.displayRows[0].Selectable)
196+ assert.False(t, fs.displayRows[1].Selectable)
197+ fs.tagCallbacks = []interface{}{func() {}, func() {}}
198+ fs.tagCallbacks = nil
199+ assert.Empty(t, fs.tagCallbacks)
200+}
201+
202+func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) {
203+ assert.True(t, shouldFocusSearchForSlash(false))
204+ assert.False(t, shouldFocusSearchForSlash(true))
205+}
206+
207+func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) {
208+ fs := newFavoritesSidebarHarness(nil, nil)
209+ fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
210+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}}
211+ var called []string
212+ fs.onNavigate = func(_ context.Context, url string) error {
213+ called = append(called, "nav:"+url)
214+ return nil
215+ }
216+ fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
217+ called = append(called, "keep:"+url)
218+ return nil
219+ }
220+ fs.onOpenInNewPane = func(_ context.Context, url string) error {
221+ called = append(called, "pane:"+url)
222+ return nil
223+ }
224+
225+ assert.True(t, fs.handleEnterKey(0))
226+ assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue))
227+ assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue))
228+
229+ assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)
230+}

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