v0.26.2 3cd9d2e

history_repo_test.go RawHistoryFolder

1package sqlite_test
2
3import (
4	"context"
5	"path/filepath"
6	"testing"
7	"time"
8
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14)
15
16func historyTestCtx() context.Context {
17	logger := logging.NewFromConfigValues("debug", "console")
18	return logging.WithContext(context.Background(), logger)
19}
20
21func TestHistoryRepository_GetRecentSince(t *testing.T) {
22	ctx := historyTestCtx()
23	dbPath := filepath.Join(t.TempDir(), "dumber.db")
24
25	db, err := sqlite.NewConnection(ctx, dbPath)
26	require.NoError(t, err)
27	t.Cleanup(func() { _ = db.Close() })
28
29	repo := sqlite.NewHistoryRepository(db)
30
31	// Save some history entries
32	entries := []*entity.HistoryEntry{
33		{URL: "https://example.com", Title: "Example"},
34		{URL: "https://github.com", Title: "GitHub"},
35		{URL: "https://google.com", Title: "Google"},
36	}
37
38	for _, e := range entries {
39		require.NoError(t, repo.Save(ctx, e))
40	}
41
42	// Get recent since 30 days (all should be included)
43	results, err := repo.GetRecentSince(ctx, 30)
44	require.NoError(t, err)
45	require.Len(t, results, 3)
46
47	// Results should be sorted by last_visited DESC
48	// Since they were all just inserted, order may vary
49	urls := make(map[string]bool)
50	for _, r := range results {
51		urls[r.URL] = true
52	}
53	assert.True(t, urls["https://example.com"])
54	assert.True(t, urls["https://github.com"])
55	assert.True(t, urls["https://google.com"])
56}
57
58func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
59	ctx := historyTestCtx()
60	dbPath := filepath.Join(t.TempDir(), "dumber.db")
61
62	db, err := sqlite.NewConnection(ctx, dbPath)
63	require.NoError(t, err)
64	t.Cleanup(func() { _ = db.Close() })
65
66	repo := sqlite.NewHistoryRepository(db)
67
68	// Get recent with no data
69	results, err := repo.GetRecentSince(ctx, 30)
70	require.NoError(t, err)
71	assert.Empty(t, results)
72}
73
74func TestHistoryRepository_GetMostVisited(t *testing.T) {
75	ctx := historyTestCtx()
76	dbPath := filepath.Join(t.TempDir(), "dumber.db")
77
78	db, err := sqlite.NewConnection(ctx, dbPath)
79	require.NoError(t, err)
80	t.Cleanup(func() { _ = db.Close() })
81
82	repo := sqlite.NewHistoryRepository(db)
83
84	// Save entries with different visit counts
85	entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
86	entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
87	entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
88
89	require.NoError(t, repo.Save(ctx, entry1))
90	require.NoError(t, repo.Save(ctx, entry2))
91	require.NoError(t, repo.Save(ctx, entry3))
92
93	// Increment visit counts to create different popularities
94	// github: 5 visits, google: 3 visits, example: 1 visit
95	for i := 0; i < 4; i++ {
96		require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
97	}
98	for i := 0; i < 2; i++ {
99		require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
100	}
101
102	// Get most visited
103	results, err := repo.GetMostVisited(ctx, 30)
104	require.NoError(t, err)
105	require.Len(t, results, 3)
106
107	// First result should be github (5 visits)
108	assert.Equal(t, "https://github.com", results[0].URL)
109	assert.Equal(t, int64(5), results[0].VisitCount)
110
111	// Second should be google (3 visits)
112	assert.Equal(t, "https://google.com", results[1].URL)
113	assert.Equal(t, int64(3), results[1].VisitCount)
114
115	// Third should be example (1 visit)
116	assert.Equal(t, "https://example.com", results[2].URL)
117	assert.Equal(t, int64(1), results[2].VisitCount)
118}
119
120func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
121	ctx := historyTestCtx()
122	dbPath := filepath.Join(t.TempDir(), "dumber.db")
123
124	db, err := sqlite.NewConnection(ctx, dbPath)
125	require.NoError(t, err)
126	t.Cleanup(func() { _ = db.Close() })
127
128	repo := sqlite.NewHistoryRepository(db)
129
130	// Get most visited with no data
131	results, err := repo.GetMostVisited(ctx, 30)
132	require.NoError(t, err)
133	assert.Empty(t, results)
134}
135
136func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
137	ctx := historyTestCtx()
138	dbPath := filepath.Join(t.TempDir(), "dumber.db")
139
140	db, err := sqlite.NewConnection(ctx, dbPath)
141	require.NoError(t, err)
142	t.Cleanup(func() { _ = db.Close() })
143
144	repo := sqlite.NewHistoryRepository(db)
145
146	// Save an entry
147	entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
148	require.NoError(t, repo.Save(ctx, entry))
149
150	// Query with 1 day filter - should include recently added entry
151	results, err := repo.GetRecentSince(ctx, 1)
152	require.NoError(t, err)
153	require.Len(t, results, 1)
154
155	// Query with very large days filter - should still work
156	results, err = repo.GetRecentSince(ctx, 365)
157	require.NoError(t, err)
158	require.Len(t, results, 1)
159}
160
161func TestHistoryRepository_CRUD(t *testing.T) {
162	ctx := historyTestCtx()
163	dbPath := filepath.Join(t.TempDir(), "dumber.db")
164
165	db, err := sqlite.NewConnection(ctx, dbPath)
166	require.NoError(t, err)
167	t.Cleanup(func() { _ = db.Close() })
168
169	repo := sqlite.NewHistoryRepository(db)
170
171	// Create
172	entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
173	require.NoError(t, repo.Save(ctx, entry))
174
175	// Read
176	found, err := repo.FindByURL(ctx, "https://example.com")
177	require.NoError(t, err)
178	require.NotNil(t, found)
179	assert.Equal(t, "https://example.com", found.URL)
180	assert.Equal(t, "Example", found.Title)
181	assert.Equal(t, int64(1), found.VisitCount)
182
183	// Update (save again increments visit count)
184	require.NoError(t, repo.Save(ctx, entry))
185	found2, err := repo.FindByURL(ctx, "https://example.com")
186	require.NoError(t, err)
187	assert.Equal(t, int64(2), found2.VisitCount)
188
189	// Get recent
190	recent, err := repo.GetRecent(ctx, 10, 0)
191	require.NoError(t, err)
192	require.Len(t, recent, 1)
193
194	// Delete
195	require.NoError(t, repo.Delete(ctx, found.ID))
196
197	// Verify deleted
198	deleted, err := repo.FindByURL(ctx, "https://example.com")
199	require.NoError(t, err)
200	assert.Nil(t, deleted)
201}
202
203// Ensure the test uses an isolated timestamp for time-sensitive queries
204func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
205	ctx := historyTestCtx()
206	dbPath := filepath.Join(t.TempDir(), "dumber.db")
207
208	db, err := sqlite.NewConnection(ctx, dbPath)
209	require.NoError(t, err)
210	t.Cleanup(func() { _ = db.Close() })
211
212	repo := sqlite.NewHistoryRepository(db)
213
214	// Save entry
215	entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
216	require.NoError(t, repo.Save(ctx, entry))
217
218	// Verify entry was saved with recent timestamp
219	found, err := repo.FindByURL(ctx, "https://test.com")
220	require.NoError(t, err)
221	require.NotNil(t, found)
222
223	// LastVisited should be within the last minute
224	timeDiff := time.Since(found.LastVisited)
225	assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
226}
227
228func TestHistoryRepository_GetRecentSince_RejectsNonPositiveDays(t *testing.T) {
229	ctx := historyTestCtx()
230	dbPath := filepath.Join(t.TempDir(), "dumber.db")
231
232	db, err := sqlite.NewConnection(ctx, dbPath)
233	require.NoError(t, err)
234	t.Cleanup(func() { _ = db.Close() })
235
236	repo := sqlite.NewHistoryRepository(db)
237
238	// days = 0 should return error
239	_, err = repo.GetRecentSince(ctx, 0)
240	require.Error(t, err)
241	assert.Contains(t, err.Error(), "days must be positive")
242
243	// days < 0 should return error
244	_, err = repo.GetRecentSince(ctx, -5)
245	require.Error(t, err)
246	assert.Contains(t, err.Error(), "days must be positive")
247}
248
249func TestHistoryRepository_GetMostVisited_RejectsNonPositiveDays(t *testing.T) {
250	ctx := historyTestCtx()
251	dbPath := filepath.Join(t.TempDir(), "dumber.db")
252
253	db, err := sqlite.NewConnection(ctx, dbPath)
254	require.NoError(t, err)
255	t.Cleanup(func() { _ = db.Close() })
256
257	repo := sqlite.NewHistoryRepository(db)
258
259	// days = 0 should return error
260	_, err = repo.GetMostVisited(ctx, 0)
261	require.Error(t, err)
262	assert.Contains(t, err.Error(), "days must be positive")
263
264	// days < 0 should return error
265	_, err = repo.GetMostVisited(ctx, -5)
266	require.Error(t, err)
267	assert.Contains(t, err.Error(), "days must be positive")
268}
269
270func TestHistoryRepository_GetAllRecentHistory(t *testing.T) {
271	ctx := historyTestCtx()
272	dbPath := filepath.Join(t.TempDir(), "dumber.db")
273
274	db, err := sqlite.NewConnection(ctx, dbPath)
275	require.NoError(t, err)
276	t.Cleanup(func() { _ = db.Close() })
277
278	repo := sqlite.NewHistoryRepository(db)
279
280	// Save some entries
281	entries := []*entity.HistoryEntry{
282		{URL: "https://example.com", Title: "Example"},
283		{URL: "https://github.com", Title: "GitHub"},
284	}
285	for _, e := range entries {
286		require.NoError(t, repo.Save(ctx, e))
287	}
288
289	// Get all recent history
290	results, err := repo.GetAllRecentHistory(ctx)
291	require.NoError(t, err)
292	require.Len(t, results, 2)
293}
294
295func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
296	ctx := historyTestCtx()
297	dbPath := filepath.Join(t.TempDir(), "dumber.db")
298
299	db, err := sqlite.NewConnection(ctx, dbPath)
300	require.NoError(t, err)
301	t.Cleanup(func() { _ = db.Close() })
302
303	repo := sqlite.NewHistoryRepository(db)
304
305	// Save entries with different visit counts
306	entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
307	entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
308
309	require.NoError(t, repo.Save(ctx, entry1))
310	require.NoError(t, repo.Save(ctx, entry2))
311
312	// Increment github visits
313	for i := 0; i < 3; i++ {
314		require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
315	}
316
317	// Get all most visited
318	results, err := repo.GetAllMostVisited(ctx)
319	require.NoError(t, err)
320	require.Len(t, results, 2)
321
322	// First should be github (4 visits)
323	assert.Equal(t, "https://github.com", results[0].URL)
324	assert.Equal(t, int64(4), results[0].VisitCount)
325}
326
327func TestHistoryRepository_Search_SingleWord(t *testing.T) {
328	ctx := historyTestCtx()
329	dbPath := filepath.Join(t.TempDir(), "dumber.db")
330
331	db, err := sqlite.NewConnection(ctx, dbPath)
332	require.NoError(t, err)
333	t.Cleanup(func() { _ = db.Close() })
334
335	repo := sqlite.NewHistoryRepository(db)
336
337	// Save entries
338	entries := []*entity.HistoryEntry{
339		{URL: "https://github.com/user/repo", Title: "My GitHub Repository"},
340		{URL: "https://gitlab.com/project", Title: "GitLab Project"},
341		{URL: "https://example.com", Title: "Example Site"},
342	}
343	for _, e := range entries {
344		require.NoError(t, repo.Save(ctx, e))
345	}
346
347	// Search for "github" - should find the github entry
348	results, err := repo.Search(ctx, "github", 10)
349	require.NoError(t, err)
350	require.Len(t, results, 1)
351	assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
352}
353
354func TestHistoryRepository_Search_MultiWord(t *testing.T) {
355	ctx := historyTestCtx()
356	dbPath := filepath.Join(t.TempDir(), "dumber.db")
357
358	db, err := sqlite.NewConnection(ctx, dbPath)
359	require.NoError(t, err)
360	t.Cleanup(func() { _ = db.Close() })
361
362	repo := sqlite.NewHistoryRepository(db)
363
364	// Save entries
365	entries := []*entity.HistoryEntry{
366		{URL: "https://github.com/issues", Title: "GitHub Issues"},
367		{URL: "https://github.com/pulls", Title: "GitHub Pull Requests"},
368		{URL: "https://jira.com/issues", Title: "Jira Issues"},
369	}
370	for _, e := range entries {
371		require.NoError(t, repo.Save(ctx, e))
372	}
373
374	// Search for "github issues" - should find only the github issues entry
375	results, err := repo.Search(ctx, "github issues", 10)
376	require.NoError(t, err)
377	require.Len(t, results, 1)
378	assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
379}
380
381func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
382	ctx := historyTestCtx()
383	dbPath := filepath.Join(t.TempDir(), "dumber.db")
384
385	db, err := sqlite.NewConnection(ctx, dbPath)
386	require.NoError(t, err)
387	t.Cleanup(func() { _ = db.Close() })
388
389	repo := sqlite.NewHistoryRepository(db)
390
391	// Save entry with specific title that doesn't appear in URL
392	entry := &entity.HistoryEntry{
393		URL:   "https://docs.example.com/guide",
394		Title: "Comprehensive Documentation Guide",
395	}
396	require.NoError(t, repo.Save(ctx, entry))
397
398	// Search for "Documentation" - should find by title
399	results, err := repo.Search(ctx, "Documentation", 10)
400	require.NoError(t, err)
401	require.Len(t, results, 1)
402	assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
403}
404
405func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
406	ctx := historyTestCtx()
407	dbPath := filepath.Join(t.TempDir(), "dumber.db")
408
409	db, err := sqlite.NewConnection(ctx, dbPath)
410	require.NoError(t, err)
411	t.Cleanup(func() { _ = db.Close() })
412
413	repo := sqlite.NewHistoryRepository(db)
414
415	// Save an entry
416	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
417		URL:   "https://example.com",
418		Title: "Example",
419	}))
420
421	// Empty query should return empty results, no error
422	results, err := repo.Search(ctx, "", 10)
423	require.NoError(t, err)
424	assert.Empty(t, results)
425
426	// Whitespace-only query should also return empty results
427	results, err = repo.Search(ctx, "   ", 10)
428	require.NoError(t, err)
429	assert.Empty(t, results)
430}
431
432func TestHistoryRepository_Search_NoResults(t *testing.T) {
433	ctx := historyTestCtx()
434	dbPath := filepath.Join(t.TempDir(), "dumber.db")
435
436	db, err := sqlite.NewConnection(ctx, dbPath)
437	require.NoError(t, err)
438	t.Cleanup(func() { _ = db.Close() })
439
440	repo := sqlite.NewHistoryRepository(db)
441
442	// Save an entry
443	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
444		URL:   "https://example.com",
445		Title: "Example",
446	}))
447
448	// Search for something that doesn't exist
449	results, err := repo.Search(ctx, "nonexistent", 10)
450	require.NoError(t, err)
451	assert.Empty(t, results)
452}
453
454func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
455	ctx := historyTestCtx()
456	dbPath := filepath.Join(t.TempDir(), "dumber.db")
457
458	db, err := sqlite.NewConnection(ctx, dbPath)
459	require.NoError(t, err)
460	t.Cleanup(func() { _ = db.Close() })
461
462	repo := sqlite.NewHistoryRepository(db)
463
464	// Save entries
465	entries := []*entity.HistoryEntry{
466		{URL: "https://github.com", Title: "GitHub"},
467		{URL: "https://gitlab.com", Title: "GitLab"},
468		{URL: "https://example.com", Title: "Example"},
469	}
470	for _, e := range entries {
471		require.NoError(t, repo.Save(ctx, e))
472	}
473
474	// Search for "git" - should find both github and gitlab (prefix match)
475	results, err := repo.Search(ctx, "git", 10)
476	require.NoError(t, err)
477	require.Len(t, results, 2)
478
479	// Verify both git* entries are found
480	urls := make(map[string]bool)
481	for _, r := range results {
482		urls[r.Entry.URL] = true
483	}
484	assert.True(t, urls["https://github.com"])
485	assert.True(t, urls["https://gitlab.com"])
486}
487
488func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) {
489	ctx := historyTestCtx()
490	dbPath := filepath.Join(t.TempDir(), "dumber.db")
491
492	db, err := sqlite.NewConnection(ctx, dbPath)
493	require.NoError(t, err)
494	t.Cleanup(func() { _ = db.Close() })
495
496	repo := sqlite.NewHistoryRepository(db)
497
498	// Save entries with domain-like URLs
499	entries := []*entity.HistoryEntry{
500		{URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"},
501		{URL: "https://example.com", Title: "Example Site"},
502		{URL: "https://other.gordon.io", Title: "Another Gordon Site"},
503	}
504	for _, e := range entries {
505		require.NoError(t, repo.Save(ctx, e))
506	}
507
508	// Search with period in query (domain-like) - periods should be treated as separators
509	// "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)
510	results, err := repo.Search(ctx, "gordon.bnem", 10)
511	require.NoError(t, err)
512	require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")
513
514	// Verify the gordon.bnema.dev entry is found
515	found := false
516	for _, r := range results {
517		if r.Entry.URL == "https://gordon.bnema.dev/" {
518			found = true
519			break
520		}
521	}
522	assert.True(t, found, "gordon.bnema.dev should be in results")
523}
524
525func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
526	ctx := historyTestCtx()
527	dbPath := filepath.Join(t.TempDir(), "dumber.db")
528
529	db, err := sqlite.NewConnection(ctx, dbPath)
530	require.NoError(t, err)
531	t.Cleanup(func() { _ = db.Close() })
532
533	repo := sqlite.NewHistoryRepository(db)
534
535	// Save entry
536	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
537		URL:   "https://example.com/path",
538		Title: "Example Page",
539	}))
540
541	// Search with FTS5 special characters - should not cause errors
542	specialQueries := []string{
543		"example:",     // colon
544		"example*",     // asterisk
545		"example()",    // parentheses
546		`example"test`, // quotes
547		"example^",     // caret
548		"example-test", // hyphen
549		"example/",     // slash
550	}
551
552	for _, q := range specialQueries {
553		results, err := repo.Search(ctx, q, 10)
554		require.NoError(t, err, "query %q should not cause error", q)
555		// May or may not find results, but should not error
556		_ = results
557	}
558}
559
560func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
561	ctx := historyTestCtx()
562	dbPath := filepath.Join(t.TempDir(), "dumber.db")
563
564	db, err := sqlite.NewConnection(ctx, dbPath)
565	require.NoError(t, err)
566	t.Cleanup(func() { _ = db.Close() })
567
568	repo := sqlite.NewHistoryRepository(db)
569
570	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
571		URL:   "https://github.com/bnema/dumber",
572		Title: "Dumber",
573	}))
574
575	results, err := repo.Search(ctx, "github.com/bnema", 10)
576	require.NoError(t, err)
577	require.NotEmpty(t, results, "slash-separated query should match history")
578}
579
580func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {
581	ctx := historyTestCtx()
582	dbPath := filepath.Join(t.TempDir(), "dumber.db")
583
584	db, err := sqlite.NewConnection(ctx, dbPath)
585	require.NoError(t, err)
586	t.Cleanup(func() { _ = db.Close() })
587
588	repo := sqlite.NewHistoryRepository(db)
589
590	// Save about:blank multiple times - visit count should stay at 1
591	for i := 0; i < 5; i++ {
592		require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
593			URL:   "about:blank",
594			Title: "New Tab",
595		}))
596	}
597
598	// Verify about:blank has visit_count = 1
599	found, err := repo.FindByURL(ctx, "about:blank")
600	require.NoError(t, err)
601	require.NotNil(t, found)
602	assert.Equal(t, int64(1), found.VisitCount, "about:blank should always have visit_count = 1")
603
604	// Try incrementing visit count directly - should be ignored
605	require.NoError(t, repo.IncrementVisitCount(ctx, "about:blank"))
606
607	found2, err := repo.FindByURL(ctx, "about:blank")
608	require.NoError(t, err)
609	assert.Equal(t, int64(1), found2.VisitCount, "about:blank increment should be ignored")
610}
611
612func TestHistoryRepository_AboutBlank_NotDominant(t *testing.T) {
613	ctx := historyTestCtx()
614	dbPath := filepath.Join(t.TempDir(), "dumber.db")
615
616	db, err := sqlite.NewConnection(ctx, dbPath)
617	require.NoError(t, err)
618	t.Cleanup(func() { _ = db.Close() })
619
620	repo := sqlite.NewHistoryRepository(db)
621
622	// Save about:blank many times
623	for i := 0; i < 10; i++ {
624		require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
625			URL:   "about:blank",
626			Title: "New Tab",
627		}))
628	}
629
630	// Save a regular entry with multiple visits
631	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
632		URL:   "https://example.com",
633		Title: "Example",
634	}))
635	require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
636	require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
637
638	// Get most visited - example.com should be first (3 visits vs 1)
639	results, err := repo.GetAllMostVisited(ctx)
640	require.NoError(t, err)
641	require.Len(t, results, 2)
642
643	assert.Equal(t, "https://example.com", results[0].URL, "example.com should be first with higher visit count")
644	assert.Equal(t, int64(3), results[0].VisitCount)
645
646	assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")
647	assert.Equal(t, int64(1), results[1].VisitCount)
648}

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