v0.8.0 0fa6785

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

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