v0.30.0 eb3068f

keyboard.go RawHistoryFolder

1package dispatcher
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/application/port"
8	"github.com/bnema/dumber/internal/application/usecase"
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/dumber/internal/ui/component"
12	"github.com/bnema/dumber/internal/ui/coordinator"
13	"github.com/bnema/dumber/internal/ui/input"
14	"github.com/bnema/puregotk/v4/glib"
15)
16
17const (
18	// historySystemViewURL is the full-page/systemview history surface.
19	// It remains reachable by direct navigation (for example typing
20	// dumb://history in the omnibox), but Ctrl+H no longer falls back to it.
21	// Ctrl+H is reserved for the native GTK history sidebar only.
22	historySystemViewURL   = "dumb://history"
23	favoritesSystemViewURL = "dumb://favorites"
24	configSystemViewURL    = "dumb://config"
25)
26
27type KeyboardActions struct {
28	NewTab         func(context.Context) error
29	CloseTab       func(context.Context) error
30	NextTab        func(context.Context) error
31	PreviousTab    func(context.Context) error
32	SwitchLastTab  func(context.Context) error
33	SwitchTabIndex func(context.Context, int) error
34	ActiveWebView  func(context.Context) port.WebView
35}
36
37// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
38type KeyboardDispatcher struct {
39	actions                KeyboardActions
40	wsCoord                *coordinator.WorkspaceCoordinator
41	navCoord               *coordinator.NavigationCoordinator
42	zoomUC                 *usecase.ManageZoomUseCase
43	copyURLUC              *usecase.CopyURLUseCase
44	actionHandlers         map[input.Action]func(ctx context.Context) error
45	onQuit                 func()
46	onFindOpen             func(ctx context.Context) error
47	onFindNext             func(ctx context.Context) error
48	onFindPrev             func(ctx context.Context) error
49	onFindClose            func(ctx context.Context) error
50	activePaneID           func(ctx context.Context) entity.PaneID
51	onSessionOpen          func(ctx context.Context, paneID entity.PaneID) error
52	onMovePaneToTab        func(ctx context.Context, paneID entity.PaneID) error
53	onMovePaneToNext       func(ctx context.Context, paneID entity.PaneID) error
54	onEjectPaneToWindow    func(ctx context.Context, paneID entity.PaneID) error
55	onToggleHistorySidebar func(ctx context.Context) error
56	onToggleFloating       func(ctx context.Context) error
57	onOpenFloating         func(ctx context.Context, target input.FloatingProfileTarget) error
58}
59
60// NewKeyboardDispatcher creates a new KeyboardDispatcher.
61func NewKeyboardDispatcher(
62	ctx context.Context,
63	wsCoord *coordinator.WorkspaceCoordinator,
64	navCoord *coordinator.NavigationCoordinator,
65	zoomUC *usecase.ManageZoomUseCase,
66	copyURLUC *usecase.CopyURLUseCase,
67	actions KeyboardActions,
68	activePaneID func(context.Context) entity.PaneID,
69) *KeyboardDispatcher {
70	log := logging.FromContext(ctx)
71	log.Debug().Msg("creating keyboard dispatcher")
72
73	dispatcher := &KeyboardDispatcher{
74		actions:      actions,
75		wsCoord:      wsCoord,
76		navCoord:     navCoord,
77		zoomUC:       zoomUC,
78		copyURLUC:    copyURLUC,
79		activePaneID: activePaneID,
80	}
81	dispatcher.initActionHandlers()
82	return dispatcher
83}
84
85// SetOnQuit sets the callback for quit action.
86func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
87	d.onQuit = fn
88}
89
90// SetOnFindOpen sets the callback for opening the find bar.
91func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
92	d.onFindOpen = fn
93}
94
95// SetOnFindNext sets the callback for the next match action.
96func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
97	d.onFindNext = fn
98}
99
100// SetOnFindPrev sets the callback for the previous match action.
101func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
102	d.onFindPrev = fn
103}
104
105// SetOnFindClose sets the callback for closing the find bar.
106func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
107	d.onFindClose = fn
108}
109
110// SetOnSessionOpen sets the callback for opening the session manager.
111func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) {
112	d.onSessionOpen = fn
113}
114
115func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
116	d.onMovePaneToTab = fn
117}
118
119func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
120	d.onMovePaneToNext = fn
121}
122
123func (d *KeyboardDispatcher) SetOnEjectPaneToWindow(fn func(ctx context.Context, paneID entity.PaneID) error) {
124	d.onEjectPaneToWindow = fn
125}
126
127func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Context) error) {
128	d.onToggleHistorySidebar = fn
129}
130
131func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
132	d.onToggleFloating = fn
133}
134
135func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
136	if fn == nil {
137		d.onOpenFloating = nil
138		return
139	}
140	d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
141		return fn(ctx, target.URL)
142	}
143}
144
145func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
146	d.onOpenFloating = fn
147}
148
149func (d *KeyboardDispatcher) initActionHandlers() {
150	const (
151		firstTabIndex   = 0
152		secondTabIndex  = 1
153		thirdTabIndex   = 2
154		fourthTabIndex  = 3
155		fifthTabIndex   = 4
156		sixthTabIndex   = 5
157		seventhTabIndex = 6
158		eighthTabIndex  = 7
159		ninthTabIndex   = 8
160		tenthTabIndex   = 9
161	)
162	d.actionHandlers = map[input.Action]func(ctx context.Context) error{
163		// Tab actions
164		input.ActionNewTab:   func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) },
165		input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) },
166		input.ActionNextTab:  func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) },
167		input.ActionPreviousTab: func(ctx context.Context) error {
168			return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab)
169		},
170		input.ActionSwitchLastTab: func(ctx context.Context) error {
171			return d.handleKeyboardAction(ctx, "switch last tab", d.actions.SwitchLastTab)
172		},
173		input.ActionSwitchTabIndex1:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
174		input.ActionSwitchTabIndex2:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
175		input.ActionSwitchTabIndex3:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
176		input.ActionSwitchTabIndex4:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fourthTabIndex) },
177		input.ActionSwitchTabIndex5:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fifthTabIndex) },
178		input.ActionSwitchTabIndex6:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, sixthTabIndex) },
179		input.ActionSwitchTabIndex7:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, seventhTabIndex) },
180		input.ActionSwitchTabIndex8:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, eighthTabIndex) },
181		input.ActionSwitchTabIndex9:  func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, ninthTabIndex) },
182		input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, tenthTabIndex) },
183		input.ActionRenameTab: func(ctx context.Context) error {
184			return d.logNoop(ctx, "rename tab action (not yet implemented)")
185		},
186		// Pane actions
187		input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) },
188		input.ActionSplitLeft:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) },
189		input.ActionSplitUp:    func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) },
190		input.ActionSplitDown:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
191		input.ActionClosePane:  d.wsCoord.ClosePane,
192		input.ActionStackPane:  d.wsCoord.StackPane,
193		input.ActionMovePaneToTab: func(ctx context.Context) error {
194			return d.handleMovePaneToTab(ctx)
195		},
196		input.ActionMovePaneToNextTab: func(ctx context.Context) error {
197			return d.handleMovePaneToNextTab(ctx)
198		},
199		input.ActionEjectPaneToWindow: func(ctx context.Context) error {
200			return d.handleEjectPaneToWindow(ctx)
201		},
202		input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
203			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
204		},
205		input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
206			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
207		},
208		input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
209			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
210		},
211		input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
212			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
213		},
214		input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
215		input.ActionFocusLeft:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
216		input.ActionFocusUp:    func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
217		input.ActionFocusDown:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
218		// Resize actions
219		input.ActionResizeIncreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
220		input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
221		input.ActionResizeIncreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
222		input.ActionResizeIncreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
223		input.ActionResizeDecreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
224		input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
225		input.ActionResizeDecreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
226		input.ActionResizeDecreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
227		input.ActionResizeIncrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
228		input.ActionResizeDecrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
229		// Stack navigation
230		input.ActionStackNavUp:   func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
231		input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
232		// Navigation
233		input.ActionGoBack:     d.handleGoBack,
234		input.ActionGoForward:  d.handleGoForward,
235		input.ActionReload:     d.handleReload,
236		input.ActionHardReload: d.handleHardReload,
237		input.ActionPrintPage:  d.handlePrintPage,
238		// Zoom actions
239		input.ActionZoomIn:    func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
240		input.ActionZoomOut:   func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
241		input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
242		// UI
243		input.ActionOpenOmnibox:  d.navCoord.OpenOmnibox,
244		input.ActionOpenFind:     d.handleFindOpen,
245		input.ActionFindNext:     d.handleFindNext,
246		input.ActionFindPrev:     d.handleFindPrev,
247		input.ActionCloseFind:    d.handleFindClose,
248		input.ActionOpenDevTools: d.handleOpenDevTools,
249		input.ActionToggleFloatingPane: func(ctx context.Context) error {
250			if d.onToggleFloating != nil {
251				return d.onToggleFloating(ctx)
252			}
253			return d.logNoop(ctx, "toggle floating pane action (no handler)")
254		},
255		// ActionToggleHistorySystemView (default Ctrl+H) is intentionally bound
256		// to the native GTK history sidebar only. The dumb://history systemview
257		// remains available through direct navigation, not shortcut fallback.
258		input.ActionToggleHistorySystemView: func(ctx context.Context) error {
259			if d.onToggleHistorySidebar == nil {
260				return fmt.Errorf("history sidebar unavailable: toggle handler not wired")
261			}
262			return d.onToggleHistorySidebar(ctx)
263		},
264		input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
265			return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)
266		},
267		input.ActionToggleConfigSystemView: func(ctx context.Context) error {
268			return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
269		},
270		input.ActionToggleFullscreen: func(ctx context.Context) error {
271			return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
272		},
273		// Clipboard
274		input.ActionCopyURL: d.handleCopyURL,
275		// Session management
276		input.ActionOpenSessionManager: d.handleSessionOpen,
277		// Application
278		input.ActionQuit: d.handleQuit,
279	}
280}
281
282// Dispatch routes a keyboard action to the appropriate coordinator.
283func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
284	log := logging.FromContext(ctx)
285	log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
286
287	if target, ok := input.ParseFloatingProfileTarget(action); ok {
288		if d.onOpenFloating != nil {
289			return d.onOpenFloating(ctx, target)
290		}
291		log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
292		return nil
293	}
294
295	if handler, ok := d.actionHandlers[action]; ok {
296		return handler(ctx)
297	}
298
299	log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
300	return nil
301}
302
303func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
304	if d.onFindOpen != nil {
305		return d.onFindOpen(ctx)
306	}
307	logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
308	return nil
309}
310
311func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error {
312	if d.onFindNext != nil {
313		return d.onFindNext(ctx)
314	}
315	logging.FromContext(ctx).Debug().Msg("find next action (no handler)")
316	return nil
317}
318
319func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error {
320	if d.onFindPrev != nil {
321		return d.onFindPrev(ctx)
322	}
323	logging.FromContext(ctx).Debug().Msg("find prev action (no handler)")
324	return nil
325}
326
327func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
328	if d.onFindClose != nil {
329		return d.onFindClose(ctx)
330	}
331	logging.FromContext(ctx).Debug().Msg("find close action (no handler)")
332	return nil
333}
334
335func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
336	if d.onSessionOpen != nil {
337		return d.onSessionOpen(ctx, d.activePaneID(ctx))
338	}
339	logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
340	return nil
341}
342
343func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
344	if d.onMovePaneToTab != nil {
345		return d.onMovePaneToTab(ctx, d.activePaneID(ctx))
346	}
347	logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
348	return nil
349}
350
351func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
352	if d.onMovePaneToNext != nil {
353		return d.onMovePaneToNext(ctx, d.activePaneID(ctx))
354	}
355	logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
356	return nil
357}
358
359func (d *KeyboardDispatcher) handleEjectPaneToWindow(ctx context.Context) error {
360	if d.onEjectPaneToWindow != nil {
361		return d.onEjectPaneToWindow(ctx, d.activePaneID(ctx))
362	}
363	logging.FromContext(ctx).Debug().Msg("eject pane to window action (no handler)")
364	return nil
365}
366
367func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
368	if d.actions.SwitchTabIndex == nil {
369		logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
370		return nil
371	}
372	return d.actions.SwitchTabIndex(ctx, index)
373}
374
375func (d *KeyboardDispatcher) handleKeyboardAction(ctx context.Context, name string, fn func(context.Context) error) error {
376	if fn == nil {
377		logging.FromContext(ctx).Debug().Str("action", name).Msg("keyboard action ignored (no handler)")
378		return nil
379	}
380	return fn(ctx)
381}
382
383func (d *KeyboardDispatcher) activeWebView(ctx context.Context) port.WebView {
384	if d.actions.ActiveWebView == nil {
385		logging.FromContext(ctx).Debug().Msg("active webview unavailable (no handler)")
386		return nil
387	}
388	return d.actions.ActiveWebView(ctx)
389}
390
391func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
392	if ctx == nil {
393		return fmt.Errorf("missing context")
394	}
395	logging.FromContext(ctx).Debug().Msg(message)
396	return nil
397}
398
399func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
400	if d.onQuit != nil {
401		d.onQuit()
402	}
403	if ctx == nil {
404		return fmt.Errorf("missing context")
405	}
406	return nil
407}
408
409func (d *KeyboardDispatcher) withActiveWebView(ctx context.Context, action string, fn func(port.WebView) error) error {
410	wv := d.activeWebView(ctx)
411	if wv == nil {
412		logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for keyboard action")
413		return nil
414	}
415	return fn(wv)
416}
417
418func (d *KeyboardDispatcher) handleReload(ctx context.Context) error {
419	return d.withActiveWebView(ctx, "reload", func(wv port.WebView) error {
420		return d.navCoord.ReloadWebView(ctx, wv, false)
421	})
422}
423
424func (d *KeyboardDispatcher) handleHardReload(ctx context.Context) error {
425	return d.withActiveWebView(ctx, "hard reload", func(wv port.WebView) error {
426		return d.navCoord.ReloadWebView(ctx, wv, true)
427	})
428}
429
430func (d *KeyboardDispatcher) handleGoBack(ctx context.Context) error {
431	return d.withActiveWebView(ctx, "go back", func(wv port.WebView) error {
432		return d.navCoord.GoBackWebView(ctx, wv)
433	})
434}
435
436func (d *KeyboardDispatcher) handleGoForward(ctx context.Context) error {
437	return d.withActiveWebView(ctx, "go forward", func(wv port.WebView) error {
438		return d.navCoord.GoForwardWebView(ctx, wv)
439	})
440}
441
442func (d *KeyboardDispatcher) handlePrintPage(ctx context.Context) error {
443	return d.withActiveWebView(ctx, "print page", func(wv port.WebView) error {
444		return d.navCoord.PrintWebView(ctx, wv)
445	})
446}
447
448func (d *KeyboardDispatcher) handleOpenDevTools(ctx context.Context) error {
449	return d.withActiveWebView(ctx, "open devtools", func(wv port.WebView) error {
450		return d.navCoord.OpenDevToolsWebView(ctx, wv)
451	})
452}
453
454// handleZoom processes zoom in/out/reset actions for the active WebView.
455func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
456	log := logging.FromContext(ctx)
457
458	if d.zoomUC == nil {
459		log.Warn().Msg("zoom use case not available")
460		return nil
461	}
462
463	wv := d.activeWebView(ctx)
464	if wv == nil {
465		log.Debug().Msg("no active webview for zoom")
466		return nil
467	}
468
469	zoomKey, err := usecase.ExtractZoomKey(wv.URI())
470	if err != nil {
471		log.Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
472		return nil
473	}
474
475	current := wv.GetZoomLevel()
476	var newZoom *entity.ZoomLevel
477
478	switch action {
479	case "in":
480		newZoom, err = d.zoomUC.ZoomIn(ctx, zoomKey, current)
481	case "out":
482		newZoom, err = d.zoomUC.ZoomOut(ctx, zoomKey, current)
483	case "reset":
484		err = d.zoomUC.ResetZoom(ctx, zoomKey)
485		if err == nil {
486			newZoom = entity.NewZoomLevel(zoomKey, d.zoomUC.DefaultZoom())
487		}
488	}
489
490	if err != nil {
491		log.Error().Err(err).Str("action", action).Msg("zoom action failed")
492		return err
493	}
494
495	if newZoom != nil {
496		if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil {
497			log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom")
498			return setErr
499		}
500
501		// Notify omnibox to update zoom indicator
502		d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
503
504		// Show zoom toast on the active pane
505		zoomPercent := int(newZoom.ZoomFactor * 100)
506		d.wsCoord.ShowZoomToast(ctx, zoomPercent)
507
508		log.Debug().
509			Str("zoom_key", zoomKey).
510			Str("action", action).
511			Float64("zoom", newZoom.ZoomFactor).
512			Msg("zoom applied")
513	}
514
515	return nil
516}
517
518// handleCopyURL copies the active pane's URL to clipboard.
519func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
520	log := logging.FromContext(ctx)
521
522	if d.copyURLUC == nil {
523		log.Warn().Msg("copy URL use case not available")
524		return nil
525	}
526
527	wv := d.activeWebView(ctx)
528	if wv == nil {
529		log.Debug().Msg("no active webview for copy URL")
530		return nil
531	}
532
533	uri := wv.URI()
534	if uri == "" {
535		log.Debug().Msg("active webview has empty URI")
536		return nil
537	}
538
539	// Copy URL in background goroutine
540	go func() {
541		if err := d.copyURLUC.Copy(ctx, uri); err != nil {
542			log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
543			return
544		}
545
546		// Show toast on GTK main thread
547		cb := glib.SourceFunc(func(_ uintptr) bool {
548			d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
549			return false
550		})
551		glib.IdleAdd(&cb, 0)
552	}()
553
554	return nil
555}

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