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}