detached 79d72a8

feat(browser): implement Ctrl+Shift+C URL copy with reusable toast system

commit
79d72a808e3311bc6ef04dbb22d49f5125616501
author
Brice · · 2025-09-10 20:45 UTC
parent
5fcbde5
signature
unsigned
9 files · +366 -18
M CHANGELOG.md +5 -0
@@ -22,6 +22,11 @@ All notable changes to this project will be documented in this file.
2222 - Browser data includes cookies, localStorage, sessionStorage
2323 - Confirmation prompt with `--force` flag to skip
2424 - Targets all data locations: SQLite database, WebKit cache/data, dmenu fuzzy cache, configuration files
25+- Ctrl+Shift+C keyboard shortcut to copy current URL to clipboard:
26+ - Cross-platform clipboard support with wl-copy (Wayland) and xclip (X11) fallback
27+ - Reusable toast notification system for user feedback
28+ - Injectable JavaScript component for consistent styling across all websites
29+ - Native backend integration for reliable clipboard access
2530
2631 ### Changed
2732 - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster)
@@ -22,6 +22,11 @@ All notable changes to this project will be documented in this file.
22 - Browser data includes cookies, localStorage, sessionStorage22 - Browser data includes cookies, localStorage, sessionStorage
23 - Confirmation prompt with `--force` flag to skip23 - Confirmation prompt with `--force` flag to skip
24 - Targets all data locations: SQLite database, WebKit cache/data, dmenu fuzzy cache, configuration files24 - Targets all data locations: SQLite database, WebKit cache/data, dmenu fuzzy cache, configuration files
25+- Ctrl+Shift+C keyboard shortcut to copy current URL to clipboard:
26+ - Cross-platform clipboard support with wl-copy (Wayland) and xclip (X11) fallback
27+ - Reusable toast notification system for user feedback
28+ - Injectable JavaScript component for consistent styling across all websites
29+ - Native backend integration for reliable clipboard access
25 30
26 ### Changed31 ### Changed
27 - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster)32 - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster)
M README.md +1 -0
@@ -39,6 +39,7 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl
3939 | **Ctrl/Cmd++** | Zoom In | Alternative plus key |
4040 | **Ctrl/Cmd+-** | Zoom Out | Works across keyboard layouts |
4141 | **Ctrl/Cmd+0** | Reset Zoom | Return to 100% zoom |
42+| **Ctrl/Cmd+Shift+C** | Copy URL | Copy current URL to clipboard with toast |
4243 | **Alt+←** | Navigate Back | Go to previous page |
4344 | **Alt+→** | Navigate Forward | Go to next page |
4445
@@ -39,6 +39,7 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl
39 | **Ctrl/Cmd++** | Zoom In | Alternative plus key |39 | **Ctrl/Cmd++** | Zoom In | Alternative plus key |
40 | **Ctrl/Cmd+-** | Zoom Out | Works across keyboard layouts |40 | **Ctrl/Cmd+-** | Zoom Out | Works across keyboard layouts |
41 | **Ctrl/Cmd+0** | Reset Zoom | Return to 100% zoom |41 | **Ctrl/Cmd+0** | Reset Zoom | Return to 100% zoom |
42+| **Ctrl/Cmd+Shift+C** | Copy URL | Copy current URL to clipboard with toast |
42 | **Alt+←** | Navigate Back | Go to previous page |43 | **Alt+←** | Navigate Back | Go to previous page |
43 | **Alt+→** | Navigate Forward | Go to next page |44 | **Alt+→** | Navigate Forward | Go to next page |
44 45
M frontend/src/injected-controls.ts +0 -17
@@ -6,7 +6,6 @@
66 *
77 * Only includes essential controls that can work cross-origin:
88 * - Navigation (back/forward)
9- * - URL copying
109 * - Mouse navigation buttons
1110 *
1211 * Zoom controls are handled natively by the GTK backend
@@ -31,22 +30,6 @@
3130 const isCmd = ctrlKey || metaKey;
3231
3332 try {
34- // Copy URL (Cmd+Shift+C)
35- if (isCmd && shiftKey && key === 'C') {
36- event.preventDefault();
37- event.stopPropagation();
38-
39- // Copy current URL to clipboard
40- if (navigator.clipboard && navigator.clipboard.writeText) {
41- navigator.clipboard.writeText(window.location.href)
42- .then(() => console.log('🔗 URL copied:', window.location.href))
43- .catch((err) => console.error('❌ Failed to copy URL:', err));
44- } else {
45- console.warn('⚠️ Clipboard API not available');
46- }
47- return;
48- }
49-
5033 // Navigation: Alt + Left Arrow (Back)
5134 if (altKey && key === 'ArrowLeft') {
5235 event.preventDefault();
@@ -6,7 +6,6 @@
6 * 6 *
7 * Only includes essential controls that can work cross-origin:7 * Only includes essential controls that can work cross-origin:
8 * - Navigation (back/forward)8 * - Navigation (back/forward)
9- * - URL copying
10 * - Mouse navigation buttons9 * - Mouse navigation buttons
11 * 10 *
12 * Zoom controls are handled natively by the GTK backend11 * Zoom controls are handled natively by the GTK backend
@@ -31,22 +30,6 @@
31 const isCmd = ctrlKey || metaKey;30 const isCmd = ctrlKey || metaKey;
32 31
33 try {32 try {
34- // Copy URL (Cmd+Shift+C)
35- if (isCmd && shiftKey && key === 'C') {
36- event.preventDefault();
37- event.stopPropagation();
38-
39- // Copy current URL to clipboard
40- if (navigator.clipboard && navigator.clipboard.writeText) {
41- navigator.clipboard.writeText(window.location.href)
42- .then(() => console.log('🔗 URL copied:', window.location.href))
43- .catch((err) => console.error('❌ Failed to copy URL:', err));
44- } else {
45- console.warn('⚠️ Clipboard API not available');
46- }
47- return;
48- }
49-
50 // Navigation: Alt + Left Arrow (Back)33 // Navigation: Alt + Left Arrow (Back)
51 if (altKey && key === 'ArrowLeft') {34 if (altKey && key === 'ArrowLeft') {
52 event.preventDefault();35 event.preventDefault();
M main.go +19 -0
@@ -20,6 +20,7 @@ import (
2020 "github.com/bnema/dumber/internal/cli"
2121 "github.com/bnema/dumber/internal/config"
2222 "github.com/bnema/dumber/internal/db"
23+ "github.com/bnema/dumber/pkg/clipboard"
2324 "github.com/bnema/dumber/pkg/webkit"
2425 "github.com/bnema/dumber/services"
2526 )
@@ -565,6 +566,24 @@ func runBrowser() {
565566 log.Printf("Shortcut: Omnibox toggle")
566567 _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
567568 })
569+ // Copy URL (Ctrl+Shift+C)
570+ _ = view.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
571+ log.Printf("Shortcut: Copy URL")
572+ currentURL := view.GetCurrentURL()
573+ if currentURL == "" {
574+ log.Printf("No URL to copy")
575+ _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
576+ return
577+ }
578+
579+ if err := clipboard.CopyToClipboard(currentURL); err != nil {
580+ log.Printf("Failed to copy URL to clipboard: %v", err)
581+ _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
582+ } else {
583+ log.Printf("URL copied to clipboard: %s", currentURL)
584+ _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`)
585+ }
586+ })
568587 // Zoom handled natively in webkit package (built-in shortcuts)
569588 }
570589
@@ -20,6 +20,7 @@ import (
20 "github.com/bnema/dumber/internal/cli"20 "github.com/bnema/dumber/internal/cli"
21 "github.com/bnema/dumber/internal/config"21 "github.com/bnema/dumber/internal/config"
22 "github.com/bnema/dumber/internal/db"22 "github.com/bnema/dumber/internal/db"
23+ "github.com/bnema/dumber/pkg/clipboard"
23 "github.com/bnema/dumber/pkg/webkit"24 "github.com/bnema/dumber/pkg/webkit"
24 "github.com/bnema/dumber/services"25 "github.com/bnema/dumber/services"
25 )26 )
@@ -565,6 +566,24 @@ func runBrowser() {
565 log.Printf("Shortcut: Omnibox toggle")566 log.Printf("Shortcut: Omnibox toggle")
566 _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")567 _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
567 })568 })
569+ // Copy URL (Ctrl+Shift+C)
570+ _ = view.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
571+ log.Printf("Shortcut: Copy URL")
572+ currentURL := view.GetCurrentURL()
573+ if currentURL == "" {
574+ log.Printf("No URL to copy")
575+ _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
576+ return
577+ }
578+
579+ if err := clipboard.CopyToClipboard(currentURL); err != nil {
580+ log.Printf("Failed to copy URL to clipboard: %v", err)
581+ _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
582+ } else {
583+ log.Printf("URL copied to clipboard: %s", currentURL)
584+ _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`)
585+ }
586+ })
568 // Zoom handled natively in webkit package (built-in shortcuts)587 // Zoom handled natively in webkit package (built-in shortcuts)
569 }588 }
570 589
A pkg/clipboard/clipboard.go +117 -0
@@ -0,0 +1,117 @@
1+package clipboard
2+
3+import (
4+ "fmt"
5+ "os/exec"
6+)
7+
8+// CopyToClipboard copies the given text to the system clipboard.
9+// It tries wl-copy first (Wayland), then falls back to xclip (X11).
10+// Returns an error if both fail.
11+func CopyToClipboard(text string) error {
12+ if text == "" {
13+ return fmt.Errorf("cannot copy empty text to clipboard")
14+ }
15+
16+ // Try wl-copy first (Wayland)
17+ if err := tryWlCopy(text); err == nil {
18+ return nil
19+ }
20+
21+ // Fallback to xclip (X11)
22+ if err := tryXclip(text); err == nil {
23+ return nil
24+ }
25+
26+ return fmt.Errorf("clipboard copy failed: neither wl-copy nor xclip available")
27+}
28+
29+// tryWlCopy attempts to copy text using wl-copy (Wayland clipboard)
30+func tryWlCopy(text string) error {
31+ cmd := exec.Command("wl-copy")
32+ cmd.Stdin = nil
33+
34+ // Set up stdin pipe to pass the text
35+ stdin, err := cmd.StdinPipe()
36+ if err != nil {
37+ return fmt.Errorf("failed to create stdin pipe for wl-copy: %w", err)
38+ }
39+
40+ // Start the command
41+ if err := cmd.Start(); err != nil {
42+ stdin.Close()
43+ return fmt.Errorf("failed to start wl-copy: %w", err)
44+ }
45+
46+ // Write text to stdin and close it
47+ _, writeErr := stdin.Write([]byte(text))
48+ closeErr := stdin.Close()
49+
50+ // Wait for command to complete
51+ waitErr := cmd.Wait()
52+
53+ // Return the first error encountered
54+ if writeErr != nil {
55+ return fmt.Errorf("failed to write to wl-copy stdin: %w", writeErr)
56+ }
57+ if closeErr != nil {
58+ return fmt.Errorf("failed to close wl-copy stdin: %w", closeErr)
59+ }
60+ if waitErr != nil {
61+ return fmt.Errorf("wl-copy command failed: %w", waitErr)
62+ }
63+
64+ return nil
65+}
66+
67+// tryXclip attempts to copy text using xclip (X11 clipboard)
68+func tryXclip(text string) error {
69+ cmd := exec.Command("xclip", "-selection", "clipboard")
70+
71+ // Set up stdin pipe to pass the text
72+ stdin, err := cmd.StdinPipe()
73+ if err != nil {
74+ return fmt.Errorf("failed to create stdin pipe for xclip: %w", err)
75+ }
76+
77+ // Start the command
78+ if err := cmd.Start(); err != nil {
79+ stdin.Close()
80+ return fmt.Errorf("failed to start xclip: %w", err)
81+ }
82+
83+ // Write text to stdin and close it
84+ _, writeErr := stdin.Write([]byte(text))
85+ closeErr := stdin.Close()
86+
87+ // Wait for command to complete
88+ waitErr := cmd.Wait()
89+
90+ // Return the first error encountered
91+ if writeErr != nil {
92+ return fmt.Errorf("failed to write to xclip stdin: %w", writeErr)
93+ }
94+ if closeErr != nil {
95+ return fmt.Errorf("failed to close xclip stdin: %w", closeErr)
96+ }
97+ if waitErr != nil {
98+ return fmt.Errorf("xclip command failed: %w", waitErr)
99+ }
100+
101+ return nil
102+}
103+
104+// IsAvailable checks if clipboard functionality is available on the system
105+func IsAvailable() bool {
106+ // Check if wl-copy is available
107+ if _, err := exec.LookPath("wl-copy"); err == nil {
108+ return true
109+ }
110+
111+ // Check if xclip is available
112+ if _, err := exec.LookPath("xclip"); err == nil {
113+ return true
114+ }
115+
116+ return false
117+}⏎̸
@@ -0,0 +1,117 @@
1+package clipboard
2+
3+import (
4+ "fmt"
5+ "os/exec"
6+)
7+
8+// CopyToClipboard copies the given text to the system clipboard.
9+// It tries wl-copy first (Wayland), then falls back to xclip (X11).
10+// Returns an error if both fail.
11+func CopyToClipboard(text string) error {
12+ if text == "" {
13+ return fmt.Errorf("cannot copy empty text to clipboard")
14+ }
15+
16+ // Try wl-copy first (Wayland)
17+ if err := tryWlCopy(text); err == nil {
18+ return nil
19+ }
20+
21+ // Fallback to xclip (X11)
22+ if err := tryXclip(text); err == nil {
23+ return nil
24+ }
25+
26+ return fmt.Errorf("clipboard copy failed: neither wl-copy nor xclip available")
27+}
28+
29+// tryWlCopy attempts to copy text using wl-copy (Wayland clipboard)
30+func tryWlCopy(text string) error {
31+ cmd := exec.Command("wl-copy")
32+ cmd.Stdin = nil
33+
34+ // Set up stdin pipe to pass the text
35+ stdin, err := cmd.StdinPipe()
36+ if err != nil {
37+ return fmt.Errorf("failed to create stdin pipe for wl-copy: %w", err)
38+ }
39+
40+ // Start the command
41+ if err := cmd.Start(); err != nil {
42+ stdin.Close()
43+ return fmt.Errorf("failed to start wl-copy: %w", err)
44+ }
45+
46+ // Write text to stdin and close it
47+ _, writeErr := stdin.Write([]byte(text))
48+ closeErr := stdin.Close()
49+
50+ // Wait for command to complete
51+ waitErr := cmd.Wait()
52+
53+ // Return the first error encountered
54+ if writeErr != nil {
55+ return fmt.Errorf("failed to write to wl-copy stdin: %w", writeErr)
56+ }
57+ if closeErr != nil {
58+ return fmt.Errorf("failed to close wl-copy stdin: %w", closeErr)
59+ }
60+ if waitErr != nil {
61+ return fmt.Errorf("wl-copy command failed: %w", waitErr)
62+ }
63+
64+ return nil
65+}
66+
67+// tryXclip attempts to copy text using xclip (X11 clipboard)
68+func tryXclip(text string) error {
69+ cmd := exec.Command("xclip", "-selection", "clipboard")
70+
71+ // Set up stdin pipe to pass the text
72+ stdin, err := cmd.StdinPipe()
73+ if err != nil {
74+ return fmt.Errorf("failed to create stdin pipe for xclip: %w", err)
75+ }
76+
77+ // Start the command
78+ if err := cmd.Start(); err != nil {
79+ stdin.Close()
80+ return fmt.Errorf("failed to start xclip: %w", err)
81+ }
82+
83+ // Write text to stdin and close it
84+ _, writeErr := stdin.Write([]byte(text))
85+ closeErr := stdin.Close()
86+
87+ // Wait for command to complete
88+ waitErr := cmd.Wait()
89+
90+ // Return the first error encountered
91+ if writeErr != nil {
92+ return fmt.Errorf("failed to write to xclip stdin: %w", writeErr)
93+ }
94+ if closeErr != nil {
95+ return fmt.Errorf("failed to close xclip stdin: %w", closeErr)
96+ }
97+ if waitErr != nil {
98+ return fmt.Errorf("xclip command failed: %w", waitErr)
99+ }
100+
101+ return nil
102+}
103+
104+// IsAvailable checks if clipboard functionality is available on the system
105+func IsAvailable() bool {
106+ // Check if wl-copy is available
107+ if _, err := exec.LookPath("wl-copy"); err == nil {
108+ return true
109+ }
110+
111+ // Check if xclip is available
112+ if _, err := exec.LookPath("xclip"); err == nil {
113+ return true
114+ }
115+
116+ return false
117+}⏎̸
A pkg/clipboard/clipboard_test.go +71 -0
@@ -0,0 +1,71 @@
1+package clipboard
2+
3+import (
4+ "os/exec"
5+ "testing"
6+)
7+
8+func TestCopyToClipboard(t *testing.T) {
9+ // Check if either wl-copy or xclip is available
10+ if !IsAvailable() {
11+ t.Skip("Neither wl-copy nor xclip is available, skipping clipboard tests")
12+ }
13+
14+ testText := "https://example.com/test-url"
15+
16+ err := CopyToClipboard(testText)
17+ if err != nil {
18+ t.Errorf("CopyToClipboard failed: %v", err)
19+ }
20+}
21+
22+func TestCopyToClipboard_EmptyString(t *testing.T) {
23+ err := CopyToClipboard("")
24+ if err == nil {
25+ t.Error("Expected error when copying empty string, got nil")
26+ }
27+}
28+
29+func TestIsAvailable(t *testing.T) {
30+ available := IsAvailable()
31+
32+ // Check if the result matches the actual availability
33+ wlCopyExists := false
34+ xclipExists := false
35+
36+ if _, err := exec.LookPath("wl-copy"); err == nil {
37+ wlCopyExists = true
38+ }
39+
40+ if _, err := exec.LookPath("xclip"); err == nil {
41+ xclipExists = true
42+ }
43+
44+ expected := wlCopyExists || xclipExists
45+
46+ if available != expected {
47+ t.Errorf("IsAvailable() = %v, expected %v", available, expected)
48+ }
49+}
50+
51+func TestTryWlCopy(t *testing.T) {
52+ if _, err := exec.LookPath("wl-copy"); err != nil {
53+ t.Skip("wl-copy not available, skipping test")
54+ }
55+
56+ err := tryWlCopy("test content")
57+ if err != nil {
58+ t.Errorf("tryWlCopy failed: %v", err)
59+ }
60+}
61+
62+func TestTryXclip(t *testing.T) {
63+ if _, err := exec.LookPath("xclip"); err != nil {
64+ t.Skip("xclip not available, skipping test")
65+ }
66+
67+ err := tryXclip("test content")
68+ if err != nil {
69+ t.Errorf("tryXclip failed: %v", err)
70+ }
71+}⏎̸
@@ -0,0 +1,71 @@
1+package clipboard
2+
3+import (
4+ "os/exec"
5+ "testing"
6+)
7+
8+func TestCopyToClipboard(t *testing.T) {
9+ // Check if either wl-copy or xclip is available
10+ if !IsAvailable() {
11+ t.Skip("Neither wl-copy nor xclip is available, skipping clipboard tests")
12+ }
13+
14+ testText := "https://example.com/test-url"
15+
16+ err := CopyToClipboard(testText)
17+ if err != nil {
18+ t.Errorf("CopyToClipboard failed: %v", err)
19+ }
20+}
21+
22+func TestCopyToClipboard_EmptyString(t *testing.T) {
23+ err := CopyToClipboard("")
24+ if err == nil {
25+ t.Error("Expected error when copying empty string, got nil")
26+ }
27+}
28+
29+func TestIsAvailable(t *testing.T) {
30+ available := IsAvailable()
31+
32+ // Check if the result matches the actual availability
33+ wlCopyExists := false
34+ xclipExists := false
35+
36+ if _, err := exec.LookPath("wl-copy"); err == nil {
37+ wlCopyExists = true
38+ }
39+
40+ if _, err := exec.LookPath("xclip"); err == nil {
41+ xclipExists = true
42+ }
43+
44+ expected := wlCopyExists || xclipExists
45+
46+ if available != expected {
47+ t.Errorf("IsAvailable() = %v, expected %v", available, expected)
48+ }
49+}
50+
51+func TestTryWlCopy(t *testing.T) {
52+ if _, err := exec.LookPath("wl-copy"); err != nil {
53+ t.Skip("wl-copy not available, skipping test")
54+ }
55+
56+ err := tryWlCopy("test content")
57+ if err != nil {
58+ t.Errorf("tryWlCopy failed: %v", err)
59+ }
60+}
61+
62+func TestTryXclip(t *testing.T) {
63+ if _, err := exec.LookPath("xclip"); err != nil {
64+ t.Skip("xclip not available, skipping test")
65+ }
66+
67+ err := tryXclip("test content")
68+ if err != nil {
69+ t.Errorf("tryXclip failed: %v", err)
70+ }
71+}⏎̸
M pkg/webkit/keyboard_cgo.go +7 -1
@@ -127,6 +127,7 @@ func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) {
127127 func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
128128 ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
129129 alt := (state & uint(C.GDK_ALT_MASK)) != 0
130+ shift := (state & uint(C.GDK_SHIFT_MASK)) != 0
130131 // Map keyval to string names
131132 var keyName string
132133 switch keyval {
@@ -144,6 +145,8 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
144145 keyName = "ArrowRight"
145146 case uint(C.GDK_KEY_F12):
146147 keyName = "F12"
148+ case uint(C.GDK_KEY_c), uint(C.GDK_KEY_C):
149+ keyName = "c"
147150 default:
148151 // If Control held, log unknown keyval for diagnostics
149152 if ctrl {
@@ -154,7 +157,10 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
154157
155158 // Candidate accelerator strings in order
156159 candidates := []string{keyName}
157- if ctrl {
160+ if ctrl && shift {
161+ // Ctrl+Shift combinations
162+ candidates = append([]string{"cmdorctrl+shift+" + keyName, "ctrl+shift+" + keyName}, candidates...)
163+ } else if ctrl {
158164 // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=)
159165 candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)
160166 // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-)
@@ -127,6 +127,7 @@ func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) {
127 func dispatchAccelerator(uid uintptr, keyval uint, state uint) {127 func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
128 ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0128 ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
129 alt := (state & uint(C.GDK_ALT_MASK)) != 0129 alt := (state & uint(C.GDK_ALT_MASK)) != 0
130+ shift := (state & uint(C.GDK_SHIFT_MASK)) != 0
130 // Map keyval to string names131 // Map keyval to string names
131 var keyName string132 var keyName string
132 switch keyval {133 switch keyval {
@@ -144,6 +145,8 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
144 keyName = "ArrowRight"145 keyName = "ArrowRight"
145 case uint(C.GDK_KEY_F12):146 case uint(C.GDK_KEY_F12):
146 keyName = "F12"147 keyName = "F12"
148+ case uint(C.GDK_KEY_c), uint(C.GDK_KEY_C):
149+ keyName = "c"
147 default:150 default:
148 // If Control held, log unknown keyval for diagnostics151 // If Control held, log unknown keyval for diagnostics
149 if ctrl {152 if ctrl {
@@ -154,7 +157,10 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
154 157
155 // Candidate accelerator strings in order158 // Candidate accelerator strings in order
156 candidates := []string{keyName}159 candidates := []string{keyName}
157- if ctrl {160+ if ctrl && shift {
161+ // Ctrl+Shift combinations
162+ candidates = append([]string{"cmdorctrl+shift+" + keyName, "ctrl+shift+" + keyName}, candidates...)
163+ } else if ctrl {
158 // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=)164 // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=)
159 candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)165 candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)
160 // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-)166 // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-)
A pkg/webkit/toast_js_cgo.go +136 -0
@@ -0,0 +1,136 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+// getToastScript returns the injected toast notification component script.
6+// Provides a reusable toast system that can display messages across all pages.
7+func getToastScript() string {
8+ return `(() => {
9+ try {
10+ if (window.__dumber_toast_loaded) return; // idempotent
11+ window.__dumber_toast_loaded = true;
12+
13+ const T = {
14+ container: null,
15+ toasts: [], // {id, element, timeout}
16+ counter: 0,
17+
18+ init() {
19+ if (T.container) return;
20+
21+ // Create toast container
22+ const container = document.createElement('div');
23+ container.id = 'dumber-toast-container';
24+ container.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483646;pointer-events:none;';
25+ document.documentElement.appendChild(container);
26+ T.container = container;
27+
28+ // Add CSS styles
29+ const style = document.createElement('style');
30+ style.textContent = ` +
31+"`" + `
32+ .dumber-toast {
33+ background: rgba(18, 18, 18, 0.95);
34+ color: #eee;
35+ padding: 12px 16px;
36+ border-radius: 8px;
37+ border: 1px solid #444;
38+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
39+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, sans-serif;
40+ font-size: 14px;
41+ line-height: 1.4;
42+ margin-bottom: 8px;
43+ max-width: 320px;
44+ word-wrap: break-word;
45+ backdrop-filter: blur(8px);
46+ -webkit-backdrop-filter: blur(8px);
47+ pointer-events: auto;
48+ cursor: default;
49+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
50+ transform: translateX(100%);
51+ opacity: 0;
52+ }
53+
54+ .dumber-toast.show {
55+ transform: translateX(0);
56+ opacity: 1;
57+ }
58+
59+ .dumber-toast:hover {
60+ background: rgba(27, 27, 27, 0.95);
61+ border-color: #555;
62+ }
63+ ` + "`" + `;
64+ document.documentElement.appendChild(style);
65+ },
66+
67+ show(message, duration = 2500) {
68+ T.init();
69+
70+ const id = ++T.counter;
71+ const toast = document.createElement('div');
72+ toast.className = 'dumber-toast';
73+ toast.textContent = message || 'Notification';
74+ toast.setAttribute('data-toast-id', id);
75+
76+ // Click to dismiss
77+ toast.addEventListener('click', () => T.dismiss(id));
78+
79+ T.container.appendChild(toast);
80+
81+ // Trigger show animation
82+ requestAnimationFrame(() => {
83+ toast.classList.add('show');
84+ });
85+
86+ // Auto-dismiss
87+ const timeout = setTimeout(() => T.dismiss(id), duration);
88+
89+ T.toasts.push({ id, element: toast, timeout });
90+
91+ console.log('📋 Toast:', message);
92+ return id;
93+ },
94+
95+ dismiss(id) {
96+ const toastIndex = T.toasts.findIndex(t => t.id === id);
97+ if (toastIndex === -1) return;
98+
99+ const toast = T.toasts[toastIndex];
100+ clearTimeout(toast.timeout);
101+
102+ // Animate out
103+ toast.element.style.transform = 'translateX(100%)';
104+ toast.element.style.opacity = '0';
105+
106+ setTimeout(() => {
107+ if (toast.element && toast.element.parentNode) {
108+ toast.element.parentNode.removeChild(toast.element);
109+ }
110+ T.toasts.splice(toastIndex, 1);
111+ }, 300);
112+ },
113+
114+ clear() {
115+ T.toasts.forEach(toast => {
116+ clearTimeout(toast.timeout);
117+ if (toast.element && toast.element.parentNode) {
118+ toast.element.parentNode.removeChild(toast.element);
119+ }
120+ });
121+ T.toasts = [];
122+ }
123+ };
124+
125+ // Public API for native integration
126+ window.__dumber_showToast = (message, duration) => T.show(message, duration);
127+ window.__dumber_dismissToast = (id) => T.dismiss(id);
128+ window.__dumber_clearToasts = () => T.clear();
129+
130+ console.log('✅ Dumber Browser toast system loaded');
131+
132+ } catch (e) {
133+ console.error('❌ Failed to initialize Dumber toast system:', e);
134+ }
135+})();`
136+}⏎̸
@@ -0,0 +1,136 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+// getToastScript returns the injected toast notification component script.
6+// Provides a reusable toast system that can display messages across all pages.
7+func getToastScript() string {
8+ return `(() => {
9+ try {
10+ if (window.__dumber_toast_loaded) return; // idempotent
11+ window.__dumber_toast_loaded = true;
12+
13+ const T = {
14+ container: null,
15+ toasts: [], // {id, element, timeout}
16+ counter: 0,
17+
18+ init() {
19+ if (T.container) return;
20+
21+ // Create toast container
22+ const container = document.createElement('div');
23+ container.id = 'dumber-toast-container';
24+ container.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483646;pointer-events:none;';
25+ document.documentElement.appendChild(container);
26+ T.container = container;
27+
28+ // Add CSS styles
29+ const style = document.createElement('style');
30+ style.textContent = ` +
31+"`" + `
32+ .dumber-toast {
33+ background: rgba(18, 18, 18, 0.95);
34+ color: #eee;
35+ padding: 12px 16px;
36+ border-radius: 8px;
37+ border: 1px solid #444;
38+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
39+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, sans-serif;
40+ font-size: 14px;
41+ line-height: 1.4;
42+ margin-bottom: 8px;
43+ max-width: 320px;
44+ word-wrap: break-word;
45+ backdrop-filter: blur(8px);
46+ -webkit-backdrop-filter: blur(8px);
47+ pointer-events: auto;
48+ cursor: default;
49+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
50+ transform: translateX(100%);
51+ opacity: 0;
52+ }
53+
54+ .dumber-toast.show {
55+ transform: translateX(0);
56+ opacity: 1;
57+ }
58+
59+ .dumber-toast:hover {
60+ background: rgba(27, 27, 27, 0.95);
61+ border-color: #555;
62+ }
63+ ` + "`" + `;
64+ document.documentElement.appendChild(style);
65+ },
66+
67+ show(message, duration = 2500) {
68+ T.init();
69+
70+ const id = ++T.counter;
71+ const toast = document.createElement('div');
72+ toast.className = 'dumber-toast';
73+ toast.textContent = message || 'Notification';
74+ toast.setAttribute('data-toast-id', id);
75+
76+ // Click to dismiss
77+ toast.addEventListener('click', () => T.dismiss(id));
78+
79+ T.container.appendChild(toast);
80+
81+ // Trigger show animation
82+ requestAnimationFrame(() => {
83+ toast.classList.add('show');
84+ });
85+
86+ // Auto-dismiss
87+ const timeout = setTimeout(() => T.dismiss(id), duration);
88+
89+ T.toasts.push({ id, element: toast, timeout });
90+
91+ console.log('📋 Toast:', message);
92+ return id;
93+ },
94+
95+ dismiss(id) {
96+ const toastIndex = T.toasts.findIndex(t => t.id === id);
97+ if (toastIndex === -1) return;
98+
99+ const toast = T.toasts[toastIndex];
100+ clearTimeout(toast.timeout);
101+
102+ // Animate out
103+ toast.element.style.transform = 'translateX(100%)';
104+ toast.element.style.opacity = '0';
105+
106+ setTimeout(() => {
107+ if (toast.element && toast.element.parentNode) {
108+ toast.element.parentNode.removeChild(toast.element);
109+ }
110+ T.toasts.splice(toastIndex, 1);
111+ }, 300);
112+ },
113+
114+ clear() {
115+ T.toasts.forEach(toast => {
116+ clearTimeout(toast.timeout);
117+ if (toast.element && toast.element.parentNode) {
118+ toast.element.parentNode.removeChild(toast.element);
119+ }
120+ });
121+ T.toasts = [];
122+ }
123+ };
124+
125+ // Public API for native integration
126+ window.__dumber_showToast = (message, duration) => T.show(message, duration);
127+ window.__dumber_dismissToast = (id) => T.dismiss(id);
128+ window.__dumber_clearToasts = () => T.clear();
129+
130+ console.log('✅ Dumber Browser toast system loaded');
131+
132+ } catch (e) {
133+ console.error('❌ Failed to initialize Dumber toast system:', e);
134+ }
135+})();`
136+}⏎̸
M pkg/webkit/webview_cgo.go +10 -0
@@ -869,6 +869,16 @@ func (w *WebView) enableUserContentManager() {
869869 log.Printf("[webkit] UCM omnibox script injected at document-start")
870870 }
871871
872+ // Add user script at document-start (toast notification system)
873+ toastSrc := C.CString(getToastScript())
874+ defer C.free(unsafe.Pointer(toastSrc))
875+ toastScript := C.webkit_user_script_new((*C.gchar)(toastSrc), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
876+ if toastScript != nil {
877+ C.webkit_user_content_manager_add_script(w.native.ucm, toastScript)
878+ C.webkit_user_script_unref(toastScript)
879+ log.Printf("[webkit] UCM toast script injected at document-start")
880+ }
881+
872882 // Inject Wails runtime fetch interceptor for homepage bridging
873883 wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();`
874884 cWails := C.CString(wailsBridge)
@@ -869,6 +869,16 @@ func (w *WebView) enableUserContentManager() {
869 log.Printf("[webkit] UCM omnibox script injected at document-start")869 log.Printf("[webkit] UCM omnibox script injected at document-start")
870 }870 }
871 871
872+ // Add user script at document-start (toast notification system)
873+ toastSrc := C.CString(getToastScript())
874+ defer C.free(unsafe.Pointer(toastSrc))
875+ toastScript := C.webkit_user_script_new((*C.gchar)(toastSrc), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
876+ if toastScript != nil {
877+ C.webkit_user_content_manager_add_script(w.native.ucm, toastScript)
878+ C.webkit_user_script_unref(toastScript)
879+ log.Printf("[webkit] UCM toast script injected at document-start")
880+ }
881+
872 // Inject Wails runtime fetch interceptor for homepage bridging882 // Inject Wails runtime fetch interceptor for homepage bridging
873 wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();`883 wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();`
874 cWails := C.CString(wailsBridge)884 cWails := C.CString(wailsBridge)

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