v0.14.0 76d1892

handler.go RawHistoryFolder

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

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