detached 3857325

feat(favorites): manage bookmarks from sidebar

commit
3857325407c2e9c452c812009b8e02a2b8b93e32
author
brice · · 2026-06-28 15:53 UTC
committer
brice · · 2026-06-28 16:03 UTC
parent
5b8fa27
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +886 -19
M internal/ui/component/favorites_sidebar.go +23 -0
@@ -29,6 +29,17 @@ type FavoritesSidebar struct {
2929 tagBox *gtk.Box
3030 scrolledWin *gtk.ScrolledWindow
3131 listBox *gtk.ListBox
32+ formBox *gtk.Box
33+
34+ formURLEntry *gtk.SearchEntry
35+ formTitleEntry *gtk.SearchEntry
36+ formTagsEntry *gtk.SearchEntry
37+ formShortcutEntry *gtk.SearchEntry
38+ formSaveButton *gtk.Button
39+ formURL string
40+ formTitle string
41+ formTags string
42+ formShortcut string
3243
3344 favoritesUC port.FavoritesSidebarFavorites
3445 onNavigate func(ctx context.Context, url string) error
@@ -45,6 +56,10 @@ type FavoritesSidebar struct {
4556 loadGen uint64
4657 destroyed bool
4758 visible bool
59+ focusZone favoritesSidebarFocusZone
60+ mode favoritesSidebarMode
61+ editingID entity.FavoriteID
62+ confirmDelete bool
4863
4964 retainedCallbacks []interface{}
5065 tagCallbacks []interface{}
@@ -189,6 +204,14 @@ func (fs *FavoritesSidebar) createWidgets() error {
189204
190205 fs.scrolledWin.SetChild(&fs.listBox.Widget)
191206 fs.outerBox.Append(&fs.scrolledWin.Widget)
207+
208+ fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4)
209+ if fs.formBox == nil {
210+ return fmt.Errorf("favorites sidebar: form box creation failed")
211+ }
212+ fs.formBox.AddCssClass("favorites-sidebar-form")
213+ fs.formBox.SetVisible(false)
214+ fs.outerBox.Append(&fs.formBox.Widget)
192215 return nil
193216 }
194217
@@ -29,6 +29,17 @@ type FavoritesSidebar struct {
29 tagBox *gtk.Box29 tagBox *gtk.Box
30 scrolledWin *gtk.ScrolledWindow30 scrolledWin *gtk.ScrolledWindow
31 listBox *gtk.ListBox31 listBox *gtk.ListBox
32+ formBox *gtk.Box
33+
34+ formURLEntry *gtk.SearchEntry
35+ formTitleEntry *gtk.SearchEntry
36+ formTagsEntry *gtk.SearchEntry
37+ formShortcutEntry *gtk.SearchEntry
38+ formSaveButton *gtk.Button
39+ formURL string
40+ formTitle string
41+ formTags string
42+ formShortcut string
32 43
33 favoritesUC port.FavoritesSidebarFavorites44 favoritesUC port.FavoritesSidebarFavorites
34 onNavigate func(ctx context.Context, url string) error45 onNavigate func(ctx context.Context, url string) error
@@ -45,6 +56,10 @@ type FavoritesSidebar struct {
45 loadGen uint6456 loadGen uint64
46 destroyed bool57 destroyed bool
47 visible bool58 visible bool
59+ focusZone favoritesSidebarFocusZone
60+ mode favoritesSidebarMode
61+ editingID entity.FavoriteID
62+ confirmDelete bool
48 63
49 retainedCallbacks []interface{}64 retainedCallbacks []interface{}
50 tagCallbacks []interface{}65 tagCallbacks []interface{}
@@ -189,6 +204,14 @@ func (fs *FavoritesSidebar) createWidgets() error {
189 204
190 fs.scrolledWin.SetChild(&fs.listBox.Widget)205 fs.scrolledWin.SetChild(&fs.listBox.Widget)
191 fs.outerBox.Append(&fs.scrolledWin.Widget)206 fs.outerBox.Append(&fs.scrolledWin.Widget)
207+
208+ fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4)
209+ if fs.formBox == nil {
210+ return fmt.Errorf("favorites sidebar: form box creation failed")
211+ }
212+ fs.formBox.AddCssClass("favorites-sidebar-form")
213+ fs.formBox.SetVisible(false)
214+ fs.outerBox.Append(&fs.formBox.Widget)
192 return nil215 return nil
193 }216 }
194 217
A internal/ui/component/favorites_sidebar_forms.go +433 -0
@@ -0,0 +1,433 @@
1+package component
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/puregotk/v4/glib"
9+ "github.com/bnema/puregotk/v4/gtk"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+)
14+
15+type favoritesSidebarMode int
16+
17+const (
18+ favoritesSidebarModeNone favoritesSidebarMode = iota
19+ favoritesSidebarModeAdd
20+ favoritesSidebarModeEdit
21+ favoritesSidebarModeTag
22+ favoritesSidebarModeShortcut
23+)
24+
25+type favoritesSidebarFocusZone int
26+
27+const (
28+ favoritesSidebarFocusSearch favoritesSidebarFocusZone = iota
29+ favoritesSidebarFocusTags
30+ favoritesSidebarFocusList
31+ favoritesSidebarFocusForm
32+ favoritesSidebarFocusConfirm
33+)
34+
35+func (fs *FavoritesSidebar) beginAddForm() {
36+ fs.mu.Lock()
37+ if fs.destroyed {
38+ fs.mu.Unlock()
39+ return
40+ }
41+ fs.mode = favoritesSidebarModeAdd
42+ fs.editingID = 0
43+ fs.confirmDelete = false
44+ fs.notice = "Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save."
45+ fs.mu.Unlock()
46+ fs.renderForm(nil)
47+ fs.rebuildList()
48+}
49+
50+func (fs *FavoritesSidebar) beginEditForm() {
51+ fav := fs.selectedFavorite()
52+ if fav == nil {
53+ fs.setNotice("Select a favorite to edit")
54+ return
55+ }
56+ fs.mu.Lock()
57+ if fs.destroyed {
58+ fs.mu.Unlock()
59+ return
60+ }
61+ fs.mode = favoritesSidebarModeEdit
62+ fs.editingID = fav.ID
63+ fs.confirmDelete = false
64+ fs.notice = "Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save."
65+ fs.mu.Unlock()
66+ fs.renderForm(fav)
67+ fs.rebuildList()
68+}
69+
70+func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
71+ if fs == nil || fs.formBox == nil {
72+ return
73+ }
74+ clearBoxChildren(fs.formBox)
75+ fs.formURLEntry = gtk.NewSearchEntry()
76+ fs.formTitleEntry = gtk.NewSearchEntry()
77+ fs.formTagsEntry = nil
78+ if fav == nil {
79+ fs.formTagsEntry = gtk.NewSearchEntry()
80+ }
81+ fs.formShortcutEntry = gtk.NewSearchEntry()
82+ entries := []struct {
83+ entry *gtk.SearchEntry
84+ label string
85+ }{
86+ {entry: fs.formURLEntry, label: "URL"},
87+ {entry: fs.formTitleEntry, label: "Title"},
88+ {entry: fs.formTagsEntry, label: "Tag IDs"},
89+ {entry: fs.formShortcutEntry, label: "Shortcut"},
90+ }
91+ for _, item := range entries {
92+ if item.entry == nil {
93+ continue
94+ }
95+ placeholder := item.label
96+ item.entry.SetPlaceholderText(&placeholder)
97+ fs.formBox.Append(&item.entry.Widget)
98+ }
99+ fs.formSaveButton = gtk.NewButtonWithLabel("Save")
100+ if fs.formSaveButton != nil {
101+ cb := func(_ gtk.Button) {
102+ fs.submitForm()
103+ }
104+ fs.retainedCallbacks = append(fs.retainedCallbacks, cb)
105+ fs.formSaveButton.ConnectClicked(&cb)
106+ fs.formBox.Append(&fs.formSaveButton.Widget)
107+ }
108+ if fav != nil {
109+ fs.formURL = fav.URL
110+ fs.formTitle = fav.Title
111+ fs.formTags = joinFavoriteTagIDs(fav.Tags)
112+ fs.formShortcut = ""
113+ if fav.ShortcutKey != nil {
114+ fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
115+ }
116+ if fs.formURLEntry != nil {
117+ fs.formURLEntry.SetText(fs.formURL)
118+ fs.formURLEntry.SetEditable(false)
119+ }
120+ if fs.formTitleEntry != nil {
121+ fs.formTitleEntry.SetText(fs.formTitle)
122+ }
123+ if fs.formTagsEntry != nil {
124+ fs.formTagsEntry.SetText(fs.formTags)
125+ }
126+ if fs.formShortcutEntry != nil {
127+ fs.formShortcutEntry.SetText(fs.formShortcut)
128+ }
129+ } else {
130+ fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""
131+ }
132+ fs.formBox.SetVisible(true)
133+ fs.focusForm()
134+}
135+
136+func (fs *FavoritesSidebar) focusForm() {
137+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
138+ if fs != nil && fs.formURLEntry != nil {
139+ fs.formURLEntry.GrabFocus()
140+ }
141+ return false
142+ }))
143+}
144+
145+func (fs *FavoritesSidebar) cancelManagement() bool {
146+ fs.mu.Lock()
147+ if fs.destroyed {
148+ fs.mu.Unlock()
149+ return false
150+ }
151+ active := fs.mode != favoritesSidebarModeNone || fs.confirmDelete
152+ fs.mode = favoritesSidebarModeNone
153+ fs.editingID = 0
154+ fs.confirmDelete = false
155+ if active {
156+ fs.notice = ""
157+ }
158+ fs.mu.Unlock()
159+ if fs.formBox != nil {
160+ fs.formBox.SetVisible(false)
161+ clearBoxChildren(fs.formBox)
162+ }
163+ fs.rebuildList()
164+ return active
165+}
166+
167+func (fs *FavoritesSidebar) submitForm() bool {
168+ if fs == nil {
169+ return false
170+ }
171+ fs.mu.RLock()
172+ mode := fs.mode
173+ id := fs.editingID
174+ uc := fs.favoritesUC
175+ ctx := fs.ctx
176+ fs.mu.RUnlock()
177+ if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
178+ return false
179+ }
180+ url, title, tagsText, shortcutText := fs.formValues()
181+ if mode == favoritesSidebarModeAdd {
182+ tags, err := parseTagIDs(tagsText)
183+ if err != nil {
184+ fs.setNotice(err.Error())
185+ return true
186+ }
187+ key, err := parseShortcut(shortcutText)
188+ if err != nil {
189+ fs.setNotice(err.Error())
190+ return true
191+ }
192+ input := dto.FavoriteCreateInput{URL: url, Title: title, Tags: tags}
193+ fav, err := uc.AddFavorite(ctx, input)
194+ if err != nil {
195+ fs.setNotice(err.Error())
196+ return true
197+ }
198+ if key != nil && fav != nil {
199+ if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
200+ fs.setNotice(err.Error())
201+ return true
202+ }
203+ }
204+ } else {
205+ key, err := parseShortcut(shortcutText)
206+ if err != nil {
207+ fs.setNotice(err.Error())
208+ return true
209+ }
210+ _, err = uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ID: id, Title: title, ShortcutKey: key, ShortcutKeySet: shortcutText != ""})
211+ if err != nil {
212+ fs.setNotice(err.Error())
213+ return true
214+ }
215+ }
216+ fs.cancelManagement()
217+ fs.startLoad()
218+ return true
219+}
220+
221+func (fs *FavoritesSidebar) enterTagMode() {
222+ fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite")
223+}
224+func (fs *FavoritesSidebar) enterShortcutMode() {
225+ fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
226+}
227+
228+func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice string) {
229+ fs.mu.Lock()
230+ if !fs.destroyed {
231+ fs.mode = mode
232+ fs.confirmDelete = false
233+ fs.notice = notice
234+ }
235+ fs.mu.Unlock()
236+ fs.rebuildList()
237+}
238+
239+func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool {
240+ fav := fs.selectedFavorite()
241+ if fav == nil || ord < 1 {
242+ fs.setNotice("Select a favorite and tag 1-9")
243+ return true
244+ }
245+ fs.mu.RLock()
246+ if ord > len(fs.allTags) || fs.favoritesUC == nil {
247+ fs.mu.RUnlock()
248+ fs.setNotice("No tag for key")
249+ return true
250+ }
251+ tagID := fs.allTags[ord-1].ID
252+ uc := fs.favoritesUC
253+ ctx := fs.ctx
254+ fs.mu.RUnlock()
255+ has := false
256+ for _, tag := range fav.Tags {
257+ if tag.ID == tagID {
258+ has = true
259+ break
260+ }
261+ }
262+ var err error
263+ if has {
264+ err = uc.UntagFavorite(ctx, fav.ID, tagID)
265+ } else {
266+ err = uc.TagFavorite(ctx, fav.ID, tagID)
267+ }
268+ if err != nil {
269+ fs.setNotice(err.Error())
270+ return true
271+ }
272+ fs.startLoad()
273+ return true
274+}
275+
276+func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
277+ fav := fs.selectedFavorite()
278+ if fav == nil {
279+ fs.setNotice("Select a favorite for shortcut")
280+ return true
281+ }
282+ fs.mu.RLock()
283+ uc := fs.favoritesUC
284+ ctx := fs.ctx
285+ fs.mu.RUnlock()
286+ if uc == nil {
287+ return true
288+ }
289+ if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
290+ fs.setNotice(err.Error())
291+ return true
292+ }
293+ fs.startLoad()
294+ return true
295+}
296+
297+func (fs *FavoritesSidebar) requestDeleteConfirmation() bool {
298+ if fs.selectedFavorite() == nil {
299+ fs.setNotice("Select a favorite to delete")
300+ return true
301+ }
302+ fs.mu.Lock()
303+ if !fs.destroyed {
304+ fs.confirmDelete = true
305+ fs.notice = "Delete selected favorite? Press Delete again to confirm, Esc to cancel."
306+ }
307+ fs.mu.Unlock()
308+ fs.rebuildList()
309+ return true
310+}
311+
312+func (fs *FavoritesSidebar) formModeActive() bool {
313+ if fs == nil {
314+ return false
315+ }
316+ fs.mu.RLock()
317+ defer fs.mu.RUnlock()
318+ return fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
319+}
320+
321+func (fs *FavoritesSidebar) confirmDeleteActive() bool {
322+ if fs == nil {
323+ return false
324+ }
325+ fs.mu.RLock()
326+ defer fs.mu.RUnlock()
327+ return fs.confirmDelete
328+}
329+
330+func (fs *FavoritesSidebar) confirmDeleteFavorite() bool {
331+ fav := fs.selectedFavorite()
332+ if fav == nil {
333+ return fs.requestDeleteConfirmation()
334+ }
335+ fs.mu.RLock()
336+ uc := fs.favoritesUC
337+ ctx := fs.ctx
338+ fs.mu.RUnlock()
339+ if uc == nil {
340+ return true
341+ }
342+ if err := uc.DeleteFavorite(ctx, fav.ID); err != nil {
343+ fs.setNotice(err.Error())
344+ return true
345+ }
346+ fs.cancelManagement()
347+ fs.startLoad()
348+ return true
349+}
350+
351+func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite {
352+ fs.mu.RLock()
353+ defer fs.mu.RUnlock()
354+ if len(fs.displayRows) == 1 {
355+ return fs.displayRows[0].Favorite
356+ }
357+ idx := firstSelectableIndex(fs.displayRows)
358+ if fs.listBox != nil {
359+ if row := fs.listBox.GetSelectedRow(); row != nil {
360+ idx = row.GetIndex()
361+ }
362+ }
363+ if idx >= 0 && idx < len(fs.displayRows) {
364+ return fs.displayRows[idx].Favorite
365+ }
366+ return nil
367+}
368+
369+func (fs *FavoritesSidebar) setNotice(notice string) {
370+ fs.mu.Lock()
371+ if !fs.destroyed {
372+ fs.notice = notice
373+ }
374+ fs.mu.Unlock()
375+ fs.rebuildList()
376+}
377+
378+func (fs *FavoritesSidebar) formValues() (string, string, string, string) {
379+ url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut
380+ if fs.formURLEntry != nil {
381+ url = fs.formURLEntry.GetText()
382+ }
383+ if fs.formTitleEntry != nil {
384+ title = fs.formTitleEntry.GetText()
385+ }
386+ if fs.formTagsEntry != nil {
387+ tagsText = fs.formTagsEntry.GetText()
388+ }
389+ if fs.formShortcutEntry != nil {
390+ shortcut = fs.formShortcutEntry.GetText()
391+ }
392+ return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut)
393+}
394+
395+func parseTagIDs(text string) ([]entity.TagID, error) {
396+ if text == "" {
397+ return nil, nil
398+ }
399+ parts := strings.Split(text, ",")
400+ ids := make([]entity.TagID, 0, len(parts))
401+ for _, part := range parts {
402+ trimmed := strings.TrimSpace(part)
403+ if trimmed == "" {
404+ return nil, fmt.Errorf("invalid tag ID %q", part)
405+ }
406+ v, err := strconv.Atoi(trimmed)
407+ if err != nil || v <= 0 {
408+ return nil, fmt.Errorf("invalid tag ID %q", trimmed)
409+ }
410+ ids = append(ids, entity.TagID(v))
411+ }
412+ return ids, nil
413+}
414+
415+func parseShortcut(text string) (*int, error) {
416+ text = strings.TrimSpace(text)
417+ if text == "" {
418+ return nil, nil
419+ }
420+ v, err := strconv.Atoi(text)
421+ if err != nil || v < 1 || v > 9 {
422+ return nil, fmt.Errorf("invalid shortcut %q (use 1-9)", text)
423+ }
424+ return &v, nil
425+}
426+
427+func joinFavoriteTagIDs(tags []entity.Tag) string {
428+ parts := make([]string, 0, len(tags))
429+ for _, tag := range tags {
430+ parts = append(parts, fmt.Sprint(tag.ID))
431+ }
432+ return strings.Join(parts, ",")
433+}
@@ -0,0 +1,433 @@
1+package component
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/puregotk/v4/glib"
9+ "github.com/bnema/puregotk/v4/gtk"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+)
14+
15+type favoritesSidebarMode int
16+
17+const (
18+ favoritesSidebarModeNone favoritesSidebarMode = iota
19+ favoritesSidebarModeAdd
20+ favoritesSidebarModeEdit
21+ favoritesSidebarModeTag
22+ favoritesSidebarModeShortcut
23+)
24+
25+type favoritesSidebarFocusZone int
26+
27+const (
28+ favoritesSidebarFocusSearch favoritesSidebarFocusZone = iota
29+ favoritesSidebarFocusTags
30+ favoritesSidebarFocusList
31+ favoritesSidebarFocusForm
32+ favoritesSidebarFocusConfirm
33+)
34+
35+func (fs *FavoritesSidebar) beginAddForm() {
36+ fs.mu.Lock()
37+ if fs.destroyed {
38+ fs.mu.Unlock()
39+ return
40+ }
41+ fs.mode = favoritesSidebarModeAdd
42+ fs.editingID = 0
43+ fs.confirmDelete = false
44+ fs.notice = "Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save."
45+ fs.mu.Unlock()
46+ fs.renderForm(nil)
47+ fs.rebuildList()
48+}
49+
50+func (fs *FavoritesSidebar) beginEditForm() {
51+ fav := fs.selectedFavorite()
52+ if fav == nil {
53+ fs.setNotice("Select a favorite to edit")
54+ return
55+ }
56+ fs.mu.Lock()
57+ if fs.destroyed {
58+ fs.mu.Unlock()
59+ return
60+ }
61+ fs.mode = favoritesSidebarModeEdit
62+ fs.editingID = fav.ID
63+ fs.confirmDelete = false
64+ fs.notice = "Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save."
65+ fs.mu.Unlock()
66+ fs.renderForm(fav)
67+ fs.rebuildList()
68+}
69+
70+func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
71+ if fs == nil || fs.formBox == nil {
72+ return
73+ }
74+ clearBoxChildren(fs.formBox)
75+ fs.formURLEntry = gtk.NewSearchEntry()
76+ fs.formTitleEntry = gtk.NewSearchEntry()
77+ fs.formTagsEntry = nil
78+ if fav == nil {
79+ fs.formTagsEntry = gtk.NewSearchEntry()
80+ }
81+ fs.formShortcutEntry = gtk.NewSearchEntry()
82+ entries := []struct {
83+ entry *gtk.SearchEntry
84+ label string
85+ }{
86+ {entry: fs.formURLEntry, label: "URL"},
87+ {entry: fs.formTitleEntry, label: "Title"},
88+ {entry: fs.formTagsEntry, label: "Tag IDs"},
89+ {entry: fs.formShortcutEntry, label: "Shortcut"},
90+ }
91+ for _, item := range entries {
92+ if item.entry == nil {
93+ continue
94+ }
95+ placeholder := item.label
96+ item.entry.SetPlaceholderText(&placeholder)
97+ fs.formBox.Append(&item.entry.Widget)
98+ }
99+ fs.formSaveButton = gtk.NewButtonWithLabel("Save")
100+ if fs.formSaveButton != nil {
101+ cb := func(_ gtk.Button) {
102+ fs.submitForm()
103+ }
104+ fs.retainedCallbacks = append(fs.retainedCallbacks, cb)
105+ fs.formSaveButton.ConnectClicked(&cb)
106+ fs.formBox.Append(&fs.formSaveButton.Widget)
107+ }
108+ if fav != nil {
109+ fs.formURL = fav.URL
110+ fs.formTitle = fav.Title
111+ fs.formTags = joinFavoriteTagIDs(fav.Tags)
112+ fs.formShortcut = ""
113+ if fav.ShortcutKey != nil {
114+ fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
115+ }
116+ if fs.formURLEntry != nil {
117+ fs.formURLEntry.SetText(fs.formURL)
118+ fs.formURLEntry.SetEditable(false)
119+ }
120+ if fs.formTitleEntry != nil {
121+ fs.formTitleEntry.SetText(fs.formTitle)
122+ }
123+ if fs.formTagsEntry != nil {
124+ fs.formTagsEntry.SetText(fs.formTags)
125+ }
126+ if fs.formShortcutEntry != nil {
127+ fs.formShortcutEntry.SetText(fs.formShortcut)
128+ }
129+ } else {
130+ fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""
131+ }
132+ fs.formBox.SetVisible(true)
133+ fs.focusForm()
134+}
135+
136+func (fs *FavoritesSidebar) focusForm() {
137+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
138+ if fs != nil && fs.formURLEntry != nil {
139+ fs.formURLEntry.GrabFocus()
140+ }
141+ return false
142+ }))
143+}
144+
145+func (fs *FavoritesSidebar) cancelManagement() bool {
146+ fs.mu.Lock()
147+ if fs.destroyed {
148+ fs.mu.Unlock()
149+ return false
150+ }
151+ active := fs.mode != favoritesSidebarModeNone || fs.confirmDelete
152+ fs.mode = favoritesSidebarModeNone
153+ fs.editingID = 0
154+ fs.confirmDelete = false
155+ if active {
156+ fs.notice = ""
157+ }
158+ fs.mu.Unlock()
159+ if fs.formBox != nil {
160+ fs.formBox.SetVisible(false)
161+ clearBoxChildren(fs.formBox)
162+ }
163+ fs.rebuildList()
164+ return active
165+}
166+
167+func (fs *FavoritesSidebar) submitForm() bool {
168+ if fs == nil {
169+ return false
170+ }
171+ fs.mu.RLock()
172+ mode := fs.mode
173+ id := fs.editingID
174+ uc := fs.favoritesUC
175+ ctx := fs.ctx
176+ fs.mu.RUnlock()
177+ if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
178+ return false
179+ }
180+ url, title, tagsText, shortcutText := fs.formValues()
181+ if mode == favoritesSidebarModeAdd {
182+ tags, err := parseTagIDs(tagsText)
183+ if err != nil {
184+ fs.setNotice(err.Error())
185+ return true
186+ }
187+ key, err := parseShortcut(shortcutText)
188+ if err != nil {
189+ fs.setNotice(err.Error())
190+ return true
191+ }
192+ input := dto.FavoriteCreateInput{URL: url, Title: title, Tags: tags}
193+ fav, err := uc.AddFavorite(ctx, input)
194+ if err != nil {
195+ fs.setNotice(err.Error())
196+ return true
197+ }
198+ if key != nil && fav != nil {
199+ if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
200+ fs.setNotice(err.Error())
201+ return true
202+ }
203+ }
204+ } else {
205+ key, err := parseShortcut(shortcutText)
206+ if err != nil {
207+ fs.setNotice(err.Error())
208+ return true
209+ }
210+ _, err = uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ID: id, Title: title, ShortcutKey: key, ShortcutKeySet: shortcutText != ""})
211+ if err != nil {
212+ fs.setNotice(err.Error())
213+ return true
214+ }
215+ }
216+ fs.cancelManagement()
217+ fs.startLoad()
218+ return true
219+}
220+
221+func (fs *FavoritesSidebar) enterTagMode() {
222+ fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite")
223+}
224+func (fs *FavoritesSidebar) enterShortcutMode() {
225+ fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
226+}
227+
228+func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice string) {
229+ fs.mu.Lock()
230+ if !fs.destroyed {
231+ fs.mode = mode
232+ fs.confirmDelete = false
233+ fs.notice = notice
234+ }
235+ fs.mu.Unlock()
236+ fs.rebuildList()
237+}
238+
239+func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool {
240+ fav := fs.selectedFavorite()
241+ if fav == nil || ord < 1 {
242+ fs.setNotice("Select a favorite and tag 1-9")
243+ return true
244+ }
245+ fs.mu.RLock()
246+ if ord > len(fs.allTags) || fs.favoritesUC == nil {
247+ fs.mu.RUnlock()
248+ fs.setNotice("No tag for key")
249+ return true
250+ }
251+ tagID := fs.allTags[ord-1].ID
252+ uc := fs.favoritesUC
253+ ctx := fs.ctx
254+ fs.mu.RUnlock()
255+ has := false
256+ for _, tag := range fav.Tags {
257+ if tag.ID == tagID {
258+ has = true
259+ break
260+ }
261+ }
262+ var err error
263+ if has {
264+ err = uc.UntagFavorite(ctx, fav.ID, tagID)
265+ } else {
266+ err = uc.TagFavorite(ctx, fav.ID, tagID)
267+ }
268+ if err != nil {
269+ fs.setNotice(err.Error())
270+ return true
271+ }
272+ fs.startLoad()
273+ return true
274+}
275+
276+func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
277+ fav := fs.selectedFavorite()
278+ if fav == nil {
279+ fs.setNotice("Select a favorite for shortcut")
280+ return true
281+ }
282+ fs.mu.RLock()
283+ uc := fs.favoritesUC
284+ ctx := fs.ctx
285+ fs.mu.RUnlock()
286+ if uc == nil {
287+ return true
288+ }
289+ if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
290+ fs.setNotice(err.Error())
291+ return true
292+ }
293+ fs.startLoad()
294+ return true
295+}
296+
297+func (fs *FavoritesSidebar) requestDeleteConfirmation() bool {
298+ if fs.selectedFavorite() == nil {
299+ fs.setNotice("Select a favorite to delete")
300+ return true
301+ }
302+ fs.mu.Lock()
303+ if !fs.destroyed {
304+ fs.confirmDelete = true
305+ fs.notice = "Delete selected favorite? Press Delete again to confirm, Esc to cancel."
306+ }
307+ fs.mu.Unlock()
308+ fs.rebuildList()
309+ return true
310+}
311+
312+func (fs *FavoritesSidebar) formModeActive() bool {
313+ if fs == nil {
314+ return false
315+ }
316+ fs.mu.RLock()
317+ defer fs.mu.RUnlock()
318+ return fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
319+}
320+
321+func (fs *FavoritesSidebar) confirmDeleteActive() bool {
322+ if fs == nil {
323+ return false
324+ }
325+ fs.mu.RLock()
326+ defer fs.mu.RUnlock()
327+ return fs.confirmDelete
328+}
329+
330+func (fs *FavoritesSidebar) confirmDeleteFavorite() bool {
331+ fav := fs.selectedFavorite()
332+ if fav == nil {
333+ return fs.requestDeleteConfirmation()
334+ }
335+ fs.mu.RLock()
336+ uc := fs.favoritesUC
337+ ctx := fs.ctx
338+ fs.mu.RUnlock()
339+ if uc == nil {
340+ return true
341+ }
342+ if err := uc.DeleteFavorite(ctx, fav.ID); err != nil {
343+ fs.setNotice(err.Error())
344+ return true
345+ }
346+ fs.cancelManagement()
347+ fs.startLoad()
348+ return true
349+}
350+
351+func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite {
352+ fs.mu.RLock()
353+ defer fs.mu.RUnlock()
354+ if len(fs.displayRows) == 1 {
355+ return fs.displayRows[0].Favorite
356+ }
357+ idx := firstSelectableIndex(fs.displayRows)
358+ if fs.listBox != nil {
359+ if row := fs.listBox.GetSelectedRow(); row != nil {
360+ idx = row.GetIndex()
361+ }
362+ }
363+ if idx >= 0 && idx < len(fs.displayRows) {
364+ return fs.displayRows[idx].Favorite
365+ }
366+ return nil
367+}
368+
369+func (fs *FavoritesSidebar) setNotice(notice string) {
370+ fs.mu.Lock()
371+ if !fs.destroyed {
372+ fs.notice = notice
373+ }
374+ fs.mu.Unlock()
375+ fs.rebuildList()
376+}
377+
378+func (fs *FavoritesSidebar) formValues() (string, string, string, string) {
379+ url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut
380+ if fs.formURLEntry != nil {
381+ url = fs.formURLEntry.GetText()
382+ }
383+ if fs.formTitleEntry != nil {
384+ title = fs.formTitleEntry.GetText()
385+ }
386+ if fs.formTagsEntry != nil {
387+ tagsText = fs.formTagsEntry.GetText()
388+ }
389+ if fs.formShortcutEntry != nil {
390+ shortcut = fs.formShortcutEntry.GetText()
391+ }
392+ return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut)
393+}
394+
395+func parseTagIDs(text string) ([]entity.TagID, error) {
396+ if text == "" {
397+ return nil, nil
398+ }
399+ parts := strings.Split(text, ",")
400+ ids := make([]entity.TagID, 0, len(parts))
401+ for _, part := range parts {
402+ trimmed := strings.TrimSpace(part)
403+ if trimmed == "" {
404+ return nil, fmt.Errorf("invalid tag ID %q", part)
405+ }
406+ v, err := strconv.Atoi(trimmed)
407+ if err != nil || v <= 0 {
408+ return nil, fmt.Errorf("invalid tag ID %q", trimmed)
409+ }
410+ ids = append(ids, entity.TagID(v))
411+ }
412+ return ids, nil
413+}
414+
415+func parseShortcut(text string) (*int, error) {
416+ text = strings.TrimSpace(text)
417+ if text == "" {
418+ return nil, nil
419+ }
420+ v, err := strconv.Atoi(text)
421+ if err != nil || v < 1 || v > 9 {
422+ return nil, fmt.Errorf("invalid shortcut %q (use 1-9)", text)
423+ }
424+ return &v, nil
425+}
426+
427+func joinFavoriteTagIDs(tags []entity.Tag) string {
428+ parts := make([]string, 0, len(tags))
429+ for _, tag := range tags {
430+ parts = append(parts, fmt.Sprint(tag.ID))
431+ }
432+ return strings.Join(parts, ",")
433+}
M internal/ui/component/favorites_sidebar_keyboard.go +190 -2
@@ -4,6 +4,8 @@ import (
44 "github.com/bnema/puregotk/v4/gdk"
55 "github.com/bnema/puregotk/v4/glib"
66 "github.com/bnema/puregotk/v4/gtk"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
79 )
810
911 func (fs *FavoritesSidebar) setupKeyboardNavigation() {
@@ -27,6 +29,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
2729
2830 switch keyval {
2931 case uint(gdk.KEY_Escape):
32+ if fs.cancelManagement() {
33+ return true
34+ }
3035 if searchEntry != nil && searchEntry.GetText() != "" {
3136 searchEntry.SetText("")
3237 return true
@@ -36,7 +41,10 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
3641 }
3742 return true
3843 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
39- return fs.handleEnterKey(state)
44+ return fs.handleReturnKey(state)
45+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
46+ fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
47+ return true
4048 case uint(gdk.KEY_Page_Up):
4149 fs.selectAdjacentRow(-5)
4250 return true
@@ -62,6 +70,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
6270 fs.selectAdjacentRow(1)
6371 return true
6472 case uint(gdk.KEY_slash):
73+ if fs.inTextEditContext() {
74+ return false
75+ }
6576 searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus()
6677 if !shouldFocusSearchForSlash(searchFocused) {
6778 return false
@@ -69,13 +80,30 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
6980 fs.focusSearch()
7081 return true
7182 }
72- return false
83+ return fs.handleSingleKeyCommand(keyval)
7384 }
7485 fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
7586 keyController.ConnectKeyPressed(&keyPressedCb)
7687 fs.outerBox.AddController(&keyController.EventController)
7788 }
7889
90+func (fs *FavoritesSidebar) handleReturnKey(state gdk.ModifierType) bool {
91+ if fs == nil {
92+ return false
93+ }
94+ if fs.confirmDeleteActive() {
95+ return fs.confirmDeleteFavorite()
96+ }
97+ formActive := fs.formModeActive()
98+ if state&gdk.ControlMaskValue != 0 && fs.submitForm() {
99+ return true
100+ }
101+ if formActive {
102+ return false
103+ }
104+ return fs.handleEnterKey(state)
105+}
106+
79107 func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool {
80108 if fs == nil {
81109 return false
@@ -231,6 +259,166 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) {
231259
232260 func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
233261
262+func (fs *FavoritesSidebar) inTextEditContext() bool {
263+ if fs == nil {
264+ return false
265+ }
266+ if fs.searchEntry != nil && fs.searchEntry.Widget.HasFocus() {
267+ return true
268+ }
269+ for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} {
270+ if entry != nil && entry.Widget.HasFocus() {
271+ return true
272+ }
273+ }
274+ return false
275+}
276+
277+func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint) bool {
278+ if fs == nil || fs.inTextEditContext() {
279+ return false
280+ }
281+ fs.mu.RLock()
282+ mode := fs.mode
283+ confirm := fs.confirmDelete
284+ fs.mu.RUnlock()
285+ if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
286+ return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0)))
287+ }
288+ if mode == favoritesSidebarModeShortcut {
289+ if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
290+ v := int(keyval - uint(gdk.KEY_0))
291+ return fs.setShortcutKey(&v)
292+ }
293+ if keyval == uint(gdk.KEY_BackSpace) || keyval == uint(gdk.KEY_Delete) {
294+ return fs.setShortcutKey(nil)
295+ }
296+ }
297+ if keyval == uint(gdk.KEY_Delete) {
298+ if confirm {
299+ return fs.confirmDeleteFavorite()
300+ }
301+ return fs.requestDeleteConfirmation()
302+ }
303+ switch keyval {
304+ case uint(gdk.KEY_a):
305+ fs.beginAddForm()
306+ return true
307+ case uint(gdk.KEY_e):
308+ fs.beginEditForm()
309+ return true
310+ case uint(gdk.KEY_t):
311+ fs.enterTagMode()
312+ return true
313+ case uint(gdk.KEY_s):
314+ fs.enterShortcutMode()
315+ return true
316+ case uint(gdk.KEY_r):
317+ fs.startLoad()
318+ return true
319+ case uint(gdk.KEY_c):
320+ fs.clearQueryAndTags()
321+ return true
322+ }
323+ return false
324+}
325+
326+func (fs *FavoritesSidebar) clearQueryAndTags() {
327+ if fs == nil {
328+ return
329+ }
330+ if fs.searchEntry != nil {
331+ fs.searchEntry.SetText("")
332+ }
333+ fs.mu.Lock()
334+ if !fs.destroyed {
335+ fs.currentQuery = ""
336+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
337+ fs.rebuildDisplayRowsLocked()
338+ }
339+ fs.mu.Unlock()
340+ fs.renderTags()
341+ fs.rebuildList()
342+}
343+
344+func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
345+ if fs == nil {
346+ return
347+ }
348+ zones := fs.availableFocusZones()
349+ if len(zones) == 0 {
350+ return
351+ }
352+ fs.mu.RLock()
353+ current := fs.focusZone
354+ fs.mu.RUnlock()
355+ idx := -1
356+ for i, zone := range zones {
357+ if zone == current {
358+ idx = i
359+ break
360+ }
361+ }
362+ if reverse {
363+ idx--
364+ if idx < 0 {
365+ idx = len(zones) - 1
366+ }
367+ } else {
368+ idx++
369+ if idx >= len(zones) {
370+ idx = 0
371+ }
372+ }
373+ fs.focusZoneWidget(zones[idx])
374+}
375+
376+func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
377+ zones := []favoritesSidebarFocusZone{}
378+ if fs.searchEntry != nil {
379+ zones = append(zones, favoritesSidebarFocusSearch)
380+ }
381+ if fs.tagBox != nil {
382+ zones = append(zones, favoritesSidebarFocusTags)
383+ }
384+ if fs.listBox != nil {
385+ zones = append(zones, favoritesSidebarFocusList)
386+ }
387+ fs.mu.RLock()
388+ formActive := fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
389+ confirmActive := fs.confirmDelete
390+ fs.mu.RUnlock()
391+ if formActive && fs.formBox != nil {
392+ zones = append(zones, favoritesSidebarFocusForm)
393+ }
394+ if confirmActive {
395+ zones = append(zones, favoritesSidebarFocusConfirm)
396+ }
397+ return zones
398+}
399+
400+func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
401+ fs.mu.Lock()
402+ if !fs.destroyed {
403+ fs.focusZone = zone
404+ }
405+ fs.mu.Unlock()
406+ switch zone {
407+ case favoritesSidebarFocusSearch:
408+ fs.focusSearch()
409+ case favoritesSidebarFocusTags:
410+ if fs.tagBox != nil {
411+ fs.tagBox.GrabFocus()
412+ }
413+ case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
414+ if fs.listBox != nil {
415+ fs.listBox.GrabFocus()
416+ }
417+ case favoritesSidebarFocusForm:
418+ fs.focusForm()
419+ }
420+}
421+
234422 func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool {
235423 if fs == nil || url == "" {
236424 return false
@@ -4,6 +4,8 @@ import (
4 "github.com/bnema/puregotk/v4/gdk"4 "github.com/bnema/puregotk/v4/gdk"
5 "github.com/bnema/puregotk/v4/glib"5 "github.com/bnema/puregotk/v4/glib"
6 "github.com/bnema/puregotk/v4/gtk"6 "github.com/bnema/puregotk/v4/gtk"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
7 )9 )
8 10
9 func (fs *FavoritesSidebar) setupKeyboardNavigation() {11 func (fs *FavoritesSidebar) setupKeyboardNavigation() {
@@ -27,6 +29,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
27 29
28 switch keyval {30 switch keyval {
29 case uint(gdk.KEY_Escape):31 case uint(gdk.KEY_Escape):
32+ if fs.cancelManagement() {
33+ return true
34+ }
30 if searchEntry != nil && searchEntry.GetText() != "" {35 if searchEntry != nil && searchEntry.GetText() != "" {
31 searchEntry.SetText("")36 searchEntry.SetText("")
32 return true37 return true
@@ -36,7 +41,10 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
36 }41 }
37 return true42 return true
38 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):43 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
39- return fs.handleEnterKey(state)44+ return fs.handleReturnKey(state)
45+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
46+ fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
47+ return true
40 case uint(gdk.KEY_Page_Up):48 case uint(gdk.KEY_Page_Up):
41 fs.selectAdjacentRow(-5)49 fs.selectAdjacentRow(-5)
42 return true50 return true
@@ -62,6 +70,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
62 fs.selectAdjacentRow(1)70 fs.selectAdjacentRow(1)
63 return true71 return true
64 case uint(gdk.KEY_slash):72 case uint(gdk.KEY_slash):
73+ if fs.inTextEditContext() {
74+ return false
75+ }
65 searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus()76 searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus()
66 if !shouldFocusSearchForSlash(searchFocused) {77 if !shouldFocusSearchForSlash(searchFocused) {
67 return false78 return false
@@ -69,13 +80,30 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
69 fs.focusSearch()80 fs.focusSearch()
70 return true81 return true
71 }82 }
72- return false83+ return fs.handleSingleKeyCommand(keyval)
73 }84 }
74 fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)85 fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
75 keyController.ConnectKeyPressed(&keyPressedCb)86 keyController.ConnectKeyPressed(&keyPressedCb)
76 fs.outerBox.AddController(&keyController.EventController)87 fs.outerBox.AddController(&keyController.EventController)
77 }88 }
78 89
90+func (fs *FavoritesSidebar) handleReturnKey(state gdk.ModifierType) bool {
91+ if fs == nil {
92+ return false
93+ }
94+ if fs.confirmDeleteActive() {
95+ return fs.confirmDeleteFavorite()
96+ }
97+ formActive := fs.formModeActive()
98+ if state&gdk.ControlMaskValue != 0 && fs.submitForm() {
99+ return true
100+ }
101+ if formActive {
102+ return false
103+ }
104+ return fs.handleEnterKey(state)
105+}
106+
79 func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool {107 func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool {
80 if fs == nil {108 if fs == nil {
81 return false109 return false
@@ -231,6 +259,166 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) {
231 259
232 func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }260 func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
233 261
262+func (fs *FavoritesSidebar) inTextEditContext() bool {
263+ if fs == nil {
264+ return false
265+ }
266+ if fs.searchEntry != nil && fs.searchEntry.Widget.HasFocus() {
267+ return true
268+ }
269+ for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} {
270+ if entry != nil && entry.Widget.HasFocus() {
271+ return true
272+ }
273+ }
274+ return false
275+}
276+
277+func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint) bool {
278+ if fs == nil || fs.inTextEditContext() {
279+ return false
280+ }
281+ fs.mu.RLock()
282+ mode := fs.mode
283+ confirm := fs.confirmDelete
284+ fs.mu.RUnlock()
285+ if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
286+ return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0)))
287+ }
288+ if mode == favoritesSidebarModeShortcut {
289+ if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
290+ v := int(keyval - uint(gdk.KEY_0))
291+ return fs.setShortcutKey(&v)
292+ }
293+ if keyval == uint(gdk.KEY_BackSpace) || keyval == uint(gdk.KEY_Delete) {
294+ return fs.setShortcutKey(nil)
295+ }
296+ }
297+ if keyval == uint(gdk.KEY_Delete) {
298+ if confirm {
299+ return fs.confirmDeleteFavorite()
300+ }
301+ return fs.requestDeleteConfirmation()
302+ }
303+ switch keyval {
304+ case uint(gdk.KEY_a):
305+ fs.beginAddForm()
306+ return true
307+ case uint(gdk.KEY_e):
308+ fs.beginEditForm()
309+ return true
310+ case uint(gdk.KEY_t):
311+ fs.enterTagMode()
312+ return true
313+ case uint(gdk.KEY_s):
314+ fs.enterShortcutMode()
315+ return true
316+ case uint(gdk.KEY_r):
317+ fs.startLoad()
318+ return true
319+ case uint(gdk.KEY_c):
320+ fs.clearQueryAndTags()
321+ return true
322+ }
323+ return false
324+}
325+
326+func (fs *FavoritesSidebar) clearQueryAndTags() {
327+ if fs == nil {
328+ return
329+ }
330+ if fs.searchEntry != nil {
331+ fs.searchEntry.SetText("")
332+ }
333+ fs.mu.Lock()
334+ if !fs.destroyed {
335+ fs.currentQuery = ""
336+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
337+ fs.rebuildDisplayRowsLocked()
338+ }
339+ fs.mu.Unlock()
340+ fs.renderTags()
341+ fs.rebuildList()
342+}
343+
344+func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
345+ if fs == nil {
346+ return
347+ }
348+ zones := fs.availableFocusZones()
349+ if len(zones) == 0 {
350+ return
351+ }
352+ fs.mu.RLock()
353+ current := fs.focusZone
354+ fs.mu.RUnlock()
355+ idx := -1
356+ for i, zone := range zones {
357+ if zone == current {
358+ idx = i
359+ break
360+ }
361+ }
362+ if reverse {
363+ idx--
364+ if idx < 0 {
365+ idx = len(zones) - 1
366+ }
367+ } else {
368+ idx++
369+ if idx >= len(zones) {
370+ idx = 0
371+ }
372+ }
373+ fs.focusZoneWidget(zones[idx])
374+}
375+
376+func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
377+ zones := []favoritesSidebarFocusZone{}
378+ if fs.searchEntry != nil {
379+ zones = append(zones, favoritesSidebarFocusSearch)
380+ }
381+ if fs.tagBox != nil {
382+ zones = append(zones, favoritesSidebarFocusTags)
383+ }
384+ if fs.listBox != nil {
385+ zones = append(zones, favoritesSidebarFocusList)
386+ }
387+ fs.mu.RLock()
388+ formActive := fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
389+ confirmActive := fs.confirmDelete
390+ fs.mu.RUnlock()
391+ if formActive && fs.formBox != nil {
392+ zones = append(zones, favoritesSidebarFocusForm)
393+ }
394+ if confirmActive {
395+ zones = append(zones, favoritesSidebarFocusConfirm)
396+ }
397+ return zones
398+}
399+
400+func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
401+ fs.mu.Lock()
402+ if !fs.destroyed {
403+ fs.focusZone = zone
404+ }
405+ fs.mu.Unlock()
406+ switch zone {
407+ case favoritesSidebarFocusSearch:
408+ fs.focusSearch()
409+ case favoritesSidebarFocusTags:
410+ if fs.tagBox != nil {
411+ fs.tagBox.GrabFocus()
412+ }
413+ case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
414+ if fs.listBox != nil {
415+ fs.listBox.GrabFocus()
416+ }
417+ case favoritesSidebarFocusForm:
418+ fs.focusForm()
419+ }
420+}
421+
234 func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool {422 func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool {
235 if fs == nil || url == "" {423 if fs == nil || url == "" {
236 return false424 return false
M internal/ui/component/favorites_sidebar_rows.go +18 -6
@@ -180,21 +180,33 @@ func (fs *FavoritesSidebar) rebuildList() {
180180
181181 listBox.RemoveAll()
182182 if len(rows) == 0 {
183- text := "No favorites"
184- if notice != "" {
185- text = notice
186- } else if query != "" {
187- text = noResultsText(query)
183+ for _, text := range favoriteSidebarNoticeRows(notice, query, false) {
184+ fs.appendNoticeRow(listBox, text)
188185 }
189- fs.appendNoticeRow(listBox, text)
190186 return
191187 }
192188 for _, row := range rows {
193189 fs.appendFavoriteRow(listBox, row.Favorite)
194190 }
191+ for _, text := range favoriteSidebarNoticeRows(notice, query, true) {
192+ fs.appendNoticeRow(listBox, text)
193+ }
195194 fs.ensureAtLeastOneSelectionInListBox(listBox)
196195 }
197196
197+func favoriteSidebarNoticeRows(notice, query string, hasRows bool) []string {
198+ if notice != "" {
199+ return []string{notice}
200+ }
201+ if hasRows {
202+ return nil
203+ }
204+ if query != "" {
205+ return []string{noResultsText(query)}
206+ }
207+ return []string{"No favorites"}
208+}
209+
198210 func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
199211 label := gtk.NewLabel(&text)
200212 if label == nil {
@@ -180,21 +180,33 @@ func (fs *FavoritesSidebar) rebuildList() {
180 180
181 listBox.RemoveAll()181 listBox.RemoveAll()
182 if len(rows) == 0 {182 if len(rows) == 0 {
183- text := "No favorites"183+ for _, text := range favoriteSidebarNoticeRows(notice, query, false) {
184- if notice != "" {184+ fs.appendNoticeRow(listBox, text)
185- text = notice
186- } else if query != "" {
187- text = noResultsText(query)
188 }185 }
189- fs.appendNoticeRow(listBox, text)
190 return186 return
191 }187 }
192 for _, row := range rows {188 for _, row := range rows {
193 fs.appendFavoriteRow(listBox, row.Favorite)189 fs.appendFavoriteRow(listBox, row.Favorite)
194 }190 }
191+ for _, text := range favoriteSidebarNoticeRows(notice, query, true) {
192+ fs.appendNoticeRow(listBox, text)
193+ }
195 fs.ensureAtLeastOneSelectionInListBox(listBox)194 fs.ensureAtLeastOneSelectionInListBox(listBox)
196 }195 }
197 196
197+func favoriteSidebarNoticeRows(notice, query string, hasRows bool) []string {
198+ if notice != "" {
199+ return []string{notice}
200+ }
201+ if hasRows {
202+ return nil
203+ }
204+ if query != "" {
205+ return []string{noResultsText(query)}
206+ }
207+ return []string{"No favorites"}
208+}
209+
198 func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {210 func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
199 label := gtk.NewLabel(&text)211 label := gtk.NewLabel(&text)
200 if label == nil {212 if label == nil {
M internal/ui/component/favorites_sidebar_test.go +222 -11
@@ -17,6 +17,23 @@ import (
1717 type fakeFavoritesSidebarUC struct {
1818 favorites []*entity.Favorite
1919 tags []*entity.Tag
20+
21+ addInputs []dto.FavoriteCreateInput
22+ updateInputs []dto.FavoriteUpdateInput
23+ deletedIDs []entity.FavoriteID
24+ shortcuts []struct {
25+ id entity.FavoriteID
26+ key *int
27+ }
28+ tagged []struct {
29+ favID entity.FavoriteID
30+ tagID entity.TagID
31+ }
32+ untagged []struct {
33+ favID entity.FavoriteID
34+ tagID entity.TagID
35+ }
36+ err error
2037 }
2138
2239 func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
@@ -25,21 +42,44 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
2542 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
2643 return f.tags, nil
2744 }
28-func (f *fakeFavoritesSidebarUC) AddFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) {
29- return nil, nil
45+func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
46+ f.addInputs = append(f.addInputs, input)
47+ if f.err != nil {
48+ return nil, f.err
49+ }
50+ return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
51+}
52+func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
53+ f.updateInputs = append(f.updateInputs, input)
54+ if f.err != nil {
55+ return nil, f.err
56+ }
57+ return &entity.Favorite{ID: input.ID, Title: input.Title}, nil
3058 }
31-func (f *fakeFavoritesSidebarUC) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) {
32- return nil, nil
59+func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error {
60+ f.deletedIDs = append(f.deletedIDs, id)
61+ return f.err
3362 }
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
63+func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error {
64+ f.shortcuts = append(f.shortcuts, struct {
65+ id entity.FavoriteID
66+ key *int
67+ }{id: id, key: key})
68+ return f.err
3769 }
38-func (f *fakeFavoritesSidebarUC) TagFavorite(context.Context, entity.FavoriteID, entity.TagID) error {
39- return nil
70+func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
71+ f.tagged = append(f.tagged, struct {
72+ favID entity.FavoriteID
73+ tagID entity.TagID
74+ }{favID: favID, tagID: tagID})
75+ return f.err
4076 }
41-func (f *fakeFavoritesSidebarUC) UntagFavorite(context.Context, entity.FavoriteID, entity.TagID) error {
42- return nil
77+func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
78+ f.untagged = append(f.untagged, struct {
79+ favID entity.FavoriteID
80+ tagID entity.TagID
81+ }{favID: favID, tagID: tagID})
82+ return f.err
4383 }
4484
4585 func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
@@ -194,6 +234,8 @@ func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testin
194234 require.Len(t, fs.displayRows, 2)
195235 assert.True(t, fs.displayRows[0].Selectable)
196236 assert.False(t, fs.displayRows[1].Selectable)
237+ assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true))
238+ assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false))
197239 fs.tagCallbacks = []interface{}{func() {}, func() {}}
198240 fs.tagCallbacks = nil
199241 assert.Empty(t, fs.tagCallbacks)
@@ -228,3 +270,172 @@ func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) {
228270
229271 assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)
230272 }
273+
274+func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) {
275+ fs := newFavoritesSidebarHarness(nil, nil)
276+ fs.searchEntry = nil
277+ fs.tagBox = nil
278+ fs.listBox = nil
279+ assert.Empty(t, fs.availableFocusZones())
280+
281+ fs.formBox = nil
282+ fs.mode = favoritesSidebarModeAdd
283+ fs.confirmDelete = true
284+ assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones())
285+}
286+
287+func TestFavoritesSidebarSingleKeyCommandsAreGatedInTextEditContext(t *testing.T) {
288+ fs := newFavoritesSidebarHarness(nil, nil)
289+ fs.searchEntry = nil
290+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}
291+
292+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a)))
293+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
294+
295+ fs.mode = favoritesSidebarModeNone
296+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e)))
297+ assert.Equal(t, favoritesSidebarModeEdit, fs.mode)
298+}
299+
300+func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
301+ uc := &fakeFavoritesSidebarUC{}
302+ fs := newFavoritesSidebarHarness(nil, nil)
303+ fs.favoritesUC = uc
304+ fs.formURL = "https://add.test"
305+ fs.formTitle = "Added"
306+ fs.formTags = "1, 2"
307+ fs.formShortcut = "3"
308+ fs.mode = favoritesSidebarModeAdd
309+
310+ assert.True(t, fs.submitForm())
311+ require.Len(t, uc.addInputs, 1)
312+ assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0])
313+ require.Len(t, uc.shortcuts, 1)
314+ assert.Equal(t, 3, *uc.shortcuts[0].key)
315+
316+ fs.mode = favoritesSidebarModeEdit
317+ fs.editingID = 7
318+ fs.formTitle = "Edited"
319+ fs.formTags = "999"
320+ fs.formShortcut = "4"
321+ assert.True(t, fs.submitForm())
322+ require.Len(t, uc.updateInputs, 1)
323+ assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
324+ assert.Equal(t, "Edited", uc.updateInputs[0].Title)
325+ require.NotNil(t, uc.updateInputs[0].ShortcutKey)
326+ assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey)
327+}
328+
329+func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
330+ uc := &fakeFavoritesSidebarUC{}
331+ fs := newFavoritesSidebarHarness(nil, nil)
332+ fs.favoritesUC = uc
333+ fs.formURL = "https://add.test"
334+ fs.formTitle = "Added"
335+ fs.formTags = "1, nope"
336+ fs.mode = favoritesSidebarModeAdd
337+
338+ assert.True(t, fs.submitForm())
339+ assert.Empty(t, uc.addInputs)
340+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
341+ assert.Contains(t, fs.notice, "invalid tag ID")
342+ assert.Equal(t, "1, nope", fs.formTags)
343+
344+ fs.formTags = "1"
345+ fs.formShortcut = "10"
346+ assert.True(t, fs.submitForm())
347+ assert.Empty(t, uc.addInputs)
348+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
349+ assert.Contains(t, fs.notice, "invalid shortcut")
350+ assert.Equal(t, "10", fs.formShortcut)
351+}
352+
353+func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
354+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
355+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
356+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
357+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
358+ fs.confirmDelete = true
359+
360+ assert.True(t, fs.confirmDeleteActive())
361+ assert.True(t, fs.confirmDeleteFavorite())
362+ assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
363+}
364+
365+func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) {
366+ uc := &fakeFavoritesSidebarUC{}
367+ fs := newFavoritesSidebarHarness(nil, nil)
368+ fs.favoritesUC = uc
369+ fs.formURL = "https://add.test"
370+ fs.formTitle = "Added"
371+ fs.mode = favoritesSidebarModeAdd
372+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}}
373+ fs.onNavigate = func(context.Context, string) error {
374+ t.Fatal("plain Enter in form mode must not activate selected row")
375+ return nil
376+ }
377+
378+ assert.False(t, fs.handleReturnKey(0))
379+ assert.Empty(t, uc.addInputs)
380+ fs.mode = favoritesSidebarModeAdd
381+ assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue))
382+ require.Len(t, uc.addInputs, 1)
383+}
384+
385+func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
386+ uc := &fakeFavoritesSidebarUC{}
387+ fs := newFavoritesSidebarHarness(nil, nil)
388+ fs.favoritesUC = uc
389+ fs.mode = favoritesSidebarModeEdit
390+ fs.editingID = 7
391+ fs.formTitle = "Edited"
392+ fs.formTags = "999"
393+
394+ assert.True(t, fs.submitForm())
395+ require.Len(t, uc.updateInputs, 1)
396+ assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
397+ assert.Empty(t, uc.tagged)
398+ assert.Empty(t, uc.untagged)
399+}
400+
401+func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
402+ dev := entity.Tag{ID: 10, Name: "dev"}
403+ news := entity.Tag{ID: 11, Name: "news"}
404+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
405+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
406+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
407+ fs.allTags = uc.tags
408+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
409+
410+ fs.mode = favoritesSidebarModeTag
411+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1)))
412+ require.Len(t, uc.untagged, 1)
413+ assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID)
414+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2)))
415+ require.Len(t, uc.tagged, 1)
416+ assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID)
417+
418+ fs.mode = favoritesSidebarModeShortcut
419+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9)))
420+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace)))
421+ require.GreaterOrEqual(t, len(uc.shortcuts), 2)
422+ assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)
423+ assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)
424+
425+ fs.mode = favoritesSidebarModeNone
426+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete)))
427+ assert.True(t, fs.confirmDelete)
428+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete)))
429+ assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
430+}
431+
432+func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) {
433+ fs := newFavoritesSidebarHarness(nil, nil)
434+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
435+ uc.err = errors.New("add failed")
436+ fs.mode = favoritesSidebarModeAdd
437+
438+ assert.True(t, fs.submitForm())
439+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
440+ assert.Equal(t, "add failed", fs.notice)
441+}
@@ -17,6 +17,23 @@ import (
17 type fakeFavoritesSidebarUC struct {17 type fakeFavoritesSidebarUC struct {
18 favorites []*entity.Favorite18 favorites []*entity.Favorite
19 tags []*entity.Tag19 tags []*entity.Tag
20+
21+ addInputs []dto.FavoriteCreateInput
22+ updateInputs []dto.FavoriteUpdateInput
23+ deletedIDs []entity.FavoriteID
24+ shortcuts []struct {
25+ id entity.FavoriteID
26+ key *int
27+ }
28+ tagged []struct {
29+ favID entity.FavoriteID
30+ tagID entity.TagID
31+ }
32+ untagged []struct {
33+ favID entity.FavoriteID
34+ tagID entity.TagID
35+ }
36+ err error
20 }37 }
21 38
22 func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {39 func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
@@ -25,21 +42,44 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
25 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {42 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
26 return f.tags, nil43 return f.tags, nil
27 }44 }
28-func (f *fakeFavoritesSidebarUC) AddFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) {45+func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
29- return nil, nil46+ f.addInputs = append(f.addInputs, input)
47+ if f.err != nil {
48+ return nil, f.err
49+ }
50+ return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
51+}
52+func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
53+ f.updateInputs = append(f.updateInputs, input)
54+ if f.err != nil {
55+ return nil, f.err
56+ }
57+ return &entity.Favorite{ID: input.ID, Title: input.Title}, nil
30 }58 }
31-func (f *fakeFavoritesSidebarUC) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) {59+func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error {
32- return nil, nil60+ f.deletedIDs = append(f.deletedIDs, id)
61+ return f.err
33 }62 }
34-func (f *fakeFavoritesSidebarUC) DeleteFavorite(context.Context, entity.FavoriteID) error { return nil }63+func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error {
35-func (f *fakeFavoritesSidebarUC) SetShortcut(context.Context, entity.FavoriteID, *int) error {64+ f.shortcuts = append(f.shortcuts, struct {
36- return nil65+ id entity.FavoriteID
66+ key *int
67+ }{id: id, key: key})
68+ return f.err
37 }69 }
38-func (f *fakeFavoritesSidebarUC) TagFavorite(context.Context, entity.FavoriteID, entity.TagID) error {70+func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
39- return nil71+ f.tagged = append(f.tagged, struct {
72+ favID entity.FavoriteID
73+ tagID entity.TagID
74+ }{favID: favID, tagID: tagID})
75+ return f.err
40 }76 }
41-func (f *fakeFavoritesSidebarUC) UntagFavorite(context.Context, entity.FavoriteID, entity.TagID) error {77+func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
42- return nil78+ f.untagged = append(f.untagged, struct {
79+ favID entity.FavoriteID
80+ tagID entity.TagID
81+ }{favID: favID, tagID: tagID})
82+ return f.err
43 }83 }
44 84
45 func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {85 func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
@@ -194,6 +234,8 @@ func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testin
194 require.Len(t, fs.displayRows, 2)234 require.Len(t, fs.displayRows, 2)
195 assert.True(t, fs.displayRows[0].Selectable)235 assert.True(t, fs.displayRows[0].Selectable)
196 assert.False(t, fs.displayRows[1].Selectable)236 assert.False(t, fs.displayRows[1].Selectable)
237+ assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true))
238+ assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false))
197 fs.tagCallbacks = []interface{}{func() {}, func() {}}239 fs.tagCallbacks = []interface{}{func() {}, func() {}}
198 fs.tagCallbacks = nil240 fs.tagCallbacks = nil
199 assert.Empty(t, fs.tagCallbacks)241 assert.Empty(t, fs.tagCallbacks)
@@ -228,3 +270,172 @@ func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) {
228 270
229 assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)271 assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)
230 }272 }
273+
274+func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) {
275+ fs := newFavoritesSidebarHarness(nil, nil)
276+ fs.searchEntry = nil
277+ fs.tagBox = nil
278+ fs.listBox = nil
279+ assert.Empty(t, fs.availableFocusZones())
280+
281+ fs.formBox = nil
282+ fs.mode = favoritesSidebarModeAdd
283+ fs.confirmDelete = true
284+ assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones())
285+}
286+
287+func TestFavoritesSidebarSingleKeyCommandsAreGatedInTextEditContext(t *testing.T) {
288+ fs := newFavoritesSidebarHarness(nil, nil)
289+ fs.searchEntry = nil
290+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}
291+
292+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a)))
293+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
294+
295+ fs.mode = favoritesSidebarModeNone
296+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e)))
297+ assert.Equal(t, favoritesSidebarModeEdit, fs.mode)
298+}
299+
300+func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
301+ uc := &fakeFavoritesSidebarUC{}
302+ fs := newFavoritesSidebarHarness(nil, nil)
303+ fs.favoritesUC = uc
304+ fs.formURL = "https://add.test"
305+ fs.formTitle = "Added"
306+ fs.formTags = "1, 2"
307+ fs.formShortcut = "3"
308+ fs.mode = favoritesSidebarModeAdd
309+
310+ assert.True(t, fs.submitForm())
311+ require.Len(t, uc.addInputs, 1)
312+ assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0])
313+ require.Len(t, uc.shortcuts, 1)
314+ assert.Equal(t, 3, *uc.shortcuts[0].key)
315+
316+ fs.mode = favoritesSidebarModeEdit
317+ fs.editingID = 7
318+ fs.formTitle = "Edited"
319+ fs.formTags = "999"
320+ fs.formShortcut = "4"
321+ assert.True(t, fs.submitForm())
322+ require.Len(t, uc.updateInputs, 1)
323+ assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
324+ assert.Equal(t, "Edited", uc.updateInputs[0].Title)
325+ require.NotNil(t, uc.updateInputs[0].ShortcutKey)
326+ assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey)
327+}
328+
329+func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
330+ uc := &fakeFavoritesSidebarUC{}
331+ fs := newFavoritesSidebarHarness(nil, nil)
332+ fs.favoritesUC = uc
333+ fs.formURL = "https://add.test"
334+ fs.formTitle = "Added"
335+ fs.formTags = "1, nope"
336+ fs.mode = favoritesSidebarModeAdd
337+
338+ assert.True(t, fs.submitForm())
339+ assert.Empty(t, uc.addInputs)
340+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
341+ assert.Contains(t, fs.notice, "invalid tag ID")
342+ assert.Equal(t, "1, nope", fs.formTags)
343+
344+ fs.formTags = "1"
345+ fs.formShortcut = "10"
346+ assert.True(t, fs.submitForm())
347+ assert.Empty(t, uc.addInputs)
348+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
349+ assert.Contains(t, fs.notice, "invalid shortcut")
350+ assert.Equal(t, "10", fs.formShortcut)
351+}
352+
353+func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
354+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
355+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
356+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
357+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
358+ fs.confirmDelete = true
359+
360+ assert.True(t, fs.confirmDeleteActive())
361+ assert.True(t, fs.confirmDeleteFavorite())
362+ assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
363+}
364+
365+func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) {
366+ uc := &fakeFavoritesSidebarUC{}
367+ fs := newFavoritesSidebarHarness(nil, nil)
368+ fs.favoritesUC = uc
369+ fs.formURL = "https://add.test"
370+ fs.formTitle = "Added"
371+ fs.mode = favoritesSidebarModeAdd
372+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}}
373+ fs.onNavigate = func(context.Context, string) error {
374+ t.Fatal("plain Enter in form mode must not activate selected row")
375+ return nil
376+ }
377+
378+ assert.False(t, fs.handleReturnKey(0))
379+ assert.Empty(t, uc.addInputs)
380+ fs.mode = favoritesSidebarModeAdd
381+ assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue))
382+ require.Len(t, uc.addInputs, 1)
383+}
384+
385+func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
386+ uc := &fakeFavoritesSidebarUC{}
387+ fs := newFavoritesSidebarHarness(nil, nil)
388+ fs.favoritesUC = uc
389+ fs.mode = favoritesSidebarModeEdit
390+ fs.editingID = 7
391+ fs.formTitle = "Edited"
392+ fs.formTags = "999"
393+
394+ assert.True(t, fs.submitForm())
395+ require.Len(t, uc.updateInputs, 1)
396+ assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
397+ assert.Empty(t, uc.tagged)
398+ assert.Empty(t, uc.untagged)
399+}
400+
401+func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
402+ dev := entity.Tag{ID: 10, Name: "dev"}
403+ news := entity.Tag{ID: 11, Name: "news"}
404+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
405+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
406+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
407+ fs.allTags = uc.tags
408+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
409+
410+ fs.mode = favoritesSidebarModeTag
411+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1)))
412+ require.Len(t, uc.untagged, 1)
413+ assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID)
414+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2)))
415+ require.Len(t, uc.tagged, 1)
416+ assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID)
417+
418+ fs.mode = favoritesSidebarModeShortcut
419+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9)))
420+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace)))
421+ require.GreaterOrEqual(t, len(uc.shortcuts), 2)
422+ assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)
423+ assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)
424+
425+ fs.mode = favoritesSidebarModeNone
426+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete)))
427+ assert.True(t, fs.confirmDelete)
428+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete)))
429+ assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
430+}
431+
432+func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) {
433+ fs := newFavoritesSidebarHarness(nil, nil)
434+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
435+ uc.err = errors.New("add failed")
436+ fs.mode = favoritesSidebarModeAdd
437+
438+ assert.True(t, fs.submitForm())
439+ assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
440+ assert.Equal(t, "add failed", fs.notice)
441+}

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