v0.11.0 dc50343

handler.go RawHistoryFolder

1package messaging
2
3import (
4	"context"
5	"encoding/json"
6	"log"
7	"strconv"
8	"strings"
9	"time"
10
11	"github.com/bnema/dumber/internal/app/constants"
12	"github.com/bnema/dumber/internal/app/control"
13	"github.com/bnema/dumber/internal/config"
14	"github.com/bnema/dumber/internal/logging"
15	"github.com/bnema/dumber/internal/services"
16	"github.com/bnema/dumber/pkg/webkit"
17)
18
19// Handler processes script messages from the WebView
20type WorkspaceObserver interface {
21	OnWorkspaceMessage(source *webkit.WebView, msg Message)
22}
23
24type Handler struct {
25	parserService        *services.ParserService
26	browserService       *services.BrowserService
27	webView              *webkit.WebView
28	navigationController *control.NavigationController
29	lastTheme            string
30	workspaceObserver    WorkspaceObserver
31}
32
33// Message represents a script message from the WebView
34type Message struct {
35	Type      string `json:"type"`
36	URL       string `json:"url"`
37	Q         string `json:"q"`
38	Limit     int    `json:"limit"`
39	Offset    int    `json:"offset"`
40	Value     string `json:"value"`
41	Event     string `json:"event"`
42	Action    string `json:"action"`
43	Direction string `json:"direction"`
44	// History operations
45	HistoryID string `json:"historyId"`
46	// Request tracking
47	RequestID string `json:"requestId"`
48	// Popup close tracking
49	WebViewID string `json:"webviewId"`
50	Reason    string `json:"reason"`
51	// Wails fetch bridge
52	ID      string          `json:"id"`
53	Payload json.RawMessage `json:"payload"`
54}
55
56// WindowIntent represents an intercepted window.open call from JavaScript
57type WindowIntent struct {
58	URL           string `json:"url"`
59	Target        string `json:"target"`
60	Features      string `json:"features"`
61	Timestamp     int64  `json:"timestamp"`
62	WindowType    string `json:"windowType"` // "tab", "popup", "unknown"
63	IsPopup       bool   `json:"isPopup"`
64	IsTab         bool   `json:"isTab"`
65	RequestID     string `json:"requestId"`     // NEW: Unique request identifier
66	UserTriggered bool   `json:"userTriggered"` // NEW: Whether this was user-initiated
67	// Parsed features
68	Width     *int  `json:"width,omitempty"`
69	Height    *int  `json:"height,omitempty"`
70	Toolbar   *bool `json:"toolbar,omitempty"`
71	Location  *bool `json:"location,omitempty"`
72	Menubar   *bool `json:"menubar,omitempty"`
73	Resizable *bool `json:"resizable,omitempty"`
74}
75
76// NewHandler creates a new message handler
77func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
78	return &Handler{
79		parserService:  parserService,
80		browserService: browserService,
81	}
82}
83
84// SetNavigationController injects the navigation controller for unified navigation flow.
85func (h *Handler) SetNavigationController(controller *control.NavigationController) {
86	h.navigationController = controller
87}
88
89// SetWorkspaceObserver registers a workspace event observer.
90func (h *Handler) SetWorkspaceObserver(observer WorkspaceObserver) {
91	h.workspaceObserver = observer
92}
93
94// Handle processes incoming script messages
95func (h *Handler) Handle(payload string) {
96	var msg Message
97	if err := json.Unmarshal([]byte(payload), &msg); err != nil {
98		log.Printf("[ERROR] Failed to unmarshal message: %v", err)
99		return
100	}
101
102	switch msg.Type {
103	case "navigate":
104		h.handleNavigation(msg)
105	case "query":
106		log.Printf("[DEBUG] Handling query message: q=%s, limit=%d", msg.Q, msg.Limit)
107		if h.webView == nil {
108			log.Printf("[WARN] Dropping query response: webview unavailable for suggestions")
109			return
110		}
111		h.handleQuery(msg)
112	case "wails":
113		h.handleWailsBridge(msg)
114	case "theme":
115		h.handleTheme(msg)
116	case "history_recent":
117		h.handleHistoryRecent(msg)
118	case "history_stats":
119		h.handleHistoryStats(msg)
120	case "history_search":
121		h.handleHistorySearch(msg)
122	case "history_delete":
123		h.handleHistoryDelete(msg)
124	case "workspace":
125		h.handleWorkspace(msg)
126	case "handle-window-open":
127		h.handleWindowOpen(msg)
128	case "close-popup":
129		h.handleClosePopup(msg)
130	case "console-message":
131		h.handleConsoleMessage(msg)
132	case "request-webview-id":
133		h.handleWebViewIDRequest(msg)
134	case "get_search_shortcuts":
135		h.handleGetSearchShortcuts(msg)
136	case "get_color_palettes":
137		h.handleGetColorPalettes(msg)
138	}
139}
140
141// SetWebView sets the WebView reference (needed for script injection)
142func (h *Handler) SetWebView(webView *webkit.WebView) {
143	h.webView = webView
144}
145
146// handleNavigation processes navigation requests from the frontend
147func (h *Handler) handleNavigation(msg Message) {
148	if h.navigationController != nil {
149		if err := h.navigationController.NavigateToURL(msg.URL); err != nil {
150			log.Printf("[messaging] Navigation controller failed for input %q: %v", msg.URL, err)
151			h.legacyNavigate(msg)
152		}
153		return
154	}
155
156	h.legacyNavigate(msg)
157}
158
159func (h *Handler) legacyNavigate(msg Message) {
160	ctx := context.Background()
161	res, err := h.parserService.ParseInput(ctx, msg.URL)
162	if err != nil {
163		log.Printf("[messaging] Legacy navigation parse failed for %q: %v", msg.URL, err)
164		return
165	}
166
167	if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil {
168		log.Printf("Warning: failed to navigate to %s: %v", res.URL, navErr)
169	}
170
171	if h.webView == nil {
172		return
173	}
174
175	if err := h.webView.LoadURL(res.URL); err != nil {
176		log.Printf("[messaging] Legacy LoadURL failed for %s: %v", res.URL, err)
177	}
178
179	if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil {
180		if err := h.webView.SetZoom(z); err != nil {
181			log.Printf("[messaging] Legacy SetZoom failed for %s: %v", res.URL, err)
182		}
183	}
184}
185
186// (legacy query handler removed; omnibox suggestions now fetched via dumb://api/omnibox/suggestions)
187// handleQuery computes omnibox suggestions natively and returns them to the GUI without fetch
188func (h *Handler) handleWorkspace(msg Message) {
189	if h.workspaceObserver == nil {
190		log.Printf("[workspace] Received workspace event %q but no observer registered", msg.Event)
191		return
192	}
193	if h.webView == nil {
194		log.Printf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event)
195		return
196	}
197	log.Printf("[workspace] Forwarding workspace event: event=%s direction=%s action=%s", msg.Event, msg.Direction, msg.Action)
198	h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
199}
200
201func (h *Handler) handleClosePopup(msg Message) {
202	log.Printf("[messaging] Received close-popup request: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
203
204	if h.workspaceObserver == nil {
205		log.Printf("[messaging] No workspace observer registered for close-popup request")
206		return
207	}
208	if h.webView == nil {
209		log.Printf("[messaging] No webview attached for close-popup request")
210		return
211	}
212
213	// Forward close-popup request to workspace manager via observer
214	closeMsg := Message{
215		Type:      "workspace",
216		Event:     "close-popup",
217		WebViewID: msg.WebViewID,
218		Reason:    msg.Reason,
219	}
220
221	log.Printf("[messaging] Forwarding close-popup to workspace: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
222	h.workspaceObserver.OnWorkspaceMessage(h.webView, closeMsg)
223}
224
225func (h *Handler) handleQuery(msg Message) {
226	if h.webView == nil {
227		log.Printf("[WARN] Skipping query handling: webview is nil")
228		return
229	}
230	log.Printf("[DEBUG] handleQuery called: q='%s', limit=%d", msg.Q, msg.Limit)
231	if h.browserService == nil {
232		log.Printf("[ERROR] handleQuery: browserService is nil")
233		return
234	}
235	ctx := context.Background()
236	q := msg.Q
237	if q == "" {
238		q = ""
239	}
240	limit := msg.Limit
241	if limit <= 0 {
242		limit = 10
243	}
244
245	// Build suggestions: shortcuts first, then history search
246	type suggestion struct {
247		URL     string `json:"url"`
248		Favicon string `json:"favicon,omitempty"`
249	}
250
251	buildFavicon := func(raw string) string {
252		if raw == "" {
253			return ""
254		}
255		scheme := "https"
256		host := raw
257		if i := strings.Index(raw, "://"); i >= 0 {
258			if i > 0 {
259				scheme = raw[:i]
260			}
261			rest := raw[i+3:]
262			if j := strings.IndexByte(rest, '/'); j >= 0 {
263				host = rest[:j]
264			} else {
265				host = rest
266			}
267		} else {
268			if j := strings.IndexByte(raw, '/'); j >= 0 {
269				host = raw[:j]
270			}
271		}
272		if host == "" {
273			return ""
274		}
275		return scheme + "://" + host + "/favicon.ico"
276	}
277
278	results := make([]suggestion, 0, limit)
279	seen := make(map[string]struct{}, limit*2)
280
281	// Shortcuts intentionally omitted; rely on explicit prefix commands (e.g., gh:) or history results
282
283	// History
284	if len(results) < limit {
285		remaining := limit - len(results)
286		log.Printf("[DEBUG] Searching history for '%s' with limit %d", q, remaining)
287		if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err != nil {
288			log.Printf("[ERROR] Failed to search history: %v", err)
289		} else {
290			log.Printf("[DEBUG] Found %d history entries", len(entries))
291			for i, e := range entries {
292				// JSON roundtrip to map to get url field agnostic of struct tag
293				bb, _ := json.Marshal(e)
294				var m map[string]any
295				_ = json.Unmarshal(bb, &m)
296				var url string
297				if s, ok := m["url"].(string); ok {
298					url = s
299				} else if s, ok := m["URL"].(string); ok {
300					url = s
301				}
302				log.Printf("[DEBUG] History entry %d: url=%s", i, url)
303				if url == "" {
304					continue
305				}
306				if _, ok := seen[url]; ok {
307					continue
308				}
309				// Use favicon_url from database if available, otherwise build it
310				favicon := ""
311				if s, ok := m["favicon_url"].(string); ok && s != "" {
312					favicon = s
313				} else {
314					favicon = buildFavicon(url)
315				}
316				results = append(results, suggestion{URL: url, Favicon: favicon})
317				seen[url] = struct{}{}
318				if len(results) >= limit {
319					break
320				}
321			}
322		}
323	}
324
325	// Inject back to GUI
326	log.Printf("[DEBUG] Final results count: %d", len(results))
327	if b, err := json.Marshal(results); err != nil {
328		log.Printf("[ERROR] Failed to marshal results: %v", err)
329	} else {
330		// Prefer unified page-world API; fallback to legacy global function
331		script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
332		log.Print("[DEBUG] Injecting omnibox suggestions")
333		if injErr := h.webView.InjectScript(script); injErr != nil {
334			log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)
335		} else {
336			log.Printf("[DEBUG] Successfully injected omnibox suggestions")
337		}
338	}
339}
340
341// handleWailsBridge processes Wails runtime bridge calls for homepage
342func (h *Handler) handleWailsBridge(msg Message) {
343	// Payload contains { methodID, methodName?, args }
344	var p struct {
345		MethodID   uint32          `json:"methodID"`
346		MethodName string          `json:"methodName"`
347		Args       json.RawMessage `json:"args"`
348	}
349	if err := json.Unmarshal(msg.Payload, &p); err != nil {
350		return
351	}
352
353	// Only implement the IDs we need
354	switch p.MethodID {
355	case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit)
356		var args []interface{}
357		_ = json.Unmarshal(p.Args, &args)
358		limit := 50
359		if len(args) > 0 {
360			if f, ok := args[0].(float64); ok {
361				limit = int(f)
362			}
363		}
364		ctx := context.Background()
365		entries, err := h.browserService.GetRecentHistory(ctx, limit)
366		if err != nil {
367			return
368		}
369		resp, _ := json.Marshal(entries)
370		if h.webView != nil {
371			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
372		}
373	case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts()
374		ctx := context.Background()
375		shortcuts, err := h.browserService.GetSearchShortcuts(ctx)
376		if err != nil {
377			return
378		}
379		resp, _ := json.Marshal(shortcuts)
380		if h.webView != nil {
381			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
382		}
383	default:
384		// Return empty JSON to avoid breaking UI
385		if h.webView != nil {
386			_ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')")
387		}
388	}
389}
390
391// handleTheme processes theme-related messages
392func (h *Handler) handleTheme(msg Message) {
393	if msg.Value != "" && msg.Value != h.lastTheme {
394		log.Printf("[theme] color-scheme changed: %s", msg.Value)
395		h.lastTheme = msg.Value
396	}
397}
398
399// handleHistoryRecent processes recent history requests
400func (h *Handler) handleHistoryRecent(msg Message) {
401	if h.browserService == nil || h.webView == nil {
402		return
403	}
404
405	ctx := context.Background()
406	limit := msg.Limit
407	if limit <= 0 {
408		limit = 20
409	}
410	offset := msg.Offset
411
412	var entries []interface{}
413	var err error
414
415	// Use paginated version if offset is provided
416	if offset > 0 {
417		histEntries, e := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset)
418		if e != nil {
419			err = e
420		} else {
421			entries = make([]interface{}, len(histEntries))
422			for i, entry := range histEntries {
423				entries[i] = entry
424			}
425		}
426	} else {
427		// Use original method for backward compatibility
428		histEntries, e := h.browserService.GetRecentHistory(ctx, limit)
429		if e != nil {
430			err = e
431		} else {
432			entries = make([]interface{}, len(histEntries))
433			for i, entry := range histEntries {
434				entries[i] = entry
435			}
436		}
437	}
438
439	if err != nil {
440		log.Printf("[messaging] Failed to get recent history: %v", err)
441		requestIdParam := ""
442		if msg.RequestID != "" {
443			requestIdParam = ", '" + msg.RequestID + "'"
444		}
445		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load recent history'" + requestIdParam + ")")
446		return
447	}
448
449	b, _ := json.Marshal(entries)
450	requestIdParam := ""
451	if msg.RequestID != "" {
452		requestIdParam = ", '" + msg.RequestID + "'"
453	}
454	_ = h.webView.InjectScript("window.__dumber_history_recent && window.__dumber_history_recent(" + string(b) + requestIdParam + ")")
455}
456
457// handleHistoryStats processes history stats requests
458func (h *Handler) handleHistoryStats(_ Message) {
459	if h.browserService == nil || h.webView == nil {
460		return
461	}
462
463	ctx := context.Background()
464	stats, err := h.browserService.GetHistoryStats(ctx)
465	if err != nil {
466		log.Printf("[messaging] Failed to get history stats: %v", err)
467		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load history stats')")
468		return
469	}
470
471	b, _ := json.Marshal(stats)
472	_ = h.webView.InjectScript("window.__dumber_history_stats && window.__dumber_history_stats(" + string(b) + ")")
473}
474
475// handleHistorySearch processes history search requests
476func (h *Handler) handleHistorySearch(msg Message) {
477	if h.browserService == nil || h.webView == nil {
478		return
479	}
480
481	ctx := context.Background()
482	query := msg.Q
483	limit := msg.Limit
484	if limit <= 0 {
485		limit = 5
486	}
487
488	entries, err := h.browserService.SearchHistory(ctx, query, limit)
489	if err != nil {
490		log.Printf("[messaging] Failed to search history: %v", err)
491		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to search history')")
492		return
493	}
494
495	b, _ := json.Marshal(entries)
496	_ = h.webView.InjectScript("window.__dumber_history_search && window.__dumber_history_search(" + string(b) + ")")
497}
498
499// handleHistoryDelete processes history deletion requests
500func (h *Handler) handleHistoryDelete(msg Message) {
501	if h.browserService == nil || h.webView == nil {
502		return
503	}
504
505	if msg.HistoryID == "" {
506		log.Printf("[messaging] History delete: missing historyId")
507		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Missing history ID')")
508		return
509	}
510
511	// Convert string ID to int64
512	id, err := strconv.ParseInt(msg.HistoryID, 10, 64)
513	if err != nil {
514		log.Printf("[messaging] History delete: invalid ID format: %v", err)
515		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Invalid history ID format')")
516		return
517	}
518
519	ctx := context.Background()
520	err = h.browserService.DeleteHistoryEntry(ctx, id)
521	if err != nil {
522		log.Printf("[messaging] Failed to delete history entry: %v", err)
523		_ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to delete history entry')")
524		return
525	}
526
527	// Send success response with the deleted ID
528	successData := map[string]string{"deletedId": msg.HistoryID}
529	b, _ := json.Marshal(successData)
530	_ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
531}
532
533// handleWindowOpen processes handle-window-open messages to create panes directly
534func (h *Handler) handleWindowOpen(msg Message) {
535	var intent WindowIntent
536	if err := json.Unmarshal(msg.Payload, &intent); err != nil {
537		log.Printf("[messaging] Failed to unmarshal handle-window-open: %v", err)
538		return
539	}
540
541	log.Printf("[messaging] Handling direct window.open request: url=%s type=%s requestId=%s", intent.URL, intent.WindowType, intent.RequestID)
542
543	// Forward to workspace observer if available
544	if h.workspaceObserver != nil && h.webView != nil {
545		// Create a workspace message to trigger pane creation
546		workspaceMsg := Message{
547			Type:      "workspace",
548			Event:     "create-pane",
549			URL:       intent.URL,
550			Action:    intent.WindowType, // "tab" or "popup"
551			RequestID: intent.RequestID,  // NEW: Pass through request ID
552		}
553		log.Printf("[messaging] Forwarding to workspace: %+v", workspaceMsg)
554		h.workspaceObserver.OnWorkspaceMessage(h.webView, workspaceMsg)
555	} else {
556		log.Printf("[messaging] No workspace observer available for direct window.open")
557	}
558}
559
560// handleConsoleMessage processes console-message from JavaScript
561func (h *Handler) handleConsoleMessage(msg Message) {
562	// Check if console capture is enabled
563	cfg := config.Get()
564	if !cfg.Logging.CaptureConsole {
565		return
566	}
567
568	// Parse the console message payload
569	var consolePayload struct {
570		Level   string `json:"level"`
571		Message string `json:"message"`
572		URL     string `json:"url"`
573	}
574
575	if err := json.Unmarshal(msg.Payload, &consolePayload); err != nil {
576		log.Printf("[messaging] Failed to unmarshal console-message: %v", err)
577		return
578	}
579
580	// Send to logging system with [CONSOLE] tag
581	logging.CaptureWebKitLog(consolePayload.Message)
582}
583
584// handleWebViewIDRequest responds to JavaScript requests for the webview ID
585func (h *Handler) handleWebViewIDRequest(msg Message) {
586	if h.webView == nil {
587		log.Printf("[messaging] Cannot provide webview ID - no webview available")
588		return
589	}
590
591	// Check if WebView is destroyed before attempting any operations
592	if h.webView.IsDestroyed() {
593		log.Printf("[messaging] Cannot provide webview ID - webview is destroyed")
594		return
595	}
596
597	webViewID := h.webView.ID()
598	log.Printf("[messaging] Sending webview ID %s to JavaScript", webViewID)
599
600	// Send the webview ID back to JavaScript via custom event
601	if err := h.webView.DispatchCustomEvent("dumber:webview-id", map[string]any{
602		"webviewId": webViewID,
603		"timestamp": time.Now().UnixMilli(),
604	}); err != nil {
605		log.Printf("[messaging] Failed to send webview ID: %v", err)
606	}
607}
608
609// handleGetSearchShortcuts sends search shortcuts configuration to JavaScript
610func (h *Handler) handleGetSearchShortcuts(msg Message) {
611	if h.webView == nil {
612		log.Printf("[messaging] Cannot provide search shortcuts - no webview available")
613		return
614	}
615
616	if h.webView.IsDestroyed() {
617		log.Printf("[messaging] Cannot provide search shortcuts - webview is destroyed")
618		return
619	}
620
621	// Get search shortcuts from browser service config
622	shortcuts, err := h.browserService.GetSearchShortcuts(context.Background())
623	if err != nil {
624		log.Printf("[messaging] Failed to get search shortcuts: %v", err)
625		_ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to get search shortcuts')")
626		return
627	}
628
629	// Marshal to JSON
630	b, err := json.Marshal(shortcuts)
631	if err != nil {
632		log.Printf("[messaging] Failed to marshal search shortcuts: %v", err)
633		_ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to load search shortcuts')")
634		return
635	}
636
637	// Inject the search shortcuts into the page
638	log.Printf("[messaging] Sending search shortcuts to JavaScript")
639	_ = h.webView.InjectScript("window.__dumber_search_shortcuts && window.__dumber_search_shortcuts(" + string(b) + ")")
640}
641
642// handleGetColorPalettes sends color palettes configuration to JavaScript
643func (h *Handler) handleGetColorPalettes(msg Message) {
644	if h.webView == nil {
645		log.Printf("[messaging] Cannot provide color palettes - no webview available")
646		return
647	}
648
649	if h.webView.IsDestroyed() {
650		log.Printf("[messaging] Cannot provide color palettes - webview is destroyed")
651		return
652	}
653
654	// Get color palettes from browser service config
655	palettes := h.browserService.GetColorPalettesForMessaging()
656
657	// Marshal to JSON
658	b, err := json.Marshal(palettes)
659	if err != nil {
660		log.Printf("[messaging] Failed to marshal color palettes: %v", err)
661		_ = h.webView.InjectScript("window.__dumber_color_palettes_error && window.__dumber_color_palettes_error('Failed to load color palettes')")
662		return
663	}
664
665	// Inject the color palettes into the page
666	log.Printf("[messaging] Sending color palettes to JavaScript")
667	_ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")
668}

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