v0.9.0 636c44c

KeyboardService.ts RawHistoryFolder

1/**
2 * Keyboard Service
3 *
4 * Centralized keyboard shortcut management for the Dumber Browser GUI.
5 * Bridges between native GTK keyboard events and Svelte component handlers.
6 */
7
8import type {
9  ShortcutConfig,
10  ComponentShortcuts,
11  KeyboardServiceConfig,
12  KeyboardEventDetail,
13  KeyboardEvent,
14  KeyboardEventType,
15} from "./types";
16// COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers
17
18export class KeyboardService {
19  private components = new Map<string, ComponentShortcuts>();
20  private activeComponent: string | null = null;
21  private globalShortcuts: ShortcutConfig[] = [];
22  private eventListeners = new Map<
23    KeyboardEventType,
24    Set<(event: KeyboardEvent) => void>
25  >();
26  private debug: boolean = false;
27  // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now
28
29  constructor(config: KeyboardServiceConfig = {}) {
30    this.debug = config.debug ?? false;
31    this.globalShortcuts = config.globalShortcuts ?? [];
32
33    this.log("info", "KeyboardService initialized", { config });
34  }
35
36  /**
37   * Register shortcuts for a component
38   */
39  registerShortcuts(componentId: string, shortcuts: ShortcutConfig[]): void {
40    if (this.components.has(componentId)) {
41      this.log(
42        "warn",
43        `Component ${componentId} already registered, overwriting shortcuts`,
44      );
45    }
46
47    const componentShortcuts: ComponentShortcuts = {
48      componentId,
49      shortcuts: shortcuts.map((shortcut) => ({
50        ...shortcut,
51        preventDefault: shortcut.preventDefault ?? true,
52        stopPropagation: shortcut.stopPropagation ?? true,
53        whenFocused: shortcut.whenFocused ?? false,
54      })),
55      focused: false,
56    };
57
58    this.components.set(componentId, componentShortcuts);
59
60    this.log("info", "Shortcuts registered", {
61      componentId,
62      shortcuts: shortcuts.length,
63    });
64    this.emit("shortcut-registered", {
65      shortcut: componentId,
66      timestamp: Date.now(),
67      targetComponent: componentId,
68    });
69  }
70
71  /**
72   * Unregister shortcuts for a component
73   */
74  unregisterShortcuts(componentId: string): void {
75    const component = this.components.get(componentId);
76    if (!component) {
77      this.log("warn", `Component ${componentId} not found for unregistration`);
78      return;
79    }
80
81    // Call cleanup function if provided
82    if (component.cleanup) {
83      component.cleanup();
84    }
85
86    this.components.delete(componentId);
87
88    // Clear active component if it was this one
89    if (this.activeComponent === componentId) {
90      this.activeComponent = null;
91    }
92
93    this.log("info", "Shortcuts unregistered", { componentId });
94    this.emit("shortcut-unregistered", {
95      shortcut: componentId,
96      timestamp: Date.now(),
97      targetComponent: componentId,
98    });
99  }
100
101  /**
102   * Set which component is currently focused
103   */
104  setActiveComponent(componentId: string | null): void {
105    // Blur previous component
106    if (this.activeComponent && this.components.has(this.activeComponent)) {
107      const prevComponent = this.components.get(this.activeComponent)!;
108      prevComponent.focused = false;
109      this.emit("component-blurred", {
110        shortcut: this.activeComponent,
111        timestamp: Date.now(),
112        targetComponent: this.activeComponent,
113      });
114    }
115
116    this.activeComponent = componentId;
117
118    // Focus new component
119    if (componentId && this.components.has(componentId)) {
120      const newComponent = this.components.get(componentId)!;
121      newComponent.focused = true;
122      this.emit("component-focused", {
123        shortcut: componentId,
124        timestamp: Date.now(),
125        targetComponent: componentId,
126      });
127    }
128
129    this.log("info", "Active component changed", {
130      from: this.activeComponent,
131      to: componentId,
132    });
133  }
134
135  /**
136   * Handle a keyboard shortcut from native GTK layer
137   */
138  handleNativeShortcut(shortcut: string): boolean {
139    this.log("info", "Native shortcut received", { shortcut });
140
141    // Normalize shortcut string
142    const normalizedShortcut = this.normalizeShortcut(shortcut);
143
144    // Try to handle with focused component first
145    if (this.activeComponent) {
146      const handled = this.handleComponentShortcut(
147        this.activeComponent,
148        normalizedShortcut,
149        true,
150      );
151      if (handled) {
152        return true;
153      }
154    }
155
156    // Try global shortcuts
157    const globalHandled = this.handleGlobalShortcuts(normalizedShortcut);
158    if (globalHandled) {
159      return true;
160    }
161
162    // Try all components (for non-focused shortcuts)
163    for (const [componentId] of this.components) {
164      if (componentId === this.activeComponent) continue; // Already tried above
165
166      const handled = this.handleComponentShortcut(
167        componentId,
168        normalizedShortcut,
169        false,
170      );
171      if (handled) {
172        return true;
173      }
174    }
175
176    this.log("info", "Shortcut not handled", { shortcut: normalizedShortcut });
177    return false;
178  }
179
180  /**
181   * Handle JavaScript keyboard events (for web page events)
182   */
183  handleKeyboardEvent(event: globalThis.KeyboardEvent): boolean {
184    const shortcut = this.eventToShortcut(event);
185    if (!shortcut) return false;
186
187    this.log("info", "Keyboard event received", { shortcut });
188    return this.handleNativeShortcut(shortcut);
189  }
190
191  /**
192   * Handle JavaScript mouse events (for navigation buttons)
193   */
194  handleMouseEvent(event: globalThis.MouseEvent): boolean {
195    try {
196      // Mouse button 3 (back button)
197      if (event.button === 3) {
198        event.preventDefault();
199        event.stopPropagation();
200
201        if (window.history.length > 1) {
202          console.log("⬅️ Mouse back button pressed");
203          window.history.back();
204        }
205        return true;
206      }
207
208      // Mouse button 4 (forward button)
209      if (event.button === 4) {
210        event.preventDefault();
211        event.stopPropagation();
212
213        console.log("➡️ Mouse forward button pressed");
214        window.history.forward();
215        return true;
216      }
217    } catch (error) {
218      this.log("error", "Mouse handler error", { error });
219    }
220
221    return false;
222  }
223
224  /**
225   * Add event listener for keyboard service events
226   */
227  addEventListener(
228    type: KeyboardEventType,
229    listener: (event: KeyboardEvent) => void,
230  ): void {
231    if (!this.eventListeners.has(type)) {
232      this.eventListeners.set(type, new Set());
233    }
234    this.eventListeners.get(type)!.add(listener);
235  }
236
237  /**
238   * Remove event listener
239   */
240  removeEventListener(
241    type: KeyboardEventType,
242    listener: (event: KeyboardEvent) => void,
243  ): void {
244    const listeners = this.eventListeners.get(type);
245    if (listeners) {
246      listeners.delete(listener);
247    }
248  }
249
250  /**
251   * Get all registered shortcuts for debugging
252   */
253  getDebugInfo(): object {
254    return {
255      components: Array.from(this.components.entries()).map(([id, comp]) => ({
256        id,
257        shortcuts: comp.shortcuts.length,
258        focused: comp.focused,
259      })),
260      activeComponent: this.activeComponent,
261      globalShortcuts: this.globalShortcuts.length,
262    };
263  }
264
265  // Private methods
266
267  private handleComponentShortcut(
268    componentId: string,
269    shortcut: string,
270    respectFocus: boolean,
271  ): boolean {
272    const component = this.components.get(componentId);
273    if (!component) return false;
274
275    for (const shortcutConfig of component.shortcuts) {
276      if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
277        // Check focus requirement
278        if (respectFocus && shortcutConfig.whenFocused && !component.focused) {
279          continue;
280        }
281
282        try {
283          shortcutConfig.handler();
284          this.log("info", "Shortcut handled", {
285            shortcut,
286            componentId,
287            focused: component.focused,
288            description: shortcutConfig.description,
289          });
290
291          this.emit("shortcut-triggered", {
292            shortcut,
293            timestamp: Date.now(),
294            targetComponent: componentId,
295          });
296
297          return true;
298        } catch (error) {
299          this.log("error", "Shortcut handler error", {
300            shortcut,
301            componentId,
302            error,
303          });
304        }
305      }
306    }
307
308    return false;
309  }
310
311  private handleGlobalShortcuts(shortcut: string): boolean {
312    for (const shortcutConfig of this.globalShortcuts) {
313      if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
314        try {
315          shortcutConfig.handler();
316          this.log("info", "Global shortcut handled", {
317            shortcut,
318            description: shortcutConfig.description,
319          });
320
321          this.emit("shortcut-triggered", {
322            shortcut,
323            timestamp: Date.now(),
324          });
325
326          return true;
327        } catch (error) {
328          this.log("error", "Global shortcut handler error", {
329            shortcut,
330            error,
331          });
332        }
333      }
334    }
335    return false;
336  }
337
338  private normalizeShortcut(shortcut: string): string {
339    return shortcut.toLowerCase().replace(/\s+/g, "");
340  }
341
342  private eventToShortcut(event: globalThis.KeyboardEvent): string | null {
343    const parts: string[] = [];
344
345    // Add modifiers using MODIFIER_KEYS mapping
346    if (event.ctrlKey && event.metaKey) {
347      // Both ctrl and meta pressed - use cmdorctrl
348      parts.push("cmdorctrl");
349    } else if (event.ctrlKey) {
350      parts.push("ctrl");
351    } else if (event.metaKey) {
352      parts.push("cmd");
353    }
354
355    if (event.altKey) parts.push("alt");
356    if (event.shiftKey) parts.push("shift");
357
358    // Add key
359    if (event.key) {
360      parts.push(event.key.toLowerCase());
361    }
362
363    return parts.length > 0 ? parts.join("+") : null;
364  }
365
366  private emit(type: KeyboardEventType, detail: KeyboardEventDetail): void {
367    const listeners = this.eventListeners.get(type);
368    if (listeners) {
369      const keyboardEvent: KeyboardEvent = { type, detail };
370      listeners.forEach((listener) => {
371        try {
372          listener(keyboardEvent);
373        } catch (error) {
374          this.log("error", "Event listener error", { type, error });
375        }
376      });
377    }
378  }
379
380  private log(
381    level: "info" | "warn" | "error" | string,
382    message?: string,
383    data?: unknown,
384  ): void {
385    if (!this.debug && level !== "error") return;
386
387    const logMessage = message
388      ? `[KeyboardService] ${message}`
389      : `[KeyboardService] ${level}`;
390
391    switch (level) {
392      case "error":
393        console.error(logMessage, data);
394        break;
395      case "warn":
396        console.warn(logMessage, data);
397        break;
398      default:
399        console.log(logMessage, data);
400    }
401  }
402}
403
404// Create and export singleton instance with global shortcuts
405export const keyboardService = new KeyboardService({
406  debug: false, // Disable debug logging in production
407  globalShortcuts: [
408    // Navigation shortcuts (migrated from controls module)
409    {
410      key: "cmdorctrl+arrowleft",
411      handler: () => {
412        if (window.history.length > 1) {
413          window.history.back();
414        }
415      },
416      description: "Navigate back (Ctrl/Cmd+Left Arrow)",
417    },
418    {
419      key: "cmdorctrl+arrowright",
420      handler: () => {
421        window.history.forward();
422      },
423      description: "Navigate forward (Ctrl/Cmd+Right Arrow)",
424    },
425    // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled
426    // directly by the native Go shortcut handlers to ensure proper active pane detection.
427    // This avoids conflicts between multiple webview instances.
428  ],
429});
430
431// Export types for use in components
432export type {
433  ShortcutConfig,
434  ComponentShortcuts,
435  KeyboardServiceConfig,
436} from "./types";
437// COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers

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