v0.4.0 6203a22

fuzzy_cache_test.go RawHistoryFolder

1package cache
2
3import (
4	"context"
5	"database/sql"
6	"os"
7	"path/filepath"
8	"testing"
9	"time"
10
11	mock_cache "github.com/bnema/dumber/internal/cache/mocks"
12	"github.com/bnema/dumber/internal/db"
13	"github.com/golang/mock/gomock"
14)
15
16func createTestHistory() []db.History {
17	now := time.Now()
18	return []db.History{
19		{
20			ID:          1,
21			Url:         "https://github.com",
22			Title:       sql.NullString{String: "GitHub", Valid: true},
23			VisitCount:  sql.NullInt64{Int64: 25, Valid: true},
24			LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
25		},
26		{
27			ID:          2,
28			Url:         "https://stackoverflow.com",
29			Title:       sql.NullString{String: "Stack Overflow", Valid: true},
30			VisitCount:  sql.NullInt64{Int64: 15, Valid: true},
31			LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
32		},
33		{
34			ID:          3,
35			Url:         "https://golang.org",
36			Title:       sql.NullString{String: "The Go Programming Language", Valid: true},
37			VisitCount:  sql.NullInt64{Int64: 8, Valid: true},
38			LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
39		},
40		{
41			ID:          4,
42			Url:         "https://news.ycombinator.com",
43			Title:       sql.NullString{String: "Hacker News", Valid: true},
44			VisitCount:  sql.NullInt64{Int64: 30, Valid: true},
45			LastVisited: sql.NullTime{Time: now.Add(-30 * time.Minute), Valid: true},
46		},
47		{
48			ID:          5,
49			Url:         "https://youtube.com",
50			Title:       sql.NullString{String: "YouTube", Valid: true},
51			VisitCount:  sql.NullInt64{Int64: 50, Valid: true},
52			LastVisited: sql.NullTime{Time: now.Add(-10 * time.Minute), Valid: true},
53		},
54	}
55}
56
57func TestCacheManager_BuildCache(t *testing.T) {
58	ctrl := gomock.NewController(t)
59	defer ctrl.Finish()
60
61	mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
62	history := createTestHistory()
63
64	// Mock both calls - one for building cache, one for calculating hash
65	mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(10000)).Return(history, nil)
66	mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(20)).Return(history, nil)
67
68	tempDir, err := os.MkdirTemp("", "dumber_cache_test")
69	if err != nil {
70		t.Fatalf("Failed to create temp dir: %v", err)
71	}
72	defer os.RemoveAll(tempDir)
73
74	config := DefaultCacheConfig()
75	config.CacheFile = filepath.Join(tempDir, "test_cache.bin")
76	config.MaxResults = 10
77
78	manager := NewCacheManager(mockQuerier, config)
79	ctx := context.Background()
80
81	// Test cache building
82	cache, err := manager.GetCache(ctx)
83	if err != nil {
84		t.Fatalf("Failed to get cache: %v", err)
85	}
86
87	if cache.entryCount != 5 {
88		t.Errorf("Expected 5 entries, got %d", cache.entryCount)
89	}
90
91	// Test that indices are built
92	if len(cache.trigramIndex) == 0 {
93		t.Error("Trigram index should not be empty")
94	}
95
96	if cache.prefixTrie == nil || cache.prefixTrie.Root == nil {
97		t.Error("Prefix trie should be initialized")
98	}
99}
100
101func TestFuzzySearcher_JaroWinklerSimilarity(t *testing.T) {
102	config := DefaultCacheConfig()
103	searcher := NewFuzzySearcher(config)
104
105	tests := []struct {
106		name     string
107		s1       string
108		s2       string
109		expected float64
110		delta    float64
111	}{
112		{"identical strings", "test", "test", 1.0, 0.0},
113		{"empty strings", "", "", 1.0, 0.0},
114		{"one empty", "", "test", 0.0, 0.0},
115		{"similar strings", "github", "gihub", 0.88, 0.1},
116		{"different strings", "github", "stackoverflow", 0.41, 0.05},
117		{"prefix match", "programming", "program", 0.9, 0.1},
118	}
119
120	for _, tt := range tests {
121		t.Run(tt.name, func(t *testing.T) {
122			result := searcher.jaroWinklerSimilarity(tt.s1, tt.s2)
123
124			if result < 0.0 || result > 1.0 {
125				t.Errorf("Score %f should be between 0.0 and 1.0", result)
126			}
127
128			diff := result - tt.expected
129			if diff < 0 {
130				diff = -diff
131			}
132			if diff > tt.delta {
133				t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
134			}
135		})
136	}
137}
138
139func TestFuzzySearcher_SubstringSimilarity(t *testing.T) {
140	config := DefaultCacheConfig()
141	searcher := NewFuzzySearcher(config)
142
143	tests := []struct {
144		name     string
145		query    string
146		text     string
147		expected float64
148		delta    float64
149	}{
150		{"exact match", "github", "github", 1.0, 0.0},
151		{"substring at start", "git", "github.com", 0.45, 0.2},
152		{"substring at end", "com", "github.com", 0.3, 0.1},
153		{"no match", "xyz", "github.com", 0.0, 0.0},
154		{"empty query", "", "github.com", 0.0, 0.0},
155		{"empty text", "git", "", 0.0, 0.0},
156	}
157
158	for _, tt := range tests {
159		t.Run(tt.name, func(t *testing.T) {
160			result := searcher.substringSimilarity(tt.query, tt.text)
161
162			if result < 0.0 || result > 1.0 {
163				t.Errorf("Score %f should be between 0.0 and 1.0", result)
164			}
165
166			diff := result - tt.expected
167			if diff < 0 {
168				diff = -diff
169			}
170			if diff > tt.delta {
171				t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
172			}
173		})
174	}
175}
176
177func TestFuzzySearcher_TokenizedSimilarity(t *testing.T) {
178	config := DefaultCacheConfig()
179	searcher := NewFuzzySearcher(config)
180
181	tests := []struct {
182		name     string
183		query    string
184		text     string
185		expected float64
186		delta    float64
187	}{
188		{"exact token match", "go programming", "The Go Programming Language", 0.8, 0.2},
189		{"partial token match", "stack overflow", "Stack Overflow Questions", 0.8, 0.2},
190		{"no token match", "python django", "JavaScript React Framework", 0.0, 0.1},
191		{"single token", "github", "GitHub Repository", 0.78, 0.05},
192	}
193
194	for _, tt := range tests {
195		t.Run(tt.name, func(t *testing.T) {
196			result := searcher.tokenizedSimilarity(tt.query, tt.text)
197
198			if result < 0.0 || result > 1.0 {
199				t.Errorf("Score %f should be between 0.0 and 1.0", result)
200			}
201
202			diff := result - tt.expected
203			if diff < 0 {
204				diff = -diff
205			}
206			if diff > tt.delta {
207				t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
208			}
209		})
210	}
211}
212
213func TestFuzzySearcher_RecencyScore(t *testing.T) {
214	config := DefaultCacheConfig()
215	searcher := NewFuzzySearcher(config)
216
217	now := time.Now()
218	tests := []struct {
219		name          string
220		lastVisitDays uint32
221		expected      float64
222		delta         float64
223	}{
224		{"recent visit", DaysFromTime(now.Add(-1 * time.Hour)), 0.95, 0.1},
225		{"one day ago", DaysFromTime(now.Add(-24 * time.Hour)), 0.88, 0.1},
226		{"one week ago", DaysFromTime(now.Add(-7 * 24 * time.Hour)), 0.7, 0.1},
227		{"one month ago", DaysFromTime(now.Add(-30 * 24 * time.Hour)), 0.37, 0.1},
228		{"very old", DaysFromTime(now.Add(-365 * 24 * time.Hour)), 0.01, 0.01},
229	}
230
231	for _, tt := range tests {
232		t.Run(tt.name, func(t *testing.T) {
233			result := searcher.calculateRecencyScore(tt.lastVisitDays)
234
235			if result < 0.0 || result > 1.0 {
236				t.Errorf("Score %f should be between 0.0 and 1.0", result)
237			}
238
239			diff := result - tt.expected
240			if diff < 0 {
241				diff = -diff
242			}
243			if diff > tt.delta {
244				t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
245			}
246		})
247	}
248}
249
250func TestFuzzySearcher_VisitScore(t *testing.T) {
251	config := DefaultCacheConfig()
252	searcher := NewFuzzySearcher(config)
253
254	tests := []struct {
255		name       string
256		visitCount uint16
257		expected   float64
258		delta      float64
259	}{
260		{"zero visits", 0, 0.0, 0.0},
261		{"single visit", 1, 0.1, 0.05},
262		{"moderate visits", 10, 0.35, 0.1},
263		{"many visits", 100, 0.67, 0.1},
264		{"max visits", 1000, 1.0, 0.01},
265	}
266
267	for _, tt := range tests {
268		t.Run(tt.name, func(t *testing.T) {
269			result := searcher.calculateVisitScore(tt.visitCount)
270
271			if result < 0.0 || result > 1.0 {
272				t.Errorf("Score %f should be between 0.0 and 1.0", result)
273			}
274
275			diff := result - tt.expected
276			if diff < 0 {
277				diff = -diff
278			}
279			if diff > tt.delta {
280				t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
281			}
282		})
283	}
284}
285
286func TestTextNormalization(t *testing.T) {
287	tests := []struct {
288		name     string
289		input    string
290		expected string
291	}{
292		{"basic url", "https://github.com", "github com"},
293		{"with www", "https://www.google.com", "google com"},
294		{"with path", "https://stackoverflow.com/questions/123", "stackoverflow com questions 123"},
295		{"mixed case", "GitHub.COM/User", "github com user"},
296		{"with special chars", "test-site_name.com", "test site name com"},
297		{"unicode chars", "café-münü.com", "café münü com"},
298	}
299
300	for _, tt := range tests {
301		t.Run(tt.name, func(t *testing.T) {
302			result := normalizeText(tt.input)
303			if result != tt.expected {
304				t.Errorf("Expected %q, got %q", tt.expected, result)
305			}
306		})
307	}
308}
309
310func TestTrigramExtraction(t *testing.T) {
311	tests := []struct {
312		name     string
313		input    string
314		expected []string
315	}{
316		{"short string", "ab", nil},
317		{"exact length", "abc", []string{"abc"}},
318		{"longer string", "github", []string{"git", "ith", "thu", "hub"}},
319		{"with spaces", "go lang", []string{"go ", "o l", " la", "lan", "ang"}},
320	}
321
322	for _, tt := range tests {
323		t.Run(tt.name, func(t *testing.T) {
324			result := extractTrigrams(tt.input)
325			if len(result) != len(tt.expected) {
326				t.Errorf("Expected %d trigrams, got %d", len(tt.expected), len(result))
327				return
328			}
329			for i, expected := range tt.expected {
330				if result[i] != expected {
331					t.Errorf("Expected trigram %q at index %d, got %q", expected, i, result[i])
332				}
333			}
334		})
335	}
336}
337
338func TestCompactEntry_BaseScore(t *testing.T) {
339	now := time.Now()
340
341	tests := []struct {
342		name        string
343		visitCount  uint16
344		lastVisit   time.Time
345		expectRange [2]uint16 // min, max expected score
346	}{
347		{"high visits, recent", 100, now.Add(-1 * time.Hour), [2]uint16{44000, 65535}},
348		{"low visits, recent", 5, now.Add(-1 * time.Hour), [2]uint16{35000, 50000}},
349		{"high visits, old", 100, now.Add(-90 * 24 * time.Hour), [2]uint16{5000, 25000}},
350		{"low visits, old", 5, now.Add(-90 * 24 * time.Hour), [2]uint16{1000, 15000}},
351	}
352
353	for _, tt := range tests {
354		t.Run(tt.name, func(t *testing.T) {
355			entry := CompactEntry{
356				VisitCount: tt.visitCount,
357				LastVisit:  DaysFromTime(tt.lastVisit),
358			}
359
360			score := entry.calculateBaseScore()
361
362			if score < tt.expectRange[0] || score > tt.expectRange[1] {
363				t.Errorf("Expected score between %d and %d, got %d",
364					tt.expectRange[0], tt.expectRange[1], score)
365			}
366		})
367	}
368}
369
370func TestFuzzySearch_Integration(t *testing.T) {
371	ctrl := gomock.NewController(t)
372	defer ctrl.Finish()
373
374	mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
375	history := createTestHistory()
376
377	mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
378
379	tempDir, err := os.MkdirTemp("", "dumber_search_test")
380	if err != nil {
381		t.Fatalf("Failed to create temp dir: %v", err)
382	}
383	defer os.RemoveAll(tempDir)
384
385	config := DefaultCacheConfig()
386	config.CacheFile = filepath.Join(tempDir, "search_cache.bin")
387	config.ScoreThreshold = 0.1
388
389	manager := NewCacheManager(mockQuerier, config)
390	ctx := context.Background()
391
392	// Critical fuzzy search tests
393	searchTests := []struct {
394		name            string
395		query           string
396		expectCount     int
397		expectFirst     string
398		expectMatchType MatchType
399	}{
400		{
401			name:            "exact domain match",
402			query:           "github",
403			expectCount:     1,
404			expectFirst:     "https://github.com",
405			expectMatchType: MatchTypeExact,
406		},
407		{
408			name:            "partial title match",
409			query:           "programming",
410			expectCount:     1,
411			expectFirst:     "https://golang.org",
412			expectMatchType: MatchTypeExact,
413		},
414		{
415			name:            "fuzzy typo match",
416			query:           "githb", // typo in github
417			expectCount:     1,
418			expectFirst:     "https://github.com",
419			expectMatchType: MatchTypeFuzzy,
420		},
421		{
422			name:            "prefix match",
423			query:           "you",
424			expectCount:     1,
425			expectFirst:     "https://youtube.com",
426			expectMatchType: MatchTypePrefix,
427		},
428		{
429			name:        "no matches",
430			query:       "nonexistentsite12345",
431			expectCount: 0,
432		},
433	}
434
435	for _, tt := range searchTests {
436		t.Run(tt.name, func(t *testing.T) {
437			result, err := manager.Search(ctx, tt.query)
438			if err != nil {
439				t.Fatalf("Search failed: %v", err)
440			}
441
442			if len(result.Matches) < tt.expectCount {
443				t.Errorf("Expected at least %d matches, got %d", tt.expectCount, len(result.Matches))
444				return
445			}
446
447			if tt.expectCount == 0 {
448				return // No further checks needed
449			}
450
451			// Check first result
452			if result.Matches[0].Entry.URL != tt.expectFirst {
453				t.Errorf("Expected first result %q, got %q",
454					tt.expectFirst, result.Matches[0].Entry.URL)
455			}
456
457			// Verify results are sorted by score
458			for i := 1; i < len(result.Matches); i++ {
459				if result.Matches[i].Score > result.Matches[i-1].Score {
460					t.Errorf("Results not sorted by score: %f > %f at position %d",
461						result.Matches[i].Score, result.Matches[i-1].Score, i)
462				}
463			}
464
465			// Verify all scores meet threshold
466			for i, match := range result.Matches {
467				if match.Score < config.ScoreThreshold {
468					t.Errorf("Match %d score %f below threshold %f",
469						i, match.Score, config.ScoreThreshold)
470				}
471			}
472
473			// Verify query time is recorded
474			if result.QueryTime <= 0 {
475				t.Error("Query time should be positive")
476			}
477		})
478	}
479}
480
481func TestCacheInvalidation(t *testing.T) {
482	ctrl := gomock.NewController(t)
483	defer ctrl.Finish()
484
485	mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
486
487	// First call returns initial history
488	firstHistory := createTestHistory()[:3] // Only 3 entries
489	secondHistory := createTestHistory() // All 5 entries
490	
491	// Set up multiple expectations for the different calls
492	gomock.InOrder(
493		mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash
494		mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(secondHistory, nil).AnyTimes(), // Subsequent calls
495	)
496
497	tempDir, err := os.MkdirTemp("", "dumber_invalidation_test")
498	if err != nil {
499		t.Fatalf("Failed to create temp dir: %v", err)
500	}
501	defer os.RemoveAll(tempDir)
502
503	config := DefaultCacheConfig()
504	config.CacheFile = filepath.Join(tempDir, "invalidation_cache.bin")
505
506	manager := NewCacheManager(mockQuerier, config)
507	ctx := context.Background()
508
509	// Initial cache build
510	cache1, err := manager.GetCache(ctx)
511	if err != nil {
512		t.Fatalf("Failed to get initial cache: %v", err)
513	}
514
515	if cache1.entryCount != 3 {
516		t.Errorf("Expected 3 entries in initial cache, got %d", cache1.entryCount)
517	}
518
519	// Invalidate and refresh
520	manager.InvalidateAndRefresh(ctx)
521
522	// Wait for background refresh
523	time.Sleep(100 * time.Millisecond)
524
525	// Get updated cache
526	cache2, err := manager.GetCache(ctx)
527	if err != nil {
528		t.Fatalf("Failed to get updated cache: %v", err)
529	}
530
531	if cache2.entryCount != 5 {
532		t.Errorf("Expected 5 entries in updated cache, got %d", cache2.entryCount)
533	}
534}
535
536// create40TestEntries creates a realistic dataset with 40 entries for benchmarking
537func create40TestEntries() []db.History {
538	now := time.Now()
539	entries := make([]db.History, 40)
540	
541	urls := []string{
542		"https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com",
543		"https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com",
544		"https://linkedin.com", "https://medium.com", "https://dev.to", "https://docs.python.org",
545		"https://reactjs.org", "https://nodejs.org", "https://kubernetes.io", "https://docker.com",
546		"https://aws.amazon.com", "https://cloud.google.com", "https://azure.microsoft.com", "https://heroku.com",
547		"https://netlify.com", "https://vercel.com", "https://stripe.com", "https://shopify.com",
548		"https://gitlab.com", "https://bitbucket.org", "https://atlassian.com", "https://slack.com",
549		"https://discord.com", "https://telegram.org", "https://whatsapp.com", "https://facebook.com",
550		"https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com",
551		"https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org",
552	}
553	
554	titles := []string{
555		"GitHub", "Stack Overflow", "Go Programming Language", "Hacker News",
556		"YouTube", "Google", "Reddit", "Twitter",
557		"LinkedIn", "Medium", "Dev.to", "Python Documentation",
558		"React", "Node.js", "Kubernetes", "Docker",
559		"AWS", "Google Cloud", "Microsoft Azure", "Heroku",
560		"Netlify", "Vercel", "Stripe", "Shopify",
561		"GitLab", "Bitbucket", "Atlassian", "Slack",
562		"Discord", "Telegram", "WhatsApp", "Facebook",
563		"Instagram", "TikTok", "Netflix", "Spotify",
564		"Apple", "Microsoft", "Amazon", "Wikipedia",
565	}
566	
567	for i := 0; i < 40; i++ {
568		entries[i] = db.History{
569			ID:          int64(i + 1),
570			Url:         urls[i],
571			Title:       sql.NullString{String: titles[i], Valid: true},
572			VisitCount:  sql.NullInt64{Int64: int64(40 - i), Valid: true}, // Decreasing visit counts
573			LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
574		}
575	}
576	
577	return entries
578}
579
580// BenchmarkGetTopEntries benchmarks the critical path for dmenu performance
581func BenchmarkGetTopEntries(b *testing.B) {
582	ctrl := gomock.NewController(b)
583	defer ctrl.Finish()
584	
585	mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
586	
587	// Setup 40 test entries
588	testHistory := create40TestEntries()
589	mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
590	
591	// Create temporary directory for cache file
592	tempDir, err := os.MkdirTemp("", "cache_benchmark")
593	if err != nil {
594		b.Fatalf("Failed to create temp dir: %v", err)
595	}
596	defer os.RemoveAll(tempDir)
597	
598	config := DefaultCacheConfig()
599	config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin")
600	config.MaxResults = 50
601	
602	manager := NewCacheManager(mockQuerier, config)
603	ctx := context.Background()
604	
605	// Pre-build cache once (this isn't part of the benchmark)
606	_, err = manager.GetCache(ctx)
607	if err != nil {
608		b.Fatalf("Failed to pre-build cache: %v", err)
609	}
610	
611	b.ResetTimer() // Start timing from here
612	b.ReportAllocs()
613	
614	// This is the critical path that needs to be under 2ms
615	for i := 0; i < b.N; i++ {
616		result, err := manager.GetTopEntries(ctx)
617		if err != nil {
618			b.Fatalf("GetTopEntries failed: %v", err)
619		}
620		if len(result.Matches) == 0 {
621			b.Fatalf("Expected matches, got none")
622		}
623	}
624}
625
626// BenchmarkGetTopEntriesParallel benchmarks concurrent access performance
627func BenchmarkGetTopEntriesParallel(b *testing.B) {
628	ctrl := gomock.NewController(b)
629	defer ctrl.Finish()
630	
631	mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
632	
633	// Setup 40 test entries
634	testHistory := create40TestEntries()
635	mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
636	
637	// Create temporary directory for cache file
638	tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel")
639	if err != nil {
640		b.Fatalf("Failed to create temp dir: %v", err)
641	}
642	defer os.RemoveAll(tempDir)
643	
644	config := DefaultCacheConfig()
645	config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin")
646	config.MaxResults = 50
647	
648	manager := NewCacheManager(mockQuerier, config)
649	ctx := context.Background()
650	
651	// Pre-build cache once (this isn't part of the benchmark)
652	_, err = manager.GetCache(ctx)
653	if err != nil {
654		b.Fatalf("Failed to pre-build cache: %v", err)
655	}
656	
657	b.ResetTimer()
658	b.ReportAllocs()
659	
660	b.RunParallel(func(pb *testing.PB) {
661		for pb.Next() {
662			result, err := manager.GetTopEntries(ctx)
663			if err != nil {
664				b.Fatalf("GetTopEntries failed: %v", err)
665			}
666			if len(result.Matches) == 0 {
667				b.Fatalf("Expected matches, got none")
668			}
669		}
670	})
671}

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