v0.10.0 0f4e9eb

browser.go RawHistoryFolder

1// Package services contains application services that orchestrate business logic.
2package services
3
4import (
5	"context"
6	"crypto/md5"
7	"database/sql"
8	"embed"
9	"errors"
10	"fmt"
11	"log"
12	"math"
13	neturl "net/url"
14	"time"
15
16	"github.com/bnema/dumber/internal/cache"
17	"github.com/bnema/dumber/internal/config"
18	"github.com/bnema/dumber/internal/db"
19	"github.com/bnema/dumber/pkg/webkit"
20)
21
22func clampToInt32(i int64) int32 {
23	if i > math.MaxInt32 {
24		i = math.MaxInt32
25	}
26	if i < math.MinInt32 {
27		i = math.MinInt32
28	}
29	return int32(i) //nolint:gosec // value is clamped to int32 bounds
30}
31
32// WindowTitleUpdater interface allows the service to update the window title
33// Decoupled from any specific GUI framework.
34type WindowTitleUpdater interface {
35	SetTitle(title string)
36}
37
38// BrowserService handles browser-related operations for the built-in browser.
39type BrowserService struct {
40	config             *config.Config
41	dbQueries          db.DatabaseQuerier
42	windowTitleUpdater WindowTitleUpdater
43	webView            *webkit.WebView
44	initialURL         string
45	guiBundle          string
46}
47
48// ServiceName returns the service name for frontend binding
49func (s *BrowserService) ServiceName() string {
50	return "BrowserService"
51}
52
53// NavigationResult represents the result of a navigation operation.
54type NavigationResult struct {
55	URL           string        `json:"url"`
56	Title         string        `json:"title"`
57	Success       bool          `json:"success"`
58	Error         string        `json:"error,omitempty"`
59	LoadTime      time.Duration `json:"load_time"`
60	RedirectChain []string      `json:"redirect_chain,omitempty"`
61}
62
63// HistoryEntry represents a simplified history entry for frontend.
64type HistoryEntry struct {
65	ID          int64     `json:"id"`
66	URL         string    `json:"url"`
67	Title       string    `json:"title"`
68	FaviconURL  string    `json:"favicon_url"`
69	VisitCount  int32     `json:"visit_count"`
70	LastVisited time.Time `json:"last_visited"`
71	CreatedAt   time.Time `json:"created_at"`
72}
73
74// NewBrowserService creates a new BrowserService instance.
75func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
76	return &BrowserService{
77		config:             cfg,
78		dbQueries:          queries,
79		windowTitleUpdater: nil,
80		webView:            nil,
81		initialURL:         "",
82		guiBundle:          "",
83	}
84}
85
86// SetWindowTitleUpdater sets the window title updater interface
87func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
88	s.windowTitleUpdater = updater
89}
90
91// AttachWebView connects a native WebKit WebView to this service for integration.
92func (s *BrowserService) AttachWebView(view *webkit.WebView) {
93	s.webView = view
94
95	// Register legacy favicon change handler (binary data - fallback)
96	view.RegisterFaviconChangedHandler(func(data []byte) {
97		if currentURL := view.GetCurrentURL(); currentURL != "" {
98			log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data))
99			s.handleFaviconChanged(currentURL, data)
100		}
101	})
102
103	// Register favicon URI change handler (WebKit native)
104	view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {
105		s.handleFaviconURIChanged(pageURI, faviconURI)
106	})
107}
108
109// LoadGUIBundle loads the unified GUI bundle from assets
110func (s *BrowserService) LoadGUIBundle(assets embed.FS) error {
111	log.Printf("[browser] Attempting to load GUI bundle from assets/gui/gui.min.js")
112	bundleBytes, err := assets.ReadFile("assets/gui/gui.min.js")
113	if err != nil {
114		log.Printf("[browser] ERROR: Failed to load GUI bundle: %v", err)
115		return fmt.Errorf("failed to load GUI bundle: %w", err)
116	}
117	s.guiBundle = string(bundleBytes)
118	log.Printf("[browser] GUI bundle loaded successfully into browser service, size: %d bytes", len(bundleBytes))
119	return nil
120}
121
122// GetGUIBundle returns the loaded GUI bundle string
123func (s *BrowserService) GetGUIBundle() string {
124	return s.guiBundle
125}
126
127// InjectGUIBundle injects the unified GUI bundle and initializes controls
128func (s *BrowserService) InjectGUIBundle(ctx context.Context) error {
129	_ = ctx
130	if s.webView == nil {
131		return fmt.Errorf("webview not attached")
132	}
133	if s.guiBundle == "" {
134		return fmt.Errorf("GUI bundle not loaded")
135	}
136
137	// Inject the unified GUI bundle first
138	bundleScript := s.guiBundle +
139		";(function(){" +
140		"if(window.__dumber_gui && window.__dumber_gui.initializeControls){" +
141		"window.__dumber_gui.initializeControls();" +
142		"console.log('✅ Controls initialized via GUI bundle');" +
143		"}else{console.error('❌ GUI bundle not properly loaded');}" +
144		"})();"
145
146	return s.webView.InjectScript(bundleScript)
147}
148
149// InjectToastSystem injects the GUI bundle and initializes the toast system
150func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {
151	_ = ctx
152	if s.webView == nil {
153		return fmt.Errorf("webview not attached")
154	}
155	if s.guiBundle == "" {
156		return fmt.Errorf("GUI bundle not loaded")
157	}
158
159	// Set theme if provided
160	themeScript := ""
161	if theme != "" {
162		themeScript = fmt.Sprintf("window.__dumber_initial_theme = '%s';", theme)
163	}
164
165	// Inject the unified GUI bundle and initialize toast system after DOM ready
166	toastScript := s.guiBundle + ";" + themeScript +
167		"(function(){" +
168		"function initToast(){" +
169		"if(window.__dumber_gui && window.__dumber_gui.initializeToast){" +
170		"window.__dumber_gui.initializeToast().then(function(){" +
171		"console.log('✅ Toast system initialized via GUI bundle');" +
172		"}).catch(function(e){console.error('❌ Toast initialization failed:', e);});" +
173		"}else{console.error('❌ GUI bundle not properly loaded');}" +
174		"}" +
175		"if(document.readyState==='loading'){" +
176		"document.addEventListener('DOMContentLoaded',initToast);" +
177		"}else{initToast();}" +
178		"})();"
179
180	return s.webView.InjectScript(toastScript)
181}
182
183// Navigate handles navigation to a URL and records it in history.
184func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
185	startTime := time.Now()
186
187	if url == "" {
188		return &NavigationResult{
189			Success: false,
190			Error:   "URL cannot be empty",
191		}, nil
192	}
193
194	// Store the initial URL for frontend synchronization
195	if s.initialURL == "" {
196		s.initialURL = url
197	}
198
199	// Record the navigation in history
200	err := s.recordHistory(ctx, url, "")
201	if err != nil {
202		// Don't fail navigation if history recording fails
203		fmt.Printf("Failed to record history: %v\n", err)
204	}
205
206	// Favicon detection is now handled automatically by WebKit's native favicon database
207
208	return &NavigationResult{
209		URL:      url,
210		Success:  true,
211		LoadTime: time.Since(startTime),
212	}, nil
213}
214
215// UpdatePageTitle updates the title of the current page in history and the window title.
216func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
217	if url == "" || title == "" {
218		return fmt.Errorf("URL and title cannot be empty")
219	}
220
221	// Use AddOrUpdateHistory to update the title
222	titleNull := sql.NullString{String: title, Valid: true}
223	err := s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
224	if err != nil {
225		return err
226	}
227
228	// Update the window title if we have a title updater
229	if s.windowTitleUpdater != nil && title != "" {
230		windowTitle := fmt.Sprintf("Dumber - %s", title)
231		updater := s.windowTitleUpdater
232		if s.webView != nil {
233			s.webView.RunOnMainThread(func() {
234				updater.SetTitle(windowTitle)
235			})
236		} else {
237			updater.SetTitle(windowTitle)
238		}
239	}
240
241	return nil
242}
243
244// GetRecentHistory returns recent browser history entries.
245func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
246	if limit <= 0 {
247		limit = 50 // Default limit
248	}
249
250	entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
251	if err != nil {
252		return nil, err
253	}
254
255	result := make([]HistoryEntry, len(entries))
256	for i, entry := range entries {
257		// Defensive cast to int32 to prevent overflow
258		vc := clampToInt32(entry.VisitCount.Int64)
259		result[i] = HistoryEntry{
260			ID:          entry.ID,
261			URL:         entry.Url,
262			Title:       entry.Title.String,
263			FaviconURL:  entry.FaviconUrl.String,
264			VisitCount:  vc,
265			LastVisited: entry.LastVisited.Time,
266			CreatedAt:   entry.CreatedAt.Time,
267		}
268	}
269
270	return result, nil
271}
272
273// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
274func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
275	if limit <= 0 {
276		limit = 20 // Default limit for pagination
277	}
278	if offset < 0 {
279		offset = 0
280	}
281
282	entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
283	if err != nil {
284		return nil, err
285	}
286
287	result := make([]HistoryEntry, len(entries))
288	for i, entry := range entries {
289		// Defensive cast to int32 to prevent overflow
290		vc := clampToInt32(entry.VisitCount.Int64)
291		result[i] = HistoryEntry{
292			ID:          entry.ID,
293			URL:         entry.Url,
294			Title:       entry.Title.String,
295			FaviconURL:  entry.FaviconUrl.String,
296			VisitCount:  vc,
297			LastVisited: entry.LastVisited.Time,
298			CreatedAt:   entry.CreatedAt.Time,
299		}
300	}
301
302	return result, nil
303}
304
305// SearchHistory searches browser history.
306func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
307	if query == "" {
308		return s.GetRecentHistory(ctx, limit)
309	}
310
311	if limit <= 0 {
312		limit = 50
313	}
314
315	urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
316	titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
317
318	entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
319	if err != nil {
320		return nil, err
321	}
322
323	result := make([]HistoryEntry, len(entries))
324	for i, entry := range entries {
325		vc := clampToInt32(entry.VisitCount.Int64)
326		result[i] = HistoryEntry{
327			ID:          entry.ID,
328			URL:         entry.Url,
329			Title:       entry.Title.String,
330			FaviconURL:  entry.FaviconUrl.String,
331			VisitCount:  vc,
332			LastVisited: entry.LastVisited.Time,
333			CreatedAt:   entry.CreatedAt.Time,
334		}
335	}
336
337	return result, nil
338}
339
340// DeleteHistoryEntry removes a specific history entry.
341func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
342	return s.dbQueries.DeleteHistory(ctx, id)
343}
344
345// ClearHistory removes all history entries.
346func (s *BrowserService) ClearHistory(ctx context.Context) error {
347	_ = ctx
348	// Note: ClearAllHistory method doesn't exist in current schema
349	// This would need to be implemented in the database layer
350	return fmt.Errorf("clear all history not implemented yet")
351}
352
353// GetHistoryStats returns statistics about browser history.
354func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
355	// Get recent entries for basic stats
356	recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
357	if err != nil {
358		return nil, err
359	}
360
361	// Calculate basic statistics
362	stats := map[string]interface{}{
363		"total_entries": len(recentEntries),
364		"recent_count":  len(recentEntries),
365	}
366
367	// Add more detailed stats if we have entries
368	if len(recentEntries) > 0 {
369		stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
370		stats["newest_entry"] = recentEntries[0].CreatedAt.Time
371	}
372
373	return stats, nil
374}
375
376// GetConfig returns the current browser configuration.
377func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
378	_ = ctx
379	return s.config, nil
380}
381
382// UpdateConfig updates the browser configuration.
383func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
384	_ = ctx
385	// In a real implementation, you'd want to validate and persist the config
386	s.config = newConfig
387	return nil
388}
389
390// GetSearchShortcuts returns available search shortcuts.
391func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
392	return s.config.SearchShortcuts, nil
393}
394
395// recordHistory adds or updates a history entry.
396func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
397	// Use AddOrUpdateHistory which handles both cases
398	titleNull := sql.NullString{Valid: false}
399	if title != "" {
400		titleNull = sql.NullString{String: title, Valid: true}
401	}
402
403	return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
404}
405
406// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
407var firefoxZoomLevels = []float64{0.3, 0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.2, 1.33, 1.5, 1.7, 2.0, 2.4, 3.0, 4.0, 5.0}
408
409// ZoomIn increases the zoom level to the next Firefox zoom level.
410func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
411	currentZoom, err := s.GetZoomLevel(ctx, url)
412	if err != nil {
413		return 1.0, err
414	}
415
416	newZoom := s.getNextZoomLevel(currentZoom, true)
417	err = s.SetZoomLevel(ctx, url, newZoom)
418	if err != nil {
419		return currentZoom, err
420	}
421
422	return newZoom, nil
423}
424
425// ZoomOut decreases the zoom level to the previous Firefox zoom level.
426func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
427	currentZoom, err := s.GetZoomLevel(ctx, url)
428	if err != nil {
429		return 1.0, err
430	}
431
432	newZoom := s.getNextZoomLevel(currentZoom, false)
433	err = s.SetZoomLevel(ctx, url, newZoom)
434	if err != nil {
435		return currentZoom, err
436	}
437
438	return newZoom, nil
439}
440
441// ResetZoom resets the zoom level to 1.0 for the current URL.
442func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
443	return s.setZoom(ctx, url, 1.0)
444}
445
446// GetZoomLevel retrieves the saved zoom level for a URL.
447func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
448	if url == "" {
449		return 1.0, nil
450	}
451
452	key := zoomKeyFromURL(url)
453	zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
454	if err != nil {
455		if errors.Is(err, sql.ErrNoRows) {
456			// Fallback to exact URL record if present (backward compatibility)
457			zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
458			if err2 == nil {
459				return zl, nil
460			}
461			// No zoom setting found, return default
462			return 1.0, nil
463		}
464		return 1.0, err
465	}
466
467	return zoomLevel, nil
468}
469
470// SetZoomLevel sets the zoom level for a URL.
471func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
472	if url == "" {
473		return fmt.Errorf("URL cannot be empty")
474	}
475
476	// Clamp zoom level to reasonable bounds
477	if zoomLevel < zoomMin {
478		zoomLevel = zoomMin
479	} else if zoomLevel > zoomMax {
480		zoomLevel = zoomMax
481	}
482
483	key := zoomKeyFromURL(url)
484	return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
485}
486
487// GetCurrentURL returns the current URL (this would be implemented by the frontend)
488func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
489	if s.webView == nil {
490		return "", nil
491	}
492	return s.webView.GetCurrentURL(), nil
493}
494
495// GetInitialURL returns the initially navigated URL for frontend synchronization
496func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
497	return s.initialURL, nil
498}
499
500// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
501func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
502	// This is primarily for logging/recording the copy action
503	// The actual clipboard operation is handled by the frontend
504	if url != "" {
505		// Could record this action in analytics if needed
506		fmt.Printf("URL copied to clipboard: %s\n", url)
507	}
508	return nil
509}
510
511// GoBack provides navigation back functionality
512func (s *BrowserService) GoBack(ctx context.Context) error {
513	if s.webView != nil {
514		return s.webView.GoBack()
515	}
516	fmt.Println("Navigation: Go back")
517	return nil
518}
519
520// GoForward provides navigation forward functionality
521func (s *BrowserService) GoForward(ctx context.Context) error {
522	if s.webView != nil {
523		return s.webView.GoForward()
524	}
525	fmt.Println("Navigation: Go forward")
526	return nil
527}
528
529// getNextZoomLevel returns the next Firefox zoom level in the specified direction
530const (
531	zoomMin            = 0.25
532	zoomMax            = 5.0
533	recentHistoryLimit = 1000
534)
535
536func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
537	// Find the closest current zoom level
538	closestIndex := 0
539	minDiff := 10.0 // Large initial value
540
541	for i, level := range firefoxZoomLevels {
542		diff := currentZoom - level
543		if diff < 0 {
544			diff = -diff // abs value
545		}
546		if diff < minDiff {
547			minDiff = diff
548			closestIndex = i
549		}
550	}
551
552	if zoomIn {
553		// Move to next higher zoom level
554		if closestIndex < len(firefoxZoomLevels)-1 {
555			return firefoxZoomLevels[closestIndex+1]
556		}
557		return firefoxZoomLevels[closestIndex] // Already at max
558	}
559	// Move to next lower zoom level
560	if closestIndex > 0 {
561		return firefoxZoomLevels[closestIndex-1]
562	}
563	return firefoxZoomLevels[closestIndex] // Already at min
564}
565
566// setZoom sets the zoom level to a specific value
567func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
568	err := s.SetZoomLevel(ctx, url, zoomLevel)
569	if err != nil {
570		return 1.0, err
571	}
572	return zoomLevel, nil
573}
574
575// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
576// Uses hostname if present; falls back to the full input URL string.
577func zoomKeyFromURL(raw string) string {
578	u, err := neturl.Parse(raw)
579	if err != nil || u.Host == "" {
580		return raw
581	}
582	return u.Host
583}
584
585// extractDomain extracts the domain from a URL for zoom level storage
586// Domain-based zoom allows settings to persist across different pages of the same site
587// extractDomain is no longer used; zoom is stored per URL for contract parity.
588
589// ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
590func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
591
592
593// handleFaviconChanged processes favicon data received from WebKit (legacy binary data method)
594func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
595	log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData))
596
597	if len(pngData) == 0 {
598		log.Printf("[favicon] No favicon data for %s", pageURL)
599		return
600	}
601
602	ctx := context.Background()
603
604	// Parse URL for validation
605	parsedURL, err := neturl.Parse(pageURL)
606	if err != nil {
607		return
608	}
609
610	// Skip favicon update for localhost, file://, or special schemes
611	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
612		return
613	}
614	if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
615		return
616	}
617
618	// Cache the PNG data directly
619	if faviconCache, err := cache.NewFaviconCache(); err == nil {
620		if err := faviconCache.CachePNGData(pageURL, pngData); err != nil {
621			// Log error but don't fail
622			log.Printf("[favicon] Failed to cache favicon for %s: %v", pageURL, err)
623		} else {
624			log.Printf("[favicon] Successfully cached favicon for %s (%d bytes)", pageURL, len(pngData))
625		}
626	} else {
627		log.Printf("[favicon] Failed to create favicon cache: %v", err)
628	}
629
630	// Store a reference to the cached file in the database
631	// We use a consistent path format for the favicon URL
632	faviconCacheURL := fmt.Sprintf("dumber://favicon/%x.png", md5.Sum([]byte(pageURL)))
633	faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
634	if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
635		// Silently fail - favicon is not critical
636		log.Printf("[browser] Failed to update favicon URL in database for %s: %v", pageURL, err)
637	}
638}
639
640// handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database
641func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) {
642	log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI)
643
644	ctx := context.Background()
645
646	// Parse URL for validation
647	parsedURL, err := neturl.Parse(pageURL)
648	if err != nil {
649		log.Printf("[favicon] Invalid page URL: %s", pageURL)
650		return
651	}
652
653	// Skip favicon update for localhost, file://, or special schemes
654	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
655		return
656	}
657	if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
658		return
659	}
660
661	// Store the favicon URI directly in the database
662	// The frontend will handle SVG vs PNG/ICO detection
663	faviconNullString := sql.NullString{String: faviconURI, Valid: true}
664	if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
665		log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
666	} else {
667		log.Printf("[browser] Successfully stored favicon URI for %s: %s", pageURL, faviconURI)
668	}
669}

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