v0.10.0 0f4e9eb

window_shortcuts.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"log"
6	"sync"
7	"time"
8
9	"github.com/bnema/dumber/internal/app/messaging"
10	"github.com/bnema/dumber/pkg/webkit"
11)
12
13// WindowShortcutHandler manages global shortcuts at the window level
14type WindowShortcutHandler struct {
15	window    *webkit.Window
16	app       *BrowserApp
17	shortcuts *webkit.WindowShortcuts
18	mu        sync.Mutex
19
20	// Debounce tracking
21	lastOmniboxToggle time.Time
22	lastFindToggle    time.Time
23	lastCopyURL       time.Time
24	lastDevTools      time.Time
25	lastPrint         time.Time
26}
27
28// NewWindowShortcutHandler creates a new window-level shortcut handler
29func NewWindowShortcutHandler(window *webkit.Window, app *BrowserApp) *WindowShortcutHandler {
30	h := &WindowShortcutHandler{
31		window: window,
32		app:    app,
33	}
34
35	if err := h.initialize(); err != nil {
36		log.Printf("[window-shortcuts] Failed to initialize: %v", err)
37		return nil
38	}
39
40	return h
41}
42
43func (h *WindowShortcutHandler) initialize() error {
44	// Initialize GTK4 global shortcuts
45	h.shortcuts = h.window.InitializeGlobalShortcuts()
46	if h.shortcuts == nil {
47		return ErrFailedToInitialize
48	}
49
50	return h.registerGlobalShortcuts()
51}
52
53func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
54	shortcuts := []struct {
55		key     string
56		handler func()
57		desc    string
58	}{
59		{"ctrl+l", h.handleOmniboxToggle, "Omnibox toggle"},
60		{"ctrl+f", h.handleFindToggle, "Find in page"},
61		{"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
62		{"ctrl+shift+p", h.handlePrint, "Print page"},
63		{"ctrl+w", h.handleClosePane, "Close current pane"},
64		{"F12", h.handleDevTools, "Developer tools"},
65		// Zoom shortcuts - global level for proper active pane targeting
66		{"ctrl+plus", h.handleZoomIn, "Zoom in"},
67		{"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
68		{"ctrl+minus", h.handleZoomOut, "Zoom out"},
69		{"ctrl+0", h.handleZoomReset, "Zoom reset"},
70		// Workspace navigation shortcuts - global level for proper active pane targeting
71		{"alt+ArrowLeft", func() { h.handleWorkspaceNavigation("left") }, "Navigate left pane"},
72		{"alt+ArrowRight", func() { h.handleWorkspaceNavigation("right") }, "Navigate right pane"},
73		{"alt+ArrowUp", func() { h.handleWorkspaceNavigation("up") }, "Navigate up pane"},
74		{"alt+ArrowDown", func() { h.handleWorkspaceNavigation("down") }, "Navigate down pane"},
75	}
76
77	for _, shortcut := range shortcuts {
78		if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil {
79			log.Printf("[window-shortcuts] Failed to register %s (%s): %v",
80				shortcut.key, shortcut.desc, err)
81			return err
82		}
83		log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
84			shortcut.key, shortcut.desc)
85	}
86
87	return nil
88}
89
90func (h *WindowShortcutHandler) handleOmniboxToggle() {
91	h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
92}
93
94func (h *WindowShortcutHandler) handleFindToggle() {
95	h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
96}
97
98func (h *WindowShortcutHandler) handleCopyURL() {
99	h.mu.Lock()
100	defer h.mu.Unlock()
101
102	if time.Since(h.lastCopyURL) < 100*time.Millisecond {
103		return
104	}
105	h.lastCopyURL = time.Now()
106
107	if h.app.activePane == nil || h.app.activePane.webView == nil {
108		log.Printf("[window-shortcuts] No active pane for copy URL")
109		return
110	}
111
112	log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
113
114	// Execute copy URL script directly on active pane
115	script := `
116		(async function() {
117			const toast = (message, type) => {
118				document.dispatchEvent(new CustomEvent('dumber:showToast', {
119					detail: { message, duration: 2000, type }
120				}));
121			};
122
123			try {
124				const url = window.location.href;
125				await navigator.clipboard.writeText(url);
126				console.log('🔗 Window shortcut copied URL:', url);
127				toast('URL copied to clipboard!', 'success');
128			} catch (error) {
129				console.error('❌ Failed to copy URL:', error);
130				toast('Failed to copy URL', 'error');
131			}
132		})();
133	`
134
135	if err := h.app.activePane.webView.InjectScript(script); err != nil {
136		log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
137	}
138}
139
140func (h *WindowShortcutHandler) handleDevTools() {
141	h.mu.Lock()
142	defer h.mu.Unlock()
143
144	if time.Since(h.lastDevTools) < 200*time.Millisecond {
145		return
146	}
147	h.lastDevTools = time.Now()
148
149	if h.app.activePane == nil || h.app.activePane.webView == nil {
150		log.Printf("[window-shortcuts] No active pane for devtools")
151		return
152	}
153
154	log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
155
156	if err := h.app.activePane.webView.ShowDevTools(); err != nil {
157		log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
158	}
159}
160
161func (h *WindowShortcutHandler) handlePrint() {
162	h.mu.Lock()
163	defer h.mu.Unlock()
164
165	if time.Since(h.lastPrint) < 200*time.Millisecond {
166		return
167	}
168	h.lastPrint = time.Now()
169
170	if h.app.activePane == nil || h.app.activePane.webView == nil {
171		log.Printf("[window-shortcuts] No active pane for print")
172		return
173	}
174
175	log.Printf("[window-shortcuts] Print -> pane %p", h.app.activePane.webView)
176
177	if err := h.app.activePane.webView.ShowPrintDialog(); err != nil {
178		log.Printf("[window-shortcuts] Failed to show print dialog: %v", err)
179	}
180}
181
182// handleZoomIn increases zoom level by 10%
183func (h *WindowShortcutHandler) handleZoomIn() {
184	h.handleZoom("in", 1.1)
185}
186
187// handleZoomOut decreases zoom level by 10%
188func (h *WindowShortcutHandler) handleZoomOut() {
189	h.handleZoom("out", 1.0/1.1)
190}
191
192// handleZoomReset resets zoom to 100%
193func (h *WindowShortcutHandler) handleZoomReset() {
194	h.handleZoom("reset", 1.0)
195}
196
197// handleZoom applies zoom changes to the active pane
198func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
199	h.mu.Lock()
200	defer h.mu.Unlock()
201
202	if h.app.activePane == nil || h.app.activePane.webView == nil {
203		log.Printf("[window-shortcuts] No active pane for zoom %s", action)
204		return
205	}
206
207	activeWebView := h.app.activePane.webView
208	log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
209
210	// Get current zoom level
211	currentZoom, err := activeWebView.GetZoom()
212	if err != nil {
213		log.Printf("[window-shortcuts] Failed to get current zoom: %v", err)
214		return
215	}
216
217	// Calculate new zoom level
218	var newZoom float64
219	if action == "reset" {
220		newZoom = 1.0
221	} else {
222		newZoom = currentZoom * multiplier
223		// Apply zoom limits
224		if newZoom < 0.25 {
225			newZoom = 0.25
226		}
227		if newZoom > 5.0 {
228			newZoom = 5.0
229		}
230	}
231
232	log.Printf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom)
233
234	// Apply zoom to active pane
235	if err := activeWebView.SetZoom(newZoom); err != nil {
236		log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
237	}
238}
239
240func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
241	if h.app.activePane == nil {
242		return
243	}
244
245	pane := h.app.activePane
246	if !pane.HasGUI() {
247		log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
248		if h.app.workspace != nil {
249			h.app.workspace.ensureGUIInPane(pane)
250		}
251	}
252
253	// Ensure specific component is available
254	if !pane.HasGUIComponent(component) {
255		log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
256		pane.SetGUIComponent(component, true)
257	}
258}
259
260func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
261	if pane == nil || pane.webView == nil {
262		return "unknown"
263	}
264	return pane.webView.ID()
265}
266
267func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
268	h.mu.Lock()
269	defer h.mu.Unlock()
270
271	if time.Since(*lastToggle) < 50*time.Millisecond {
272		log.Printf("[window-shortcuts] %s toggle debounced", featureName)
273		return
274	}
275	*lastToggle = time.Now()
276
277	if h.app.activePane == nil || h.app.activePane.webView == nil {
278		log.Printf("[window-shortcuts] No active pane for %s", featureName)
279		return
280	}
281
282	log.Printf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView)
283
284	h.ensureGUIInActivePane("omnibox")
285
286	if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
287		"action":    action,
288		"webviewId": h.getWebViewId(h.app.activePane),
289		"timestamp": time.Now().UnixMilli(),
290		"source":    "window-global",
291	}); err != nil {
292		log.Printf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err)
293	}
294}
295
296func (h *WindowShortcutHandler) handleWorkspaceNavigation(direction string) {
297	if h.app == nil || h.app.workspace == nil {
298		log.Printf("[window-shortcuts] No workspace for navigation")
299		return
300	}
301
302	log.Printf("[window-shortcuts] Workspace navigation: %s", direction)
303
304	if h.app.workspace.FocusNeighbor(direction) {
305		log.Printf("[window-shortcuts] Workspace navigation %s successful", direction)
306	} else {
307		log.Printf("[window-shortcuts] Workspace navigation %s failed", direction)
308	}
309}
310
311func (h *WindowShortcutHandler) handleClosePane() {
312	if h.app == nil || h.app.workspace == nil {
313		log.Printf("[window-shortcuts] No workspace for close pane")
314		return
315	}
316
317	// Find the currently active WebView
318	var activeWebView *webkit.WebView
319	log.Printf("[window-shortcuts] Searching for active WebView among %d WebViews", len(h.app.workspace.viewToNode))
320	for webView := range h.app.workspace.viewToNode {
321		if webView != nil {
322			isActive := webView.IsActive()
323			log.Printf("[window-shortcuts] WebView %s: IsActive=%t", webView.ID(), isActive)
324			if isActive {
325				activeWebView = webView
326				log.Printf("[window-shortcuts] Found active WebView: %s", webView.ID())
327				break
328			}
329		}
330	}
331
332	if activeWebView == nil {
333		log.Printf("[window-shortcuts] No active WebView found, using workspace closeCurrentPane")
334		h.app.workspace.closeCurrentPane()
335		return
336	}
337
338	// Check if the active WebView is a popup
339	node := h.app.workspace.viewToNode[activeWebView]
340	if node != nil && node.isPopup {
341		log.Printf("[window-shortcuts] Closing popup via OnWorkspaceMessage")
342		// Use the proper close-popup message for popups
343		msg := messaging.Message{
344			Event:     "close-popup",
345			WebViewID: activeWebView.ID(),
346			Reason:    "user-ctrl-w",
347		}
348		h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
349	} else {
350		log.Printf("[window-shortcuts] Closing regular pane via workspace closeCurrentPane")
351		// Use regular close for non-popup panes
352		h.app.workspace.closeCurrentPane()
353	}
354}
355
356// Cleanup releases resources
357func (h *WindowShortcutHandler) Cleanup() {
358	if h.shortcuts != nil {
359		h.shortcuts.Cleanup()
360		h.shortcuts = nil
361	}
362}
363
364// Error definitions
365var (
366	ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
367)

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