v0.31.0 bf0756b

favorites_sidebar_test.go RawHistoryFolder

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

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