detached 91c19f7

fix(favorites): address sidebar review findings

commit
91c19f7fcac581fe5fa80d5c4f7662efd258e5fe
author
brice · · 2026-06-28 16:39 UTC
parent
a2631c0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +125 -25
M internal/ui/component/favorites_sidebar.go +12 -0
@@ -260,8 +260,20 @@ func (fs *FavoritesSidebar) startLoad() {
260260 gen := fs.loadGen
261261 uc := fs.favoritesUC
262262 ctx := fs.ctx
263+ visible := fs.visible
263264 fs.notice = "Loading favorites..."
264265 fs.mu.Unlock()
266+ if visible {
267+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
268+ fs.mu.RLock()
269+ destroyed := fs.destroyed
270+ fs.mu.RUnlock()
271+ if !destroyed {
272+ fs.rebuildList()
273+ }
274+ return false
275+ }))
276+ }
265277
266278 if uc == nil || ctx == nil {
267279 fs.applyLoadedData(nil, nil, gen, nil)
@@ -260,8 +260,20 @@ func (fs *FavoritesSidebar) startLoad() {
260 gen := fs.loadGen260 gen := fs.loadGen
261 uc := fs.favoritesUC261 uc := fs.favoritesUC
262 ctx := fs.ctx262 ctx := fs.ctx
263+ visible := fs.visible
263 fs.notice = "Loading favorites..."264 fs.notice = "Loading favorites..."
264 fs.mu.Unlock()265 fs.mu.Unlock()
266+ if visible {
267+ fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
268+ fs.mu.RLock()
269+ destroyed := fs.destroyed
270+ fs.mu.RUnlock()
271+ if !destroyed {
272+ fs.rebuildList()
273+ }
274+ return false
275+ }))
276+ }
265 277
266 if uc == nil || ctx == nil {278 if uc == nil || ctx == nil {
267 fs.applyLoadedData(nil, nil, gen, nil)279 fs.applyLoadedData(nil, nil, gen, nil)
M internal/ui/component/favorites_sidebar_forms.go +22 -2
@@ -135,7 +135,21 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
135135
136136 func (fs *FavoritesSidebar) focusForm() {
137137 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
138- if fs != nil && fs.formURLEntry != nil {
138+ if fs == nil {
139+ return false
140+ }
141+ fs.mu.RLock()
142+ mode := fs.mode
143+ destroyed := fs.destroyed
144+ fs.mu.RUnlock()
145+ if destroyed {
146+ return false
147+ }
148+ if mode == favoritesSidebarModeEdit && fs.formTitleEntry != nil {
149+ fs.formTitleEntry.GrabFocus()
150+ return false
151+ }
152+ if fs.formURLEntry != nil {
139153 fs.formURLEntry.GrabFocus()
140154 }
141155 return false
@@ -197,7 +211,9 @@ func (fs *FavoritesSidebar) submitForm() bool {
197211 }
198212 if key != nil && fav != nil {
199213 if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
200- fs.setNotice(err.Error())
214+ fs.cancelManagement()
215+ fs.startLoad()
216+ fs.setNotice("Added favorite, but failed to set shortcut: " + err.Error())
201217 return true
202218 }
203219 }
@@ -333,9 +349,13 @@ func (fs *FavoritesSidebar) confirmDeleteFavorite() bool {
333349 return fs.requestDeleteConfirmation()
334350 }
335351 fs.mu.RLock()
352+ confirmed := fs.confirmDelete
336353 uc := fs.favoritesUC
337354 ctx := fs.ctx
338355 fs.mu.RUnlock()
356+ if !confirmed {
357+ return fs.requestDeleteConfirmation()
358+ }
339359 if uc == nil {
340360 return true
341361 }
@@ -135,7 +135,21 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
135 135
136 func (fs *FavoritesSidebar) focusForm() {136 func (fs *FavoritesSidebar) focusForm() {
137 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {137 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
138- if fs != nil && fs.formURLEntry != nil {138+ if fs == nil {
139+ return false
140+ }
141+ fs.mu.RLock()
142+ mode := fs.mode
143+ destroyed := fs.destroyed
144+ fs.mu.RUnlock()
145+ if destroyed {
146+ return false
147+ }
148+ if mode == favoritesSidebarModeEdit && fs.formTitleEntry != nil {
149+ fs.formTitleEntry.GrabFocus()
150+ return false
151+ }
152+ if fs.formURLEntry != nil {
139 fs.formURLEntry.GrabFocus()153 fs.formURLEntry.GrabFocus()
140 }154 }
141 return false155 return false
@@ -197,7 +211,9 @@ func (fs *FavoritesSidebar) submitForm() bool {
197 }211 }
198 if key != nil && fav != nil {212 if key != nil && fav != nil {
199 if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {213 if err := uc.SetShortcut(ctx, fav.ID, key); err != nil {
200- fs.setNotice(err.Error())214+ fs.cancelManagement()
215+ fs.startLoad()
216+ fs.setNotice("Added favorite, but failed to set shortcut: " + err.Error())
201 return true217 return true
202 }218 }
203 }219 }
@@ -333,9 +349,13 @@ func (fs *FavoritesSidebar) confirmDeleteFavorite() bool {
333 return fs.requestDeleteConfirmation()349 return fs.requestDeleteConfirmation()
334 }350 }
335 fs.mu.RLock()351 fs.mu.RLock()
352+ confirmed := fs.confirmDelete
336 uc := fs.favoritesUC353 uc := fs.favoritesUC
337 ctx := fs.ctx354 ctx := fs.ctx
338 fs.mu.RUnlock()355 fs.mu.RUnlock()
356+ if !confirmed {
357+ return fs.requestDeleteConfirmation()
358+ }
339 if uc == nil {359 if uc == nil {
340 return true360 return true
341 }361 }
M internal/ui/component/favorites_sidebar_keyboard.go +24 -4
@@ -28,6 +28,7 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
2828 onClose := fs.onClose
2929 fs.mu.RUnlock()
3030
31+ textEditing := fs.inTextEditContext()
3132 switch keyval {
3233 case uint(gdk.KEY_Escape):
3334 if fs.cancelManagement() {
@@ -47,31 +48,49 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
4748 fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
4849 return true
4950 case uint(gdk.KEY_Page_Up):
51+ if textEditing {
52+ return false
53+ }
5054 fs.selectAdjacentRow(-5)
5155 return true
5256 case uint(gdk.KEY_Page_Down):
57+ if textEditing {
58+ return false
59+ }
5360 fs.selectAdjacentRow(5)
5461 return true
5562 case uint(gdk.KEY_Home):
63+ if textEditing {
64+ return false
65+ }
5666 fs.mu.RLock()
5767 index := firstSelectableIndex(fs.displayRows)
5868 fs.mu.RUnlock()
5969 fs.selectIndex(index)
6070 return true
6171 case uint(gdk.KEY_End):
72+ if textEditing {
73+ return false
74+ }
6275 fs.mu.RLock()
6376 index := lastSelectableIndex(fs.displayRows)
6477 fs.mu.RUnlock()
6578 fs.selectIndex(index)
6679 return true
6780 case uint(gdk.KEY_Up):
81+ if textEditing {
82+ return false
83+ }
6884 fs.selectAdjacentRow(-1)
6985 return true
7086 case uint(gdk.KEY_Down):
87+ if textEditing {
88+ return false
89+ }
7190 fs.selectAdjacentRow(1)
7291 return true
7392 case uint(gdk.KEY_slash):
74- if fs.inTextEditContext() {
93+ if textEditing {
7594 return false
7695 }
7796 searchFocused := searchEntry != nil && searchEntry.HasFocus()
@@ -81,7 +100,7 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
81100 fs.focusSearch()
82101 return true
83102 }
84- return fs.handleSingleKeyCommand(keyval)
103+ return fs.handleSingleKeyCommand(keyval, state)
85104 }
86105 fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
87106 keyController.ConnectKeyPressed(&keyPressedCb)
@@ -275,8 +294,9 @@ func (fs *FavoritesSidebar) inTextEditContext() bool {
275294 return false
276295 }
277296
278-func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint) bool {
279- if fs == nil || fs.inTextEditContext() {
297+func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
298+ shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299+ if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
280300 return false
281301 }
282302 fs.mu.RLock()
@@ -28,6 +28,7 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
28 onClose := fs.onClose28 onClose := fs.onClose
29 fs.mu.RUnlock()29 fs.mu.RUnlock()
30 30
31+ textEditing := fs.inTextEditContext()
31 switch keyval {32 switch keyval {
32 case uint(gdk.KEY_Escape):33 case uint(gdk.KEY_Escape):
33 if fs.cancelManagement() {34 if fs.cancelManagement() {
@@ -47,31 +48,49 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
47 fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)48 fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
48 return true49 return true
49 case uint(gdk.KEY_Page_Up):50 case uint(gdk.KEY_Page_Up):
51+ if textEditing {
52+ return false
53+ }
50 fs.selectAdjacentRow(-5)54 fs.selectAdjacentRow(-5)
51 return true55 return true
52 case uint(gdk.KEY_Page_Down):56 case uint(gdk.KEY_Page_Down):
57+ if textEditing {
58+ return false
59+ }
53 fs.selectAdjacentRow(5)60 fs.selectAdjacentRow(5)
54 return true61 return true
55 case uint(gdk.KEY_Home):62 case uint(gdk.KEY_Home):
63+ if textEditing {
64+ return false
65+ }
56 fs.mu.RLock()66 fs.mu.RLock()
57 index := firstSelectableIndex(fs.displayRows)67 index := firstSelectableIndex(fs.displayRows)
58 fs.mu.RUnlock()68 fs.mu.RUnlock()
59 fs.selectIndex(index)69 fs.selectIndex(index)
60 return true70 return true
61 case uint(gdk.KEY_End):71 case uint(gdk.KEY_End):
72+ if textEditing {
73+ return false
74+ }
62 fs.mu.RLock()75 fs.mu.RLock()
63 index := lastSelectableIndex(fs.displayRows)76 index := lastSelectableIndex(fs.displayRows)
64 fs.mu.RUnlock()77 fs.mu.RUnlock()
65 fs.selectIndex(index)78 fs.selectIndex(index)
66 return true79 return true
67 case uint(gdk.KEY_Up):80 case uint(gdk.KEY_Up):
81+ if textEditing {
82+ return false
83+ }
68 fs.selectAdjacentRow(-1)84 fs.selectAdjacentRow(-1)
69 return true85 return true
70 case uint(gdk.KEY_Down):86 case uint(gdk.KEY_Down):
87+ if textEditing {
88+ return false
89+ }
71 fs.selectAdjacentRow(1)90 fs.selectAdjacentRow(1)
72 return true91 return true
73 case uint(gdk.KEY_slash):92 case uint(gdk.KEY_slash):
74- if fs.inTextEditContext() {93+ if textEditing {
75 return false94 return false
76 }95 }
77 searchFocused := searchEntry != nil && searchEntry.HasFocus()96 searchFocused := searchEntry != nil && searchEntry.HasFocus()
@@ -81,7 +100,7 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
81 fs.focusSearch()100 fs.focusSearch()
82 return true101 return true
83 }102 }
84- return fs.handleSingleKeyCommand(keyval)103+ return fs.handleSingleKeyCommand(keyval, state)
85 }104 }
86 fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)105 fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
87 keyController.ConnectKeyPressed(&keyPressedCb)106 keyController.ConnectKeyPressed(&keyPressedCb)
@@ -275,8 +294,9 @@ func (fs *FavoritesSidebar) inTextEditContext() bool {
275 return false294 return false
276 }295 }
277 296
278-func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint) bool {297+func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
279- if fs == nil || fs.inTextEditContext() {298+ shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299+ if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
280 return false300 return false
281 }301 }
282 fs.mu.RLock()302 fs.mu.RLock()
M internal/ui/component/favorites_sidebar_rows.go +7 -6
@@ -186,7 +186,7 @@ func (fs *FavoritesSidebar) rebuildList() {
186186 return
187187 }
188188 for _, row := range rows {
189- fs.appendFavoriteRow(listBox, row.Favorite)
189+ fs.appendFavoriteRow(listBox, row)
190190 }
191191 for _, text := range favoriteSidebarNoticeRows(notice, query, true) {
192192 fs.appendNoticeRow(listBox, text)
@@ -225,7 +225,8 @@ func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
225225 listBox.Append(&row.Widget)
226226 }
227227
228-func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, fav *entity.Favorite) {
228+func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow favoriteSidebarDisplayRow) {
229+ fav := displayRow.Favorite
229230 if fav == nil {
230231 return
231232 }
@@ -243,10 +244,10 @@ func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, fav *entity.
243244 return
244245 }
245246 row.AddCssClass("favorites-sidebar-row")
246- row.SetSelectable(true)
247- row.SetActivatable(true)
248- row.SetCanFocus(true)
249- row.SetFocusOnClick(true)
247+ row.SetSelectable(displayRow.Selectable)
248+ row.SetActivatable(displayRow.Selectable)
249+ row.SetCanFocus(displayRow.Selectable)
250+ row.SetFocusOnClick(displayRow.Selectable)
250251 row.SetChild(&rowBox.Widget)
251252 listBox.Append(&row.Widget)
252253 }
@@ -186,7 +186,7 @@ func (fs *FavoritesSidebar) rebuildList() {
186 return186 return
187 }187 }
188 for _, row := range rows {188 for _, row := range rows {
189- fs.appendFavoriteRow(listBox, row.Favorite)189+ fs.appendFavoriteRow(listBox, row)
190 }190 }
191 for _, text := range favoriteSidebarNoticeRows(notice, query, true) {191 for _, text := range favoriteSidebarNoticeRows(notice, query, true) {
192 fs.appendNoticeRow(listBox, text)192 fs.appendNoticeRow(listBox, text)
@@ -225,7 +225,8 @@ func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
225 listBox.Append(&row.Widget)225 listBox.Append(&row.Widget)
226 }226 }
227 227
228-func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, fav *entity.Favorite) {228+func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow favoriteSidebarDisplayRow) {
229+ fav := displayRow.Favorite
229 if fav == nil {230 if fav == nil {
230 return231 return
231 }232 }
@@ -243,10 +244,10 @@ func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, fav *entity.
243 return244 return
244 }245 }
245 row.AddCssClass("favorites-sidebar-row")246 row.AddCssClass("favorites-sidebar-row")
246- row.SetSelectable(true)247+ row.SetSelectable(displayRow.Selectable)
247- row.SetActivatable(true)248+ row.SetActivatable(displayRow.Selectable)
248- row.SetCanFocus(true)249+ row.SetCanFocus(displayRow.Selectable)
249- row.SetFocusOnClick(true)250+ row.SetFocusOnClick(displayRow.Selectable)
250 row.SetChild(&rowBox.Widget)251 row.SetChild(&rowBox.Widget)
251 listBox.Append(&row.Widget)252 listBox.Append(&row.Widget)
252 }253 }
M internal/ui/component/favorites_sidebar_test.go +60 -13
@@ -34,8 +34,9 @@ type fakeFavoritesSidebarUC struct {
3434 favID entity.FavoriteID
3535 tagID entity.TagID
3636 }
37- err error
38- getAllCalled chan struct{}
37+ err error
38+ errOnSetShortcut error
39+ getAllCalled chan struct{}
3940 }
4041
4142 func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
@@ -45,10 +46,10 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
4546 default:
4647 }
4748 }
48- return f.favorites, nil
49+ return f.favorites, f.err
4950 }
5051 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
51- return f.tags, nil
52+ return f.tags, f.err
5253 }
5354 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
5455 f.addInputs = append(f.addInputs, input)
@@ -73,6 +74,9 @@ func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.Favori
7374 id entity.FavoriteID
7475 key *int
7576 }{id: id, key: key})
77+ if f.errOnSetShortcut != nil {
78+ return f.errOnSetShortcut
79+ }
7680 return f.err
7781 }
7882 func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
@@ -193,7 +197,11 @@ func TestFavoritesSidebarActivationCallbacks(t *testing.T) {
193197
194198 func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {
195199 called := make(chan struct{}, 1)
196- uc := &fakeFavoritesSidebarUC{getAllCalled: called}
200+ uc := &fakeFavoritesSidebarUC{
201+ favorites: []*entity.Favorite{{ID: 1, URL: "https://go.dev", Title: "Go"}},
202+ tags: []*entity.Tag{{ID: 10, Name: "dev"}},
203+ getAllCalled: called,
204+ }
197205 fs := newFavoritesSidebarHarness(nil, nil)
198206 fs.favoritesUC = uc
199207 fs.visible = true
@@ -208,6 +216,11 @@ func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {
208216 return false
209217 }
210218 }, time.Second, 10*time.Millisecond)
219+ require.Eventually(t, func() bool {
220+ fs.mu.RLock()
221+ defer fs.mu.RUnlock()
222+ return len(fs.allFavorites) == 1 && len(fs.allTags) == 1
223+ }, time.Second, 10*time.Millisecond)
211224 }
212225
213226 func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {
@@ -313,11 +326,11 @@ func TestFavoritesSidebarSingleKeyCommandsStartManagementOutsideTextEditContext(
313326 fs.searchEntry = nil
314327 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}
315328
316- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a)))
329+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), 0))
317330 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
318331
319332 fs.mode = favoritesSidebarModeNone
320- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e)))
333+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
321334 assert.Equal(t, favoritesSidebarModeEdit, fs.mode)
322335 }
323336
@@ -431,6 +444,40 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
431444 assert.Empty(t, uc.untagged)
432445 }
433446
447+func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
448+ fs := newFavoritesSidebarHarness(nil, nil)
449+ fs.searchEntry = nil
450+
451+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), gdk.ControlMaskValue))
452+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
453+}
454+
455+func TestFavoritesSidebarConfirmDeleteRequiresConfirmationState(t *testing.T) {
456+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
457+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
458+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
459+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
460+
461+ assert.True(t, fs.confirmDeleteFavorite())
462+ assert.Empty(t, uc.deletedIDs)
463+ assert.True(t, fs.confirmDelete)
464+}
465+
466+func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
467+ uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}
468+ fs := newFavoritesSidebarHarness(nil, nil)
469+ fs.favoritesUC = uc
470+ fs.formURL = "https://add.test"
471+ fs.formTitle = "Added"
472+ fs.formShortcut = "3"
473+ fs.mode = favoritesSidebarModeAdd
474+
475+ assert.True(t, fs.submitForm())
476+ require.Len(t, uc.addInputs, 1)
477+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
478+ assert.Contains(t, fs.notice, "failed to set shortcut")
479+}
480+
434481 func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
435482 dev := entity.Tag{ID: 10, Name: "dev"}
436483 news := entity.Tag{ID: 11, Name: "news"}
@@ -441,24 +488,24 @@ func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
441488 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
442489
443490 fs.mode = favoritesSidebarModeTag
444- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1)))
491+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1), 0))
445492 require.Len(t, uc.untagged, 1)
446493 assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID)
447- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2)))
494+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2), 0))
448495 require.Len(t, uc.tagged, 1)
449496 assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID)
450497
451498 fs.mode = favoritesSidebarModeShortcut
452- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9)))
453- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace)))
499+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
500+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))
454501 require.GreaterOrEqual(t, len(uc.shortcuts), 2)
455502 assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)
456503 assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)
457504
458505 fs.mode = favoritesSidebarModeNone
459- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete)))
506+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
460507 assert.True(t, fs.confirmDelete)
461- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete)))
508+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
462509 assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
463510 }
464511
@@ -34,8 +34,9 @@ type fakeFavoritesSidebarUC struct {
34 favID entity.FavoriteID34 favID entity.FavoriteID
35 tagID entity.TagID35 tagID entity.TagID
36 }36 }
37- err error37+ err error
38- getAllCalled chan struct{}38+ errOnSetShortcut error
39+ getAllCalled chan struct{}
39 }40 }
40 41
41 func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {42 func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
@@ -45,10 +46,10 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
45 default:46 default:
46 }47 }
47 }48 }
48- return f.favorites, nil49+ return f.favorites, f.err
49 }50 }
50 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {51 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
51- return f.tags, nil52+ return f.tags, f.err
52 }53 }
53 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {54 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
54 f.addInputs = append(f.addInputs, input)55 f.addInputs = append(f.addInputs, input)
@@ -73,6 +74,9 @@ func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.Favori
73 id entity.FavoriteID74 id entity.FavoriteID
74 key *int75 key *int
75 }{id: id, key: key})76 }{id: id, key: key})
77+ if f.errOnSetShortcut != nil {
78+ return f.errOnSetShortcut
79+ }
76 return f.err80 return f.err
77 }81 }
78 func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {82 func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
@@ -193,7 +197,11 @@ func TestFavoritesSidebarActivationCallbacks(t *testing.T) {
193 197
194 func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {198 func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {
195 called := make(chan struct{}, 1)199 called := make(chan struct{}, 1)
196- uc := &fakeFavoritesSidebarUC{getAllCalled: called}200+ uc := &fakeFavoritesSidebarUC{
201+ favorites: []*entity.Favorite{{ID: 1, URL: "https://go.dev", Title: "Go"}},
202+ tags: []*entity.Tag{{ID: 10, Name: "dev"}},
203+ getAllCalled: called,
204+ }
197 fs := newFavoritesSidebarHarness(nil, nil)205 fs := newFavoritesSidebarHarness(nil, nil)
198 fs.favoritesUC = uc206 fs.favoritesUC = uc
199 fs.visible = true207 fs.visible = true
@@ -208,6 +216,11 @@ func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {
208 return false216 return false
209 }217 }
210 }, time.Second, 10*time.Millisecond)218 }, time.Second, 10*time.Millisecond)
219+ require.Eventually(t, func() bool {
220+ fs.mu.RLock()
221+ defer fs.mu.RUnlock()
222+ return len(fs.allFavorites) == 1 && len(fs.allTags) == 1
223+ }, time.Second, 10*time.Millisecond)
211 }224 }
212 225
213 func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {226 func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {
@@ -313,11 +326,11 @@ func TestFavoritesSidebarSingleKeyCommandsStartManagementOutsideTextEditContext(
313 fs.searchEntry = nil326 fs.searchEntry = nil
314 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}327 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}
315 328
316- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a)))329+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), 0))
317 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)330 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
318 331
319 fs.mode = favoritesSidebarModeNone332 fs.mode = favoritesSidebarModeNone
320- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e)))333+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
321 assert.Equal(t, favoritesSidebarModeEdit, fs.mode)334 assert.Equal(t, favoritesSidebarModeEdit, fs.mode)
322 }335 }
323 336
@@ -431,6 +444,40 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
431 assert.Empty(t, uc.untagged)444 assert.Empty(t, uc.untagged)
432 }445 }
433 446
447+func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
448+ fs := newFavoritesSidebarHarness(nil, nil)
449+ fs.searchEntry = nil
450+
451+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), gdk.ControlMaskValue))
452+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
453+}
454+
455+func TestFavoritesSidebarConfirmDeleteRequiresConfirmationState(t *testing.T) {
456+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
457+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
458+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
459+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
460+
461+ assert.True(t, fs.confirmDeleteFavorite())
462+ assert.Empty(t, uc.deletedIDs)
463+ assert.True(t, fs.confirmDelete)
464+}
465+
466+func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
467+ uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}
468+ fs := newFavoritesSidebarHarness(nil, nil)
469+ fs.favoritesUC = uc
470+ fs.formURL = "https://add.test"
471+ fs.formTitle = "Added"
472+ fs.formShortcut = "3"
473+ fs.mode = favoritesSidebarModeAdd
474+
475+ assert.True(t, fs.submitForm())
476+ require.Len(t, uc.addInputs, 1)
477+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
478+ assert.Contains(t, fs.notice, "failed to set shortcut")
479+}
480+
434 func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {481 func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
435 dev := entity.Tag{ID: 10, Name: "dev"}482 dev := entity.Tag{ID: 10, Name: "dev"}
436 news := entity.Tag{ID: 11, Name: "news"}483 news := entity.Tag{ID: 11, Name: "news"}
@@ -441,24 +488,24 @@ func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
441 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}488 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
442 489
443 fs.mode = favoritesSidebarModeTag490 fs.mode = favoritesSidebarModeTag
444- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1)))491+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1), 0))
445 require.Len(t, uc.untagged, 1)492 require.Len(t, uc.untagged, 1)
446 assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID)493 assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID)
447- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2)))494+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2), 0))
448 require.Len(t, uc.tagged, 1)495 require.Len(t, uc.tagged, 1)
449 assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID)496 assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID)
450 497
451 fs.mode = favoritesSidebarModeShortcut498 fs.mode = favoritesSidebarModeShortcut
452- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9)))499+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
453- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace)))500+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))
454 require.GreaterOrEqual(t, len(uc.shortcuts), 2)501 require.GreaterOrEqual(t, len(uc.shortcuts), 2)
455 assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)502 assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)
456 assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)503 assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)
457 504
458 fs.mode = favoritesSidebarModeNone505 fs.mode = favoritesSidebarModeNone
459- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete)))506+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
460 assert.True(t, fs.confirmDelete)507 assert.True(t, fs.confirmDelete)
461- assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete)))508+ assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
462 assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)509 assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
463 }510 }
464 511

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