feat(gui): add workspace-aware focus management and pane controls
- commit
- ed638e0087f738a29823ff98d13bf9b3ddedb386
- author
- bamen · · 2025-09-19 03:42 UTC
- parent
- 6260495
- signature
- unsigned
4 files · +501 -68
M gui/src/injected/gui.ts +112 -1
| @@ -8,7 +8,8 @@ | |||
| 8 | 8 | // Import all GUI modules | |
| 9 | 9 | import { initializeToast, type ToastConfig } from './modules/toast'; | |
| 10 | 10 | import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox'; | |
| 11 | - | import { keyboardService, type KeyboardService } from '../lib/keyboard'; | |
| 11 | + | import { initializeWorkspace, type WorkspaceConfigPayload, type WorkspaceRuntime } from './modules/workspace'; | |
| 12 | + | import { keyboardService, type KeyboardService } from '$lib/keyboard'; | |
| 12 | 13 | // Note: color-scheme module is loaded separately at document-start by WebKit | |
| 13 | 14 | ||
| 14 | 15 | // Global interface for the unified GUI system | |
| @@ -45,6 +46,8 @@ declare global { | |||
| 45 | 46 | __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void; | |
| 46 | 47 | __dumber_showZoomToast?: (level: number) => void; | |
| 47 | 48 | __dumber_keyboard?: KeyboardService; | |
| 49 | + | __dumber_workspace_config?: WorkspaceConfigPayload; | |
| 50 | + | __dumber_workspace?: WorkspaceRuntime; | |
| 48 | 51 | // Legacy compatibility functions for Go bridge | |
| 49 | 52 | __dumber_toggle?: () => void; | |
| 50 | 53 | __dumber_find_open?: (query?: string) => void; | |
| @@ -61,6 +64,43 @@ if (!window.__dumber_gui_ready) { | |||
| 61 | 64 | } else { | |
| 62 | 65 | window.__dumber_gui_ready = true; | |
| 63 | 66 | ||
| 67 | + | let workspaceHasFocus = document.hasFocus(); | |
| 68 | + | let currentPaneId = window.__dumber_pane?.id || 'unknown'; | |
| 69 | + | ||
| 70 | + | document.addEventListener('dumber:workspace-focus', (event: Event) => { | |
| 71 | + | const detail = (event as CustomEvent).detail || {}; | |
| 72 | + | console.log('[workspace focus event]', detail, 'prev focus=', workspaceHasFocus); | |
| 73 | + | ||
| 74 | + | // Update focus state | |
| 75 | + | if (typeof detail?.active === 'boolean') { | |
| 76 | + | workspaceHasFocus = detail.active; | |
| 77 | + | } else { | |
| 78 | + | workspaceHasFocus = detail !== false; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Update pane tracking | |
| 82 | + | if (detail?.paneId) { | |
| 83 | + | currentPaneId = detail.paneId; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Update global pane state | |
| 87 | + | if (window.__dumber_pane) { | |
| 88 | + | window.__dumber_pane.active = workspaceHasFocus; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | console.log('[workspace focus updated] focus=', workspaceHasFocus, 'paneId=', currentPaneId); | |
| 92 | + | ||
| 93 | + | // Handle GUI visibility based on focus | |
| 94 | + | if (window.__dumber_omnibox) { | |
| 95 | + | if (workspaceHasFocus) { | |
| 96 | + | window.__dumber_omnibox.setActive?.(true); | |
| 97 | + | } else { | |
| 98 | + | window.__dumber_omnibox.setActive?.(false); | |
| 99 | + | window.__dumber_omnibox.close?.(); | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | }); | |
| 103 | + | ||
| 64 | 104 | // Initialize Svelte GUI systems immediately | |
| 65 | 105 | whenDOMReady(async () => { | |
| 66 | 106 | try { | |
| @@ -77,6 +117,9 @@ if (!window.__dumber_gui_ready) { | |||
| 77 | 117 | // Initialize omnibox system | |
| 78 | 118 | await initializeOmnibox(); | |
| 79 | 119 | ||
| 120 | + | // Initialize workspace controls (pane/tab scaffolding) | |
| 121 | + | initializeWorkspace(window.__dumber_workspace_config); | |
| 122 | + | ||
| 80 | 123 | console.log('✅ All GUI systems initialized successfully'); | |
| 81 | 124 | } catch (e) { | |
| 82 | 125 | console.error('❌ Failed to initialize Svelte toast system:', e); | |
| @@ -167,6 +210,55 @@ if (!window.__dumber_gui_ready) { | |||
| 167 | 210 | } | |
| 168 | 211 | }); | |
| 169 | 212 | ||
| 213 | + | document.addEventListener('dumber:ui:shortcut', (e: Event) => { | |
| 214 | + | const detail = (e as CustomEvent).detail || {}; | |
| 215 | + | const action = detail?.action; | |
| 216 | + | const eventPaneId = detail?.paneId; | |
| 217 | + | const source = detail?.source || 'unknown'; | |
| 218 | + | ||
| 219 | + | if (typeof action !== 'string') { | |
| 220 | + | return; | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // Enhanced focus checking with pane ID validation | |
| 224 | + | const isForThisPane = !eventPaneId || eventPaneId === currentPaneId; | |
| 225 | + | const shouldHandle = workspaceHasFocus && isForThisPane; | |
| 226 | + | ||
| 227 | + | if (!shouldHandle) { | |
| 228 | + | console.log('[dumber shortcuts] ignored action', { | |
| 229 | + | action, | |
| 230 | + | workspaceHasFocus, | |
| 231 | + | isForThisPane, | |
| 232 | + | eventPaneId, | |
| 233 | + | currentPaneId, | |
| 234 | + | source, | |
| 235 | + | detail | |
| 236 | + | }); | |
| 237 | + | return; | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | console.log('[dumber shortcuts] handling action', { action, source, paneId: currentPaneId }); | |
| 241 | + | ||
| 242 | + | const omnibox = window.__dumber_omnibox; | |
| 243 | + | ||
| 244 | + | switch (action) { | |
| 245 | + | case 'omnibox-toggle': | |
| 246 | + | omnibox?.toggle?.(); | |
| 247 | + | break; | |
| 248 | + | case 'omnibox-open': | |
| 249 | + | omnibox?.open?.('omnibox', detail?.query); | |
| 250 | + | break; | |
| 251 | + | case 'omnibox-find': | |
| 252 | + | omnibox?.open?.('find', detail?.query); | |
| 253 | + | break; | |
| 254 | + | case 'omnibox-close': | |
| 255 | + | omnibox?.close?.(); | |
| 256 | + | break; | |
| 257 | + | default: | |
| 258 | + | console.warn('[dumber] Unknown UI shortcut action:', action); | |
| 259 | + | } | |
| 260 | + | }); | |
| 261 | + | ||
| 170 | 262 | // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed | |
| 171 | 263 | }); | |
| 172 | 264 | ||
| @@ -232,5 +324,24 @@ if (!window.__dumber_gui_ready) { | |||
| 232 | 324 | isReady: true | |
| 233 | 325 | }; | |
| 234 | 326 | ||
| 327 | + | // Bootstrap function for lazy GUI initialization by workspace manager | |
| 328 | + | window.__dumber_gui_bootstrap = function(paneId: string) { | |
| 329 | + | console.log(`[gui-bootstrap] Initializing GUI for pane ${paneId}`); | |
| 330 | + | ||
| 331 | + | // Update current pane ID | |
| 332 | + | currentPaneId = paneId; | |
| 333 | + | ||
| 334 | + | // Ensure omnibox is available for this pane | |
| 335 | + | if (!window.__dumber_omnibox) { | |
| 336 | + | console.log(`[gui-bootstrap] Omnibox not yet available for pane ${paneId}, will be loaded when needed`); | |
| 337 | + | } else { | |
| 338 | + | console.log(`[gui-bootstrap] Omnibox already available for pane ${paneId}`); | |
| 339 | + | // Ensure omnibox is aware of the current pane | |
| 340 | + | window.__dumber_omnibox.setActive?.(workspaceHasFocus); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | console.log(`[gui-bootstrap] GUI bootstrap complete for pane ${paneId}`); | |
| 344 | + | }; | |
| 345 | + | ||
| 235 | 346 | } | |
| 236 | 347 | } | |
| @@ -8,7 +8,8 @@ | |||||
| 8 | // Import all GUI modules | 8 | // Import all GUI modules | ||
| 9 | import { initializeToast, type ToastConfig } from './modules/toast'; | 9 | import { initializeToast, type ToastConfig } from './modules/toast'; | ||
| 10 | import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox'; | 10 | import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox'; | ||
| 11 | - | import { keyboardService, type KeyboardService } from '../lib/keyboard'; | 11 | + | import { initializeWorkspace, type WorkspaceConfigPayload, type WorkspaceRuntime } from './modules/workspace'; |
| 12 | + | import { keyboardService, type KeyboardService } from '$lib/keyboard'; | |||
| 12 | // Note: color-scheme module is loaded separately at document-start by WebKit | 13 | // Note: color-scheme module is loaded separately at document-start by WebKit | ||
| 13 | 14 | ||||
| 14 | // Global interface for the unified GUI system | 15 | // Global interface for the unified GUI system | ||
| @@ -45,6 +46,8 @@ declare global { | |||||
| 45 | __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void; | 46 | __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void; | ||
| 46 | __dumber_showZoomToast?: (level: number) => void; | 47 | __dumber_showZoomToast?: (level: number) => void; | ||
| 47 | __dumber_keyboard?: KeyboardService; | 48 | __dumber_keyboard?: KeyboardService; | ||
| 49 | + | __dumber_workspace_config?: WorkspaceConfigPayload; | |||
| 50 | + | __dumber_workspace?: WorkspaceRuntime; | |||
| 48 | // Legacy compatibility functions for Go bridge | 51 | // Legacy compatibility functions for Go bridge | ||
| 49 | __dumber_toggle?: () => void; | 52 | __dumber_toggle?: () => void; | ||
| 50 | __dumber_find_open?: (query?: string) => void; | 53 | __dumber_find_open?: (query?: string) => void; | ||
| @@ -61,6 +64,43 @@ if (!window.__dumber_gui_ready) { | |||||
| 61 | } else { | 64 | } else { | ||
| 62 | window.__dumber_gui_ready = true; | 65 | window.__dumber_gui_ready = true; | ||
| 63 | 66 | ||||
| 67 | + | let workspaceHasFocus = document.hasFocus(); | |||
| 68 | + | let currentPaneId = window.__dumber_pane?.id || 'unknown'; | |||
| 69 | + | ||||
| 70 | + | document.addEventListener('dumber:workspace-focus', (event: Event) => { | |||
| 71 | + | const detail = (event as CustomEvent).detail || {}; | |||
| 72 | + | console.log('[workspace focus event]', detail, 'prev focus=', workspaceHasFocus); | |||
| 73 | + | ||||
| 74 | + | // Update focus state | |||
| 75 | + | if (typeof detail?.active === 'boolean') { | |||
| 76 | + | workspaceHasFocus = detail.active; | |||
| 77 | + | } else { | |||
| 78 | + | workspaceHasFocus = detail !== false; | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Update pane tracking | |||
| 82 | + | if (detail?.paneId) { | |||
| 83 | + | currentPaneId = detail.paneId; | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Update global pane state | |||
| 87 | + | if (window.__dumber_pane) { | |||
| 88 | + | window.__dumber_pane.active = workspaceHasFocus; | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | console.log('[workspace focus updated] focus=', workspaceHasFocus, 'paneId=', currentPaneId); | |||
| 92 | + | ||||
| 93 | + | // Handle GUI visibility based on focus | |||
| 94 | + | if (window.__dumber_omnibox) { | |||
| 95 | + | if (workspaceHasFocus) { | |||
| 96 | + | window.__dumber_omnibox.setActive?.(true); | |||
| 97 | + | } else { | |||
| 98 | + | window.__dumber_omnibox.setActive?.(false); | |||
| 99 | + | window.__dumber_omnibox.close?.(); | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | }); | |||
| 103 | + | ||||
| 64 | // Initialize Svelte GUI systems immediately | 104 | // Initialize Svelte GUI systems immediately | ||
| 65 | whenDOMReady(async () => { | 105 | whenDOMReady(async () => { | ||
| 66 | try { | 106 | try { | ||
| @@ -77,6 +117,9 @@ if (!window.__dumber_gui_ready) { | |||||
| 77 | // Initialize omnibox system | 117 | // Initialize omnibox system | ||
| 78 | await initializeOmnibox(); | 118 | await initializeOmnibox(); | ||
| 79 | 119 | ||||
| 120 | + | // Initialize workspace controls (pane/tab scaffolding) | |||
| 121 | + | initializeWorkspace(window.__dumber_workspace_config); | |||
| 122 | + | ||||
| 80 | console.log('✅ All GUI systems initialized successfully'); | 123 | console.log('✅ All GUI systems initialized successfully'); | ||
| 81 | } catch (e) { | 124 | } catch (e) { | ||
| 82 | console.error('❌ Failed to initialize Svelte toast system:', e); | 125 | console.error('❌ Failed to initialize Svelte toast system:', e); | ||
| @@ -167,6 +210,55 @@ if (!window.__dumber_gui_ready) { | |||||
| 167 | } | 210 | } | ||
| 168 | }); | 211 | }); | ||
| 169 | 212 | ||||
| 213 | + | document.addEventListener('dumber:ui:shortcut', (e: Event) => { | |||
| 214 | + | const detail = (e as CustomEvent).detail || {}; | |||
| 215 | + | const action = detail?.action; | |||
| 216 | + | const eventPaneId = detail?.paneId; | |||
| 217 | + | const source = detail?.source || 'unknown'; | |||
| 218 | + | ||||
| 219 | + | if (typeof action !== 'string') { | |||
| 220 | + | return; | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // Enhanced focus checking with pane ID validation | |||
| 224 | + | const isForThisPane = !eventPaneId || eventPaneId === currentPaneId; | |||
| 225 | + | const shouldHandle = workspaceHasFocus && isForThisPane; | |||
| 226 | + | ||||
| 227 | + | if (!shouldHandle) { | |||
| 228 | + | console.log('[dumber shortcuts] ignored action', { | |||
| 229 | + | action, | |||
| 230 | + | workspaceHasFocus, | |||
| 231 | + | isForThisPane, | |||
| 232 | + | eventPaneId, | |||
| 233 | + | currentPaneId, | |||
| 234 | + | source, | |||
| 235 | + | detail | |||
| 236 | + | }); | |||
| 237 | + | return; | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | console.log('[dumber shortcuts] handling action', { action, source, paneId: currentPaneId }); | |||
| 241 | + | ||||
| 242 | + | const omnibox = window.__dumber_omnibox; | |||
| 243 | + | ||||
| 244 | + | switch (action) { | |||
| 245 | + | case 'omnibox-toggle': | |||
| 246 | + | omnibox?.toggle?.(); | |||
| 247 | + | break; | |||
| 248 | + | case 'omnibox-open': | |||
| 249 | + | omnibox?.open?.('omnibox', detail?.query); | |||
| 250 | + | break; | |||
| 251 | + | case 'omnibox-find': | |||
| 252 | + | omnibox?.open?.('find', detail?.query); | |||
| 253 | + | break; | |||
| 254 | + | case 'omnibox-close': | |||
| 255 | + | omnibox?.close?.(); | |||
| 256 | + | break; | |||
| 257 | + | default: | |||
| 258 | + | console.warn('[dumber] Unknown UI shortcut action:', action); | |||
| 259 | + | } | |||
| 260 | + | }); | |||
| 261 | + | ||||
| 170 | // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed | 262 | // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed | ||
| 171 | }); | 263 | }); | ||
| 172 | 264 | ||||
| @@ -232,5 +324,24 @@ if (!window.__dumber_gui_ready) { | |||||
| 232 | isReady: true | 324 | isReady: true | ||
| 233 | }; | 325 | }; | ||
| 234 | 326 | ||||
| 327 | + | // Bootstrap function for lazy GUI initialization by workspace manager | |||
| 328 | + | window.__dumber_gui_bootstrap = function(paneId: string) { | |||
| 329 | + | console.log(`[gui-bootstrap] Initializing GUI for pane ${paneId}`); | |||
| 330 | + | ||||
| 331 | + | // Update current pane ID | |||
| 332 | + | currentPaneId = paneId; | |||
| 333 | + | ||||
| 334 | + | // Ensure omnibox is available for this pane | |||
| 335 | + | if (!window.__dumber_omnibox) { | |||
| 336 | + | console.log(`[gui-bootstrap] Omnibox not yet available for pane ${paneId}, will be loaded when needed`); | |||
| 337 | + | } else { | |||
| 338 | + | console.log(`[gui-bootstrap] Omnibox already available for pane ${paneId}`); | |||
| 339 | + | // Ensure omnibox is aware of the current pane | |||
| 340 | + | window.__dumber_omnibox.setActive?.(workspaceHasFocus); | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | console.log(`[gui-bootstrap] GUI bootstrap complete for pane ${paneId}`); | |||
| 344 | + | }; | |||
| 345 | + | ||||
| 235 | } | 346 | } | ||
| 236 | } | 347 | } | ||
M gui/src/injected/modules/color-scheme.ts +3 -3
| @@ -9,13 +9,13 @@ | |||
| 9 | 9 | declare global { | |
| 10 | 10 | interface Window { | |
| 11 | 11 | __dumber_gtk_prefers_dark?: boolean; | |
| 12 | - | __dumber_setTheme?: (theme: string) => void; | |
| 12 | + | __dumber_setTheme?: (theme: 'light' | 'dark') => void; | |
| 13 | 13 | } | |
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | 16 | interface ColorSchemeConfig { | |
| 17 | 17 | isDark: boolean; | |
| 18 | - | colorScheme: string; | |
| 18 | + | colorScheme: 'light' | 'dark'; | |
| 19 | 19 | metaContent: string; | |
| 20 | 20 | rootStyle: string; | |
| 21 | 21 | } | |
| @@ -70,7 +70,7 @@ class ColorSchemeManager { | |||
| 70 | 70 | value: this.config.colorScheme | |
| 71 | 71 | }) | |
| 72 | 72 | ); | |
| 73 | - | } catch (error) { | |
| 73 | + | } catch { | |
| 74 | 74 | // Ignore errors - native layer may not be available | |
| 75 | 75 | } | |
| 76 | 76 | } | |
| @@ -9,13 +9,13 @@ | |||||
| 9 | declare global { | 9 | declare global { | ||
| 10 | interface Window { | 10 | interface Window { | ||
| 11 | __dumber_gtk_prefers_dark?: boolean; | 11 | __dumber_gtk_prefers_dark?: boolean; | ||
| 12 | - | __dumber_setTheme?: (theme: string) => void; | 12 | + | __dumber_setTheme?: (theme: 'light' | 'dark') => void; |
| 13 | } | 13 | } | ||
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | interface ColorSchemeConfig { | 16 | interface ColorSchemeConfig { | ||
| 17 | isDark: boolean; | 17 | isDark: boolean; | ||
| 18 | - | colorScheme: string; | 18 | + | colorScheme: 'light' | 'dark'; |
| 19 | metaContent: string; | 19 | metaContent: string; | ||
| 20 | rootStyle: string; | 20 | rootStyle: string; | ||
| 21 | } | 21 | } | ||
| @@ -70,7 +70,7 @@ class ColorSchemeManager { | |||||
| 70 | value: this.config.colorScheme | 70 | value: this.config.colorScheme | ||
| 71 | }) | 71 | }) | ||
| 72 | ); | 72 | ); | ||
| 73 | - | } catch (error) { | 73 | + | } catch { |
| 74 | // Ignore errors - native layer may not be available | 74 | // Ignore errors - native layer may not be available | ||
| 75 | } | 75 | } | ||
| 76 | } | 76 | } | ||
A gui/src/injected/modules/workspace.ts +377 -0
| @@ -0,0 +1,377 @@ | |||
| 1 | + | /** | |
| 2 | + | * Workspace Controller Module | |
| 3 | + | * | |
| 4 | + | * Provides the initial scaffolding for Zellij-inspired pane management. | |
| 5 | + | * Currently tracks pane mode state, exposes configuration to the page, | |
| 6 | + | * and wires keyboard service shortcuts based on configuration defaults. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | import { keyboardService } from '$lib/keyboard'; | |
| 10 | + | ||
| 11 | + | const ACTIVATION_COMPONENT_ID = 'workspace-pane-activation'; | |
| 12 | + | const ACTION_COMPONENT_ID = 'workspace-pane-actions'; | |
| 13 | + | ||
| 14 | + | export interface WorkspaceConfigPayload { | |
| 15 | + | enable_zellij_controls?: boolean; | |
| 16 | + | pane_mode?: { | |
| 17 | + | activation_shortcut?: string; | |
| 18 | + | timeout_ms?: number; | |
| 19 | + | action_bindings?: Record<string, string>; | |
| 20 | + | }; | |
| 21 | + | tabs?: { | |
| 22 | + | new_tab?: string; | |
| 23 | + | close_tab?: string; | |
| 24 | + | next_tab?: string; | |
| 25 | + | previous_tab?: string; | |
| 26 | + | }; | |
| 27 | + | popups?: { | |
| 28 | + | placement?: string; | |
| 29 | + | open_in_new_pane?: boolean; | |
| 30 | + | follow_pane_context?: boolean; | |
| 31 | + | }; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | export interface WorkspacePaneModeConfig { | |
| 35 | + | activationShortcut: string; | |
| 36 | + | timeoutMs: number; | |
| 37 | + | actionBindings: Record<string, string>; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | export interface WorkspaceTabsConfig { | |
| 41 | + | newTab: string; | |
| 42 | + | closeTab: string; | |
| 43 | + | nextTab: string; | |
| 44 | + | previousTab: string; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export interface WorkspacePopupConfig { | |
| 48 | + | placement: string; | |
| 49 | + | openInNewPane: boolean; | |
| 50 | + | followPaneContext: boolean; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | export interface WorkspaceConfigNormalized { | |
| 54 | + | enableZellijControls: boolean; | |
| 55 | + | paneMode: WorkspacePaneModeConfig; | |
| 56 | + | tabs: WorkspaceTabsConfig; | |
| 57 | + | popups: WorkspacePopupConfig; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | export interface WorkspaceRuntime { | |
| 61 | + | enterPaneMode: () => void; | |
| 62 | + | exitPaneMode: (reason?: string) => void; | |
| 63 | + | isPaneModeActive: () => boolean; | |
| 64 | + | getConfig: () => WorkspaceConfigNormalized; | |
| 65 | + | getInstanceId: () => string; | |
| 66 | + | isActiveInstance: () => boolean; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | const DEFAULT_CONFIG: WorkspaceConfigNormalized = { | |
| 70 | + | enableZellijControls: true, | |
| 71 | + | paneMode: { | |
| 72 | + | activationShortcut: 'cmdorctrl+p', | |
| 73 | + | timeoutMs: 3000, | |
| 74 | + | actionBindings: { | |
| 75 | + | arrowright: 'split-right', | |
| 76 | + | arrowleft: 'split-left', | |
| 77 | + | arrowup: 'split-up', | |
| 78 | + | arrowdown: 'split-down', | |
| 79 | + | r: 'split-right', | |
| 80 | + | l: 'split-left', | |
| 81 | + | u: 'split-up', | |
| 82 | + | d: 'split-down', | |
| 83 | + | x: 'close-pane', | |
| 84 | + | enter: 'confirm', | |
| 85 | + | escape: 'cancel' | |
| 86 | + | } | |
| 87 | + | }, | |
| 88 | + | tabs: { | |
| 89 | + | newTab: 'cmdorctrl+t', | |
| 90 | + | closeTab: 'cmdorctrl+w', | |
| 91 | + | nextTab: 'cmdorctrl+tab', | |
| 92 | + | previousTab: 'cmdorctrl+shift+tab' | |
| 93 | + | }, | |
| 94 | + | popups: { | |
| 95 | + | placement: 'right', | |
| 96 | + | openInNewPane: true, | |
| 97 | + | followPaneContext: true | |
| 98 | + | } | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | function normalizeWorkspaceConfig(payload?: WorkspaceConfigPayload | null): WorkspaceConfigNormalized { | |
| 102 | + | const paneModePayload = payload?.pane_mode ?? {}; | |
| 103 | + | const tabsPayload = payload?.tabs ?? {}; | |
| 104 | + | const popupsPayload = payload?.popups ?? {}; | |
| 105 | + | ||
| 106 | + | return { | |
| 107 | + | enableZellijControls: payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls, | |
| 108 | + | paneMode: { | |
| 109 | + | activationShortcut: paneModePayload.activation_shortcut ?? DEFAULT_CONFIG.paneMode.activationShortcut, | |
| 110 | + | timeoutMs: paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs, | |
| 111 | + | actionBindings: { | |
| 112 | + | ...DEFAULT_CONFIG.paneMode.actionBindings, | |
| 113 | + | ...(paneModePayload.action_bindings ?? {}) | |
| 114 | + | } | |
| 115 | + | }, | |
| 116 | + | tabs: { | |
| 117 | + | newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab, | |
| 118 | + | closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab, | |
| 119 | + | nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab, | |
| 120 | + | previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab | |
| 121 | + | }, | |
| 122 | + | popups: { | |
| 123 | + | placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement, | |
| 124 | + | openInNewPane: popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane, | |
| 125 | + | followPaneContext: popupsPayload.follow_pane_context ?? DEFAULT_CONFIG.popups.followPaneContext | |
| 126 | + | } | |
| 127 | + | }; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | class WorkspaceController implements WorkspaceRuntime { | |
| 131 | + | private config: WorkspaceConfigNormalized = DEFAULT_CONFIG; | |
| 132 | + | private paneModeActive = false; | |
| 133 | + | private paneModeTimer: ReturnType<typeof setTimeout> | null = null; | |
| 134 | + | private instanceId: string; | |
| 135 | + | private lastActiveCheckTime = 0; | |
| 136 | + | ||
| 137 | + | constructor() { | |
| 138 | + | // Generate unique instance ID for this webview | |
| 139 | + | this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`; | |
| 140 | + | console.log(`[workspace] Created WorkspaceController instance: ${this.instanceId}`); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | getInstanceId(): string { | |
| 144 | + | return this.instanceId; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | isActiveInstance(): boolean { | |
| 148 | + | // Cache active check for 50ms to avoid excessive checks | |
| 149 | + | const now = Date.now(); | |
| 150 | + | if (now - this.lastActiveCheckTime < 50) { | |
| 151 | + | return true; // Assume active within debounce window | |
| 152 | + | } | |
| 153 | + | this.lastActiveCheckTime = now; | |
| 154 | + | ||
| 155 | + | // Check if this webview has focus by attempting to query document.hasFocus() | |
| 156 | + | // In a multi-pane environment, only the focused webview should handle pane mode | |
| 157 | + | try { | |
| 158 | + | const hasFocus = document.hasFocus(); | |
| 159 | + | const isVisible = document.visibilityState === 'visible'; | |
| 160 | + | const isActive = hasFocus && isVisible; | |
| 161 | + | ||
| 162 | + | if (!isActive) { | |
| 163 | + | console.log(`[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | return isActive; | |
| 167 | + | } catch (error) { | |
| 168 | + | console.warn('[workspace] Failed to check active state, assuming active:', error); | |
| 169 | + | return true; // Fallback to active if check fails | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | configure(payload?: WorkspaceConfigPayload | null): void { | |
| 174 | + | const normalized = normalizeWorkspaceConfig(payload); | |
| 175 | + | this.unregisterShortcuts(); | |
| 176 | + | this.config = normalized; | |
| 177 | + | ||
| 178 | + | if (!normalized.enableZellijControls) { | |
| 179 | + | console.log('[workspace] Zellij controls disabled via config'); | |
| 180 | + | window.__dumber_workspace = this.exportRuntime(); | |
| 181 | + | return; | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | this.registerShortcuts(); | |
| 185 | + | window.__dumber_workspace = this.exportRuntime(); | |
| 186 | + | console.log('[workspace] Zellij controls enabled with config:', this.config); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | private bridge(message: Record<string, unknown>): void { | |
| 190 | + | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 191 | + | if (!bridge || typeof bridge.postMessage !== 'function') { | |
| 192 | + | return; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | try { | |
| 196 | + | bridge.postMessage( | |
| 197 | + | JSON.stringify({ | |
| 198 | + | type: 'workspace', | |
| 199 | + | ...message | |
| 200 | + | }) | |
| 201 | + | ); | |
| 202 | + | } catch (error) { | |
| 203 | + | console.error('[workspace] Failed to post workspace message', error); | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | enterPaneMode(): void { | |
| 208 | + | if (!this.config.enableZellijControls) return; | |
| 209 | + | ||
| 210 | + | // Only allow the active instance to enter pane mode | |
| 211 | + | if (!this.isActiveInstance()) { | |
| 212 | + | console.log(`[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`); | |
| 213 | + | return; | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | console.log(`[workspace] Instance ${this.instanceId} entering pane mode`); | |
| 217 | + | this.paneModeActive = true; | |
| 218 | + | this.restartPaneModeTimer(); | |
| 219 | + | this.emitWorkspaceEvent('pane-mode-entered', { action: 'enter', config: this.config, instanceId: this.instanceId }); | |
| 220 | + | this.bridge({ event: 'pane-mode-entered', instanceId: this.instanceId }); | |
| 221 | + | this.showToast('Pane mode: awaiting direction'); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | exitPaneMode(reason: string = 'exit'): void { | |
| 225 | + | if (!this.paneModeActive) return; | |
| 226 | + | ||
| 227 | + | this.paneModeActive = false; | |
| 228 | + | if (this.paneModeTimer) { | |
| 229 | + | clearTimeout(this.paneModeTimer); | |
| 230 | + | this.paneModeTimer = null; | |
| 231 | + | } | |
| 232 | + | this.emitWorkspaceEvent('pane-mode-exited', { action: reason }); | |
| 233 | + | this.bridge({ event: 'pane-mode-exited', reason }); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | isPaneModeActive(): boolean { | |
| 237 | + | return this.paneModeActive; | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | getConfig(): WorkspaceConfigNormalized { | |
| 241 | + | return this.config; | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | private registerShortcuts(): void { | |
| 245 | + | keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [ | |
| 246 | + | { | |
| 247 | + | key: this.config.paneMode.activationShortcut, | |
| 248 | + | handler: () => this.enterPaneMode(), | |
| 249 | + | description: 'Enter pane mode', | |
| 250 | + | preventDefault: true, | |
| 251 | + | stopPropagation: true, | |
| 252 | + | whenFocused: false | |
| 253 | + | } | |
| 254 | + | ]); | |
| 255 | + | ||
| 256 | + | const paneShortcuts = Object.entries(this.config.paneMode.actionBindings).map(([key, action]) => ({ | |
| 257 | + | key, | |
| 258 | + | handler: () => this.handlePaneAction(action, key), | |
| 259 | + | description: `Pane action: ${action}`, | |
| 260 | + | preventDefault: true, | |
| 261 | + | stopPropagation: true, | |
| 262 | + | whenFocused: false | |
| 263 | + | })); | |
| 264 | + | ||
| 265 | + | keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | private unregisterShortcuts(): void { | |
| 269 | + | keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID); | |
| 270 | + | keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | private handlePaneAction(action: string, key?: string): void { | |
| 274 | + | if (!this.paneModeActive) { | |
| 275 | + | return; | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | switch (action) { | |
| 279 | + | case 'split-right': | |
| 280 | + | this.emitWorkspaceEvent('pane-split', { direction: 'right', config: this.config }); | |
| 281 | + | this.bridge({ event: 'pane-split', direction: 'right' }); | |
| 282 | + | this.showToast('Split pane to the right'); | |
| 283 | + | this.exitPaneMode('split-right'); | |
| 284 | + | break; | |
| 285 | + | case 'split-left': | |
| 286 | + | this.emitWorkspaceEvent('pane-split', { direction: 'left', config: this.config }); | |
| 287 | + | this.bridge({ event: 'pane-split', direction: 'left' }); | |
| 288 | + | this.showToast('Split pane to the left'); | |
| 289 | + | this.exitPaneMode('split-left'); | |
| 290 | + | break; | |
| 291 | + | case 'split-up': | |
| 292 | + | this.emitWorkspaceEvent('pane-split', { direction: 'up', config: this.config }); | |
| 293 | + | this.bridge({ event: 'pane-split', direction: 'up' }); | |
| 294 | + | this.showToast('Split pane upwards'); | |
| 295 | + | this.exitPaneMode('split-up'); | |
| 296 | + | break; | |
| 297 | + | case 'split-down': | |
| 298 | + | this.emitWorkspaceEvent('pane-split', { direction: 'down', config: this.config }); | |
| 299 | + | this.bridge({ event: 'pane-split', direction: 'down' }); | |
| 300 | + | this.showToast('Split pane downwards'); | |
| 301 | + | this.exitPaneMode('split-down'); | |
| 302 | + | break; | |
| 303 | + | case 'close-pane': | |
| 304 | + | this.emitWorkspaceEvent('pane-closed', { key, config: this.config }); | |
| 305 | + | this.bridge({ event: 'pane-close', action }); | |
| 306 | + | this.showToast('Closed pane'); | |
| 307 | + | this.exitPaneMode('close-pane'); | |
| 308 | + | break; | |
| 309 | + | case 'confirm': | |
| 310 | + | this.bridge({ event: 'pane-confirmed' }); | |
| 311 | + | this.showToast('Pane mode confirmed'); | |
| 312 | + | this.exitPaneMode('confirm'); | |
| 313 | + | break; | |
| 314 | + | case 'cancel': | |
| 315 | + | this.bridge({ event: 'pane-cancelled' }); | |
| 316 | + | this.showToast('Pane mode cancelled'); | |
| 317 | + | this.exitPaneMode('cancel'); | |
| 318 | + | break; | |
| 319 | + | default: | |
| 320 | + | console.warn('[workspace] Unhandled pane action:', action); | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | private restartPaneModeTimer(): void { | |
| 325 | + | if (this.paneModeTimer) { | |
| 326 | + | clearTimeout(this.paneModeTimer); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | if (this.config.paneMode.timeoutMs > 0) { | |
| 330 | + | this.paneModeTimer = setTimeout(() => { | |
| 331 | + | this.showToast('Pane mode timed out'); | |
| 332 | + | this.exitPaneMode('timeout'); | |
| 333 | + | }, this.config.paneMode.timeoutMs); | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | private showToast(message: string): void { | |
| 338 | + | if (typeof window.__dumber_showToast === 'function') { | |
| 339 | + | window.__dumber_showToast(message, 2000, 'info'); | |
| 340 | + | } else { | |
| 341 | + | console.log('[workspace toast]', message); | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | private emitWorkspaceEvent(event: string, detail: Record<string, unknown>): void { | |
| 346 | + | document.dispatchEvent(new CustomEvent(`dumber:workspace-${event}`, { detail })); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | private exportRuntime(): WorkspaceRuntime { | |
| 350 | + | return { | |
| 351 | + | enterPaneMode: () => this.enterPaneMode(), | |
| 352 | + | exitPaneMode: (reason?: string) => this.exitPaneMode(reason ?? 'external'), | |
| 353 | + | isPaneModeActive: () => this.isPaneModeActive(), | |
| 354 | + | getConfig: () => this.getConfig(), | |
| 355 | + | getInstanceId: () => this.getInstanceId(), | |
| 356 | + | isActiveInstance: () => this.isActiveInstance() | |
| 357 | + | }; | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | const workspaceController = new WorkspaceController(); | |
| 362 | + | let listenersAttached = false; | |
| 363 | + | ||
| 364 | + | export function initializeWorkspace(payload?: WorkspaceConfigPayload | null): void { | |
| 365 | + | if (!listenersAttached) { | |
| 366 | + | document.addEventListener('dumber:workspace-config', (event: Event) => { | |
| 367 | + | const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail; | |
| 368 | + | workspaceController.configure(detail); | |
| 369 | + | }); | |
| 370 | + | listenersAttached = true; | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null; | |
| 374 | + | workspaceController.configure(bootstrapPayload); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | export { workspaceController }; | |
| @@ -0,0 +1,377 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Workspace Controller Module | |||
| 3 | + | * | |||
| 4 | + | * Provides the initial scaffolding for Zellij-inspired pane management. | |||
| 5 | + | * Currently tracks pane mode state, exposes configuration to the page, | |||
| 6 | + | * and wires keyboard service shortcuts based on configuration defaults. | |||
| 7 | + | */ | |||
| 8 | + | ||||
| 9 | + | import { keyboardService } from '$lib/keyboard'; | |||
| 10 | + | ||||
| 11 | + | const ACTIVATION_COMPONENT_ID = 'workspace-pane-activation'; | |||
| 12 | + | const ACTION_COMPONENT_ID = 'workspace-pane-actions'; | |||
| 13 | + | ||||
| 14 | + | export interface WorkspaceConfigPayload { | |||
| 15 | + | enable_zellij_controls?: boolean; | |||
| 16 | + | pane_mode?: { | |||
| 17 | + | activation_shortcut?: string; | |||
| 18 | + | timeout_ms?: number; | |||
| 19 | + | action_bindings?: Record<string, string>; | |||
| 20 | + | }; | |||
| 21 | + | tabs?: { | |||
| 22 | + | new_tab?: string; | |||
| 23 | + | close_tab?: string; | |||
| 24 | + | next_tab?: string; | |||
| 25 | + | previous_tab?: string; | |||
| 26 | + | }; | |||
| 27 | + | popups?: { | |||
| 28 | + | placement?: string; | |||
| 29 | + | open_in_new_pane?: boolean; | |||
| 30 | + | follow_pane_context?: boolean; | |||
| 31 | + | }; | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | export interface WorkspacePaneModeConfig { | |||
| 35 | + | activationShortcut: string; | |||
| 36 | + | timeoutMs: number; | |||
| 37 | + | actionBindings: Record<string, string>; | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | export interface WorkspaceTabsConfig { | |||
| 41 | + | newTab: string; | |||
| 42 | + | closeTab: string; | |||
| 43 | + | nextTab: string; | |||
| 44 | + | previousTab: string; | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | export interface WorkspacePopupConfig { | |||
| 48 | + | placement: string; | |||
| 49 | + | openInNewPane: boolean; | |||
| 50 | + | followPaneContext: boolean; | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | export interface WorkspaceConfigNormalized { | |||
| 54 | + | enableZellijControls: boolean; | |||
| 55 | + | paneMode: WorkspacePaneModeConfig; | |||
| 56 | + | tabs: WorkspaceTabsConfig; | |||
| 57 | + | popups: WorkspacePopupConfig; | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | export interface WorkspaceRuntime { | |||
| 61 | + | enterPaneMode: () => void; | |||
| 62 | + | exitPaneMode: (reason?: string) => void; | |||
| 63 | + | isPaneModeActive: () => boolean; | |||
| 64 | + | getConfig: () => WorkspaceConfigNormalized; | |||
| 65 | + | getInstanceId: () => string; | |||
| 66 | + | isActiveInstance: () => boolean; | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | const DEFAULT_CONFIG: WorkspaceConfigNormalized = { | |||
| 70 | + | enableZellijControls: true, | |||
| 71 | + | paneMode: { | |||
| 72 | + | activationShortcut: 'cmdorctrl+p', | |||
| 73 | + | timeoutMs: 3000, | |||
| 74 | + | actionBindings: { | |||
| 75 | + | arrowright: 'split-right', | |||
| 76 | + | arrowleft: 'split-left', | |||
| 77 | + | arrowup: 'split-up', | |||
| 78 | + | arrowdown: 'split-down', | |||
| 79 | + | r: 'split-right', | |||
| 80 | + | l: 'split-left', | |||
| 81 | + | u: 'split-up', | |||
| 82 | + | d: 'split-down', | |||
| 83 | + | x: 'close-pane', | |||
| 84 | + | enter: 'confirm', | |||
| 85 | + | escape: 'cancel' | |||
| 86 | + | } | |||
| 87 | + | }, | |||
| 88 | + | tabs: { | |||
| 89 | + | newTab: 'cmdorctrl+t', | |||
| 90 | + | closeTab: 'cmdorctrl+w', | |||
| 91 | + | nextTab: 'cmdorctrl+tab', | |||
| 92 | + | previousTab: 'cmdorctrl+shift+tab' | |||
| 93 | + | }, | |||
| 94 | + | popups: { | |||
| 95 | + | placement: 'right', | |||
| 96 | + | openInNewPane: true, | |||
| 97 | + | followPaneContext: true | |||
| 98 | + | } | |||
| 99 | + | }; | |||
| 100 | + | ||||
| 101 | + | function normalizeWorkspaceConfig(payload?: WorkspaceConfigPayload | null): WorkspaceConfigNormalized { | |||
| 102 | + | const paneModePayload = payload?.pane_mode ?? {}; | |||
| 103 | + | const tabsPayload = payload?.tabs ?? {}; | |||
| 104 | + | const popupsPayload = payload?.popups ?? {}; | |||
| 105 | + | ||||
| 106 | + | return { | |||
| 107 | + | enableZellijControls: payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls, | |||
| 108 | + | paneMode: { | |||
| 109 | + | activationShortcut: paneModePayload.activation_shortcut ?? DEFAULT_CONFIG.paneMode.activationShortcut, | |||
| 110 | + | timeoutMs: paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs, | |||
| 111 | + | actionBindings: { | |||
| 112 | + | ...DEFAULT_CONFIG.paneMode.actionBindings, | |||
| 113 | + | ...(paneModePayload.action_bindings ?? {}) | |||
| 114 | + | } | |||
| 115 | + | }, | |||
| 116 | + | tabs: { | |||
| 117 | + | newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab, | |||
| 118 | + | closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab, | |||
| 119 | + | nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab, | |||
| 120 | + | previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab | |||
| 121 | + | }, | |||
| 122 | + | popups: { | |||
| 123 | + | placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement, | |||
| 124 | + | openInNewPane: popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane, | |||
| 125 | + | followPaneContext: popupsPayload.follow_pane_context ?? DEFAULT_CONFIG.popups.followPaneContext | |||
| 126 | + | } | |||
| 127 | + | }; | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | class WorkspaceController implements WorkspaceRuntime { | |||
| 131 | + | private config: WorkspaceConfigNormalized = DEFAULT_CONFIG; | |||
| 132 | + | private paneModeActive = false; | |||
| 133 | + | private paneModeTimer: ReturnType<typeof setTimeout> | null = null; | |||
| 134 | + | private instanceId: string; | |||
| 135 | + | private lastActiveCheckTime = 0; | |||
| 136 | + | ||||
| 137 | + | constructor() { | |||
| 138 | + | // Generate unique instance ID for this webview | |||
| 139 | + | this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`; | |||
| 140 | + | console.log(`[workspace] Created WorkspaceController instance: ${this.instanceId}`); | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | getInstanceId(): string { | |||
| 144 | + | return this.instanceId; | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | isActiveInstance(): boolean { | |||
| 148 | + | // Cache active check for 50ms to avoid excessive checks | |||
| 149 | + | const now = Date.now(); | |||
| 150 | + | if (now - this.lastActiveCheckTime < 50) { | |||
| 151 | + | return true; // Assume active within debounce window | |||
| 152 | + | } | |||
| 153 | + | this.lastActiveCheckTime = now; | |||
| 154 | + | ||||
| 155 | + | // Check if this webview has focus by attempting to query document.hasFocus() | |||
| 156 | + | // In a multi-pane environment, only the focused webview should handle pane mode | |||
| 157 | + | try { | |||
| 158 | + | const hasFocus = document.hasFocus(); | |||
| 159 | + | const isVisible = document.visibilityState === 'visible'; | |||
| 160 | + | const isActive = hasFocus && isVisible; | |||
| 161 | + | ||||
| 162 | + | if (!isActive) { | |||
| 163 | + | console.log(`[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`); | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | return isActive; | |||
| 167 | + | } catch (error) { | |||
| 168 | + | console.warn('[workspace] Failed to check active state, assuming active:', error); | |||
| 169 | + | return true; // Fallback to active if check fails | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | configure(payload?: WorkspaceConfigPayload | null): void { | |||
| 174 | + | const normalized = normalizeWorkspaceConfig(payload); | |||
| 175 | + | this.unregisterShortcuts(); | |||
| 176 | + | this.config = normalized; | |||
| 177 | + | ||||
| 178 | + | if (!normalized.enableZellijControls) { | |||
| 179 | + | console.log('[workspace] Zellij controls disabled via config'); | |||
| 180 | + | window.__dumber_workspace = this.exportRuntime(); | |||
| 181 | + | return; | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | this.registerShortcuts(); | |||
| 185 | + | window.__dumber_workspace = this.exportRuntime(); | |||
| 186 | + | console.log('[workspace] Zellij controls enabled with config:', this.config); | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | private bridge(message: Record<string, unknown>): void { | |||
| 190 | + | const bridge = window.webkit?.messageHandlers?.dumber; | |||
| 191 | + | if (!bridge || typeof bridge.postMessage !== 'function') { | |||
| 192 | + | return; | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | try { | |||
| 196 | + | bridge.postMessage( | |||
| 197 | + | JSON.stringify({ | |||
| 198 | + | type: 'workspace', | |||
| 199 | + | ...message | |||
| 200 | + | }) | |||
| 201 | + | ); | |||
| 202 | + | } catch (error) { | |||
| 203 | + | console.error('[workspace] Failed to post workspace message', error); | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | enterPaneMode(): void { | |||
| 208 | + | if (!this.config.enableZellijControls) return; | |||
| 209 | + | ||||
| 210 | + | // Only allow the active instance to enter pane mode | |||
| 211 | + | if (!this.isActiveInstance()) { | |||
| 212 | + | console.log(`[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`); | |||
| 213 | + | return; | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | console.log(`[workspace] Instance ${this.instanceId} entering pane mode`); | |||
| 217 | + | this.paneModeActive = true; | |||
| 218 | + | this.restartPaneModeTimer(); | |||
| 219 | + | this.emitWorkspaceEvent('pane-mode-entered', { action: 'enter', config: this.config, instanceId: this.instanceId }); | |||
| 220 | + | this.bridge({ event: 'pane-mode-entered', instanceId: this.instanceId }); | |||
| 221 | + | this.showToast('Pane mode: awaiting direction'); | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | exitPaneMode(reason: string = 'exit'): void { | |||
| 225 | + | if (!this.paneModeActive) return; | |||
| 226 | + | ||||
| 227 | + | this.paneModeActive = false; | |||
| 228 | + | if (this.paneModeTimer) { | |||
| 229 | + | clearTimeout(this.paneModeTimer); | |||
| 230 | + | this.paneModeTimer = null; | |||
| 231 | + | } | |||
| 232 | + | this.emitWorkspaceEvent('pane-mode-exited', { action: reason }); | |||
| 233 | + | this.bridge({ event: 'pane-mode-exited', reason }); | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | isPaneModeActive(): boolean { | |||
| 237 | + | return this.paneModeActive; | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | getConfig(): WorkspaceConfigNormalized { | |||
| 241 | + | return this.config; | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | private registerShortcuts(): void { | |||
| 245 | + | keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [ | |||
| 246 | + | { | |||
| 247 | + | key: this.config.paneMode.activationShortcut, | |||
| 248 | + | handler: () => this.enterPaneMode(), | |||
| 249 | + | description: 'Enter pane mode', | |||
| 250 | + | preventDefault: true, | |||
| 251 | + | stopPropagation: true, | |||
| 252 | + | whenFocused: false | |||
| 253 | + | } | |||
| 254 | + | ]); | |||
| 255 | + | ||||
| 256 | + | const paneShortcuts = Object.entries(this.config.paneMode.actionBindings).map(([key, action]) => ({ | |||
| 257 | + | key, | |||
| 258 | + | handler: () => this.handlePaneAction(action, key), | |||
| 259 | + | description: `Pane action: ${action}`, | |||
| 260 | + | preventDefault: true, | |||
| 261 | + | stopPropagation: true, | |||
| 262 | + | whenFocused: false | |||
| 263 | + | })); | |||
| 264 | + | ||||
| 265 | + | keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts); | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | private unregisterShortcuts(): void { | |||
| 269 | + | keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID); | |||
| 270 | + | keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID); | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | private handlePaneAction(action: string, key?: string): void { | |||
| 274 | + | if (!this.paneModeActive) { | |||
| 275 | + | return; | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | switch (action) { | |||
| 279 | + | case 'split-right': | |||
| 280 | + | this.emitWorkspaceEvent('pane-split', { direction: 'right', config: this.config }); | |||
| 281 | + | this.bridge({ event: 'pane-split', direction: 'right' }); | |||
| 282 | + | this.showToast('Split pane to the right'); | |||
| 283 | + | this.exitPaneMode('split-right'); | |||
| 284 | + | break; | |||
| 285 | + | case 'split-left': | |||
| 286 | + | this.emitWorkspaceEvent('pane-split', { direction: 'left', config: this.config }); | |||
| 287 | + | this.bridge({ event: 'pane-split', direction: 'left' }); | |||
| 288 | + | this.showToast('Split pane to the left'); | |||
| 289 | + | this.exitPaneMode('split-left'); | |||
| 290 | + | break; | |||
| 291 | + | case 'split-up': | |||
| 292 | + | this.emitWorkspaceEvent('pane-split', { direction: 'up', config: this.config }); | |||
| 293 | + | this.bridge({ event: 'pane-split', direction: 'up' }); | |||
| 294 | + | this.showToast('Split pane upwards'); | |||
| 295 | + | this.exitPaneMode('split-up'); | |||
| 296 | + | break; | |||
| 297 | + | case 'split-down': | |||
| 298 | + | this.emitWorkspaceEvent('pane-split', { direction: 'down', config: this.config }); | |||
| 299 | + | this.bridge({ event: 'pane-split', direction: 'down' }); | |||
| 300 | + | this.showToast('Split pane downwards'); | |||
| 301 | + | this.exitPaneMode('split-down'); | |||
| 302 | + | break; | |||
| 303 | + | case 'close-pane': | |||
| 304 | + | this.emitWorkspaceEvent('pane-closed', { key, config: this.config }); | |||
| 305 | + | this.bridge({ event: 'pane-close', action }); | |||
| 306 | + | this.showToast('Closed pane'); | |||
| 307 | + | this.exitPaneMode('close-pane'); | |||
| 308 | + | break; | |||
| 309 | + | case 'confirm': | |||
| 310 | + | this.bridge({ event: 'pane-confirmed' }); | |||
| 311 | + | this.showToast('Pane mode confirmed'); | |||
| 312 | + | this.exitPaneMode('confirm'); | |||
| 313 | + | break; | |||
| 314 | + | case 'cancel': | |||
| 315 | + | this.bridge({ event: 'pane-cancelled' }); | |||
| 316 | + | this.showToast('Pane mode cancelled'); | |||
| 317 | + | this.exitPaneMode('cancel'); | |||
| 318 | + | break; | |||
| 319 | + | default: | |||
| 320 | + | console.warn('[workspace] Unhandled pane action:', action); | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | private restartPaneModeTimer(): void { | |||
| 325 | + | if (this.paneModeTimer) { | |||
| 326 | + | clearTimeout(this.paneModeTimer); | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | if (this.config.paneMode.timeoutMs > 0) { | |||
| 330 | + | this.paneModeTimer = setTimeout(() => { | |||
| 331 | + | this.showToast('Pane mode timed out'); | |||
| 332 | + | this.exitPaneMode('timeout'); | |||
| 333 | + | }, this.config.paneMode.timeoutMs); | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | private showToast(message: string): void { | |||
| 338 | + | if (typeof window.__dumber_showToast === 'function') { | |||
| 339 | + | window.__dumber_showToast(message, 2000, 'info'); | |||
| 340 | + | } else { | |||
| 341 | + | console.log('[workspace toast]', message); | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | private emitWorkspaceEvent(event: string, detail: Record<string, unknown>): void { | |||
| 346 | + | document.dispatchEvent(new CustomEvent(`dumber:workspace-${event}`, { detail })); | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | private exportRuntime(): WorkspaceRuntime { | |||
| 350 | + | return { | |||
| 351 | + | enterPaneMode: () => this.enterPaneMode(), | |||
| 352 | + | exitPaneMode: (reason?: string) => this.exitPaneMode(reason ?? 'external'), | |||
| 353 | + | isPaneModeActive: () => this.isPaneModeActive(), | |||
| 354 | + | getConfig: () => this.getConfig(), | |||
| 355 | + | getInstanceId: () => this.getInstanceId(), | |||
| 356 | + | isActiveInstance: () => this.isActiveInstance() | |||
| 357 | + | }; | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | const workspaceController = new WorkspaceController(); | |||
| 362 | + | let listenersAttached = false; | |||
| 363 | + | ||||
| 364 | + | export function initializeWorkspace(payload?: WorkspaceConfigPayload | null): void { | |||
| 365 | + | if (!listenersAttached) { | |||
| 366 | + | document.addEventListener('dumber:workspace-config', (event: Event) => { | |||
| 367 | + | const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail; | |||
| 368 | + | workspaceController.configure(detail); | |||
| 369 | + | }); | |||
| 370 | + | listenersAttached = true; | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null; | |||
| 374 | + | workspaceController.configure(bootstrapPayload); | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | export { workspaceController }; | |||
M gui/src/lib/keyboard/KeyboardService.ts +9 -64
| @@ -13,7 +13,7 @@ import type { | |||
| 13 | 13 | KeyboardEvent, | |
| 14 | 14 | KeyboardEventType | |
| 15 | 15 | } from './types'; | |
| 16 | - | import { COMMON_SHORTCUTS } from './types'; | |
| 16 | + | // COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers | |
| 17 | 17 | ||
| 18 | 18 | export class KeyboardService { | |
| 19 | 19 | private components = new Map<string, ComponentShortcuts>(); | |
| @@ -362,82 +362,27 @@ export const keyboardService = new KeyboardService({ | |||
| 362 | 362 | globalShortcuts: [ | |
| 363 | 363 | // Navigation shortcuts (migrated from controls module) | |
| 364 | 364 | { | |
| 365 | - | key: 'alt+arrowleft', | |
| 365 | + | key: 'cmdorctrl+arrowleft', | |
| 366 | 366 | handler: () => { | |
| 367 | 367 | if (window.history.length > 1) { | |
| 368 | 368 | window.history.back(); | |
| 369 | 369 | } | |
| 370 | 370 | }, | |
| 371 | - | description: 'Navigate back (Alt+Left Arrow)' | |
| 371 | + | description: 'Navigate back (Ctrl/Cmd+Left Arrow)' | |
| 372 | 372 | }, | |
| 373 | 373 | { | |
| 374 | - | key: 'alt+arrowright', | |
| 374 | + | key: 'cmdorctrl+arrowright', | |
| 375 | 375 | handler: () => { | |
| 376 | 376 | window.history.forward(); | |
| 377 | 377 | }, | |
| 378 | - | description: 'Navigate forward (Alt+Right Arrow)' | |
| 378 | + | description: 'Navigate forward (Ctrl/Cmd+Right Arrow)' | |
| 379 | 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 | - | } | |
| 380 | + | // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled | |
| 381 | + | // directly by the native Go shortcut handlers to ensure proper active pane detection. | |
| 382 | + | // This avoids conflicts between multiple webview instances. | |
| 438 | 383 | ] | |
| 439 | 384 | }); | |
| 440 | 385 | ||
| 441 | 386 | // Export types for use in components | |
| 442 | 387 | export type { ShortcutConfig, ComponentShortcuts, KeyboardServiceConfig } from './types'; | |
| 443 | - | export { COMMON_SHORTCUTS } from './types'; | |
| 388 | + | // COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers | |
| @@ -13,7 +13,7 @@ import type { | |||||
| 13 | KeyboardEvent, | 13 | KeyboardEvent, | ||
| 14 | KeyboardEventType | 14 | KeyboardEventType | ||
| 15 | } from './types'; | 15 | } from './types'; | ||
| 16 | - | import { COMMON_SHORTCUTS } from './types'; | 16 | + | // COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers |
| 17 | 17 | ||||
| 18 | export class KeyboardService { | 18 | export class KeyboardService { | ||
| 19 | private components = new Map<string, ComponentShortcuts>(); | 19 | private components = new Map<string, ComponentShortcuts>(); | ||
| @@ -362,82 +362,27 @@ export const keyboardService = new KeyboardService({ | |||||
| 362 | globalShortcuts: [ | 362 | globalShortcuts: [ | ||
| 363 | // Navigation shortcuts (migrated from controls module) | 363 | // Navigation shortcuts (migrated from controls module) | ||
| 364 | { | 364 | { | ||
| 365 | - | key: 'alt+arrowleft', | 365 | + | key: 'cmdorctrl+arrowleft', |
| 366 | handler: () => { | 366 | handler: () => { | ||
| 367 | if (window.history.length > 1) { | 367 | if (window.history.length > 1) { | ||
| 368 | window.history.back(); | 368 | window.history.back(); | ||
| 369 | } | 369 | } | ||
| 370 | }, | 370 | }, | ||
| 371 | - | description: 'Navigate back (Alt+Left Arrow)' | 371 | + | description: 'Navigate back (Ctrl/Cmd+Left Arrow)' |
| 372 | }, | 372 | }, | ||
| 373 | { | 373 | { | ||
| 374 | - | key: 'alt+arrowright', | 374 | + | key: 'cmdorctrl+arrowright', |
| 375 | handler: () => { | 375 | handler: () => { | ||
| 376 | window.history.forward(); | 376 | window.history.forward(); | ||
| 377 | }, | 377 | }, | ||
| 378 | - | description: 'Navigate forward (Alt+Right Arrow)' | 378 | + | description: 'Navigate forward (Ctrl/Cmd+Right Arrow)' |
| 379 | }, | 379 | }, | ||
| 380 | - | // Global UI shortcuts (migrated from UCM JavaScript) | 380 | + | // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled |
| 381 | - | { | 381 | + | // directly by the native Go shortcut handlers to ensure proper active pane detection. |
| 382 | - | key: COMMON_SHORTCUTS.OMNIBOX_OPEN, | 382 | + | // This avoids conflicts between multiple webview instances. |
| 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 | ] | 383 | ] | ||
| 439 | }); | 384 | }); | ||
| 440 | 385 | ||||
| 441 | // Export types for use in components | 386 | // Export types for use in components | ||
| 442 | export type { ShortcutConfig, ComponentShortcuts, KeyboardServiceConfig } from './types'; | 387 | export type { ShortcutConfig, ComponentShortcuts, KeyboardServiceConfig } from './types'; | ||
| 443 | - | export { COMMON_SHORTCUTS } from './types'; | 388 | + | // COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers |