main 5041353

favorites_sidebar_test.go RawHistoryFolder

1package component
2
3import (
4	"context"
5	"errors"
6	"sync"
7	"sync/atomic"
8	"testing"
9	"time"
10
11	"github.com/bnema/dumber/internal/application/dto"
12	"github.com/bnema/dumber/internal/domain/entity"
13	"github.com/bnema/puregotk/v4/gdk"
14	"github.com/bnema/puregotk/v4/glib"
15	"github.com/bnema/puregotk/v4/gtk"
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/require"
18)
19
20type fakeFavoritesSidebarUC struct {
21	favorites []*entity.Favorite
22	tags      []*entity.Tag
23
24	addInputs    []dto.FavoriteCreateInput
25	updateInputs []dto.FavoriteUpdateInput
26	deletedIDs   []entity.FavoriteID
27	shortcuts    []struct {
28		id  entity.FavoriteID
29		key *int
30	}
31	tagged []struct {
32		favID entity.FavoriteID
33		tagID entity.TagID
34	}
35	untagged []struct {
36		favID entity.FavoriteID
37		tagID entity.TagID
38	}
39	createdTags []struct {
40		name  string
41		color string
42	}
43	err              error
44	errOnSetShortcut error
45	errOnTag         map[entity.TagID]error
46	errOnUntag       map[entity.TagID]error
47	failUntagOnCall  int
48	getAllCalled     chan struct{}
49	getAllCalls      atomic.Int64
50	getAllTagsCalls  atomic.Int64
51}
52
53func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
54	f.getAllCalls.Add(1)
55	if f.getAllCalled != nil {
56		select {
57		case f.getAllCalled <- struct{}{}:
58		default:
59		}
60	}
61	return f.favorites, f.err
62}
63func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
64	f.getAllTagsCalls.Add(1)
65	return f.tags, f.err
66}
67func (f *fakeFavoritesSidebarUC) AddTag(_ context.Context, name, color string) (*entity.Tag, error) {
68	f.createdTags = append(f.createdTags, struct {
69		name  string
70		color string
71	}{name: name, color: color})
72	if f.err != nil {
73		return nil, f.err
74	}
75	return &entity.Tag{ID: entity.TagID(len(f.tags) + 1), Name: name, Color: color}, nil
76}
77func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
78	f.addInputs = append(f.addInputs, input)
79	if f.err != nil {
80		return nil, f.err
81	}
82	return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
83}
84func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
85	f.updateInputs = append(f.updateInputs, input)
86	if f.err != nil {
87		return nil, f.err
88	}
89	return &entity.Favorite{ID: input.ID, Title: input.Title}, nil
90}
91func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error {
92	f.deletedIDs = append(f.deletedIDs, id)
93	return f.err
94}
95func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error {
96	f.shortcuts = append(f.shortcuts, struct {
97		id  entity.FavoriteID
98		key *int
99	}{id: id, key: key})
100	if f.errOnSetShortcut != nil {
101		return f.errOnSetShortcut
102	}
103	return f.err
104}
105func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
106	f.tagged = append(f.tagged, struct {
107		favID entity.FavoriteID
108		tagID entity.TagID
109	}{favID: favID, tagID: tagID})
110	if err := f.errOnTag[tagID]; err != nil {
111		return err
112	}
113	return f.err
114}
115func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
116	f.untagged = append(f.untagged, struct {
117		favID entity.FavoriteID
118		tagID entity.TagID
119	}{favID: favID, tagID: tagID})
120	if err := f.errOnUntag[tagID]; err != nil {
121		return err
122	}
123	if f.failUntagOnCall > 0 && len(f.untagged) == f.failUntagOnCall {
124		return errors.New("untag failed")
125	}
126	return f.err
127}
128func (f *fakeFavoritesSidebarUC) UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, wanted []entity.TagID) error {
129	wantedSet := make(map[entity.TagID]struct{}, len(wanted))
130	for _, id := range wanted {
131		wantedSet[id] = struct{}{}
132	}
133	var current []entity.Tag
134	for _, favorite := range f.favorites {
135		if favorite != nil && favorite.ID == favID {
136			current = favorite.Tags
137			break
138		}
139	}
140	currentSet := make(map[entity.TagID]struct{}, len(current))
141	for _, tag := range current {
142		currentSet[tag.ID] = struct{}{}
143		if _, keep := wantedSet[tag.ID]; !keep {
144			if err := f.UntagFavorite(ctx, favID, tag.ID); err != nil {
145				return err
146			}
147		}
148	}
149	for _, id := range wanted {
150		if _, exists := currentSet[id]; exists {
151			continue
152		}
153		if err := f.TagFavorite(ctx, favID, id); err != nil {
154			return err
155		}
156	}
157	return nil
158}
159
160func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
161	fs := &FavoritesSidebar{
162		favoritesUC:    &fakeFavoritesSidebarUC{favorites: favorites, tags: tags},
163		selectedTagIDs: make(map[entity.TagID]struct{}),
164		ctx:            context.Background(),
165		idleScheduler: func(cb glib.SourceFunc) {
166			cb(0)
167		},
168	}
169	return fs
170}
171
172func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) {
173	fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2}
174	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}})
175	fs.loadGen = 1
176
177	applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil)
178
179	require.True(t, applied)
180	assert.Len(t, fs.allFavorites, 1)
181	assert.Len(t, fs.allTags, 1)
182	require.Len(t, fs.displayRows, 1)
183	assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
184	assert.Equal(t, "https://go.dev", fs.displayRows[0].URL)
185}
186
187func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) {
188	devTag := entity.Tag{ID: 1, Name: "Dev"}
189	newsTag := entity.Tag{ID: 2, Name: "News"}
190	fs := newFavoritesSidebarHarness([]*entity.Favorite{
191		{ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1},
192		{ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2},
193	}, nil)
194	fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
195
196	fs.currentQuery = "golang"
197	fs.rebuildDisplayRowsLocked()
198	require.Len(t, fs.displayRows, 1)
199	assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
200
201	fs.currentQuery = "example.com"
202	fs.rebuildDisplayRowsLocked()
203	require.Len(t, fs.displayRows, 1)
204	assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID)
205
206	fs.currentQuery = "dev"
207	fs.rebuildDisplayRowsLocked()
208	require.Len(t, fs.displayRows, 1)
209	assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
210}
211
212func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) {
213	fs := newFavoritesSidebarHarness([]*entity.Favorite{
214		{ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}},
215		{ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}},
216		{ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}},
217	}, nil)
218	fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
219	fs.selectedTagIDs[1] = struct{}{}
220	fs.selectedTagIDs[2] = struct{}{}
221
222	fs.rebuildDisplayRowsLocked()
223
224	require.Len(t, fs.displayRows, 2)
225	assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
226	assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID)
227}
228
229func TestFavoritesSidebarActivationCallbacks(t *testing.T) {
230	fs := newFavoritesSidebarHarness(nil, nil)
231	fs.ctx = context.Background()
232	fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
233	var mu sync.Mutex
234	called := []string{}
235	fs.onNavigate = func(_ context.Context, url string) error {
236		mu.Lock()
237		defer mu.Unlock()
238		called = append(called, "nav:"+url)
239		return nil
240	}
241	fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
242		mu.Lock()
243		defer mu.Unlock()
244		called = append(called, "keep:"+url)
245		return nil
246	}
247	fs.onOpenInNewPane = func(_ context.Context, url string) error {
248		mu.Lock()
249		defer mu.Unlock()
250		called = append(called, "pane:"+url)
251		return nil
252	}
253
254	fs.navigateToURL("https://a.test")
255	fs.navigateWithoutClosing("https://b.test")
256	fs.navigateToNewPane("https://c.test")
257
258	assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called)
259}
260
261func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {
262	called := make(chan struct{}, 1)
263	uc := &fakeFavoritesSidebarUC{
264		favorites:    []*entity.Favorite{{ID: 1, URL: "https://go.dev", Title: "Go"}},
265		tags:         []*entity.Tag{{ID: 10, Name: "dev"}},
266		getAllCalled: called,
267	}
268	fs := newFavoritesSidebarHarness(nil, nil)
269	fs.favoritesUC = uc
270	fs.visible = true
271
272	fs.RequestReloadIfVisible("test")
273
274	require.Eventually(t, func() bool {
275		select {
276		case <-called:
277			return true
278		default:
279			return false
280		}
281	}, time.Second, 10*time.Millisecond)
282	require.Eventually(t, func() bool {
283		fs.mu.RLock()
284		defer fs.mu.RUnlock()
285		return len(fs.allFavorites) == 1 && len(fs.allTags) == 1
286	}, time.Second, 10*time.Millisecond)
287}
288
289func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {
290	fs := newFavoritesSidebarHarness(nil, nil)
291	fs.loadGen = 2
292
293	applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil)
294
295	assert.False(t, applied)
296	assert.Empty(t, fs.allFavorites)
297	assert.Empty(t, fs.displayRows)
298}
299
300func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) {
301	fs := newFavoritesSidebarHarness(nil, nil)
302	fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
303	called := false
304	fs.onNavigate = func(context.Context, string) error {
305		called = true
306		return nil
307	}
308	fs.destroyed = true
309
310	fs.navigateToURL("https://example.test")
311	fs.toggleTag(1)
312	fs.onSearchChanged()
313
314	assert.False(t, called)
315	assert.Empty(t, fs.selectedTagIDs)
316}
317
318func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) {
319	fs := newFavoritesSidebarHarness(nil, nil)
320	fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
321	fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") }
322
323	fs.navigateToURL("https://example.test")
324
325	assert.Equal(t, "navigation failed", fs.notice)
326}
327
328func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) {
329	fs := newFavoritesSidebarHarness(nil, nil)
330	fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}}
331	fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{
332		{ID: 1, URL: "https://go.dev", Title: "Go"},
333		{ID: 2, URL: "", Title: "Missing URL"},
334	})
335
336	assert.Len(t, fs.allTags, 2)
337	require.Len(t, fs.displayRows, 2)
338	assert.True(t, fs.displayRows[0].Selectable)
339	assert.False(t, fs.displayRows[1].Selectable)
340	assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true))
341	assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false))
342}
343
344func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) {
345	assert.True(t, shouldFocusSearchForSlash(false))
346	assert.False(t, shouldFocusSearchForSlash(true))
347}
348
349func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) {
350	fs := newFavoritesSidebarHarness(nil, nil)
351	fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
352	fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}}
353	var called []string
354	fs.onNavigate = func(_ context.Context, url string) error {
355		called = append(called, "nav:"+url)
356		return nil
357	}
358	fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
359		called = append(called, "keep:"+url)
360		return nil
361	}
362	fs.onOpenInNewPane = func(_ context.Context, url string) error {
363		called = append(called, "pane:"+url)
364		return nil
365	}
366
367	assert.True(t, fs.handleEnterKey(0))
368	assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue))
369	assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue))
370
371	assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)
372}
373
374func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) {
375	fs := newFavoritesSidebarHarness(nil, nil)
376	fs.searchEntry = nil
377	fs.tagBox = nil
378	fs.listBox = nil
379	assert.Empty(t, fs.availableFocusZones())
380
381	fs.formBox = nil
382	fs.mode = favoritesSidebarModeAdd
383	fs.confirmDelete = true
384	assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones())
385}
386
387func TestFavoritesSidebarSingleKeyCommandsStartManagementOutsideTextEditContext(t *testing.T) {
388	fs := newFavoritesSidebarHarness(nil, nil)
389	fs.searchEntry = nil
390	fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}
391
392	assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), 0))
393	assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
394
395	fs.mode = favoritesSidebarModeNone
396	assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
397	assert.Equal(t, favoritesSidebarModeEdit, fs.mode)
398}
399
400func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
401	uc := &fakeFavoritesSidebarUC{}
402	fs := newFavoritesSidebarHarness(nil, nil)
403	fs.favoritesUC = uc
404	fs.formURL = "https://add.test"
405	fs.formTitle = "Added"
406	fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}}
407	fs.formShortcut = "3"
408	fs.mode = favoritesSidebarModeAdd
409
410	assert.True(t, fs.submitForm())
411	require.Len(t, uc.addInputs, 1)
412	assert.ElementsMatch(t, []entity.TagID{1, 2}, uc.addInputs[0].Tags)
413	require.Len(t, uc.shortcuts, 1)
414	assert.Equal(t, 3, *uc.shortcuts[0].key)
415
416	fs.mode = favoritesSidebarModeEdit
417	fs.editingID = 7
418	fs.formTitle = "Edited"
419	fs.formShortcut = "4"
420	assert.True(t, fs.submitForm())
421	require.Len(t, uc.updateInputs, 1)
422	assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
423	assert.Equal(t, "Edited", uc.updateInputs[0].Title)
424	require.NotNil(t, uc.updateInputs[0].ShortcutKey)
425	assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey)
426
427	fs.mode = favoritesSidebarModeEdit
428	fs.editingID = 7
429	fs.formTitle = "Edited"
430	fs.formShortcut = ""
431	assert.True(t, fs.submitForm())
432	require.Len(t, uc.updateInputs, 2)
433	assert.True(t, uc.updateInputs[1].ShortcutKeySet)
434	assert.Nil(t, uc.updateInputs[1].ShortcutKey)
435}
436
437func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) {
438	uc := &fakeFavoritesSidebarUC{}
439	fs := newFavoritesSidebarHarness(nil, nil)
440	fs.favoritesUC = uc
441	fs.formURL = "https://add.test"
442	fs.formTitle = "Added"
443	fs.formShortcut = "10"
444	fs.mode = favoritesSidebarModeAdd
445
446	assert.True(t, fs.submitForm())
447	assert.Empty(t, uc.addInputs)
448	assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
449	assert.Contains(t, fs.notice, "invalid shortcut")
450	assert.Equal(t, "10", fs.formShortcut)
451}
452
453func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T) {
454	dev := &entity.Tag{ID: 10, Name: "dev"}
455	news := &entity.Tag{ID: 11, Name: "news"}
456	reading := &entity.Tag{ID: 12, Name: "reading"}
457	selected := map[entity.TagID]struct{}{dev.ID: {}}
458
459	assert.Equal(t, []*entity.Tag{dev}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, ""))
460	assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read"))
461}
462
463func TestFavoritesSidebarReloadsAfterPartiallyApplyingTagChanges(t *testing.T) {
464	first := entity.Tag{ID: 10, Name: "first"}
465	second := entity.Tag{ID: 11, Name: "second"}
466	fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{first, second}}
467	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
468	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
469	uc.failUntagOnCall = 2
470	fs.allFavorites = []*entity.Favorite{fav}
471	fs.loadGen = 4
472
473	assert.False(t, fs.updateFavoriteTags(fav.ID, nil))
474	assert.Len(t, uc.untagged, 2)
475	assert.Equal(t, uint64(5), fs.loadGen, "a partial tag mutation must refresh stale favorites")
476	assert.Equal(t, "untag failed", fs.notice)
477}
478
479func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
480	dev := entity.Tag{ID: 10, Name: "dev"}
481	news := entity.Tag{ID: 11, Name: "news"}
482	fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
483	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
484	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
485	fs.allFavorites = []*entity.Favorite{fav}
486	fs.formTagIDs = tagIDSet(fav.Tags)
487
488	fs.toggleFormTag(news.ID)
489	assert.ElementsMatch(t, []entity.TagID{dev.ID, news.ID}, fs.formTagIDsSnapshot())
490	assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
491	require.Len(t, uc.tagged, 1)
492	assert.Equal(t, news.ID, uc.tagged[0].tagID)
493	fav.Tags = []entity.Tag{dev, news}
494
495	fs.toggleFormTag(dev.ID)
496	assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
497	require.Len(t, uc.untagged, 1)
498	assert.Equal(t, dev.ID, uc.untagged[0].tagID)
499	assert.Len(t, uc.tagged, 1, "the synchronized persisted tags must not be added again")
500}
501
502func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) {
503	fav := &entity.Favorite{ID: 1, URL: "https://go.dev"}
504	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
505	if !gtk.InitCheck() {
506		t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
507	}
508	listBox := gtk.NewListBox()
509	require.NotNil(t, listBox)
510	row := gtk.NewListBoxRow()
511	require.NotNil(t, row)
512	row.SetSelectable(true)
513	listBox.Append(&row.Widget)
514	fs.listBox = listBox
515	fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
516	fs.focusZone = favoritesSidebarFocusSearch
517
518	assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
519	assert.Equal(t, favoritesSidebarFocusList, fs.focusZone)
520	assert.Equal(t, row, fs.listBox.GetSelectedRow())
521	assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
522	assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone)
523}
524
525func TestFavoritesSidebarDirectionalFocusConsumesArrowsOnTagControls(t *testing.T) {
526	fs := newFavoritesSidebarHarness(nil, nil)
527	fs.focusZone = favoritesSidebarFocusTags
528
529	assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
530	assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
531	assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
532	assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
533}
534
535func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
536	fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
537	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
538	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
539	fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
540	fs.confirmDelete = true
541	fs.confirmDeleteID = fav.ID
542
543	assert.True(t, fs.confirmDeleteActive())
544	assert.True(t, fs.confirmDeleteFavorite())
545	assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
546}
547
548func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) {
549	uc := &fakeFavoritesSidebarUC{}
550	fs := newFavoritesSidebarHarness(nil, nil)
551	fs.favoritesUC = uc
552	fs.formURL = "https://add.test"
553	fs.formTitle = "Added"
554	fs.mode = favoritesSidebarModeAdd
555	fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}}
556	fs.onNavigate = func(context.Context, string) error {
557		t.Fatal("plain Enter in form mode must not activate selected row")
558		return nil
559	}
560
561	assert.False(t, fs.handleReturnKey(0))
562	assert.Empty(t, uc.addInputs)
563	fs.mode = favoritesSidebarModeAdd
564	assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue))
565	require.Len(t, uc.addInputs, 1)
566}
567
568func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
569	uc := &fakeFavoritesSidebarUC{}
570	fs := newFavoritesSidebarHarness(nil, nil)
571	fs.favoritesUC = uc
572	fs.mode = favoritesSidebarModeEdit
573	fs.editingID = 7
574	fs.formTitle = "Edited"
575
576	assert.True(t, fs.submitForm())
577	require.Len(t, uc.updateInputs, 1)
578	assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
579	assert.Empty(t, uc.tagged)
580	assert.Empty(t, uc.untagged)
581}
582
583func TestFavoritesSidebarCreateTagTrimsNameAndUsesApplicationBoundary(t *testing.T) {
584	fs := newFavoritesSidebarHarness(nil, nil)
585	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
586
587	assert.True(t, fs.createTag("  reading  "))
588	require.Len(t, uc.createdTags, 1)
589	assert.Equal(t, "reading", uc.createdTags[0].name)
590	assert.Empty(t, uc.createdTags[0].color)
591
592	assert.True(t, fs.createTag(" \t "))
593	assert.Len(t, uc.createdTags, 1)
594	assert.Equal(t, "Tag name is required", fs.notice)
595}
596
597func TestFavoritesSidebarTagNameSubmissionCreatesTag(t *testing.T) {
598	fs := newFavoritesSidebarHarness(nil, nil)
599	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
600
601	assert.True(t, fs.submitTagName("  work  "))
602	require.Len(t, uc.createdTags, 1)
603	assert.Equal(t, "work", uc.createdTags[0].name)
604}
605
606func TestFavoritesSidebarTagBindingTogglesExistingTag(t *testing.T) {
607	dev := entity.Tag{ID: 10, Name: "dev"}
608	news := entity.Tag{ID: 11, Name: "news"}
609	fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
610	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
611	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
612	fs.allFavorites = []*entity.Favorite{fav}
613
614	fs.toggleFavoriteTag(fav.ID, dev.ID)
615	require.Len(t, uc.untagged, 1)
616	assert.Equal(t, dev.ID, uc.untagged[0].tagID)
617	fs.toggleFavoriteTag(fav.ID, news.ID)
618	require.Len(t, uc.tagged, 1)
619	assert.Equal(t, news.ID, uc.tagged[0].tagID)
620}
621
622func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testing.T) {
623	fs := newFavoritesSidebarHarness(nil, nil)
624	input := &gtk.Widget{}
625
626	fs.setTextInputFocused(input)
627	assert.True(t, fs.inTextEditContext())
628	assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
629	assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
630	fs.mode = favoritesSidebarModeAdd
631	assert.True(t, fs.cancelManagement())
632	assert.Equal(t, favoritesSidebarModeNone, fs.mode)
633
634	fs.clearTextInputFocus(input)
635	assert.False(t, fs.inTextEditContext())
636	assert.False(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
637}
638
639func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) {
640	for _, key := range []uint{
641		uint(gdk.KEY_a),
642		uint(gdk.KEY_e),
643		uint(gdk.KEY_s),
644		uint(gdk.KEY_r),
645		uint(gdk.KEY_c),
646		uint(gdk.KEY_t),
647		uint(gdk.KEY_plus),
648		uint(gdk.KEY_Up),
649		uint(gdk.KEY_Return),
650	} {
651		assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key)
652	}
653	assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab)))
654	assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab)))
655	assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Escape)))
656	assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e)))
657}
658
659func TestFavoritesSidebarPlusAndTDoNotHijackTextInput(t *testing.T) {
660	fs := newFavoritesSidebarHarness(nil, nil)
661
662	assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_t), 0))
663	assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_plus), 0))
664	assert.Equal(t, favoritesSidebarModeNone, fs.mode)
665}
666
667func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
668	fs := newFavoritesSidebarHarness(nil, nil)
669	fs.searchEntry = nil
670
671	assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), gdk.ControlMaskValue))
672	assert.Equal(t, favoritesSidebarModeNone, fs.mode)
673}
674
675func TestFavoritesSidebarConfirmDeleteRequiresConfirmationState(t *testing.T) {
676	fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
677	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
678	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
679	fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
680
681	assert.True(t, fs.confirmDeleteFavorite())
682	assert.Empty(t, uc.deletedIDs)
683	assert.True(t, fs.confirmDelete)
684}
685
686func TestFavoritesSidebarConfirmDeleteRepromptsWhenSelectionChanges(t *testing.T) {
687	first := &entity.Favorite{ID: 5, URL: "https://first.test"}
688	second := &entity.Favorite{ID: 6, URL: "https://second.test"}
689	fs := newFavoritesSidebarHarness([]*entity.Favorite{first, second}, nil)
690	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
691	fs.displayRows = []favoriteSidebarDisplayRow{
692		{FavoriteID: second.ID, Favorite: second, URL: second.URL, Selectable: true},
693		{FavoriteID: first.ID, Favorite: first, URL: first.URL, Selectable: true},
694	}
695	fs.confirmDelete = true
696	fs.confirmDeleteID = first.ID
697
698	assert.True(t, fs.confirmDeleteFavorite())
699	assert.Empty(t, uc.deletedIDs)
700	assert.True(t, fs.confirmDelete)
701	assert.Equal(t, second.ID, fs.confirmDeleteID)
702}
703
704func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
705	uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}
706	fs := newFavoritesSidebarHarness(nil, nil)
707	fs.favoritesUC = uc
708	loaded := make(chan glib.SourceFunc, 1)
709	fs.idleScheduler = func(cb glib.SourceFunc) { loaded <- cb }
710	fs.formURL = "https://add.test"
711	fs.formTitle = "Added"
712	fs.formShortcut = "3"
713	fs.mode = favoritesSidebarModeAdd
714
715	assert.True(t, fs.submitForm())
716	require.Len(t, uc.addInputs, 1)
717	select {
718	case cb := <-loaded:
719		cb(0)
720	case <-time.After(time.Second):
721		t.Fatal("timed out waiting for favorites reload")
722	}
723	assert.Equal(t, favoritesSidebarModeNone, fs.mode)
724	assert.Contains(t, fs.notice, "failed to set shortcut")
725}
726
727func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) {
728	dev := entity.Tag{ID: 10, Name: "dev"}
729	fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
730	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev})
731	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
732	fs.allTags = uc.tags
733	fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
734
735	fs.mode = favoritesSidebarModeShortcut
736	assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
737	assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))
738	require.GreaterOrEqual(t, len(uc.shortcuts), 2)
739	assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)
740	assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)
741
742	fs.mode = favoritesSidebarModeNone
743	assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
744	assert.True(t, fs.confirmDelete)
745	assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
746	assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
747}
748
749func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) {
750	fs := newFavoritesSidebarHarness(nil, nil)
751	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
752	uc.err = errors.New("add failed")
753	fs.mode = favoritesSidebarModeAdd
754
755	assert.True(t, fs.submitForm())
756	assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
757	assert.Equal(t, "add failed", fs.notice)
758}
759
760// TestFavoritesSidebar_FirstShowIssuesSingleQueryBatch proves the lazy-load
761// contract behind the on-demand host: a fresh instance issues zero queries
762// (construction performs no load), the first Show-equivalent load issues
763// exactly one GetAll/GetAllTags batch, and reopening reloads with one more
764// batch according to existing semantics.
765func TestFavoritesSidebar_FirstShowIssuesSingleQueryBatch(t *testing.T) {
766	fav := &entity.Favorite{ID: 1, URL: "https://example.com", Title: "Example"}
767	fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
768	uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
769	fs.ctx = context.Background()
770
771	// Fresh instance: construction must not have queried anything.
772	assert.Zero(t, uc.getAllCalls.Load())
773	assert.Zero(t, uc.getAllTagsCalls.Load())
774
775	// First Show issues exactly one batch.
776	fs.startLoad()
777	require.Eventually(t, func() bool {
778		fs.mu.RLock()
779		defer fs.mu.RUnlock()
780		return len(fs.allFavorites) == 1
781	}, 10*time.Second, 5*time.Millisecond, "first Show load must apply fetched favorites")
782	assert.EqualValues(t, 1, uc.getAllCalls.Load(), "first Show must issue exactly one GetAll batch")
783	assert.EqualValues(t, 1, uc.getAllTagsCalls.Load(), "first Show must issue exactly one GetAllTags batch")
784	assert.Equal(t, "https://example.com", fs.allFavorites[0].URL)
785
786	// Reopen reloads with one more batch.
787	fs.startLoad()
788	require.Eventually(t, func() bool {
789		return uc.getAllCalls.Load() == 2 && uc.getAllTagsCalls.Load() == 2
790	}, 10*time.Second, 5*time.Millisecond, "reopen must reload with one more batch")
791}

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