v0.9.0 636c44c

window_shortcuts.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"log"
6	"sync"
7	"time"
8
9	"github.com/bnema/dumber/pkg/webkit"
10)
11
12// WindowShortcutHandler manages global shortcuts at the window level
13type WindowShortcutHandler struct {
14	window    *webkit.Window
15	app       *BrowserApp
16	shortcuts *webkit.WindowShortcuts
17	mu        sync.Mutex
18
19	// Debounce tracking
20	lastOmniboxToggle time.Time
21	lastFindToggle    time.Time
22	lastCopyURL       time.Time
23	lastDevTools      time.Time
24}
25
26// NewWindowShortcutHandler creates a new window-level shortcut handler
27func NewWindowShortcutHandler(window *webkit.Window, app *BrowserApp) *WindowShortcutHandler {
28	h := &WindowShortcutHandler{
29		window: window,
30		app:    app,
31	}
32
33	if err := h.initialize(); err != nil {
34		log.Printf("[window-shortcuts] Failed to initialize: %v", err)
35		return nil
36	}
37
38	return h
39}
40
41func (h *WindowShortcutHandler) initialize() error {
42	// Initialize GTK4 global shortcuts
43	h.shortcuts = h.window.InitializeGlobalShortcuts()
44	if h.shortcuts == nil {
45		return ErrFailedToInitialize
46	}
47
48	return h.registerGlobalShortcuts()
49}
50
51func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
52	shortcuts := []struct {
53		key     string
54		handler func()
55		desc    string
56	}{
57		{"ctrl+l", h.handleOmniboxToggle, "Omnibox toggle"},
58		{"ctrl+f", h.handleFindToggle, "Find in page"},
59		{"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
60		{"F12", h.handleDevTools, "Developer tools"},
61		// Zoom shortcuts - global level for proper active pane targeting
62		{"ctrl+plus", h.handleZoomIn, "Zoom in"},
63		{"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
64		{"ctrl+minus", h.handleZoomOut, "Zoom out"},
65		{"ctrl+0", h.handleZoomReset, "Zoom reset"},
66	}
67
68	for _, shortcut := range shortcuts {
69		if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil {
70			log.Printf("[window-shortcuts] Failed to register %s (%s): %v",
71				shortcut.key, shortcut.desc, err)
72			return err
73		}
74		log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
75			shortcut.key, shortcut.desc)
76	}
77
78	return nil
79}
80
81func (h *WindowShortcutHandler) handleOmniboxToggle() {
82	h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
83}
84
85func (h *WindowShortcutHandler) handleFindToggle() {
86	h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
87}
88
89func (h *WindowShortcutHandler) handleCopyURL() {
90	h.mu.Lock()
91	defer h.mu.Unlock()
92
93	if time.Since(h.lastCopyURL) < 100*time.Millisecond {
94		return
95	}
96	h.lastCopyURL = time.Now()
97
98	if h.app.activePane == nil || h.app.activePane.webView == nil {
99		log.Printf("[window-shortcuts] No active pane for copy URL")
100		return
101	}
102
103	log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
104
105	// Execute copy URL script directly on active pane
106	script := `
107		(async function() {
108			const toast = (message, type) => {
109				document.dispatchEvent(new CustomEvent('dumber:showToast', {
110					detail: { message, duration: 2000, type }
111				}));
112			};
113
114			try {
115				const url = window.location.href;
116				await navigator.clipboard.writeText(url);
117				console.log('🔗 Window shortcut copied URL:', url);
118				toast('URL copied to clipboard!', 'success');
119			} catch (error) {
120				console.error('❌ Failed to copy URL:', error);
121				toast('Failed to copy URL', 'error');
122			}
123		})();
124	`
125
126	if err := h.app.activePane.webView.InjectScript(script); err != nil {
127		log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
128	}
129}
130
131func (h *WindowShortcutHandler) handleDevTools() {
132	h.mu.Lock()
133	defer h.mu.Unlock()
134
135	if time.Since(h.lastDevTools) < 200*time.Millisecond {
136		return
137	}
138	h.lastDevTools = time.Now()
139
140	if h.app.activePane == nil || h.app.activePane.webView == nil {
141		log.Printf("[window-shortcuts] No active pane for devtools")
142		return
143	}
144
145	log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
146
147	if err := h.app.activePane.webView.ShowDevTools(); err != nil {
148		log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
149	}
150}
151
152// handleZoomIn increases zoom level by 10%
153func (h *WindowShortcutHandler) handleZoomIn() {
154	h.handleZoom("in", 1.1)
155}
156
157// handleZoomOut decreases zoom level by 10%
158func (h *WindowShortcutHandler) handleZoomOut() {
159	h.handleZoom("out", 1.0/1.1)
160}
161
162// handleZoomReset resets zoom to 100%
163func (h *WindowShortcutHandler) handleZoomReset() {
164	h.handleZoom("reset", 1.0)
165}
166
167// handleZoom applies zoom changes to the active pane
168func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
169	h.mu.Lock()
170	defer h.mu.Unlock()
171
172	if h.app.activePane == nil || h.app.activePane.webView == nil {
173		log.Printf("[window-shortcuts] No active pane for zoom %s", action)
174		return
175	}
176
177	activeWebView := h.app.activePane.webView
178	log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
179
180	// Get current zoom level
181	currentZoom, err := activeWebView.GetZoom()
182	if err != nil {
183		log.Printf("[window-shortcuts] Failed to get current zoom: %v", err)
184		return
185	}
186
187	// Calculate new zoom level
188	var newZoom float64
189	if action == "reset" {
190		newZoom = 1.0
191	} else {
192		newZoom = currentZoom * multiplier
193		// Apply zoom limits
194		if newZoom < 0.25 {
195			newZoom = 0.25
196		}
197		if newZoom > 5.0 {
198			newZoom = 5.0
199		}
200	}
201
202	log.Printf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom)
203
204	// Apply zoom to active pane
205	if err := activeWebView.SetZoom(newZoom); err != nil {
206		log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
207	}
208}
209
210func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
211	if h.app.activePane == nil {
212		return
213	}
214
215	pane := h.app.activePane
216	if !pane.HasGUI() {
217		log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
218		if h.app.workspace != nil {
219			h.app.workspace.ensureGUIInPane(pane)
220		}
221	}
222
223	// Ensure specific component is available
224	if !pane.HasGUIComponent(component) {
225		log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
226		pane.SetGUIComponent(component, true)
227	}
228}
229
230func (h *WindowShortcutHandler) getPaneId(pane *BrowserPane) string {
231	if pane == nil {
232		return "unknown"
233	}
234	return pane.ID()
235}
236
237func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
238	h.mu.Lock()
239	defer h.mu.Unlock()
240
241	if time.Since(*lastToggle) < 50*time.Millisecond {
242		log.Printf("[window-shortcuts] %s toggle debounced", featureName)
243		return
244	}
245	*lastToggle = time.Now()
246
247	if h.app.activePane == nil || h.app.activePane.webView == nil {
248		log.Printf("[window-shortcuts] No active pane for %s", featureName)
249		return
250	}
251
252	log.Printf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView)
253
254	h.ensureGUIInActivePane("omnibox")
255
256	if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
257		"action":    action,
258		"paneId":    h.getPaneId(h.app.activePane),
259		"timestamp": time.Now().UnixMilli(),
260		"source":    "window-global",
261	}); err != nil {
262		log.Printf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err)
263	}
264}
265
266// Cleanup releases resources
267func (h *WindowShortcutHandler) Cleanup() {
268	if h.shortcuts != nil {
269		h.shortcuts.Cleanup()
270		h.shortcuts = nil
271	}
272}
273
274// Error definitions
275var (
276	ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
277)

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