detached 6c7260d

fix(favorites): harden tag interaction lifecycle

commit
6c7260d63057efc2652b59a4ee4277170c3b5e72
author
brice · · 2026-08-12 08:07 UTC
parent
0f99bd1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +91 -20
M internal/ui/component/favorites_sidebar.go +5 -4
@@ -72,6 +72,7 @@ type FavoritesSidebar struct {
7272 retainedCallbacks []any
7373 tagCallbacks []any
7474 tagPromptCallbacks []any
75+ formCallbacks []any
7576 formTagMatchCallbacks []any
7677 ctx context.Context
7778 cancel context.CancelFunc
@@ -232,7 +233,7 @@ func (fs *FavoritesSidebar) createOuterAndSearch() error {
232233 }
233234 fs.searchEntry.AddCssClass("sidebar-search")
234235 fs.searchEntry.AddCssClass("favorites-sidebar-search")
235- fs.trackTextInputFocus(&fs.searchEntry.Widget)
236+ fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks)
236237 fs.searchEntry.SetHexpand(true)
237238 placeholder := "Search favorites..."
238239 fs.searchEntry.SetPlaceholderText(&placeholder)
@@ -259,8 +260,8 @@ func (fs *FavoritesSidebar) createTagControls() error {
259260 return nil
260261 }
261262
262-func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget) {
263- if fs == nil || widget == nil {
263+func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) {
264+ if fs == nil || widget == nil || callbacks == nil {
264265 return
265266 }
266267 controller := gtk.NewEventControllerFocus()
@@ -269,7 +270,7 @@ func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget) {
269270 }
270271 entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }
271272 left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }
272- fs.retainedCallbacks = append(fs.retainedCallbacks, entered, left)
273+ *callbacks = append(*callbacks, entered, left)
273274 controller.ConnectEnter(&entered)
274275 controller.ConnectLeave(&left)
275276 widget.AddController(&controller.EventController)
@@ -72,6 +72,7 @@ type FavoritesSidebar struct {
72 retainedCallbacks []any72 retainedCallbacks []any
73 tagCallbacks []any73 tagCallbacks []any
74 tagPromptCallbacks []any74 tagPromptCallbacks []any
75+ formCallbacks []any
75 formTagMatchCallbacks []any76 formTagMatchCallbacks []any
76 ctx context.Context77 ctx context.Context
77 cancel context.CancelFunc78 cancel context.CancelFunc
@@ -232,7 +233,7 @@ func (fs *FavoritesSidebar) createOuterAndSearch() error {
232 }233 }
233 fs.searchEntry.AddCssClass("sidebar-search")234 fs.searchEntry.AddCssClass("sidebar-search")
234 fs.searchEntry.AddCssClass("favorites-sidebar-search")235 fs.searchEntry.AddCssClass("favorites-sidebar-search")
235- fs.trackTextInputFocus(&fs.searchEntry.Widget)236+ fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks)
236 fs.searchEntry.SetHexpand(true)237 fs.searchEntry.SetHexpand(true)
237 placeholder := "Search favorites..."238 placeholder := "Search favorites..."
238 fs.searchEntry.SetPlaceholderText(&placeholder)239 fs.searchEntry.SetPlaceholderText(&placeholder)
@@ -259,8 +260,8 @@ func (fs *FavoritesSidebar) createTagControls() error {
259 return nil260 return nil
260 }261 }
261 262
262-func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget) {263+func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) {
263- if fs == nil || widget == nil {264+ if fs == nil || widget == nil || callbacks == nil {
264 return265 return
265 }266 }
266 controller := gtk.NewEventControllerFocus()267 controller := gtk.NewEventControllerFocus()
@@ -269,7 +270,7 @@ func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget) {
269 }270 }
270 entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }271 entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }
271 left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }272 left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }
272- fs.retainedCallbacks = append(fs.retainedCallbacks, entered, left)273+ *callbacks = append(*callbacks, entered, left)
273 controller.ConnectEnter(&entered)274 controller.ConnectEnter(&entered)
274 controller.ConnectLeave(&left)275 controller.ConnectLeave(&left)
275 widget.AddController(&controller.EventController)276 widget.AddController(&controller.EventController)
M internal/ui/component/favorites_sidebar_form_tags.go +10 -1
@@ -28,7 +28,7 @@ func (fs *FavoritesSidebar) setupFormTagSearch() {
2828 return
2929 }
3030 changed := func(_ gtk.SearchEntry) { fs.renderFormTagMatches(fs.formTagSearch.GetText()) }
31- fs.retainedCallbacks = append(fs.retainedCallbacks, changed)
31+ fs.formCallbacks = append(fs.formCallbacks, changed)
3232 fs.formTagSearch.ConnectSearchChanged(&changed)
3333 }
3434
@@ -149,23 +149,32 @@ func (fs *FavoritesSidebar) updateFavoriteTags(favoriteID entity.FavoriteID, wan
149149 wantedSet[id] = struct{}{}
150150 }
151151 currentSet := tagIDSet(current)
152+ mutated := false
152153 for id := range currentSet {
153154 if _, ok := wantedSet[id]; ok {
154155 continue
155156 }
156157 if err := uc.UntagFavorite(ctx, favoriteID, id); err != nil {
158+ if mutated {
159+ fs.startLoad()
160+ }
157161 fs.setNotice(err.Error())
158162 return false
159163 }
164+ mutated = true
160165 }
161166 for id := range wantedSet {
162167 if _, ok := currentSet[id]; ok {
163168 continue
164169 }
165170 if err := uc.TagFavorite(ctx, favoriteID, id); err != nil {
171+ if mutated {
172+ fs.startLoad()
173+ }
166174 fs.setNotice(err.Error())
167175 return false
168176 }
177+ mutated = true
169178 }
170179 return true
171180 }
@@ -28,7 +28,7 @@ func (fs *FavoritesSidebar) setupFormTagSearch() {
28 return28 return
29 }29 }
30 changed := func(_ gtk.SearchEntry) { fs.renderFormTagMatches(fs.formTagSearch.GetText()) }30 changed := func(_ gtk.SearchEntry) { fs.renderFormTagMatches(fs.formTagSearch.GetText()) }
31- fs.retainedCallbacks = append(fs.retainedCallbacks, changed)31+ fs.formCallbacks = append(fs.formCallbacks, changed)
32 fs.formTagSearch.ConnectSearchChanged(&changed)32 fs.formTagSearch.ConnectSearchChanged(&changed)
33 }33 }
34 34
@@ -149,23 +149,32 @@ func (fs *FavoritesSidebar) updateFavoriteTags(favoriteID entity.FavoriteID, wan
149 wantedSet[id] = struct{}{}149 wantedSet[id] = struct{}{}
150 }150 }
151 currentSet := tagIDSet(current)151 currentSet := tagIDSet(current)
152+ mutated := false
152 for id := range currentSet {153 for id := range currentSet {
153 if _, ok := wantedSet[id]; ok {154 if _, ok := wantedSet[id]; ok {
154 continue155 continue
155 }156 }
156 if err := uc.UntagFavorite(ctx, favoriteID, id); err != nil {157 if err := uc.UntagFavorite(ctx, favoriteID, id); err != nil {
158+ if mutated {
159+ fs.startLoad()
160+ }
157 fs.setNotice(err.Error())161 fs.setNotice(err.Error())
158 return false162 return false
159 }163 }
164+ mutated = true
160 }165 }
161 for id := range wantedSet {166 for id := range wantedSet {
162 if _, ok := currentSet[id]; ok {167 if _, ok := currentSet[id]; ok {
163 continue168 continue
164 }169 }
165 if err := uc.TagFavorite(ctx, favoriteID, id); err != nil {170 if err := uc.TagFavorite(ctx, favoriteID, id); err != nil {
171+ if mutated {
172+ fs.startLoad()
173+ }
166 fs.setNotice(err.Error())174 fs.setNotice(err.Error())
167 return false175 return false
168 }176 }
177+ mutated = true
169 }178 }
170 return true179 return true
171 }180 }
M internal/ui/component/favorites_sidebar_forms.go +9 -6
@@ -75,12 +75,16 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
7575 return
7676 }
7777 clearBoxChildren(fs.formBox)
78+ fs.formCallbacks = nil
79+ fs.formTagMatchCallbacks = nil
7880 fs.formURLEntry = gtk.NewSearchEntry()
7981 fs.formTitleEntry = gtk.NewSearchEntry()
8082 fs.formTagSearch = gtk.NewSearchEntry()
8183 fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3)
8284 fs.formShortcutEntry = gtk.NewSearchEntry()
85+ fs.mu.Lock()
8386 fs.formTagIDs = tagIDSet(favoriteTags(fav))
87+ fs.mu.Unlock()
8488 entries := []struct {
8589 entry *gtk.SearchEntry
8690 label string
@@ -96,7 +100,7 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
96100 }
97101 placeholder := item.label
98102 item.entry.SetPlaceholderText(&placeholder)
99- fs.trackTextInputFocus(&item.entry.Widget)
103+ fs.trackTextInputFocus(&item.entry.Widget, &fs.formCallbacks)
100104 fs.formBox.Append(&item.entry.Widget)
101105 }
102106 if fs.formTagMatches != nil {
@@ -109,7 +113,7 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
109113 cb := func(_ gtk.Button) {
110114 fs.submitForm()
111115 }
112- fs.retainedCallbacks = append(fs.retainedCallbacks, cb)
116+ fs.formCallbacks = append(fs.formCallbacks, cb)
113117 fs.formSaveButton.ConnectClicked(&cb)
114118 fs.formBox.Append(&fs.formSaveButton.Widget)
115119 }
@@ -179,12 +183,11 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
179183 if fs.formBox != nil {
180184 fs.formBox.SetVisible(false)
181185 clearBoxChildren(fs.formBox)
186+ fs.formCallbacks = nil
187+ fs.formTagMatchCallbacks = nil
182188 }
183189 if fs.tagPromptBox != nil {
184- fs.clearTagPromptContent()
185- fs.tagPromptBox.SetVisible(false)
186- fs.tagNameEntry = nil
187- fs.tagPromptSaveBtn = nil
190+ fs.hideTagPrompt()
188191 }
189192 fs.rebuildList()
190193 return active
@@ -75,12 +75,16 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
75 return75 return
76 }76 }
77 clearBoxChildren(fs.formBox)77 clearBoxChildren(fs.formBox)
78+ fs.formCallbacks = nil
79+ fs.formTagMatchCallbacks = nil
78 fs.formURLEntry = gtk.NewSearchEntry()80 fs.formURLEntry = gtk.NewSearchEntry()
79 fs.formTitleEntry = gtk.NewSearchEntry()81 fs.formTitleEntry = gtk.NewSearchEntry()
80 fs.formTagSearch = gtk.NewSearchEntry()82 fs.formTagSearch = gtk.NewSearchEntry()
81 fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3)83 fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3)
82 fs.formShortcutEntry = gtk.NewSearchEntry()84 fs.formShortcutEntry = gtk.NewSearchEntry()
85+ fs.mu.Lock()
83 fs.formTagIDs = tagIDSet(favoriteTags(fav))86 fs.formTagIDs = tagIDSet(favoriteTags(fav))
87+ fs.mu.Unlock()
84 entries := []struct {88 entries := []struct {
85 entry *gtk.SearchEntry89 entry *gtk.SearchEntry
86 label string90 label string
@@ -96,7 +100,7 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
96 }100 }
97 placeholder := item.label101 placeholder := item.label
98 item.entry.SetPlaceholderText(&placeholder)102 item.entry.SetPlaceholderText(&placeholder)
99- fs.trackTextInputFocus(&item.entry.Widget)103+ fs.trackTextInputFocus(&item.entry.Widget, &fs.formCallbacks)
100 fs.formBox.Append(&item.entry.Widget)104 fs.formBox.Append(&item.entry.Widget)
101 }105 }
102 if fs.formTagMatches != nil {106 if fs.formTagMatches != nil {
@@ -109,7 +113,7 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
109 cb := func(_ gtk.Button) {113 cb := func(_ gtk.Button) {
110 fs.submitForm()114 fs.submitForm()
111 }115 }
112- fs.retainedCallbacks = append(fs.retainedCallbacks, cb)116+ fs.formCallbacks = append(fs.formCallbacks, cb)
113 fs.formSaveButton.ConnectClicked(&cb)117 fs.formSaveButton.ConnectClicked(&cb)
114 fs.formBox.Append(&fs.formSaveButton.Widget)118 fs.formBox.Append(&fs.formSaveButton.Widget)
115 }119 }
@@ -179,12 +183,11 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
179 if fs.formBox != nil {183 if fs.formBox != nil {
180 fs.formBox.SetVisible(false)184 fs.formBox.SetVisible(false)
181 clearBoxChildren(fs.formBox)185 clearBoxChildren(fs.formBox)
186+ fs.formCallbacks = nil
187+ fs.formTagMatchCallbacks = nil
182 }188 }
183 if fs.tagPromptBox != nil {189 if fs.tagPromptBox != nil {
184- fs.clearTagPromptContent()190+ fs.hideTagPrompt()
185- fs.tagPromptBox.SetVisible(false)
186- fs.tagNameEntry = nil
187- fs.tagPromptSaveBtn = nil
188 }191 }
189 fs.rebuildList()192 fs.rebuildList()
190 return active193 return active
M internal/ui/component/favorites_sidebar_keyboard.go +3 -2
@@ -307,9 +307,10 @@ func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused
307307
308308 // shouldDeferToTextInput keeps the sidebar controller from treating text typed
309309 // into a descendant entry as a global sidebar command.
310-// Tab remains the one sidebar command that deliberately traverses focus.
310+// Tab traverses focus and Escape cancels sidebar management while text is focused.
311311 func shouldDeferToTextInput(textEditing bool, keyval uint) bool {
312- return textEditing && keyval != uint(gdk.KEY_Tab) && keyval != uint(gdk.KEY_ISO_Left_Tab)
312+ return textEditing && keyval != uint(gdk.KEY_Tab) &&
313+ keyval != uint(gdk.KEY_ISO_Left_Tab) && keyval != uint(gdk.KEY_Escape)
313314 }
314315
315316 func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool {
@@ -307,9 +307,10 @@ func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused
307 307
308 // shouldDeferToTextInput keeps the sidebar controller from treating text typed308 // shouldDeferToTextInput keeps the sidebar controller from treating text typed
309 // into a descendant entry as a global sidebar command.309 // into a descendant entry as a global sidebar command.
310-// Tab remains the one sidebar command that deliberately traverses focus.310+// Tab traverses focus and Escape cancels sidebar management while text is focused.
311 func shouldDeferToTextInput(textEditing bool, keyval uint) bool {311 func shouldDeferToTextInput(textEditing bool, keyval uint) bool {
312- return textEditing && keyval != uint(gdk.KEY_Tab) && keyval != uint(gdk.KEY_ISO_Left_Tab)312+ return textEditing && keyval != uint(gdk.KEY_Tab) &&
313+ keyval != uint(gdk.KEY_ISO_Left_Tab) && keyval != uint(gdk.KEY_Escape)
313 }314 }
314 315
315 func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool {316 func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool {
M internal/ui/component/favorites_sidebar_tags.go +18 -6
@@ -12,7 +12,7 @@ func (fs *FavoritesSidebar) showCreateTagPrompt() {
1212 if fs == nil || fs.tagPromptBox == nil {
1313 return
1414 }
15- fs.clearTagPromptContent()
15+ fs.hideTagPrompt()
1616 entry := gtk.NewEntry()
1717 if entry == nil {
1818 return
@@ -20,15 +20,17 @@ func (fs *FavoritesSidebar) showCreateTagPrompt() {
2020 placeholder := "New tag name"
2121 entry.SetPlaceholderText(&placeholder)
2222 entry.AddCssClass("favorites-sidebar-tag-name")
23- fs.trackTextInputFocus(&entry.Widget)
23+ callbacks := make([]any, 0, 4)
24+ fs.trackTextInputFocus(&entry.Widget, &callbacks)
2425 entry.SetHexpand(true)
25- callbacks := make([]any, 0, 2)
2626 activateCb := func(gtk.Entry) { fs.submitTagName(entry.GetText()) }
2727 callbacks = append(callbacks, activateCb)
2828 entry.ConnectActivate(&activateCb)
29+ fs.storeTagPromptCallbacks(callbacks)
2930
3031 save := gtk.NewButtonWithLabel("Create")
3132 if save == nil {
33+ fs.hideTagPrompt()
3234 return
3335 }
3436 save.AddCssClass("favorites-sidebar-tag-create")
@@ -201,22 +203,32 @@ func favoriteHasTag(fav *entity.Favorite, tagID entity.TagID) bool {
201203 }
202204
203205 func (fs *FavoritesSidebar) focusTagControl(index int) {
204- if fs == nil || index < 0 || index >= len(fs.tagControls) || fs.tagControls[index] == nil {
206+ if fs == nil || index < 0 {
205207 return
206208 }
209+ fs.mu.RLock()
210+ controls := append([]*gtk.Button(nil), fs.tagControls...)
211+ fs.mu.RUnlock()
212+ if index >= len(controls) || controls[index] == nil {
213+ return
214+ }
215+ control := controls[index]
207216 fs.mu.Lock()
208217 if !fs.destroyed {
209218 fs.focusZone = favoritesSidebarFocusTags
210219 }
211220 fs.mu.Unlock()
212- fs.tagControls[index].GrabFocus()
221+ control.GrabFocus()
213222 }
214223
215224 func (fs *FavoritesSidebar) focusedTagControlIndex() int {
216225 if fs == nil {
217226 return -1
218227 }
219- for index, control := range fs.tagControls {
228+ fs.mu.RLock()
229+ controls := append([]*gtk.Button(nil), fs.tagControls...)
230+ fs.mu.RUnlock()
231+ for index, control := range controls {
220232 if control != nil && control.HasFocus() {
221233 return index
222234 }
@@ -12,7 +12,7 @@ func (fs *FavoritesSidebar) showCreateTagPrompt() {
12 if fs == nil || fs.tagPromptBox == nil {12 if fs == nil || fs.tagPromptBox == nil {
13 return13 return
14 }14 }
15- fs.clearTagPromptContent()15+ fs.hideTagPrompt()
16 entry := gtk.NewEntry()16 entry := gtk.NewEntry()
17 if entry == nil {17 if entry == nil {
18 return18 return
@@ -20,15 +20,17 @@ func (fs *FavoritesSidebar) showCreateTagPrompt() {
20 placeholder := "New tag name"20 placeholder := "New tag name"
21 entry.SetPlaceholderText(&placeholder)21 entry.SetPlaceholderText(&placeholder)
22 entry.AddCssClass("favorites-sidebar-tag-name")22 entry.AddCssClass("favorites-sidebar-tag-name")
23- fs.trackTextInputFocus(&entry.Widget)23+ callbacks := make([]any, 0, 4)
24+ fs.trackTextInputFocus(&entry.Widget, &callbacks)
24 entry.SetHexpand(true)25 entry.SetHexpand(true)
25- callbacks := make([]any, 0, 2)
26 activateCb := func(gtk.Entry) { fs.submitTagName(entry.GetText()) }26 activateCb := func(gtk.Entry) { fs.submitTagName(entry.GetText()) }
27 callbacks = append(callbacks, activateCb)27 callbacks = append(callbacks, activateCb)
28 entry.ConnectActivate(&activateCb)28 entry.ConnectActivate(&activateCb)
29+ fs.storeTagPromptCallbacks(callbacks)
29 30
30 save := gtk.NewButtonWithLabel("Create")31 save := gtk.NewButtonWithLabel("Create")
31 if save == nil {32 if save == nil {
33+ fs.hideTagPrompt()
32 return34 return
33 }35 }
34 save.AddCssClass("favorites-sidebar-tag-create")36 save.AddCssClass("favorites-sidebar-tag-create")
@@ -201,22 +203,32 @@ func favoriteHasTag(fav *entity.Favorite, tagID entity.TagID) bool {
201 }203 }
202 204
203 func (fs *FavoritesSidebar) focusTagControl(index int) {205 func (fs *FavoritesSidebar) focusTagControl(index int) {
204- if fs == nil || index < 0 || index >= len(fs.tagControls) || fs.tagControls[index] == nil {206+ if fs == nil || index < 0 {
205 return207 return
206 }208 }
209+ fs.mu.RLock()
210+ controls := append([]*gtk.Button(nil), fs.tagControls...)
211+ fs.mu.RUnlock()
212+ if index >= len(controls) || controls[index] == nil {
213+ return
214+ }
215+ control := controls[index]
207 fs.mu.Lock()216 fs.mu.Lock()
208 if !fs.destroyed {217 if !fs.destroyed {
209 fs.focusZone = favoritesSidebarFocusTags218 fs.focusZone = favoritesSidebarFocusTags
210 }219 }
211 fs.mu.Unlock()220 fs.mu.Unlock()
212- fs.tagControls[index].GrabFocus()221+ control.GrabFocus()
213 }222 }
214 223
215 func (fs *FavoritesSidebar) focusedTagControlIndex() int {224 func (fs *FavoritesSidebar) focusedTagControlIndex() int {
216 if fs == nil {225 if fs == nil {
217 return -1226 return -1
218 }227 }
219- for index, control := range fs.tagControls {228+ fs.mu.RLock()
229+ controls := append([]*gtk.Button(nil), fs.tagControls...)
230+ fs.mu.RUnlock()
231+ for index, control := range controls {
220 if control != nil && control.HasFocus() {232 if control != nil && control.HasFocus() {
221 return index233 return index
222 }234 }
M internal/ui/component/favorites_sidebar_test.go +46 -1
@@ -41,6 +41,9 @@ type fakeFavoritesSidebarUC struct {
4141 }
4242 err error
4343 errOnSetShortcut error
44+ errOnTag map[entity.TagID]error
45+ errOnUntag map[entity.TagID]error
46+ failUntagOnCall int
4447 getAllCalled chan struct{}
4548 }
4649
@@ -99,6 +102,9 @@ func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.Fav
99102 favID entity.FavoriteID
100103 tagID entity.TagID
101104 }{favID: favID, tagID: tagID})
105+ if err := f.errOnTag[tagID]; err != nil {
106+ return err
107+ }
102108 return f.err
103109 }
104110 func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
@@ -106,6 +112,12 @@ func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.F
106112 favID entity.FavoriteID
107113 tagID entity.TagID
108114 }{favID: favID, tagID: tagID})
115+ if err := f.errOnUntag[tagID]; err != nil {
116+ return err
117+ }
118+ if f.failUntagOnCall > 0 && len(f.untagged) == f.failUntagOnCall {
119+ return errors.New("untag failed")
120+ }
109121 return f.err
110122 }
111123
@@ -412,6 +424,22 @@ func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T)
412424 assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read"))
413425 }
414426
427+func TestFavoritesSidebarReloadsAfterPartiallyApplyingTagChanges(t *testing.T) {
428+ first := entity.Tag{ID: 10, Name: "first"}
429+ second := entity.Tag{ID: 11, Name: "second"}
430+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{first, second}}
431+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
432+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
433+ uc.failUntagOnCall = 2
434+ fs.allFavorites = []*entity.Favorite{fav}
435+ fs.loadGen = 4
436+
437+ assert.False(t, fs.updateFavoriteTags(fav.ID, nil))
438+ assert.Len(t, uc.untagged, 2)
439+ assert.Equal(t, uint64(5), fs.loadGen, "a partial tag mutation must refresh stale favorites")
440+ assert.Equal(t, "untag failed", fs.notice)
441+}
442+
415443 func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
416444 dev := entity.Tag{ID: 10, Name: "dev"}
417445 news := entity.Tag{ID: 11, Name: "news"}
@@ -426,21 +454,34 @@ func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
426454 assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
427455 require.Len(t, uc.tagged, 1)
428456 assert.Equal(t, news.ID, uc.tagged[0].tagID)
457+ fav.Tags = []entity.Tag{dev, news}
429458
430459 fs.toggleFormTag(dev.ID)
431460 assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
432461 require.Len(t, uc.untagged, 1)
433462 assert.Equal(t, dev.ID, uc.untagged[0].tagID)
463+ assert.Len(t, uc.tagged, 1, "the synchronized persisted tags must not be added again")
434464 }
435465
436466 func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) {
437467 fav := &entity.Favorite{ID: 1, URL: "https://go.dev"}
438468 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
469+ if !gtk.InitCheck() {
470+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
471+ }
472+ listBox := gtk.NewListBox()
473+ require.NotNil(t, listBox)
474+ row := gtk.NewListBoxRow()
475+ require.NotNil(t, row)
476+ row.SetSelectable(true)
477+ listBox.Append(&row.Widget)
478+ fs.listBox = listBox
439479 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
440480 fs.focusZone = favoritesSidebarFocusSearch
441481
442482 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
443483 assert.Equal(t, favoritesSidebarFocusList, fs.focusZone)
484+ assert.Equal(t, row, fs.listBox.GetSelectedRow())
444485 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
445486 assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone)
446487 }
@@ -549,6 +590,10 @@ func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testin
549590 fs.setTextInputFocused(input)
550591 assert.True(t, fs.inTextEditContext())
551592 assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
593+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
594+ fs.mode = favoritesSidebarModeAdd
595+ assert.True(t, fs.cancelManagement())
596+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
552597
553598 fs.clearTextInputFocus(input)
554599 assert.False(t, fs.inTextEditContext())
@@ -565,13 +610,13 @@ func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) {
565610 uint(gdk.KEY_t),
566611 uint(gdk.KEY_plus),
567612 uint(gdk.KEY_Up),
568- uint(gdk.KEY_Escape),
569613 uint(gdk.KEY_Return),
570614 } {
571615 assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key)
572616 }
573617 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab)))
574618 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab)))
619+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Escape)))
575620 assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e)))
576621 }
577622
@@ -41,6 +41,9 @@ type fakeFavoritesSidebarUC struct {
41 }41 }
42 err error42 err error
43 errOnSetShortcut error43 errOnSetShortcut error
44+ errOnTag map[entity.TagID]error
45+ errOnUntag map[entity.TagID]error
46+ failUntagOnCall int
44 getAllCalled chan struct{}47 getAllCalled chan struct{}
45 }48 }
46 49
@@ -99,6 +102,9 @@ func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.Fav
99 favID entity.FavoriteID102 favID entity.FavoriteID
100 tagID entity.TagID103 tagID entity.TagID
101 }{favID: favID, tagID: tagID})104 }{favID: favID, tagID: tagID})
105+ if err := f.errOnTag[tagID]; err != nil {
106+ return err
107+ }
102 return f.err108 return f.err
103 }109 }
104 func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {110 func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
@@ -106,6 +112,12 @@ func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.F
106 favID entity.FavoriteID112 favID entity.FavoriteID
107 tagID entity.TagID113 tagID entity.TagID
108 }{favID: favID, tagID: tagID})114 }{favID: favID, tagID: tagID})
115+ if err := f.errOnUntag[tagID]; err != nil {
116+ return err
117+ }
118+ if f.failUntagOnCall > 0 && len(f.untagged) == f.failUntagOnCall {
119+ return errors.New("untag failed")
120+ }
109 return f.err121 return f.err
110 }122 }
111 123
@@ -412,6 +424,22 @@ func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T)
412 assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read"))424 assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read"))
413 }425 }
414 426
427+func TestFavoritesSidebarReloadsAfterPartiallyApplyingTagChanges(t *testing.T) {
428+ first := entity.Tag{ID: 10, Name: "first"}
429+ second := entity.Tag{ID: 11, Name: "second"}
430+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{first, second}}
431+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
432+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
433+ uc.failUntagOnCall = 2
434+ fs.allFavorites = []*entity.Favorite{fav}
435+ fs.loadGen = 4
436+
437+ assert.False(t, fs.updateFavoriteTags(fav.ID, nil))
438+ assert.Len(t, uc.untagged, 2)
439+ assert.Equal(t, uint64(5), fs.loadGen, "a partial tag mutation must refresh stale favorites")
440+ assert.Equal(t, "untag failed", fs.notice)
441+}
442+
415 func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {443 func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
416 dev := entity.Tag{ID: 10, Name: "dev"}444 dev := entity.Tag{ID: 10, Name: "dev"}
417 news := entity.Tag{ID: 11, Name: "news"}445 news := entity.Tag{ID: 11, Name: "news"}
@@ -426,21 +454,34 @@ func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
426 assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))454 assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
427 require.Len(t, uc.tagged, 1)455 require.Len(t, uc.tagged, 1)
428 assert.Equal(t, news.ID, uc.tagged[0].tagID)456 assert.Equal(t, news.ID, uc.tagged[0].tagID)
457+ fav.Tags = []entity.Tag{dev, news}
429 458
430 fs.toggleFormTag(dev.ID)459 fs.toggleFormTag(dev.ID)
431 assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))460 assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
432 require.Len(t, uc.untagged, 1)461 require.Len(t, uc.untagged, 1)
433 assert.Equal(t, dev.ID, uc.untagged[0].tagID)462 assert.Equal(t, dev.ID, uc.untagged[0].tagID)
463+ assert.Len(t, uc.tagged, 1, "the synchronized persisted tags must not be added again")
434 }464 }
435 465
436 func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) {466 func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) {
437 fav := &entity.Favorite{ID: 1, URL: "https://go.dev"}467 fav := &entity.Favorite{ID: 1, URL: "https://go.dev"}
438 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)468 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
469+ if !gtk.InitCheck() {
470+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
471+ }
472+ listBox := gtk.NewListBox()
473+ require.NotNil(t, listBox)
474+ row := gtk.NewListBoxRow()
475+ require.NotNil(t, row)
476+ row.SetSelectable(true)
477+ listBox.Append(&row.Widget)
478+ fs.listBox = listBox
439 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}479 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
440 fs.focusZone = favoritesSidebarFocusSearch480 fs.focusZone = favoritesSidebarFocusSearch
441 481
442 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))482 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
443 assert.Equal(t, favoritesSidebarFocusList, fs.focusZone)483 assert.Equal(t, favoritesSidebarFocusList, fs.focusZone)
484+ assert.Equal(t, row, fs.listBox.GetSelectedRow())
444 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))485 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
445 assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone)486 assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone)
446 }487 }
@@ -549,6 +590,10 @@ func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testin
549 fs.setTextInputFocused(input)590 fs.setTextInputFocused(input)
550 assert.True(t, fs.inTextEditContext())591 assert.True(t, fs.inTextEditContext())
551 assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))592 assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
593+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
594+ fs.mode = favoritesSidebarModeAdd
595+ assert.True(t, fs.cancelManagement())
596+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
552 597
553 fs.clearTextInputFocus(input)598 fs.clearTextInputFocus(input)
554 assert.False(t, fs.inTextEditContext())599 assert.False(t, fs.inTextEditContext())
@@ -565,13 +610,13 @@ func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) {
565 uint(gdk.KEY_t),610 uint(gdk.KEY_t),
566 uint(gdk.KEY_plus),611 uint(gdk.KEY_plus),
567 uint(gdk.KEY_Up),612 uint(gdk.KEY_Up),
568- uint(gdk.KEY_Escape),
569 uint(gdk.KEY_Return),613 uint(gdk.KEY_Return),
570 } {614 } {
571 assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key)615 assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key)
572 }616 }
573 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab)))617 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab)))
574 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab)))618 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab)))
619+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Escape)))
575 assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e)))620 assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e)))
576 }621 }
577 622

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