detached 8a86fff

feat(clipboard): implement Ctrl+Shift+C shortcut to copy current URL

commit
8a86fff6eed2da36d43459b2ea2d7360709e69d3
author
brice · · 2025-12-17 18:51 UTC
committer
brice · · 2025-12-17 21:09 UTC
parent
717efa6
signature
unsigned
4 files · +83 -20
M internal/ui/component/omnibox.go +7 -10
@@ -7,7 +7,6 @@ import (
77 "sync"
88 "time"
99
10- "github.com/bnema/dumber/internal/application/port"
1110 "github.com/bnema/dumber/internal/application/usecase"
1211 "github.com/bnema/dumber/internal/domain/entity"
1312 "github.com/bnema/dumber/internal/domain/url"
@@ -81,7 +80,7 @@ type Omnibox struct {
8180 historyUC *usecase.SearchHistoryUseCase
8281 favoritesUC *usecase.ManageFavoritesUseCase
8382 faviconCache *cache.FaviconCache
84- clipboard port.Clipboard
83+ copyURLUC *usecase.CopyURLUseCase
8584 shortcuts map[string]config.SearchShortcut
8685 defaultSearch string
8786 initialBehavior string
@@ -114,7 +113,7 @@ type OmniboxConfig struct {
114113 HistoryUC *usecase.SearchHistoryUseCase
115114 FavoritesUC *usecase.ManageFavoritesUseCase
116115 FaviconCache *cache.FaviconCache
117- Clipboard port.Clipboard
116+ CopyURLUC *usecase.CopyURLUseCase
118117 Shortcuts map[string]config.SearchShortcut
119118 DefaultSearch string
120119 InitialBehavior string
@@ -139,7 +138,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
139138 historyUC: cfg.HistoryUC,
140139 favoritesUC: cfg.FavoritesUC,
141140 faviconCache: cfg.FaviconCache,
142- clipboard: cfg.Clipboard,
141+ copyURLUC: cfg.CopyURLUC,
143142 shortcuts: cfg.Shortcuts,
144143 defaultSearch: cfg.DefaultSearch,
145144 initialBehavior: cfg.InitialBehavior,
@@ -1071,8 +1070,8 @@ func (o *Omnibox) toggleFavorite() {
10711070 func (o *Omnibox) yankSelectedURL() {
10721071 log := logging.FromContext(o.ctx)
10731072
1074- if o.clipboard == nil {
1075- log.Warn().Msg("yank URL: clipboard is nil")
1073+ if o.copyURLUC == nil {
1074+ log.Warn().Msg("yank URL: copy URL use case is nil")
10761075 return
10771076 }
10781077
@@ -1105,11 +1104,9 @@ func (o *Omnibox) yankSelectedURL() {
11051104
11061105 go func() {
11071106 ctx := o.ctx
1108- if err := o.clipboard.WriteText(ctx, url); err != nil {
1109- log.Error().Err(err).Str("url", url).Msg("failed to copy URL to clipboard")
1110- return
1107+ if err := o.copyURLUC.Copy(ctx, url); err != nil {
1108+ return // Use case already logs the error
11111109 }
1112- log.Debug().Str("url", url).Msg("URL copied to clipboard")
11131110
11141111 // Show toast notification on success (must run on GTK main thread)
11151112 if o.onToast != nil {
@@ -7,7 +7,6 @@ import (
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10- "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/domain/url"12 "github.com/bnema/dumber/internal/domain/url"
@@ -81,7 +80,7 @@ type Omnibox struct {
81 historyUC *usecase.SearchHistoryUseCase80 historyUC *usecase.SearchHistoryUseCase
82 favoritesUC *usecase.ManageFavoritesUseCase81 favoritesUC *usecase.ManageFavoritesUseCase
83 faviconCache *cache.FaviconCache82 faviconCache *cache.FaviconCache
84- clipboard port.Clipboard83+ copyURLUC *usecase.CopyURLUseCase
85 shortcuts map[string]config.SearchShortcut84 shortcuts map[string]config.SearchShortcut
86 defaultSearch string85 defaultSearch string
87 initialBehavior string86 initialBehavior string
@@ -114,7 +113,7 @@ type OmniboxConfig struct {
114 HistoryUC *usecase.SearchHistoryUseCase113 HistoryUC *usecase.SearchHistoryUseCase
115 FavoritesUC *usecase.ManageFavoritesUseCase114 FavoritesUC *usecase.ManageFavoritesUseCase
116 FaviconCache *cache.FaviconCache115 FaviconCache *cache.FaviconCache
117- Clipboard port.Clipboard116+ CopyURLUC *usecase.CopyURLUseCase
118 Shortcuts map[string]config.SearchShortcut117 Shortcuts map[string]config.SearchShortcut
119 DefaultSearch string118 DefaultSearch string
120 InitialBehavior string119 InitialBehavior string
@@ -139,7 +138,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
139 historyUC: cfg.HistoryUC,138 historyUC: cfg.HistoryUC,
140 favoritesUC: cfg.FavoritesUC,139 favoritesUC: cfg.FavoritesUC,
141 faviconCache: cfg.FaviconCache,140 faviconCache: cfg.FaviconCache,
142- clipboard: cfg.Clipboard,141+ copyURLUC: cfg.CopyURLUC,
143 shortcuts: cfg.Shortcuts,142 shortcuts: cfg.Shortcuts,
144 defaultSearch: cfg.DefaultSearch,143 defaultSearch: cfg.DefaultSearch,
145 initialBehavior: cfg.InitialBehavior,144 initialBehavior: cfg.InitialBehavior,
@@ -1071,8 +1070,8 @@ func (o *Omnibox) toggleFavorite() {
1071 func (o *Omnibox) yankSelectedURL() {1070 func (o *Omnibox) yankSelectedURL() {
1072 log := logging.FromContext(o.ctx)1071 log := logging.FromContext(o.ctx)
1073 1072
1074- if o.clipboard == nil {1073+ if o.copyURLUC == nil {
1075- log.Warn().Msg("yank URL: clipboard is nil")1074+ log.Warn().Msg("yank URL: copy URL use case is nil")
1076 return1075 return
1077 }1076 }
1078 1077
@@ -1105,11 +1104,9 @@ func (o *Omnibox) yankSelectedURL() {
1105 1104
1106 go func() {1105 go func() {
1107 ctx := o.ctx1106 ctx := o.ctx
1108- if err := o.clipboard.WriteText(ctx, url); err != nil {1107+ if err := o.copyURLUC.Copy(ctx, url); err != nil {
1109- log.Error().Err(err).Str("url", url).Msg("failed to copy URL to clipboard")1108+ return // Use case already logs the error
1110- return
1111 }1109 }
1112- log.Debug().Str("url", url).Msg("URL copied to clipboard")
1113 1110
1114 // Show toast notification on success (must run on GTK main thread)1111 // Show toast notification on success (must run on GTK main thread)
1115 if o.onToast != nil {1112 if o.onToast != nil {
M internal/ui/coordinator/workspace.go +13 -0
@@ -1580,3 +1580,16 @@ func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent in
15801580 paneView.ShowZoomToast(ctx, zoomPercent)
15811581 }
15821582 }
1583+
1584+// ShowToastOnActivePane displays a toast notification on the active pane.
1585+func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, message string, level component.ToastLevel) {
1586+ _, wsView := c.getActiveWS()
1587+ if wsView == nil {
1588+ return
1589+ }
1590+
1591+ paneView := wsView.GetActivePaneView()
1592+ if paneView != nil {
1593+ paneView.ShowToast(ctx, message, level)
1594+ }
1595+}
@@ -1580,3 +1580,16 @@ func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent in
1580 paneView.ShowZoomToast(ctx, zoomPercent)1580 paneView.ShowZoomToast(ctx, zoomPercent)
1581 }1581 }
1582 }1582 }
1583+
1584+// ShowToastOnActivePane displays a toast notification on the active pane.
1585+func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, message string, level component.ToastLevel) {
1586+ _, wsView := c.getActiveWS()
1587+ if wsView == nil {
1588+ return
1589+ }
1590+
1591+ paneView := wsView.GetActivePaneView()
1592+ if paneView != nil {
1593+ paneView.ShowToast(ctx, message, level)
1594+ }
1595+}
M internal/ui/dispatcher/keyboard.go +58 -9
@@ -6,17 +6,20 @@ import (
66 "github.com/bnema/dumber/internal/application/usecase"
77 "github.com/bnema/dumber/internal/domain/entity"
88 "github.com/bnema/dumber/internal/logging"
9+ "github.com/bnema/dumber/internal/ui/component"
910 "github.com/bnema/dumber/internal/ui/coordinator"
1011 "github.com/bnema/dumber/internal/ui/input"
12+ "github.com/jwijenbergh/puregotk/v4/glib"
1113 )
1214
1315 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
1416 type KeyboardDispatcher struct {
15- tabCoord *coordinator.TabCoordinator
16- wsCoord *coordinator.WorkspaceCoordinator
17- navCoord *coordinator.NavigationCoordinator
18- zoomUC *usecase.ManageZoomUseCase
19- onQuit func()
17+ tabCoord *coordinator.TabCoordinator
18+ wsCoord *coordinator.WorkspaceCoordinator
19+ navCoord *coordinator.NavigationCoordinator
20+ zoomUC *usecase.ManageZoomUseCase
21+ copyURLUC *usecase.CopyURLUseCase
22+ onQuit func()
2023 }
2124
2225 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -26,15 +29,17 @@ func NewKeyboardDispatcher(
2629 wsCoord *coordinator.WorkspaceCoordinator,
2730 navCoord *coordinator.NavigationCoordinator,
2831 zoomUC *usecase.ManageZoomUseCase,
32+ copyURLUC *usecase.CopyURLUseCase,
2933 ) *KeyboardDispatcher {
3034 log := logging.FromContext(ctx)
3135 log.Debug().Msg("creating keyboard dispatcher")
3236
3337 return &KeyboardDispatcher{
34- tabCoord: tabCoord,
35- wsCoord: wsCoord,
36- navCoord: navCoord,
37- zoomUC: zoomUC,
38+ tabCoord: tabCoord,
39+ wsCoord: wsCoord,
40+ navCoord: navCoord,
41+ zoomUC: zoomUC,
42+ copyURLUC: copyURLUC,
3843 }
3944 }
4045
@@ -136,6 +141,10 @@ func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action)
136141 case input.ActionToggleFullscreen:
137142 log.Debug().Msg("toggle fullscreen action (not yet implemented)")
138143
144+ // Clipboard
145+ case input.ActionCopyURL:
146+ return d.handleCopyURL(ctx)
147+
139148 // Application
140149 case input.ActionQuit:
141150 if d.onQuit != nil {
@@ -212,3 +221,43 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
212221
213222 return nil
214223 }
224+
225+// handleCopyURL copies the active pane's URL to clipboard.
226+func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
227+ log := logging.FromContext(ctx)
228+
229+ if d.copyURLUC == nil {
230+ log.Warn().Msg("copy URL use case not available")
231+ return nil
232+ }
233+
234+ wv := d.navCoord.ActiveWebView(ctx)
235+ if wv == nil {
236+ log.Debug().Msg("no active webview for copy URL")
237+ return nil
238+ }
239+
240+ uri := wv.URI()
241+ if uri == "" {
242+ log.Debug().Msg("active webview has empty URI")
243+ return nil
244+ }
245+
246+ // Copy URL in background goroutine
247+ go func() {
248+ if err := d.copyURLUC.Copy(ctx, uri); err != nil {
249+ log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
250+ return
251+ }
252+
253+ // Show toast on GTK main thread
254+ var cb glib.SourceFunc
255+ cb = func(_ uintptr) bool {
256+ d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
257+ return false
258+ }
259+ glib.IdleAdd(&cb, 0)
260+ }()
261+
262+ return nil
263+}
@@ -6,17 +6,20 @@ import (
6 "github.com/bnema/dumber/internal/application/usecase"6 "github.com/bnema/dumber/internal/application/usecase"
7 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
9+ "github.com/bnema/dumber/internal/ui/component"
9 "github.com/bnema/dumber/internal/ui/coordinator"10 "github.com/bnema/dumber/internal/ui/coordinator"
10 "github.com/bnema/dumber/internal/ui/input"11 "github.com/bnema/dumber/internal/ui/input"
12+ "github.com/jwijenbergh/puregotk/v4/glib"
11 )13 )
12 14
13 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.15 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
14 type KeyboardDispatcher struct {16 type KeyboardDispatcher struct {
15- tabCoord *coordinator.TabCoordinator17+ tabCoord *coordinator.TabCoordinator
16- wsCoord *coordinator.WorkspaceCoordinator18+ wsCoord *coordinator.WorkspaceCoordinator
17- navCoord *coordinator.NavigationCoordinator19+ navCoord *coordinator.NavigationCoordinator
18- zoomUC *usecase.ManageZoomUseCase20+ zoomUC *usecase.ManageZoomUseCase
19- onQuit func()21+ copyURLUC *usecase.CopyURLUseCase
22+ onQuit func()
20 }23 }
21 24
22 // NewKeyboardDispatcher creates a new KeyboardDispatcher.25 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -26,15 +29,17 @@ func NewKeyboardDispatcher(
26 wsCoord *coordinator.WorkspaceCoordinator,29 wsCoord *coordinator.WorkspaceCoordinator,
27 navCoord *coordinator.NavigationCoordinator,30 navCoord *coordinator.NavigationCoordinator,
28 zoomUC *usecase.ManageZoomUseCase,31 zoomUC *usecase.ManageZoomUseCase,
32+ copyURLUC *usecase.CopyURLUseCase,
29 ) *KeyboardDispatcher {33 ) *KeyboardDispatcher {
30 log := logging.FromContext(ctx)34 log := logging.FromContext(ctx)
31 log.Debug().Msg("creating keyboard dispatcher")35 log.Debug().Msg("creating keyboard dispatcher")
32 36
33 return &KeyboardDispatcher{37 return &KeyboardDispatcher{
34- tabCoord: tabCoord,38+ tabCoord: tabCoord,
35- wsCoord: wsCoord,39+ wsCoord: wsCoord,
36- navCoord: navCoord,40+ navCoord: navCoord,
37- zoomUC: zoomUC,41+ zoomUC: zoomUC,
42+ copyURLUC: copyURLUC,
38 }43 }
39 }44 }
40 45
@@ -136,6 +141,10 @@ func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action)
136 case input.ActionToggleFullscreen:141 case input.ActionToggleFullscreen:
137 log.Debug().Msg("toggle fullscreen action (not yet implemented)")142 log.Debug().Msg("toggle fullscreen action (not yet implemented)")
138 143
144+ // Clipboard
145+ case input.ActionCopyURL:
146+ return d.handleCopyURL(ctx)
147+
139 // Application148 // Application
140 case input.ActionQuit:149 case input.ActionQuit:
141 if d.onQuit != nil {150 if d.onQuit != nil {
@@ -212,3 +221,43 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
212 221
213 return nil222 return nil
214 }223 }
224+
225+// handleCopyURL copies the active pane's URL to clipboard.
226+func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
227+ log := logging.FromContext(ctx)
228+
229+ if d.copyURLUC == nil {
230+ log.Warn().Msg("copy URL use case not available")
231+ return nil
232+ }
233+
234+ wv := d.navCoord.ActiveWebView(ctx)
235+ if wv == nil {
236+ log.Debug().Msg("no active webview for copy URL")
237+ return nil
238+ }
239+
240+ uri := wv.URI()
241+ if uri == "" {
242+ log.Debug().Msg("active webview has empty URI")
243+ return nil
244+ }
245+
246+ // Copy URL in background goroutine
247+ go func() {
248+ if err := d.copyURLUC.Copy(ctx, uri); err != nil {
249+ log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
250+ return
251+ }
252+
253+ // Show toast on GTK main thread
254+ var cb glib.SourceFunc
255+ cb = func(_ uintptr) bool {
256+ d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
257+ return false
258+ }
259+ glib.IdleAdd(&cb, 0)
260+ }()
261+
262+ return nil
263+}
M internal/ui/input/shortcuts.go +5 -1
@@ -96,6 +96,9 @@ const (
9696 ActionOpenDevTools Action = "open_devtools"
9797 ActionToggleFullscreen Action = "toggle_fullscreen"
9898
99+ // Clipboard
100+ ActionCopyURL Action = "copy_url"
101+
99102 // Application
100103 ActionQuit Action = "quit"
101104 )
@@ -171,7 +174,7 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
171174 // Standard browser shortcuts (hardcoded defaults)
172175 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
173176 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
174- s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl | ModShift}] = ActionHardReload
177+ s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload
175178 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
176179 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
177180 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
@@ -183,6 +186,7 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
183186 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
184187 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
185188 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
189+ s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
186190
187191 // Pane navigation (Alt+HJKL like vim)
188192 s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
@@ -96,6 +96,9 @@ const (
96 ActionOpenDevTools Action = "open_devtools"96 ActionOpenDevTools Action = "open_devtools"
97 ActionToggleFullscreen Action = "toggle_fullscreen"97 ActionToggleFullscreen Action = "toggle_fullscreen"
98 98
99+ // Clipboard
100+ ActionCopyURL Action = "copy_url"
101+
99 // Application102 // Application
100 ActionQuit Action = "quit"103 ActionQuit Action = "quit"
101 )104 )
@@ -171,7 +174,7 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
171 // Standard browser shortcuts (hardcoded defaults)174 // Standard browser shortcuts (hardcoded defaults)
172 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox175 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
173 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload176 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
174- s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl | ModShift}] = ActionHardReload177+ s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload
175 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload178 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
176 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload179 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
177 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools180 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
@@ -183,6 +186,7 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
183 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset186 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
184 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit187 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
185 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen188 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
189+ s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
186 190
187 // Pane navigation (Alt+HJKL like vim)191 // Pane navigation (Alt+HJKL like vim)
188 s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft192 s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft

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