v0.13.0 976176a

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	// Create WindowShortcuts manager
45	h.shortcuts = webkit.NewWindowShortcuts(h.window)
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+t", h.handleNewTab, "New tab (no-op)"},
64		{"ctrl+w", h.handleClosePane, "Close current pane"},
65		{"F12", h.handleDevTools, "Developer tools"},
66		// Zoom shortcuts - global level for proper active pane targeting
67		{"ctrl+plus", h.handleZoomIn, "Zoom in"},
68		{"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
69		{"ctrl+minus", h.handleZoomOut, "Zoom out"},
70		{"ctrl+0", h.handleZoomReset, "Zoom reset"},
71		// Workspace navigation shortcuts - global level for proper active pane targeting
72		{"alt+ArrowLeft", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane"},
73		{"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"},
74		{"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"},
75		{"alt+ArrowDown", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane"},
76	}
77
78	for _, shortcut := range shortcuts {
79		h.shortcuts.RegisterShortcut(shortcut.key, shortcut.handler)
80		log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
81			shortcut.key, shortcut.desc)
82	}
83
84	return nil
85}
86
87func (h *WindowShortcutHandler) handleOmniboxToggle() {
88	h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
89}
90
91func (h *WindowShortcutHandler) handleFindToggle() {
92	h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
93}
94
95func (h *WindowShortcutHandler) handleCopyURL() {
96	h.mu.Lock()
97	defer h.mu.Unlock()
98
99	if time.Since(h.lastCopyURL) < 100*time.Millisecond {
100		return
101	}
102	h.lastCopyURL = time.Now()
103
104	if h.app.activePane == nil || h.app.activePane.webView == nil {
105		log.Printf("[window-shortcuts] No active pane for copy URL")
106		return
107	}
108
109	log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
110
111	// Execute copy URL script directly on active pane
112	script := `
113		(async function() {
114			const toast = (message, type) => {
115				document.dispatchEvent(new CustomEvent('dumber:showToast', {
116					detail: { message, duration: 2000, type }
117				}));
118			};
119
120			try {
121				const url = window.location.href;
122				await navigator.clipboard.writeText(url);
123				console.log('🔗 Window shortcut copied URL:', url);
124				toast('URL copied to clipboard!', 'success');
125			} catch (error) {
126				console.error('❌ Failed to copy URL:', error);
127				toast('Failed to copy URL', 'error');
128			}
129		})();
130	`
131
132	if err := h.app.activePane.webView.InjectScript(script); err != nil {
133		log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
134	}
135}
136
137func (h *WindowShortcutHandler) handleDevTools() {
138	h.mu.Lock()
139	defer h.mu.Unlock()
140
141	if time.Since(h.lastDevTools) < 200*time.Millisecond {
142		return
143	}
144	h.lastDevTools = time.Now()
145
146	if h.app.activePane == nil || h.app.activePane.webView == nil {
147		log.Printf("[window-shortcuts] No active pane for devtools")
148		return
149	}
150
151	log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
152
153	if err := h.app.activePane.webView.ShowDevTools(); err != nil {
154		log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
155	}
156}
157
158func (h *WindowShortcutHandler) handlePrint() {
159	h.mu.Lock()
160	defer h.mu.Unlock()
161
162	if time.Since(h.lastPrint) < 200*time.Millisecond {
163		return
164	}
165	h.lastPrint = time.Now()
166
167	if h.app.activePane == nil || h.app.activePane.webView == nil {
168		log.Printf("[window-shortcuts] No active pane for print")
169		return
170	}
171
172	log.Printf("[window-shortcuts] Print -> pane %p", h.app.activePane.webView)
173
174	if err := h.app.activePane.webView.ShowPrintDialog(); err != nil {
175		log.Printf("[window-shortcuts] Failed to show print dialog: %v", err)
176	}
177}
178
179// handleZoomIn increases zoom level by 10%
180func (h *WindowShortcutHandler) handleZoomIn() {
181	h.handleZoom("in", 1.1)
182}
183
184// handleZoomOut decreases zoom level by 10%
185func (h *WindowShortcutHandler) handleZoomOut() {
186	h.handleZoom("out", 1.0/1.1)
187}
188
189// handleZoomReset resets zoom to 100%
190func (h *WindowShortcutHandler) handleZoomReset() {
191	h.handleZoom("reset", 1.0)
192}
193
194// handleZoom applies zoom changes to the active pane
195func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
196	h.mu.Lock()
197	defer h.mu.Unlock()
198
199	if h.app.activePane == nil || h.app.activePane.webView == nil {
200		log.Printf("[window-shortcuts] No active pane for zoom %s", action)
201		return
202	}
203
204	activeWebView := h.app.activePane.webView
205	log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
206
207	// Get current zoom level
208	currentZoom := activeWebView.GetZoom()
209
210	// Calculate new zoom level
211	var newZoom float64
212	if action == "reset" {
213		newZoom = 1.0
214	} else {
215		newZoom = currentZoom * multiplier
216		// Apply zoom limits
217		if newZoom < 0.25 {
218			newZoom = 0.25
219		}
220		if newZoom > 5.0 {
221			newZoom = 5.0
222		}
223	}
224
225	log.Printf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom)
226
227	// Apply zoom to active pane
228	if err := activeWebView.SetZoom(newZoom); err != nil {
229		log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
230		return
231	}
232
233	// Ensure GUI/toast component is loaded before showing toast
234	h.ensureGUIInActivePane("toast")
235
236	// Show zoom toast notification
237	toastScript := fmt.Sprintf(`
238		(function() {
239			try {
240				if (typeof window.__dumber_showZoomToast === 'function') {
241					window.__dumber_showZoomToast(%f);
242				}
243			} catch (e) {
244				console.error('[window-shortcuts] Failed to show zoom toast:', e);
245			}
246		})();
247	`, newZoom)
248
249	if err := activeWebView.InjectScript(toastScript); err != nil {
250		log.Printf("[window-shortcuts] Failed to show zoom toast: %v", err)
251	}
252}
253
254func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
255	if h.app.activePane == nil {
256		return
257	}
258
259	pane := h.app.activePane
260	if !pane.HasGUI() {
261		log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
262		if h.app.workspace != nil {
263			h.app.workspace.ensureGUIInPane(pane)
264		}
265	}
266
267	// Ensure specific component is available
268	if !pane.HasGUIComponent(component) {
269		log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
270		pane.SetGUIComponent(component, true)
271	}
272}
273
274func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
275	if pane == nil || pane.webView == nil {
276		return "unknown"
277	}
278	return pane.webView.IDString()
279}
280
281func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
282	h.mu.Lock()
283	defer h.mu.Unlock()
284
285	if time.Since(*lastToggle) < 50*time.Millisecond {
286		log.Printf("[window-shortcuts] %s toggle debounced", featureName)
287		return
288	}
289	*lastToggle = time.Now()
290
291	if h.app.activePane == nil || h.app.activePane.webView == nil {
292		log.Printf("[window-shortcuts] No active pane for %s", featureName)
293		return
294	}
295
296	log.Printf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView)
297
298	h.ensureGUIInActivePane("omnibox")
299
300	if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
301		"action":    action,
302		"webviewId": h.getWebViewId(h.app.activePane),
303		"timestamp": time.Now().UnixMilli(),
304		"source":    "window-global",
305	}); err != nil {
306		log.Printf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err)
307	}
308}
309
310func (h *WindowShortcutHandler) handleWorkspaceNavigation(direction string) {
311	if h.app == nil || h.app.workspace == nil {
312		log.Printf("[window-shortcuts] No workspace for navigation")
313		return
314	}
315
316	log.Printf("[window-shortcuts] Workspace navigation: %s", direction)
317
318	if h.app.workspace.FocusNeighbor(direction) {
319		log.Printf("[window-shortcuts] Workspace navigation %s successful", direction)
320	} else {
321		log.Printf("[window-shortcuts] Workspace navigation %s failed", direction)
322	}
323}
324
325func (h *WindowShortcutHandler) handleClosePane() {
326	if h.app == nil || h.app.workspace == nil {
327		log.Printf("[window-shortcuts] No workspace for close pane")
328		return
329	}
330
331	// Find the currently active WebView
332	var activeWebView *webkit.WebView
333	log.Printf("[window-shortcuts] Searching for active WebView among %d WebViews", len(h.app.workspace.viewToNode))
334	for webView := range h.app.workspace.viewToNode {
335		if webView != nil {
336			isActive := webView.IsActive()
337			log.Printf("[window-shortcuts] WebView %d: IsActive=%t", webView.ID(), isActive)
338			if isActive {
339				activeWebView = webView
340				log.Printf("[window-shortcuts] Found active WebView: %d", webView.ID())
341				break
342			}
343		}
344	}
345
346	if activeWebView == nil {
347		log.Printf("[window-shortcuts] No active WebView found, using workspace closeCurrentPane")
348		h.app.workspace.closeCurrentPane()
349		return
350	}
351
352	// Check if the active WebView is a popup
353	node := h.app.workspace.viewToNode[activeWebView]
354	if node != nil && node.isPopup {
355		log.Printf("[window-shortcuts] Closing popup via OnWorkspaceMessage")
356		// Use the proper close-popup message for popups
357		msg := messaging.Message{
358			Event:     "close-popup",
359			WebViewID: activeWebView.IDString(),
360			Reason:    "user-ctrl-w",
361		}
362		h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
363	} else {
364		log.Printf("[window-shortcuts] Closing regular pane via workspace closeCurrentPane")
365		// Use regular close for non-popup panes
366		h.app.workspace.closeCurrentPane()
367	}
368}
369
370// handleNewTab is a no-op handler for Ctrl+T
371// This prevents the default browser behavior and allows for future implementation
372func (h *WindowShortcutHandler) handleNewTab() {
373	log.Printf("[window-shortcuts] Ctrl+T pressed (no-op - tabs not yet implemented)")
374	// TODO: Implement new tab functionality
375}
376
377// Cleanup releases resources
378func (h *WindowShortcutHandler) Cleanup() {
379	// WindowShortcuts doesn't require explicit cleanup
380	h.shortcuts = nil
381}
382
383// Error definitions
384var (
385	ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
386)

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