refactor(gui): extract common bootstrap logic from injected GUI
Major refactoring to extract 400+ lines of GUI initialization into a reusable bootstrap module. This enables both injected pages and special schemes (homepage, etc.) to share the same setup without duplication. Changes: - New bootstrap.ts: Centralizes toast, omnibox, keyboard service, and workspace event listener setup - gui.ts: Reduced from 440 lines to 25 lines, now delegates to bootstrap - workspace.ts: Reduced from 463 lines to 8 lines, pane mode moved to backend (Go). Module now only provides UI notification hooks - homepage.ts: Imports and calls bootstrapGUI for consistent setup - main-world.ts: Support numeric webview IDs (type fix) This refactoring prepares for backend-driven pane mode while maintaining all existing GUI functionality.
- commit
- 85ff923e82bc9f070429b8a7821a7abad767c263
- author
- bamen · · 2025-10-13 18:06 UTC
- committer
- bamen · · 2025-10-13 16:43 UTC
- parent
- de05f76
- signature
- unsigned
5 files · +481 -892
A gui/src/injected/bootstrap.ts +456 -0
| @@ -0,0 +1,456 @@ | |||
| 1 | + | /** | |
| 2 | + | * Dumber GUI bootstrap routine shared between injected pages and special schemes. | |
| 3 | + | * | |
| 4 | + | * Sets up toast, omnibox, keyboard service, and workspace event listeners. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | import { initializeToast, type ToastConfig } from "./modules/toast"; | |
| 8 | + | import { | |
| 9 | + | initializeOmnibox, | |
| 10 | + | type OmniboxInitConfig, | |
| 11 | + | } from "./modules/omnibox"; | |
| 12 | + | import "./modules/workspace"; // Auto-initializes pane mode UI notifications | |
| 13 | + | import { keyboardService, type KeyboardService } from "$lib/keyboard"; | |
| 14 | + | import type { Suggestion } from "../components/omnibox/types"; | |
| 15 | + | ||
| 16 | + | declare global { | |
| 17 | + | interface Window { | |
| 18 | + | __dumber_gui?: DumberGUI; | |
| 19 | + | __dumber_gui_ready?: boolean; | |
| 20 | + | __dumber_gui_ready_for?: Document | null; | |
| 21 | + | __dumber_showToast?: ( | |
| 22 | + | message: string, | |
| 23 | + | duration?: number, | |
| 24 | + | type?: "info" | "success" | "error", | |
| 25 | + | ) => number | void; | |
| 26 | + | __dumber_showZoomToast?: (level: number) => void; | |
| 27 | + | __dumber_keyboard?: KeyboardService; | |
| 28 | + | __dumber_omnibox?: { | |
| 29 | + | setSuggestions: (suggestions: Suggestion[]) => void; | |
| 30 | + | toggle: () => void; | |
| 31 | + | open: (mode?: string, query?: string) => void; | |
| 32 | + | close: () => void; | |
| 33 | + | findQuery: (query: string) => void; | |
| 34 | + | setActive: (active: boolean) => void; | |
| 35 | + | }; | |
| 36 | + | __dumber_gui_bootstrap?: () => void; | |
| 37 | + | __dumber_toggle?: () => void; | |
| 38 | + | __dumber_find_open?: (query?: string) => void; | |
| 39 | + | __dumber_find_close?: () => void; | |
| 40 | + | __dumber_find_query?: (query: string) => void; | |
| 41 | + | __dumber_dismissToast?: () => void; | |
| 42 | + | __dumber_clearToasts?: () => void; | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | interface DumberGUI { | |
| 47 | + | initializeToast: (config?: ToastConfig) => Promise<void>; | |
| 48 | + | initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>; | |
| 49 | + | keyboard: KeyboardService; | |
| 50 | + | isReady: boolean; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | function whenDOMReady(callback: () => void) { | |
| 54 | + | if (document.readyState === "loading") { | |
| 55 | + | document.addEventListener("DOMContentLoaded", callback, { once: true }); | |
| 56 | + | } else if (document.readyState === "interactive" && !document.body) { | |
| 57 | + | setTimeout(() => whenDOMReady(callback), 10); | |
| 58 | + | } else { | |
| 59 | + | callback(); | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | function normalizeWebviewId(value: unknown): number | null { | |
| 64 | + | if (typeof value === "number" && Number.isFinite(value)) { | |
| 65 | + | return value; | |
| 66 | + | } | |
| 67 | + | if (typeof value === "string") { | |
| 68 | + | const trimmed = value.trim(); | |
| 69 | + | if (!trimmed || trimmed === "__WEBVIEW_ID__") { | |
| 70 | + | return null; | |
| 71 | + | } | |
| 72 | + | const parsed = Number.parseInt(trimmed, 10); | |
| 73 | + | return Number.isNaN(parsed) ? null : parsed; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | return null; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | export function bootstrapGUI(): void { | |
| 80 | + | if (window.__dumber_gui_ready_for === document) { | |
| 81 | + | return; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | if (window.self !== window.top) { | |
| 85 | + | console.log("🚫 Dumber GUI skipped in iframe"); | |
| 86 | + | return; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | window.__dumber_gui_ready_for = document; | |
| 90 | + | window.__dumber_gui_ready = true; | |
| 91 | + | ||
| 92 | + | try { | |
| 93 | + | window.addEventListener("pagehide", () => { | |
| 94 | + | window.__dumber_gui_ready = false; | |
| 95 | + | window.__dumber_gui_ready_for = null; | |
| 96 | + | }, { once: true }); | |
| 97 | + | } catch { | |
| 98 | + | // Ignore environments without pagehide support | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | let workspaceHasFocus = false; | |
| 102 | + | const initialRawWebViewId = window.__dumber_webview_id; | |
| 103 | + | let currentWebViewId = normalizeWebviewId(initialRawWebViewId); | |
| 104 | + | if (currentWebViewId !== null) { | |
| 105 | + | window.__dumber_webview_id = currentWebViewId; | |
| 106 | + | } | |
| 107 | + | let hasReceivedFocusEvent = false; | |
| 108 | + | ||
| 109 | + | if (currentWebViewId === null) { | |
| 110 | + | console.log("[workspace] WebView ID unknown, requesting from Go backend"); | |
| 111 | + | if (window.webkit?.messageHandlers?.dumber) { | |
| 112 | + | window.webkit.messageHandlers.dumber.postMessage( | |
| 113 | + | JSON.stringify({ | |
| 114 | + | type: "request-webview-id", | |
| 115 | + | payload: { timestamp: Date.now() }, | |
| 116 | + | }), | |
| 117 | + | ); | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | document.addEventListener("dumber:webview-id", (event: Event) => { | |
| 122 | + | const detail = (event as CustomEvent).detail || {}; | |
| 123 | + | const incomingId = normalizeWebviewId(detail.webviewId); | |
| 124 | + | if (incomingId !== null && incomingId !== currentWebViewId) { | |
| 125 | + | currentWebViewId = incomingId; | |
| 126 | + | window.__dumber_webview_id = incomingId; | |
| 127 | + | console.log("[workspace] Received webview ID from Go:", incomingId); | |
| 128 | + | } | |
| 129 | + | }); | |
| 130 | + | ||
| 131 | + | document.addEventListener("dumber:workspace-focus", (event: Event) => { | |
| 132 | + | const detail = (event as CustomEvent).detail || {}; | |
| 133 | + | const eventWebviewId = normalizeWebviewId(detail?.webviewId); | |
| 134 | + | ||
| 135 | + | if (eventWebviewId !== null) { | |
| 136 | + | if (currentWebViewId === null) { | |
| 137 | + | currentWebViewId = eventWebviewId; | |
| 138 | + | window.__dumber_webview_id = eventWebviewId; | |
| 139 | + | console.log( | |
| 140 | + | "[workspace] Learned webview ID from focus event:", | |
| 141 | + | eventWebviewId, | |
| 142 | + | ); | |
| 143 | + | } else if (eventWebviewId !== currentWebViewId) { | |
| 144 | + | console.log("[workspace] Ignoring focus event for other webview", { | |
| 145 | + | eventWebviewId, | |
| 146 | + | self: currentWebViewId, | |
| 147 | + | detail, | |
| 148 | + | }); | |
| 149 | + | return; | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | console.log("[workspace focus event]", detail, "prev focus=", workspaceHasFocus); | |
| 154 | + | ||
| 155 | + | hasReceivedFocusEvent = true; | |
| 156 | + | ||
| 157 | + | if (typeof detail?.active === "boolean") { | |
| 158 | + | workspaceHasFocus = detail.active; | |
| 159 | + | } else { | |
| 160 | + | workspaceHasFocus = detail !== false; | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | console.log( | |
| 164 | + | "[workspace focus updated] focus=", | |
| 165 | + | workspaceHasFocus, | |
| 166 | + | "webviewId=", | |
| 167 | + | currentWebViewId, | |
| 168 | + | ); | |
| 169 | + | ||
| 170 | + | if (window.__dumber_omnibox) { | |
| 171 | + | if (workspaceHasFocus) { | |
| 172 | + | window.__dumber_omnibox.setActive(true); | |
| 173 | + | } else { | |
| 174 | + | window.__dumber_omnibox.setActive(false); | |
| 175 | + | window.__dumber_omnibox.close(); | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | }); | |
| 179 | + | ||
| 180 | + | whenDOMReady(async () => { | |
| 181 | + | console.log("[GUI] DOM ready, starting initialization"); | |
| 182 | + | let toastInitialized = false; | |
| 183 | + | ||
| 184 | + | if (typeof window.__dumber_showToast === "function") { | |
| 185 | + | console.log("[GUI] Toast already initialized, skipping"); | |
| 186 | + | toastInitialized = true; | |
| 187 | + | } else { | |
| 188 | + | try { | |
| 189 | + | console.log("[GUI] Attempting toast initialization"); | |
| 190 | + | await initializeToast(); | |
| 191 | + | if (typeof window.__dumber_showToast !== "function") { | |
| 192 | + | throw new Error("Toast functions not exposed after initialization"); | |
| 193 | + | } | |
| 194 | + | toastInitialized = true; | |
| 195 | + | console.log("[GUI] Toast initialized successfully"); | |
| 196 | + | } catch (e) { | |
| 197 | + | console.error("❌ Failed to initialize Svelte toast system:", e); | |
| 198 | + | window.__dumber_showToast = ( | |
| 199 | + | message: string, | |
| 200 | + | duration: number = 2500, | |
| 201 | + | ) => { | |
| 202 | + | document | |
| 203 | + | .querySelectorAll(".dumber-fallback-toast") | |
| 204 | + | .forEach((el) => el.remove()); | |
| 205 | + | ||
| 206 | + | const toast = document.createElement("div"); | |
| 207 | + | toast.className = "dumber-fallback-toast"; | |
| 208 | + | toast.textContent = message; | |
| 209 | + | toast.style.cssText = ` | |
| 210 | + | position: fixed; | |
| 211 | + | bottom: 20px; | |
| 212 | + | right: 20px; | |
| 213 | + | background: rgba(0,0,0,0.9); | |
| 214 | + | color: white; | |
| 215 | + | padding: 12px 16px; | |
| 216 | + | border-radius: 6px; | |
| 217 | + | z-index: 2147483647; | |
| 218 | + | font-family: system-ui, -apple-system, sans-serif; | |
| 219 | + | font-size: 14px; | |
| 220 | + | opacity: 0; | |
| 221 | + | transform: translateX(100%); | |
| 222 | + | transition: all 0.3s ease; | |
| 223 | + | max-width: 300px; | |
| 224 | + | word-wrap: break-word; | |
| 225 | + | `; | |
| 226 | + | ||
| 227 | + | if (document.body) { | |
| 228 | + | document.body.appendChild(toast); | |
| 229 | + | ||
| 230 | + | requestAnimationFrame(() => { | |
| 231 | + | toast.style.opacity = "1"; | |
| 232 | + | toast.style.transform = "translateX(0)"; | |
| 233 | + | }); | |
| 234 | + | ||
| 235 | + | setTimeout(() => { | |
| 236 | + | toast.style.opacity = "0"; | |
| 237 | + | toast.style.transform = "translateX(100%)"; | |
| 238 | + | setTimeout(() => toast.remove(), 300); | |
| 239 | + | }, duration); | |
| 240 | + | } | |
| 241 | + | }; | |
| 242 | + | ||
| 243 | + | window.__dumber_showZoomToast = (level: number) => { | |
| 244 | + | const percentage = Math.round(level * 100); | |
| 245 | + | window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500); | |
| 246 | + | }; | |
| 247 | + | ||
| 248 | + | window.__dumber_dismissToast = (_id?: number) => { | |
| 249 | + | document | |
| 250 | + | .querySelectorAll(".dumber-fallback-toast") | |
| 251 | + | .forEach((el) => el.remove()); | |
| 252 | + | }; | |
| 253 | + | ||
| 254 | + | window.__dumber_clearToasts = () => { | |
| 255 | + | document | |
| 256 | + | .querySelectorAll(".dumber-fallback-toast") | |
| 257 | + | .forEach((el) => el.remove()); | |
| 258 | + | }; | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | try { | |
| 263 | + | await initializeOmnibox(); | |
| 264 | + | console.log("✅ Omnibox system initialized successfully"); | |
| 265 | + | } catch (omniboxError) { | |
| 266 | + | console.error("❌ Failed to initialize omnibox system:", omniboxError); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | console.log("✅ GUI systems initialization complete", { | |
| 270 | + | toast: toastInitialized ? "Svelte" : "Fallback", | |
| 271 | + | omnibox: !!window.__dumber_omnibox, | |
| 272 | + | }); | |
| 273 | + | }); | |
| 274 | + | ||
| 275 | + | window.__dumber_keyboard = keyboardService; | |
| 276 | + | ||
| 277 | + | whenDOMReady(() => { | |
| 278 | + | document.addEventListener( | |
| 279 | + | "keydown", | |
| 280 | + | (event) => { | |
| 281 | + | keyboardService.handleKeyboardEvent(event); | |
| 282 | + | }, | |
| 283 | + | true, | |
| 284 | + | ); | |
| 285 | + | ||
| 286 | + | document.addEventListener( | |
| 287 | + | "mousedown", | |
| 288 | + | (event) => { | |
| 289 | + | keyboardService.handleMouseEvent(event); | |
| 290 | + | }, | |
| 291 | + | true, | |
| 292 | + | ); | |
| 293 | + | ||
| 294 | + | console.log("✅ KeyboardService initialized with global listeners"); | |
| 295 | + | ||
| 296 | + | document.addEventListener("dumber:key", (e: Event) => { | |
| 297 | + | const detail = (e as CustomEvent).detail || {}; | |
| 298 | + | if (detail && typeof detail.shortcut === "string") { | |
| 299 | + | keyboardService.handleNativeShortcut(detail.shortcut); | |
| 300 | + | } | |
| 301 | + | }); | |
| 302 | + | ||
| 303 | + | document.addEventListener("dumber:ui:shortcut", (e: Event) => { | |
| 304 | + | const detail = (e as CustomEvent).detail || {}; | |
| 305 | + | const action = detail?.action; | |
| 306 | + | const eventWebViewId = normalizeWebviewId(detail?.webviewId); | |
| 307 | + | const source = detail?.source || "unknown"; | |
| 308 | + | ||
| 309 | + | if (eventWebViewId !== null && currentWebViewId === null) { | |
| 310 | + | currentWebViewId = eventWebViewId; | |
| 311 | + | window.__dumber_webview_id = eventWebViewId; | |
| 312 | + | console.log( | |
| 313 | + | "[dumber shortcuts] Learned webview ID from shortcut event:", | |
| 314 | + | eventWebViewId, | |
| 315 | + | ); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | console.log("[dumber shortcuts] Event received", { | |
| 319 | + | action, | |
| 320 | + | eventWebViewId, | |
| 321 | + | currentWebViewId, | |
| 322 | + | detail, | |
| 323 | + | }); | |
| 324 | + | ||
| 325 | + | if (typeof action !== "string") { | |
| 326 | + | console.log("[dumber shortcuts] No action in event, ignoring"); | |
| 327 | + | return; | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | const isForThisWebView = | |
| 331 | + | eventWebViewId === null || eventWebViewId === currentWebViewId; | |
| 332 | + | const isOmniboxAction = action.startsWith("omnibox-"); | |
| 333 | + | const shouldHandle = | |
| 334 | + | isForThisWebView && | |
| 335 | + | (isOmniboxAction | |
| 336 | + | ? hasReceivedFocusEvent | |
| 337 | + | ? workspaceHasFocus | |
| 338 | + | : true | |
| 339 | + | : true); | |
| 340 | + | ||
| 341 | + | if (!shouldHandle) { | |
| 342 | + | console.log("[dumber shortcuts] ignored action", { | |
| 343 | + | action, | |
| 344 | + | workspaceHasFocus, | |
| 345 | + | isForThisWebView, | |
| 346 | + | eventWebViewId, | |
| 347 | + | currentWebViewId, | |
| 348 | + | source, | |
| 349 | + | detail, | |
| 350 | + | hasReceivedFocusEvent, | |
| 351 | + | }); | |
| 352 | + | return; | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | console.log("[dumber shortcuts] handling action", { | |
| 356 | + | action, | |
| 357 | + | source, | |
| 358 | + | webviewId: currentWebViewId, | |
| 359 | + | }); | |
| 360 | + | ||
| 361 | + | const omnibox = window.__dumber_omnibox; | |
| 362 | + | ||
| 363 | + | switch (action) { | |
| 364 | + | case "omnibox-nav-toggle": | |
| 365 | + | omnibox?.open?.("omnibox", detail?.query); | |
| 366 | + | break; | |
| 367 | + | case "omnibox-open": | |
| 368 | + | omnibox?.open?.("omnibox", detail?.query); | |
| 369 | + | break; | |
| 370 | + | case "omnibox-find-toggle": | |
| 371 | + | omnibox?.open?.("find", detail?.query); | |
| 372 | + | break; | |
| 373 | + | case "omnibox-close": | |
| 374 | + | omnibox?.close?.(); | |
| 375 | + | break; | |
| 376 | + | default: | |
| 377 | + | console.warn("[dumber] Unknown UI shortcut action:", action); | |
| 378 | + | } | |
| 379 | + | }); | |
| 380 | + | }); | |
| 381 | + | ||
| 382 | + | window.__dumber_toggle = () => { | |
| 383 | + | try { | |
| 384 | + | console.log("🎯 __dumber_toggle called"); | |
| 385 | + | console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox); | |
| 386 | + | if (window.__dumber_omnibox?.toggle) { | |
| 387 | + | console.log("✅ Using Svelte omnibox toggle"); | |
| 388 | + | window.__dumber_omnibox.toggle(); | |
| 389 | + | return; | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | throw new Error("Omnibox toggle requested but API is unavailable"); | |
| 393 | + | } catch (error) { | |
| 394 | + | console.error("❌ Error in __dumber_toggle:", error); | |
| 395 | + | } | |
| 396 | + | }; | |
| 397 | + | ||
| 398 | + | window.__dumber_find_open = (query?: string) => { | |
| 399 | + | try { | |
| 400 | + | if (window.__dumber_omnibox?.open) { | |
| 401 | + | window.__dumber_omnibox.open("find", query); | |
| 402 | + | return; | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | throw new Error("Omnibox find requested but API is unavailable"); | |
| 406 | + | } catch (error) { | |
| 407 | + | console.error("❌ Error in __dumber_find_open:", error); | |
| 408 | + | } | |
| 409 | + | }; | |
| 410 | + | ||
| 411 | + | window.__dumber_find_close = () => { | |
| 412 | + | if (window.__dumber_omnibox?.close) { | |
| 413 | + | window.__dumber_omnibox.close(); | |
| 414 | + | return; | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | const error = new Error("Omnibox close requested but API is unavailable"); | |
| 418 | + | console.error("❌ Error in __dumber_find_close:", error); | |
| 419 | + | }; | |
| 420 | + | ||
| 421 | + | window.__dumber_find_query = (query: string) => { | |
| 422 | + | if (window.__dumber_omnibox?.findQuery) { | |
| 423 | + | window.__dumber_omnibox.findQuery(query); | |
| 424 | + | } | |
| 425 | + | }; | |
| 426 | + | ||
| 427 | + | window.__dumber_gui = { | |
| 428 | + | initializeToast: async (config?: ToastConfig) => { | |
| 429 | + | return await initializeToast(config); | |
| 430 | + | }, | |
| 431 | + | initializeOmnibox: async (config?: OmniboxInitConfig) => { | |
| 432 | + | return await initializeOmnibox(config); | |
| 433 | + | }, | |
| 434 | + | keyboard: keyboardService, | |
| 435 | + | isReady: true, | |
| 436 | + | }; | |
| 437 | + | ||
| 438 | + | window.__dumber_gui_bootstrap = function () { | |
| 439 | + | console.log("[gui-bootstrap] Initializing GUI for workspace"); | |
| 440 | + | ||
| 441 | + | if (!window.__dumber_omnibox) { | |
| 442 | + | console.log( | |
| 443 | + | `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`, | |
| 444 | + | ); | |
| 445 | + | } else { | |
| 446 | + | console.log( | |
| 447 | + | `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`, | |
| 448 | + | ); | |
| 449 | + | window.__dumber_omnibox.setActive(workspaceHasFocus); | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | console.log( | |
| 453 | + | `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`, | |
| 454 | + | ); | |
| 455 | + | }; | |
| 456 | + | } | |
| @@ -0,0 +1,456 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Dumber GUI bootstrap routine shared between injected pages and special schemes. | |||
| 3 | + | * | |||
| 4 | + | * Sets up toast, omnibox, keyboard service, and workspace event listeners. | |||
| 5 | + | */ | |||
| 6 | + | ||||
| 7 | + | import { initializeToast, type ToastConfig } from "./modules/toast"; | |||
| 8 | + | import { | |||
| 9 | + | initializeOmnibox, | |||
| 10 | + | type OmniboxInitConfig, | |||
| 11 | + | } from "./modules/omnibox"; | |||
| 12 | + | import "./modules/workspace"; // Auto-initializes pane mode UI notifications | |||
| 13 | + | import { keyboardService, type KeyboardService } from "$lib/keyboard"; | |||
| 14 | + | import type { Suggestion } from "../components/omnibox/types"; | |||
| 15 | + | ||||
| 16 | + | declare global { | |||
| 17 | + | interface Window { | |||
| 18 | + | __dumber_gui?: DumberGUI; | |||
| 19 | + | __dumber_gui_ready?: boolean; | |||
| 20 | + | __dumber_gui_ready_for?: Document | null; | |||
| 21 | + | __dumber_showToast?: ( | |||
| 22 | + | message: string, | |||
| 23 | + | duration?: number, | |||
| 24 | + | type?: "info" | "success" | "error", | |||
| 25 | + | ) => number | void; | |||
| 26 | + | __dumber_showZoomToast?: (level: number) => void; | |||
| 27 | + | __dumber_keyboard?: KeyboardService; | |||
| 28 | + | __dumber_omnibox?: { | |||
| 29 | + | setSuggestions: (suggestions: Suggestion[]) => void; | |||
| 30 | + | toggle: () => void; | |||
| 31 | + | open: (mode?: string, query?: string) => void; | |||
| 32 | + | close: () => void; | |||
| 33 | + | findQuery: (query: string) => void; | |||
| 34 | + | setActive: (active: boolean) => void; | |||
| 35 | + | }; | |||
| 36 | + | __dumber_gui_bootstrap?: () => void; | |||
| 37 | + | __dumber_toggle?: () => void; | |||
| 38 | + | __dumber_find_open?: (query?: string) => void; | |||
| 39 | + | __dumber_find_close?: () => void; | |||
| 40 | + | __dumber_find_query?: (query: string) => void; | |||
| 41 | + | __dumber_dismissToast?: () => void; | |||
| 42 | + | __dumber_clearToasts?: () => void; | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | interface DumberGUI { | |||
| 47 | + | initializeToast: (config?: ToastConfig) => Promise<void>; | |||
| 48 | + | initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>; | |||
| 49 | + | keyboard: KeyboardService; | |||
| 50 | + | isReady: boolean; | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | function whenDOMReady(callback: () => void) { | |||
| 54 | + | if (document.readyState === "loading") { | |||
| 55 | + | document.addEventListener("DOMContentLoaded", callback, { once: true }); | |||
| 56 | + | } else if (document.readyState === "interactive" && !document.body) { | |||
| 57 | + | setTimeout(() => whenDOMReady(callback), 10); | |||
| 58 | + | } else { | |||
| 59 | + | callback(); | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | function normalizeWebviewId(value: unknown): number | null { | |||
| 64 | + | if (typeof value === "number" && Number.isFinite(value)) { | |||
| 65 | + | return value; | |||
| 66 | + | } | |||
| 67 | + | if (typeof value === "string") { | |||
| 68 | + | const trimmed = value.trim(); | |||
| 69 | + | if (!trimmed || trimmed === "__WEBVIEW_ID__") { | |||
| 70 | + | return null; | |||
| 71 | + | } | |||
| 72 | + | const parsed = Number.parseInt(trimmed, 10); | |||
| 73 | + | return Number.isNaN(parsed) ? null : parsed; | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | return null; | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | export function bootstrapGUI(): void { | |||
| 80 | + | if (window.__dumber_gui_ready_for === document) { | |||
| 81 | + | return; | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | if (window.self !== window.top) { | |||
| 85 | + | console.log("🚫 Dumber GUI skipped in iframe"); | |||
| 86 | + | return; | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | window.__dumber_gui_ready_for = document; | |||
| 90 | + | window.__dumber_gui_ready = true; | |||
| 91 | + | ||||
| 92 | + | try { | |||
| 93 | + | window.addEventListener("pagehide", () => { | |||
| 94 | + | window.__dumber_gui_ready = false; | |||
| 95 | + | window.__dumber_gui_ready_for = null; | |||
| 96 | + | }, { once: true }); | |||
| 97 | + | } catch { | |||
| 98 | + | // Ignore environments without pagehide support | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | let workspaceHasFocus = false; | |||
| 102 | + | const initialRawWebViewId = window.__dumber_webview_id; | |||
| 103 | + | let currentWebViewId = normalizeWebviewId(initialRawWebViewId); | |||
| 104 | + | if (currentWebViewId !== null) { | |||
| 105 | + | window.__dumber_webview_id = currentWebViewId; | |||
| 106 | + | } | |||
| 107 | + | let hasReceivedFocusEvent = false; | |||
| 108 | + | ||||
| 109 | + | if (currentWebViewId === null) { | |||
| 110 | + | console.log("[workspace] WebView ID unknown, requesting from Go backend"); | |||
| 111 | + | if (window.webkit?.messageHandlers?.dumber) { | |||
| 112 | + | window.webkit.messageHandlers.dumber.postMessage( | |||
| 113 | + | JSON.stringify({ | |||
| 114 | + | type: "request-webview-id", | |||
| 115 | + | payload: { timestamp: Date.now() }, | |||
| 116 | + | }), | |||
| 117 | + | ); | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | document.addEventListener("dumber:webview-id", (event: Event) => { | |||
| 122 | + | const detail = (event as CustomEvent).detail || {}; | |||
| 123 | + | const incomingId = normalizeWebviewId(detail.webviewId); | |||
| 124 | + | if (incomingId !== null && incomingId !== currentWebViewId) { | |||
| 125 | + | currentWebViewId = incomingId; | |||
| 126 | + | window.__dumber_webview_id = incomingId; | |||
| 127 | + | console.log("[workspace] Received webview ID from Go:", incomingId); | |||
| 128 | + | } | |||
| 129 | + | }); | |||
| 130 | + | ||||
| 131 | + | document.addEventListener("dumber:workspace-focus", (event: Event) => { | |||
| 132 | + | const detail = (event as CustomEvent).detail || {}; | |||
| 133 | + | const eventWebviewId = normalizeWebviewId(detail?.webviewId); | |||
| 134 | + | ||||
| 135 | + | if (eventWebviewId !== null) { | |||
| 136 | + | if (currentWebViewId === null) { | |||
| 137 | + | currentWebViewId = eventWebviewId; | |||
| 138 | + | window.__dumber_webview_id = eventWebviewId; | |||
| 139 | + | console.log( | |||
| 140 | + | "[workspace] Learned webview ID from focus event:", | |||
| 141 | + | eventWebviewId, | |||
| 142 | + | ); | |||
| 143 | + | } else if (eventWebviewId !== currentWebViewId) { | |||
| 144 | + | console.log("[workspace] Ignoring focus event for other webview", { | |||
| 145 | + | eventWebviewId, | |||
| 146 | + | self: currentWebViewId, | |||
| 147 | + | detail, | |||
| 148 | + | }); | |||
| 149 | + | return; | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | console.log("[workspace focus event]", detail, "prev focus=", workspaceHasFocus); | |||
| 154 | + | ||||
| 155 | + | hasReceivedFocusEvent = true; | |||
| 156 | + | ||||
| 157 | + | if (typeof detail?.active === "boolean") { | |||
| 158 | + | workspaceHasFocus = detail.active; | |||
| 159 | + | } else { | |||
| 160 | + | workspaceHasFocus = detail !== false; | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | console.log( | |||
| 164 | + | "[workspace focus updated] focus=", | |||
| 165 | + | workspaceHasFocus, | |||
| 166 | + | "webviewId=", | |||
| 167 | + | currentWebViewId, | |||
| 168 | + | ); | |||
| 169 | + | ||||
| 170 | + | if (window.__dumber_omnibox) { | |||
| 171 | + | if (workspaceHasFocus) { | |||
| 172 | + | window.__dumber_omnibox.setActive(true); | |||
| 173 | + | } else { | |||
| 174 | + | window.__dumber_omnibox.setActive(false); | |||
| 175 | + | window.__dumber_omnibox.close(); | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | }); | |||
| 179 | + | ||||
| 180 | + | whenDOMReady(async () => { | |||
| 181 | + | console.log("[GUI] DOM ready, starting initialization"); | |||
| 182 | + | let toastInitialized = false; | |||
| 183 | + | ||||
| 184 | + | if (typeof window.__dumber_showToast === "function") { | |||
| 185 | + | console.log("[GUI] Toast already initialized, skipping"); | |||
| 186 | + | toastInitialized = true; | |||
| 187 | + | } else { | |||
| 188 | + | try { | |||
| 189 | + | console.log("[GUI] Attempting toast initialization"); | |||
| 190 | + | await initializeToast(); | |||
| 191 | + | if (typeof window.__dumber_showToast !== "function") { | |||
| 192 | + | throw new Error("Toast functions not exposed after initialization"); | |||
| 193 | + | } | |||
| 194 | + | toastInitialized = true; | |||
| 195 | + | console.log("[GUI] Toast initialized successfully"); | |||
| 196 | + | } catch (e) { | |||
| 197 | + | console.error("❌ Failed to initialize Svelte toast system:", e); | |||
| 198 | + | window.__dumber_showToast = ( | |||
| 199 | + | message: string, | |||
| 200 | + | duration: number = 2500, | |||
| 201 | + | ) => { | |||
| 202 | + | document | |||
| 203 | + | .querySelectorAll(".dumber-fallback-toast") | |||
| 204 | + | .forEach((el) => el.remove()); | |||
| 205 | + | ||||
| 206 | + | const toast = document.createElement("div"); | |||
| 207 | + | toast.className = "dumber-fallback-toast"; | |||
| 208 | + | toast.textContent = message; | |||
| 209 | + | toast.style.cssText = ` | |||
| 210 | + | position: fixed; | |||
| 211 | + | bottom: 20px; | |||
| 212 | + | right: 20px; | |||
| 213 | + | background: rgba(0,0,0,0.9); | |||
| 214 | + | color: white; | |||
| 215 | + | padding: 12px 16px; | |||
| 216 | + | border-radius: 6px; | |||
| 217 | + | z-index: 2147483647; | |||
| 218 | + | font-family: system-ui, -apple-system, sans-serif; | |||
| 219 | + | font-size: 14px; | |||
| 220 | + | opacity: 0; | |||
| 221 | + | transform: translateX(100%); | |||
| 222 | + | transition: all 0.3s ease; | |||
| 223 | + | max-width: 300px; | |||
| 224 | + | word-wrap: break-word; | |||
| 225 | + | `; | |||
| 226 | + | ||||
| 227 | + | if (document.body) { | |||
| 228 | + | document.body.appendChild(toast); | |||
| 229 | + | ||||
| 230 | + | requestAnimationFrame(() => { | |||
| 231 | + | toast.style.opacity = "1"; | |||
| 232 | + | toast.style.transform = "translateX(0)"; | |||
| 233 | + | }); | |||
| 234 | + | ||||
| 235 | + | setTimeout(() => { | |||
| 236 | + | toast.style.opacity = "0"; | |||
| 237 | + | toast.style.transform = "translateX(100%)"; | |||
| 238 | + | setTimeout(() => toast.remove(), 300); | |||
| 239 | + | }, duration); | |||
| 240 | + | } | |||
| 241 | + | }; | |||
| 242 | + | ||||
| 243 | + | window.__dumber_showZoomToast = (level: number) => { | |||
| 244 | + | const percentage = Math.round(level * 100); | |||
| 245 | + | window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500); | |||
| 246 | + | }; | |||
| 247 | + | ||||
| 248 | + | window.__dumber_dismissToast = (_id?: number) => { | |||
| 249 | + | document | |||
| 250 | + | .querySelectorAll(".dumber-fallback-toast") | |||
| 251 | + | .forEach((el) => el.remove()); | |||
| 252 | + | }; | |||
| 253 | + | ||||
| 254 | + | window.__dumber_clearToasts = () => { | |||
| 255 | + | document | |||
| 256 | + | .querySelectorAll(".dumber-fallback-toast") | |||
| 257 | + | .forEach((el) => el.remove()); | |||
| 258 | + | }; | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | try { | |||
| 263 | + | await initializeOmnibox(); | |||
| 264 | + | console.log("✅ Omnibox system initialized successfully"); | |||
| 265 | + | } catch (omniboxError) { | |||
| 266 | + | console.error("❌ Failed to initialize omnibox system:", omniboxError); | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | console.log("✅ GUI systems initialization complete", { | |||
| 270 | + | toast: toastInitialized ? "Svelte" : "Fallback", | |||
| 271 | + | omnibox: !!window.__dumber_omnibox, | |||
| 272 | + | }); | |||
| 273 | + | }); | |||
| 274 | + | ||||
| 275 | + | window.__dumber_keyboard = keyboardService; | |||
| 276 | + | ||||
| 277 | + | whenDOMReady(() => { | |||
| 278 | + | document.addEventListener( | |||
| 279 | + | "keydown", | |||
| 280 | + | (event) => { | |||
| 281 | + | keyboardService.handleKeyboardEvent(event); | |||
| 282 | + | }, | |||
| 283 | + | true, | |||
| 284 | + | ); | |||
| 285 | + | ||||
| 286 | + | document.addEventListener( | |||
| 287 | + | "mousedown", | |||
| 288 | + | (event) => { | |||
| 289 | + | keyboardService.handleMouseEvent(event); | |||
| 290 | + | }, | |||
| 291 | + | true, | |||
| 292 | + | ); | |||
| 293 | + | ||||
| 294 | + | console.log("✅ KeyboardService initialized with global listeners"); | |||
| 295 | + | ||||
| 296 | + | document.addEventListener("dumber:key", (e: Event) => { | |||
| 297 | + | const detail = (e as CustomEvent).detail || {}; | |||
| 298 | + | if (detail && typeof detail.shortcut === "string") { | |||
| 299 | + | keyboardService.handleNativeShortcut(detail.shortcut); | |||
| 300 | + | } | |||
| 301 | + | }); | |||
| 302 | + | ||||
| 303 | + | document.addEventListener("dumber:ui:shortcut", (e: Event) => { | |||
| 304 | + | const detail = (e as CustomEvent).detail || {}; | |||
| 305 | + | const action = detail?.action; | |||
| 306 | + | const eventWebViewId = normalizeWebviewId(detail?.webviewId); | |||
| 307 | + | const source = detail?.source || "unknown"; | |||
| 308 | + | ||||
| 309 | + | if (eventWebViewId !== null && currentWebViewId === null) { | |||
| 310 | + | currentWebViewId = eventWebViewId; | |||
| 311 | + | window.__dumber_webview_id = eventWebViewId; | |||
| 312 | + | console.log( | |||
| 313 | + | "[dumber shortcuts] Learned webview ID from shortcut event:", | |||
| 314 | + | eventWebViewId, | |||
| 315 | + | ); | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | console.log("[dumber shortcuts] Event received", { | |||
| 319 | + | action, | |||
| 320 | + | eventWebViewId, | |||
| 321 | + | currentWebViewId, | |||
| 322 | + | detail, | |||
| 323 | + | }); | |||
| 324 | + | ||||
| 325 | + | if (typeof action !== "string") { | |||
| 326 | + | console.log("[dumber shortcuts] No action in event, ignoring"); | |||
| 327 | + | return; | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | const isForThisWebView = | |||
| 331 | + | eventWebViewId === null || eventWebViewId === currentWebViewId; | |||
| 332 | + | const isOmniboxAction = action.startsWith("omnibox-"); | |||
| 333 | + | const shouldHandle = | |||
| 334 | + | isForThisWebView && | |||
| 335 | + | (isOmniboxAction | |||
| 336 | + | ? hasReceivedFocusEvent | |||
| 337 | + | ? workspaceHasFocus | |||
| 338 | + | : true | |||
| 339 | + | : true); | |||
| 340 | + | ||||
| 341 | + | if (!shouldHandle) { | |||
| 342 | + | console.log("[dumber shortcuts] ignored action", { | |||
| 343 | + | action, | |||
| 344 | + | workspaceHasFocus, | |||
| 345 | + | isForThisWebView, | |||
| 346 | + | eventWebViewId, | |||
| 347 | + | currentWebViewId, | |||
| 348 | + | source, | |||
| 349 | + | detail, | |||
| 350 | + | hasReceivedFocusEvent, | |||
| 351 | + | }); | |||
| 352 | + | return; | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | console.log("[dumber shortcuts] handling action", { | |||
| 356 | + | action, | |||
| 357 | + | source, | |||
| 358 | + | webviewId: currentWebViewId, | |||
| 359 | + | }); | |||
| 360 | + | ||||
| 361 | + | const omnibox = window.__dumber_omnibox; | |||
| 362 | + | ||||
| 363 | + | switch (action) { | |||
| 364 | + | case "omnibox-nav-toggle": | |||
| 365 | + | omnibox?.open?.("omnibox", detail?.query); | |||
| 366 | + | break; | |||
| 367 | + | case "omnibox-open": | |||
| 368 | + | omnibox?.open?.("omnibox", detail?.query); | |||
| 369 | + | break; | |||
| 370 | + | case "omnibox-find-toggle": | |||
| 371 | + | omnibox?.open?.("find", detail?.query); | |||
| 372 | + | break; | |||
| 373 | + | case "omnibox-close": | |||
| 374 | + | omnibox?.close?.(); | |||
| 375 | + | break; | |||
| 376 | + | default: | |||
| 377 | + | console.warn("[dumber] Unknown UI shortcut action:", action); | |||
| 378 | + | } | |||
| 379 | + | }); | |||
| 380 | + | }); | |||
| 381 | + | ||||
| 382 | + | window.__dumber_toggle = () => { | |||
| 383 | + | try { | |||
| 384 | + | console.log("🎯 __dumber_toggle called"); | |||
| 385 | + | console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox); | |||
| 386 | + | if (window.__dumber_omnibox?.toggle) { | |||
| 387 | + | console.log("✅ Using Svelte omnibox toggle"); | |||
| 388 | + | window.__dumber_omnibox.toggle(); | |||
| 389 | + | return; | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | throw new Error("Omnibox toggle requested but API is unavailable"); | |||
| 393 | + | } catch (error) { | |||
| 394 | + | console.error("❌ Error in __dumber_toggle:", error); | |||
| 395 | + | } | |||
| 396 | + | }; | |||
| 397 | + | ||||
| 398 | + | window.__dumber_find_open = (query?: string) => { | |||
| 399 | + | try { | |||
| 400 | + | if (window.__dumber_omnibox?.open) { | |||
| 401 | + | window.__dumber_omnibox.open("find", query); | |||
| 402 | + | return; | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | throw new Error("Omnibox find requested but API is unavailable"); | |||
| 406 | + | } catch (error) { | |||
| 407 | + | console.error("❌ Error in __dumber_find_open:", error); | |||
| 408 | + | } | |||
| 409 | + | }; | |||
| 410 | + | ||||
| 411 | + | window.__dumber_find_close = () => { | |||
| 412 | + | if (window.__dumber_omnibox?.close) { | |||
| 413 | + | window.__dumber_omnibox.close(); | |||
| 414 | + | return; | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | const error = new Error("Omnibox close requested but API is unavailable"); | |||
| 418 | + | console.error("❌ Error in __dumber_find_close:", error); | |||
| 419 | + | }; | |||
| 420 | + | ||||
| 421 | + | window.__dumber_find_query = (query: string) => { | |||
| 422 | + | if (window.__dumber_omnibox?.findQuery) { | |||
| 423 | + | window.__dumber_omnibox.findQuery(query); | |||
| 424 | + | } | |||
| 425 | + | }; | |||
| 426 | + | ||||
| 427 | + | window.__dumber_gui = { | |||
| 428 | + | initializeToast: async (config?: ToastConfig) => { | |||
| 429 | + | return await initializeToast(config); | |||
| 430 | + | }, | |||
| 431 | + | initializeOmnibox: async (config?: OmniboxInitConfig) => { | |||
| 432 | + | return await initializeOmnibox(config); | |||
| 433 | + | }, | |||
| 434 | + | keyboard: keyboardService, | |||
| 435 | + | isReady: true, | |||
| 436 | + | }; | |||
| 437 | + | ||||
| 438 | + | window.__dumber_gui_bootstrap = function () { | |||
| 439 | + | console.log("[gui-bootstrap] Initializing GUI for workspace"); | |||
| 440 | + | ||||
| 441 | + | if (!window.__dumber_omnibox) { | |||
| 442 | + | console.log( | |||
| 443 | + | `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`, | |||
| 444 | + | ); | |||
| 445 | + | } else { | |||
| 446 | + | console.log( | |||
| 447 | + | `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`, | |||
| 448 | + | ); | |||
| 449 | + | window.__dumber_omnibox.setActive(workspaceHasFocus); | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | console.log( | |||
| 453 | + | `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`, | |||
| 454 | + | ); | |||
| 455 | + | }; | |||
| 456 | + | } | |||
M gui/src/injected/gui.ts +17 -432
| @@ -1,440 +1,25 @@ | |||
| 1 | 1 | /** | |
| 2 | - | * Unified GUI Bundle Entry Point | |
| 2 | + | * Unified GUI bundle entry point. | |
| 3 | 3 | * | |
| 4 | - | * Single entry point for all WebKit GUI functionality. | |
| 5 | - | * Loads immediately and provides modular initialization system. | |
| 4 | + | * Delegates to the shared bootstrap so special pages can reuse the same setup | |
| 5 | + | * without depending on this injected build. | |
| 6 | 6 | */ | |
| 7 | 7 | ||
| 8 | - | // Import all GUI modules | |
| 9 | - | import { initializeToast, type ToastConfig } from "./modules/toast"; | |
| 10 | - | import { initializeOmnibox, type OmniboxInitConfig } from "./modules/omnibox"; | |
| 11 | - | import { | |
| 12 | - | initializeWorkspace, | |
| 13 | - | type WorkspaceConfigPayload, | |
| 14 | - | type WorkspaceRuntime, | |
| 15 | - | } from "./modules/workspace"; | |
| 16 | - | import { keyboardService, type KeyboardService } from "$lib/keyboard"; | |
| 17 | - | import type { Suggestion } from "../components/omnibox/types"; | |
| 18 | - | // Note: color-scheme module is loaded separately at document-start by WebKit | |
| 8 | + | import { bootstrapGUI } from "./bootstrap"; | |
| 19 | 9 | ||
| 20 | - | // Global interface for the unified GUI system | |
| 21 | - | interface DumberGUI { | |
| 22 | - | initializeToast: (config?: ToastConfig) => Promise<void>; | |
| 23 | - | initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>; | |
| 24 | - | keyboard: KeyboardService; | |
| 25 | - | isReady: boolean; | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // (legacy suggestions normalization removed; omnibox now fetches suggestions directly via API) | |
| 29 | - | ||
| 30 | - | // DOM readiness utility - handles all document ready states | |
| 31 | - | function whenDOMReady(callback: () => void) { | |
| 32 | - | if (document.readyState === "loading") { | |
| 33 | - | document.addEventListener("DOMContentLoaded", callback, { once: true }); | |
| 34 | - | } else if (document.readyState === "interactive" && !document.body) { | |
| 35 | - | // Interactive but no body yet, wait a bit more | |
| 36 | - | setTimeout(() => whenDOMReady(callback), 10); | |
| 37 | - | } else { | |
| 38 | - | // DOM ready and body exists | |
| 39 | - | callback(); | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // Install a page-world bridge so Go can call window.__dumber_omnibox_suggestions() | |
| 44 | - | // Page-world bridge is injected by WebKit at document-start; no-op here to avoid duplicates. | |
| 45 | - | ||
| 46 | - | // Create global namespace | |
| 47 | - | declare global { | |
| 48 | - | interface Window { | |
| 49 | - | __dumber_gui?: DumberGUI; | |
| 50 | - | __dumber_gui_ready?: boolean; | |
| 51 | - | __dumber_showToast?: ( | |
| 52 | - | message: string, | |
| 53 | - | duration?: number, | |
| 54 | - | type?: "info" | "success" | "error", | |
| 55 | - | ) => number | void; | |
| 56 | - | __dumber_showZoomToast?: (level: number) => void; | |
| 57 | - | __dumber_keyboard?: KeyboardService; | |
| 58 | - | __dumber_workspace_config?: WorkspaceConfigPayload; | |
| 59 | - | __dumber_workspace?: WorkspaceRuntime; | |
| 60 | - | __dumber_omnibox?: { | |
| 61 | - | setSuggestions: (suggestions: Suggestion[]) => void; | |
| 62 | - | toggle: () => void; | |
| 63 | - | open: (mode?: string, query?: string) => void; | |
| 64 | - | close: () => void; | |
| 65 | - | findQuery: (query: string) => void; | |
| 66 | - | setActive: (active: boolean) => void; | |
| 67 | - | }; | |
| 68 | - | __dumber_gui_bootstrap?: () => void; | |
| 69 | - | // Legacy compatibility functions for Go bridge | |
| 70 | - | __dumber_toggle?: () => void; | |
| 71 | - | __dumber_find_open?: (query?: string) => void; | |
| 72 | - | __dumber_find_close?: () => void; | |
| 73 | - | __dumber_find_query?: (query: string) => void; | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // Prevent multiple initialization with enhanced logging | |
| 78 | - | if (!window.__dumber_gui_ready) { | |
| 79 | - | // Skip initialization in iframes - GUI only needed in main frame | |
| 80 | - | if (window.self !== window.top) { | |
| 81 | - | console.log("🚫 Dumber GUI skipped in iframe"); | |
| 82 | - | } else { | |
| 83 | - | window.__dumber_gui_ready = true; | |
| 84 | - | ||
| 85 | - | let workspaceHasFocus = false; // Start inactive until first focus event | |
| 86 | - | let currentWebViewId = window.__dumber_webview_id || "unknown"; | |
| 87 | - | let hasReceivedFocusEvent = false; // Track if we've received any workspace focus events | |
| 88 | - | ||
| 89 | - | // Request webview ID from Go if it's unknown (needed for stub builds) | |
| 90 | - | if ( | |
| 91 | - | currentWebViewId === "unknown" || | |
| 92 | - | currentWebViewId === "__WEBVIEW_ID__" | |
| 93 | - | ) { | |
| 94 | - | console.log("[workspace] WebView ID unknown, requesting from Go backend"); | |
| 95 | - | if (window.webkit?.messageHandlers?.dumber) { | |
| 96 | - | window.webkit.messageHandlers.dumber.postMessage( | |
| 97 | - | JSON.stringify({ | |
| 98 | - | type: "request-webview-id", | |
| 99 | - | payload: { timestamp: Date.now() }, | |
| 100 | - | }), | |
| 101 | - | ); | |
| 102 | - | } | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Listen for webview ID from Go backend | |
| 106 | - | document.addEventListener("dumber:webview-id", (event: Event) => { | |
| 107 | - | const detail = (event as CustomEvent).detail || {}; | |
| 108 | - | if (detail.webviewId) { | |
| 109 | - | currentWebViewId = detail.webviewId; | |
| 110 | - | window.__dumber_webview_id = detail.webviewId; | |
| 111 | - | console.log( | |
| 112 | - | "[workspace] Received webview ID from Go:", | |
| 113 | - | detail.webviewId, | |
| 114 | - | ); | |
| 115 | - | } | |
| 116 | - | }); | |
| 117 | - | ||
| 118 | - | document.addEventListener("dumber:workspace-focus", (event: Event) => { | |
| 119 | - | const detail = (event as CustomEvent).detail || {}; | |
| 120 | - | console.log( | |
| 121 | - | "[workspace focus event]", | |
| 122 | - | detail, | |
| 123 | - | "prev focus=", | |
| 124 | - | workspaceHasFocus, | |
| 125 | - | ); | |
| 126 | - | ||
| 127 | - | hasReceivedFocusEvent = true; | |
| 128 | - | ||
| 129 | - | // Update focus state | |
| 130 | - | if (typeof detail?.active === "boolean") { | |
| 131 | - | workspaceHasFocus = detail.active; | |
| 132 | - | } else { | |
| 133 | - | workspaceHasFocus = detail !== false; | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | console.log( | |
| 137 | - | "[workspace focus updated] focus=", | |
| 138 | - | workspaceHasFocus, | |
| 139 | - | "webviewId=", | |
| 140 | - | currentWebViewId, | |
| 141 | - | ); | |
| 142 | - | ||
| 143 | - | // Handle GUI visibility based on focus | |
| 144 | - | if (window.__dumber_omnibox) { | |
| 145 | - | if (workspaceHasFocus) { | |
| 146 | - | window.__dumber_omnibox.setActive(true); | |
| 147 | - | } else { | |
| 148 | - | window.__dumber_omnibox.setActive(false); | |
| 149 | - | window.__dumber_omnibox.close(); | |
| 150 | - | } | |
| 151 | - | } | |
| 152 | - | }); | |
| 153 | - | ||
| 154 | - | // Initialize Svelte GUI systems immediately | |
| 155 | - | whenDOMReady(async () => { | |
| 156 | - | try { | |
| 157 | - | // Page-world bridge is injected by WebKit; avoid duplicate injection here | |
| 158 | - | // window.open interceptor is now in main-world.ts (page-world context) | |
| 159 | - | ||
| 160 | - | // Initialize toast system first | |
| 161 | - | await initializeToast(); | |
| 162 | - | ||
| 163 | - | // Verify toast functions are available | |
| 164 | - | if (typeof window.__dumber_showToast !== "function") { | |
| 165 | - | throw new Error("Toast functions not exposed after initialization"); | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Initialize omnibox system | |
| 169 | - | await initializeOmnibox(); | |
| 170 | - | ||
| 171 | - | // Initialize workspace controls (pane/tab scaffolding) | |
| 172 | - | initializeWorkspace(window.__dumber_workspace_config); | |
| 173 | - | ||
| 174 | - | console.log("✅ All GUI systems initialized successfully"); | |
| 175 | - | } catch (e) { | |
| 176 | - | console.error("❌ Failed to initialize Svelte toast system:", e); | |
| 177 | - | ||
| 178 | - | // Enhanced fallback with better error recovery | |
| 179 | - | window.__dumber_showToast = ( | |
| 180 | - | message: string, | |
| 181 | - | duration: number = 2500, | |
| 182 | - | ) => { | |
| 183 | - | // Remove existing toasts to prevent overlap | |
| 184 | - | document | |
| 185 | - | .querySelectorAll(".dumber-fallback-toast") | |
| 186 | - | .forEach((el) => el.remove()); | |
| 187 | - | ||
| 188 | - | const toast = document.createElement("div"); | |
| 189 | - | toast.className = "dumber-fallback-toast"; | |
| 190 | - | toast.textContent = message; | |
| 191 | - | toast.style.cssText = ` | |
| 192 | - | position: fixed; | |
| 193 | - | bottom: 20px; | |
| 194 | - | right: 20px; | |
| 195 | - | background: rgba(0,0,0,0.9); | |
| 196 | - | color: white; | |
| 197 | - | padding: 12px 16px; | |
| 198 | - | border-radius: 6px; | |
| 199 | - | z-index: 2147483647; | |
| 200 | - | font-family: system-ui, -apple-system, sans-serif; | |
| 201 | - | font-size: 14px; | |
| 202 | - | opacity: 0; | |
| 203 | - | transform: translateX(100%); | |
| 204 | - | transition: all 0.3s ease; | |
| 205 | - | max-width: 300px; | |
| 206 | - | word-wrap: break-word; | |
| 207 | - | `; | |
| 208 | - | ||
| 209 | - | if (document.body) { | |
| 210 | - | document.body.appendChild(toast); | |
| 211 | - | ||
| 212 | - | // Animate in | |
| 213 | - | requestAnimationFrame(() => { | |
| 214 | - | toast.style.opacity = "1"; | |
| 215 | - | toast.style.transform = "translateX(0)"; | |
| 216 | - | }); | |
| 217 | - | ||
| 218 | - | // Animate out and remove | |
| 219 | - | setTimeout(() => { | |
| 220 | - | toast.style.opacity = "0"; | |
| 221 | - | toast.style.transform = "translateX(100%)"; | |
| 222 | - | setTimeout(() => toast.remove(), 300); | |
| 223 | - | }, duration); | |
| 224 | - | } | |
| 225 | - | }; | |
| 226 | - | ||
| 227 | - | window.__dumber_showZoomToast = (level: number) => { | |
| 228 | - | const percentage = Math.round(level * 100); | |
| 229 | - | window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500); | |
| 230 | - | }; | |
| 231 | - | ||
| 232 | - | // Expose other required functions | |
| 233 | - | window.__dumber_dismissToast = () => { | |
| 234 | - | document | |
| 235 | - | .querySelectorAll(".dumber-fallback-toast") | |
| 236 | - | .forEach((el) => el.remove()); | |
| 237 | - | }; | |
| 238 | - | ||
| 239 | - | window.__dumber_clearToasts = () => { | |
| 240 | - | document | |
| 241 | - | .querySelectorAll(".dumber-fallback-toast") | |
| 242 | - | .forEach((el) => el.remove()); | |
| 243 | - | }; | |
| 244 | - | } | |
| 245 | - | }); | |
| 246 | - | ||
| 247 | - | // Expose keyboard service globally for Go bridge | |
| 248 | - | window.__dumber_keyboard = keyboardService; | |
| 249 | - | ||
| 250 | - | // Initialize global keyboard and mouse event listeners for the keyboard service | |
| 251 | - | whenDOMReady(() => { | |
| 252 | - | // Global keyboard event listener (capture phase for high priority) | |
| 253 | - | document.addEventListener( | |
| 254 | - | "keydown", | |
| 255 | - | (event) => { | |
| 256 | - | keyboardService.handleKeyboardEvent(event); | |
| 257 | - | }, | |
| 258 | - | true, | |
| 259 | - | ); | |
| 260 | - | ||
| 261 | - | // Global mouse event listener for navigation buttons | |
| 262 | - | document.addEventListener( | |
| 263 | - | "mousedown", | |
| 264 | - | (event) => { | |
| 265 | - | keyboardService.handleMouseEvent(event); | |
| 266 | - | }, | |
| 267 | - | true, | |
| 268 | - | ); | |
| 269 | - | ||
| 270 | - | console.log("✅ KeyboardService initialized with global listeners"); | |
| 271 | - | ||
| 272 | - | // Listen to bridge keyboard events from page-world and forward to KeyboardService | |
| 273 | - | document.addEventListener("dumber:key", (e: Event) => { | |
| 274 | - | const detail = (e as CustomEvent).detail || {}; | |
| 275 | - | if (detail && typeof detail.shortcut === "string") { | |
| 276 | - | keyboardService.handleNativeShortcut(detail.shortcut); | |
| 277 | - | } | |
| 278 | - | }); | |
| 279 | - | ||
| 280 | - | document.addEventListener("dumber:ui:shortcut", (e: Event) => { | |
| 281 | - | const detail = (e as CustomEvent).detail || {}; | |
| 282 | - | const action = detail?.action; | |
| 283 | - | const eventWebViewId = detail?.webviewId; | |
| 284 | - | const source = detail?.source || "unknown"; | |
| 285 | - | ||
| 286 | - | console.log("[dumber shortcuts] Event received", { | |
| 287 | - | action, | |
| 288 | - | eventWebViewId, | |
| 289 | - | currentWebViewId, | |
| 290 | - | detail, | |
| 291 | - | }); | |
| 292 | - | ||
| 293 | - | if (typeof action !== "string") { | |
| 294 | - | console.log("[dumber shortcuts] No action in event, ignoring"); | |
| 295 | - | return; | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | // Enhanced focus checking with webview ID validation | |
| 299 | - | const isForThisWebView = | |
| 300 | - | !eventWebViewId || eventWebViewId === currentWebViewId; | |
| 301 | - | // Only apply strict focus checking to omnibox actions, be permissive for others | |
| 302 | - | const isOmniboxAction = action.startsWith("omnibox-"); | |
| 303 | - | const shouldHandle = | |
| 304 | - | isForThisWebView && | |
| 305 | - | (isOmniboxAction | |
| 306 | - | ? hasReceivedFocusEvent | |
| 307 | - | ? workspaceHasFocus | |
| 308 | - | : true | |
| 309 | - | : true); | |
| 310 | - | ||
| 311 | - | if (!shouldHandle) { | |
| 312 | - | console.log("[dumber shortcuts] ignored action", { | |
| 313 | - | action, | |
| 314 | - | workspaceHasFocus, | |
| 315 | - | isForThisWebView, | |
| 316 | - | eventWebViewId, | |
| 317 | - | currentWebViewId, | |
| 318 | - | source, | |
| 319 | - | detail, | |
| 320 | - | hasReceivedFocusEvent, | |
| 321 | - | }); | |
| 322 | - | return; | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | console.log("[dumber shortcuts] handling action", { | |
| 326 | - | action, | |
| 327 | - | source, | |
| 328 | - | webviewId: currentWebViewId, | |
| 329 | - | }); | |
| 330 | - | ||
| 331 | - | const omnibox = window.__dumber_omnibox; | |
| 332 | - | ||
| 333 | - | switch (action) { | |
| 334 | - | case "omnibox-nav-toggle": | |
| 335 | - | omnibox?.open?.("omnibox", detail?.query); | |
| 336 | - | break; | |
| 337 | - | case "omnibox-open": | |
| 338 | - | omnibox?.open?.("omnibox", detail?.query); | |
| 339 | - | break; | |
| 340 | - | case "omnibox-find-toggle": | |
| 341 | - | omnibox?.open?.("find", detail?.query); | |
| 342 | - | break; | |
| 343 | - | case "omnibox-close": | |
| 344 | - | omnibox?.close?.(); | |
| 345 | - | break; | |
| 346 | - | default: | |
| 347 | - | console.warn("[dumber] Unknown UI shortcut action:", action); | |
| 348 | - | } | |
| 349 | - | }); | |
| 350 | - | ||
| 351 | - | // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed | |
| 352 | - | }); | |
| 353 | - | ||
| 354 | - | // Legacy compatibility functions for existing Go code | |
| 355 | - | window.__dumber_toggle = () => { | |
| 356 | - | try { | |
| 357 | - | console.log("🎯 __dumber_toggle called"); | |
| 358 | - | console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox); | |
| 359 | - | if (window.__dumber_omnibox?.toggle) { | |
| 360 | - | console.log("✅ Using Svelte omnibox toggle"); | |
| 361 | - | window.__dumber_omnibox.toggle(); | |
| 362 | - | return; | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | throw new Error("Omnibox toggle requested but API is unavailable"); | |
| 366 | - | } catch (error) { | |
| 367 | - | console.error("❌ Error in __dumber_toggle:", error); | |
| 368 | - | } | |
| 369 | - | }; | |
| 370 | - | ||
| 371 | - | window.__dumber_find_open = (query?: string) => { | |
| 372 | - | try { | |
| 373 | - | if (window.__dumber_omnibox?.open) { | |
| 374 | - | window.__dumber_omnibox.open("find", query); | |
| 375 | - | return; | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | throw new Error("Omnibox find requested but API is unavailable"); | |
| 379 | - | } catch (error) { | |
| 380 | - | console.error("❌ Error in __dumber_find_open:", error); | |
| 381 | - | } | |
| 382 | - | }; | |
| 383 | - | ||
| 384 | - | window.__dumber_find_close = () => { | |
| 385 | - | if (window.__dumber_omnibox?.close) { | |
| 386 | - | window.__dumber_omnibox.close(); | |
| 387 | - | return; | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | const error = new Error("Omnibox close requested but API is unavailable"); | |
| 391 | - | console.error("❌ Error in __dumber_find_close:", error); | |
| 392 | - | }; | |
| 393 | - | ||
| 394 | - | // Find query function that Go uses to set find query | |
| 395 | - | window.__dumber_find_query = (query: string) => { | |
| 396 | - | if (window.__dumber_omnibox?.findQuery) { | |
| 397 | - | window.__dumber_omnibox.findQuery(query); | |
| 398 | - | } | |
| 399 | - | }; | |
| 400 | - | ||
| 401 | - | // Create the global GUI object | |
| 402 | - | window.__dumber_gui = { | |
| 403 | - | initializeToast: async (config?: ToastConfig) => { | |
| 404 | - | return await initializeToast(config); | |
| 405 | - | }, | |
| 406 | - | ||
| 407 | - | initializeOmnibox: async (config?: OmniboxInitConfig) => { | |
| 408 | - | return await initializeOmnibox(config); | |
| 409 | - | }, | |
| 410 | - | ||
| 411 | - | keyboard: keyboardService, | |
| 412 | - | ||
| 413 | - | isReady: true, | |
| 414 | - | }; | |
| 415 | - | ||
| 416 | - | // Bootstrap function for lazy GUI initialization by workspace manager | |
| 417 | - | window.__dumber_gui_bootstrap = function () { | |
| 418 | - | console.log("[gui-bootstrap] Initializing GUI for workspace"); | |
| 419 | - | ||
| 420 | - | // Pane ID will be updated via focus events | |
| 10 | + | const href = window.location.href; | |
| 11 | + | const protocol = window.location.protocol; | |
| 12 | + | const host = window.location.host || ""; | |
| 421 | 13 | ||
| 422 | - | // Ensure omnibox is available for this pane | |
| 423 | - | if (!window.__dumber_omnibox) { | |
| 424 | - | console.log( | |
| 425 | - | `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`, | |
| 426 | - | ); | |
| 427 | - | } else { | |
| 428 | - | console.log( | |
| 429 | - | `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`, | |
| 430 | - | ); | |
| 431 | - | // Ensure omnibox is aware of the current pane | |
| 432 | - | window.__dumber_omnibox.setActive(workspaceHasFocus); | |
| 433 | - | } | |
| 14 | + | const isDumbProtocol = protocol === "dumb:" || href.startsWith("dumb://"); | |
| 15 | + | const dumbHost = host.toLowerCase(); | |
| 16 | + | const shouldSkip = | |
| 17 | + | isDumbProtocol && | |
| 18 | + | (href.toLowerCase().startsWith("dumb://home") || | |
| 19 | + | href.toLowerCase().startsWith("dumb://homepage") || | |
| 20 | + | dumbHost === "home" || | |
| 21 | + | dumbHost === "homepage"); | |
| 434 | 22 | ||
| 435 | - | console.log( | |
| 436 | - | `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`, | |
| 437 | - | ); | |
| 438 | - | }; | |
| 439 | - | } | |
| 23 | + | if (!shouldSkip) { | |
| 24 | + | bootstrapGUI(); | |
| 440 | 25 | } | |
| @@ -1,440 +1,25 @@ | |||||
| 1 | /** | 1 | /** | ||
| 2 | - | * Unified GUI Bundle Entry Point | 2 | + | * Unified GUI bundle entry point. |
| 3 | * | 3 | * | ||
| 4 | - | * Single entry point for all WebKit GUI functionality. | 4 | + | * Delegates to the shared bootstrap so special pages can reuse the same setup |
| 5 | - | * Loads immediately and provides modular initialization system. | 5 | + | * without depending on this injected build. |
| 6 | */ | 6 | */ | ||
| 7 | 7 | ||||
| 8 | - | // Import all GUI modules | 8 | + | import { bootstrapGUI } from "./bootstrap"; |
| 9 | - | import { initializeToast, type ToastConfig } from "./modules/toast"; | |||
| 10 | - | import { initializeOmnibox, type OmniboxInitConfig } from "./modules/omnibox"; | |||
| 11 | - | import { | |||
| 12 | - | initializeWorkspace, | |||
| 13 | - | type WorkspaceConfigPayload, | |||
| 14 | - | type WorkspaceRuntime, | |||
| 15 | - | } from "./modules/workspace"; | |||
| 16 | - | import { keyboardService, type KeyboardService } from "$lib/keyboard"; | |||
| 17 | - | import type { Suggestion } from "../components/omnibox/types"; | |||
| 18 | - | // Note: color-scheme module is loaded separately at document-start by WebKit | |||
| 19 | 9 | ||||
| 20 | - | // Global interface for the unified GUI system | 10 | + | const href = window.location.href; |
| 21 | - | interface DumberGUI { | 11 | + | const protocol = window.location.protocol; |
| 22 | - | initializeToast: (config?: ToastConfig) => Promise<void>; | 12 | + | const host = window.location.host || ""; |
| 23 | - | initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>; | |||
| 24 | - | keyboard: KeyboardService; | |||
| 25 | - | isReady: boolean; | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | // (legacy suggestions normalization removed; omnibox now fetches suggestions directly via API) | |||
| 29 | - | ||||
| 30 | - | // DOM readiness utility - handles all document ready states | |||
| 31 | - | function whenDOMReady(callback: () => void) { | |||
| 32 | - | if (document.readyState === "loading") { | |||
| 33 | - | document.addEventListener("DOMContentLoaded", callback, { once: true }); | |||
| 34 | - | } else if (document.readyState === "interactive" && !document.body) { | |||
| 35 | - | // Interactive but no body yet, wait a bit more | |||
| 36 | - | setTimeout(() => whenDOMReady(callback), 10); | |||
| 37 | - | } else { | |||
| 38 | - | // DOM ready and body exists | |||
| 39 | - | callback(); | |||
| 40 | - | } | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | // Install a page-world bridge so Go can call window.__dumber_omnibox_suggestions() | |||
| 44 | - | // Page-world bridge is injected by WebKit at document-start; no-op here to avoid duplicates. | |||
| 45 | - | ||||
| 46 | - | // Create global namespace | |||
| 47 | - | declare global { | |||
| 48 | - | interface Window { | |||
| 49 | - | __dumber_gui?: DumberGUI; | |||
| 50 | - | __dumber_gui_ready?: boolean; | |||
| 51 | - | __dumber_showToast?: ( | |||
| 52 | - | message: string, | |||
| 53 | - | duration?: number, | |||
| 54 | - | type?: "info" | "success" | "error", | |||
| 55 | - | ) => number | void; | |||
| 56 | - | __dumber_showZoomToast?: (level: number) => void; | |||
| 57 | - | __dumber_keyboard?: KeyboardService; | |||
| 58 | - | __dumber_workspace_config?: WorkspaceConfigPayload; | |||
| 59 | - | __dumber_workspace?: WorkspaceRuntime; | |||
| 60 | - | __dumber_omnibox?: { | |||
| 61 | - | setSuggestions: (suggestions: Suggestion[]) => void; | |||
| 62 | - | toggle: () => void; | |||
| 63 | - | open: (mode?: string, query?: string) => void; | |||
| 64 | - | close: () => void; | |||
| 65 | - | findQuery: (query: string) => void; | |||
| 66 | - | setActive: (active: boolean) => void; | |||
| 67 | - | }; | |||
| 68 | - | __dumber_gui_bootstrap?: () => void; | |||
| 69 | - | // Legacy compatibility functions for Go bridge | |||
| 70 | - | __dumber_toggle?: () => void; | |||
| 71 | - | __dumber_find_open?: (query?: string) => void; | |||
| 72 | - | __dumber_find_close?: () => void; | |||
| 73 | - | __dumber_find_query?: (query: string) => void; | |||
| 74 | - | } | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // Prevent multiple initialization with enhanced logging | |||
| 78 | - | if (!window.__dumber_gui_ready) { | |||
| 79 | - | // Skip initialization in iframes - GUI only needed in main frame | |||
| 80 | - | if (window.self !== window.top) { | |||
| 81 | - | console.log("🚫 Dumber GUI skipped in iframe"); | |||
| 82 | - | } else { | |||
| 83 | - | window.__dumber_gui_ready = true; | |||
| 84 | - | ||||
| 85 | - | let workspaceHasFocus = false; // Start inactive until first focus event | |||
| 86 | - | let currentWebViewId = window.__dumber_webview_id || "unknown"; | |||
| 87 | - | let hasReceivedFocusEvent = false; // Track if we've received any workspace focus events | |||
| 88 | - | ||||
| 89 | - | // Request webview ID from Go if it's unknown (needed for stub builds) | |||
| 90 | - | if ( | |||
| 91 | - | currentWebViewId === "unknown" || | |||
| 92 | - | currentWebViewId === "__WEBVIEW_ID__" | |||
| 93 | - | ) { | |||
| 94 | - | console.log("[workspace] WebView ID unknown, requesting from Go backend"); | |||
| 95 | - | if (window.webkit?.messageHandlers?.dumber) { | |||
| 96 | - | window.webkit.messageHandlers.dumber.postMessage( | |||
| 97 | - | JSON.stringify({ | |||
| 98 | - | type: "request-webview-id", | |||
| 99 | - | payload: { timestamp: Date.now() }, | |||
| 100 | - | }), | |||
| 101 | - | ); | |||
| 102 | - | } | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // Listen for webview ID from Go backend | |||
| 106 | - | document.addEventListener("dumber:webview-id", (event: Event) => { | |||
| 107 | - | const detail = (event as CustomEvent).detail || {}; | |||
| 108 | - | if (detail.webviewId) { | |||
| 109 | - | currentWebViewId = detail.webviewId; | |||
| 110 | - | window.__dumber_webview_id = detail.webviewId; | |||
| 111 | - | console.log( | |||
| 112 | - | "[workspace] Received webview ID from Go:", | |||
| 113 | - | detail.webviewId, | |||
| 114 | - | ); | |||
| 115 | - | } | |||
| 116 | - | }); | |||
| 117 | - | ||||
| 118 | - | document.addEventListener("dumber:workspace-focus", (event: Event) => { | |||
| 119 | - | const detail = (event as CustomEvent).detail || {}; | |||
| 120 | - | console.log( | |||
| 121 | - | "[workspace focus event]", | |||
| 122 | - | detail, | |||
| 123 | - | "prev focus=", | |||
| 124 | - | workspaceHasFocus, | |||
| 125 | - | ); | |||
| 126 | - | ||||
| 127 | - | hasReceivedFocusEvent = true; | |||
| 128 | - | ||||
| 129 | - | // Update focus state | |||
| 130 | - | if (typeof detail?.active === "boolean") { | |||
| 131 | - | workspaceHasFocus = detail.active; | |||
| 132 | - | } else { | |||
| 133 | - | workspaceHasFocus = detail !== false; | |||
| 134 | - | } | |||
| 135 | - | ||||
| 136 | - | console.log( | |||
| 137 | - | "[workspace focus updated] focus=", | |||
| 138 | - | workspaceHasFocus, | |||
| 139 | - | "webviewId=", | |||
| 140 | - | currentWebViewId, | |||
| 141 | - | ); | |||
| 142 | - | ||||
| 143 | - | // Handle GUI visibility based on focus | |||
| 144 | - | if (window.__dumber_omnibox) { | |||
| 145 | - | if (workspaceHasFocus) { | |||
| 146 | - | window.__dumber_omnibox.setActive(true); | |||
| 147 | - | } else { | |||
| 148 | - | window.__dumber_omnibox.setActive(false); | |||
| 149 | - | window.__dumber_omnibox.close(); | |||
| 150 | - | } | |||
| 151 | - | } | |||
| 152 | - | }); | |||
| 153 | - | ||||
| 154 | - | // Initialize Svelte GUI systems immediately | |||
| 155 | - | whenDOMReady(async () => { | |||
| 156 | - | try { | |||
| 157 | - | // Page-world bridge is injected by WebKit; avoid duplicate injection here | |||
| 158 | - | // window.open interceptor is now in main-world.ts (page-world context) | |||
| 159 | - | ||||
| 160 | - | // Initialize toast system first | |||
| 161 | - | await initializeToast(); | |||
| 162 | - | ||||
| 163 | - | // Verify toast functions are available | |||
| 164 | - | if (typeof window.__dumber_showToast !== "function") { | |||
| 165 | - | throw new Error("Toast functions not exposed after initialization"); | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | // Initialize omnibox system | |||
| 169 | - | await initializeOmnibox(); | |||
| 170 | - | ||||
| 171 | - | // Initialize workspace controls (pane/tab scaffolding) | |||
| 172 | - | initializeWorkspace(window.__dumber_workspace_config); | |||
| 173 | - | ||||
| 174 | - | console.log("✅ All GUI systems initialized successfully"); | |||
| 175 | - | } catch (e) { | |||
| 176 | - | console.error("❌ Failed to initialize Svelte toast system:", e); | |||
| 177 | - | ||||
| 178 | - | // Enhanced fallback with better error recovery | |||
| 179 | - | window.__dumber_showToast = ( | |||
| 180 | - | message: string, | |||
| 181 | - | duration: number = 2500, | |||
| 182 | - | ) => { | |||
| 183 | - | // Remove existing toasts to prevent overlap | |||
| 184 | - | document | |||
| 185 | - | .querySelectorAll(".dumber-fallback-toast") | |||
| 186 | - | .forEach((el) => el.remove()); | |||
| 187 | - | ||||
| 188 | - | const toast = document.createElement("div"); | |||
| 189 | - | toast.className = "dumber-fallback-toast"; | |||
| 190 | - | toast.textContent = message; | |||
| 191 | - | toast.style.cssText = ` | |||
| 192 | - | position: fixed; | |||
| 193 | - | bottom: 20px; | |||
| 194 | - | right: 20px; | |||
| 195 | - | background: rgba(0,0,0,0.9); | |||
| 196 | - | color: white; | |||
| 197 | - | padding: 12px 16px; | |||
| 198 | - | border-radius: 6px; | |||
| 199 | - | z-index: 2147483647; | |||
| 200 | - | font-family: system-ui, -apple-system, sans-serif; | |||
| 201 | - | font-size: 14px; | |||
| 202 | - | opacity: 0; | |||
| 203 | - | transform: translateX(100%); | |||
| 204 | - | transition: all 0.3s ease; | |||
| 205 | - | max-width: 300px; | |||
| 206 | - | word-wrap: break-word; | |||
| 207 | - | `; | |||
| 208 | - | ||||
| 209 | - | if (document.body) { | |||
| 210 | - | document.body.appendChild(toast); | |||
| 211 | - | ||||
| 212 | - | // Animate in | |||
| 213 | - | requestAnimationFrame(() => { | |||
| 214 | - | toast.style.opacity = "1"; | |||
| 215 | - | toast.style.transform = "translateX(0)"; | |||
| 216 | - | }); | |||
| 217 | - | ||||
| 218 | - | // Animate out and remove | |||
| 219 | - | setTimeout(() => { | |||
| 220 | - | toast.style.opacity = "0"; | |||
| 221 | - | toast.style.transform = "translateX(100%)"; | |||
| 222 | - | setTimeout(() => toast.remove(), 300); | |||
| 223 | - | }, duration); | |||
| 224 | - | } | |||
| 225 | - | }; | |||
| 226 | - | ||||
| 227 | - | window.__dumber_showZoomToast = (level: number) => { | |||
| 228 | - | const percentage = Math.round(level * 100); | |||
| 229 | - | window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500); | |||
| 230 | - | }; | |||
| 231 | - | ||||
| 232 | - | // Expose other required functions | |||
| 233 | - | window.__dumber_dismissToast = () => { | |||
| 234 | - | document | |||
| 235 | - | .querySelectorAll(".dumber-fallback-toast") | |||
| 236 | - | .forEach((el) => el.remove()); | |||
| 237 | - | }; | |||
| 238 | - | ||||
| 239 | - | window.__dumber_clearToasts = () => { | |||
| 240 | - | document | |||
| 241 | - | .querySelectorAll(".dumber-fallback-toast") | |||
| 242 | - | .forEach((el) => el.remove()); | |||
| 243 | - | }; | |||
| 244 | - | } | |||
| 245 | - | }); | |||
| 246 | - | ||||
| 247 | - | // Expose keyboard service globally for Go bridge | |||
| 248 | - | window.__dumber_keyboard = keyboardService; | |||
| 249 | - | ||||
| 250 | - | // Initialize global keyboard and mouse event listeners for the keyboard service | |||
| 251 | - | whenDOMReady(() => { | |||
| 252 | - | // Global keyboard event listener (capture phase for high priority) | |||
| 253 | - | document.addEventListener( | |||
| 254 | - | "keydown", | |||
| 255 | - | (event) => { | |||
| 256 | - | keyboardService.handleKeyboardEvent(event); | |||
| 257 | - | }, | |||
| 258 | - | true, | |||
| 259 | - | ); | |||
| 260 | - | ||||
| 261 | - | // Global mouse event listener for navigation buttons | |||
| 262 | - | document.addEventListener( | |||
| 263 | - | "mousedown", | |||
| 264 | - | (event) => { | |||
| 265 | - | keyboardService.handleMouseEvent(event); | |||
| 266 | - | }, | |||
| 267 | - | true, | |||
| 268 | - | ); | |||
| 269 | - | ||||
| 270 | - | console.log("✅ KeyboardService initialized with global listeners"); | |||
| 271 | - | ||||
| 272 | - | // Listen to bridge keyboard events from page-world and forward to KeyboardService | |||
| 273 | - | document.addEventListener("dumber:key", (e: Event) => { | |||
| 274 | - | const detail = (e as CustomEvent).detail || {}; | |||
| 275 | - | if (detail && typeof detail.shortcut === "string") { | |||
| 276 | - | keyboardService.handleNativeShortcut(detail.shortcut); | |||
| 277 | - | } | |||
| 278 | - | }); | |||
| 279 | - | ||||
| 280 | - | document.addEventListener("dumber:ui:shortcut", (e: Event) => { | |||
| 281 | - | const detail = (e as CustomEvent).detail || {}; | |||
| 282 | - | const action = detail?.action; | |||
| 283 | - | const eventWebViewId = detail?.webviewId; | |||
| 284 | - | const source = detail?.source || "unknown"; | |||
| 285 | - | ||||
| 286 | - | console.log("[dumber shortcuts] Event received", { | |||
| 287 | - | action, | |||
| 288 | - | eventWebViewId, | |||
| 289 | - | currentWebViewId, | |||
| 290 | - | detail, | |||
| 291 | - | }); | |||
| 292 | - | ||||
| 293 | - | if (typeof action !== "string") { | |||
| 294 | - | console.log("[dumber shortcuts] No action in event, ignoring"); | |||
| 295 | - | return; | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | // Enhanced focus checking with webview ID validation | |||
| 299 | - | const isForThisWebView = | |||
| 300 | - | !eventWebViewId || eventWebViewId === currentWebViewId; | |||
| 301 | - | // Only apply strict focus checking to omnibox actions, be permissive for others | |||
| 302 | - | const isOmniboxAction = action.startsWith("omnibox-"); | |||
| 303 | - | const shouldHandle = | |||
| 304 | - | isForThisWebView && | |||
| 305 | - | (isOmniboxAction | |||
| 306 | - | ? hasReceivedFocusEvent | |||
| 307 | - | ? workspaceHasFocus | |||
| 308 | - | : true | |||
| 309 | - | : true); | |||
| 310 | - | ||||
| 311 | - | if (!shouldHandle) { | |||
| 312 | - | console.log("[dumber shortcuts] ignored action", { | |||
| 313 | - | action, | |||
| 314 | - | workspaceHasFocus, | |||
| 315 | - | isForThisWebView, | |||
| 316 | - | eventWebViewId, | |||
| 317 | - | currentWebViewId, | |||
| 318 | - | source, | |||
| 319 | - | detail, | |||
| 320 | - | hasReceivedFocusEvent, | |||
| 321 | - | }); | |||
| 322 | - | return; | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | console.log("[dumber shortcuts] handling action", { | |||
| 326 | - | action, | |||
| 327 | - | source, | |||
| 328 | - | webviewId: currentWebViewId, | |||
| 329 | - | }); | |||
| 330 | - | ||||
| 331 | - | const omnibox = window.__dumber_omnibox; | |||
| 332 | - | ||||
| 333 | - | switch (action) { | |||
| 334 | - | case "omnibox-nav-toggle": | |||
| 335 | - | omnibox?.open?.("omnibox", detail?.query); | |||
| 336 | - | break; | |||
| 337 | - | case "omnibox-open": | |||
| 338 | - | omnibox?.open?.("omnibox", detail?.query); | |||
| 339 | - | break; | |||
| 340 | - | case "omnibox-find-toggle": | |||
| 341 | - | omnibox?.open?.("find", detail?.query); | |||
| 342 | - | break; | |||
| 343 | - | case "omnibox-close": | |||
| 344 | - | omnibox?.close?.(); | |||
| 345 | - | break; | |||
| 346 | - | default: | |||
| 347 | - | console.warn("[dumber] Unknown UI shortcut action:", action); | |||
| 348 | - | } | |||
| 349 | - | }); | |||
| 350 | - | ||||
| 351 | - | // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed | |||
| 352 | - | }); | |||
| 353 | - | ||||
| 354 | - | // Legacy compatibility functions for existing Go code | |||
| 355 | - | window.__dumber_toggle = () => { | |||
| 356 | - | try { | |||
| 357 | - | console.log("🎯 __dumber_toggle called"); | |||
| 358 | - | console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox); | |||
| 359 | - | if (window.__dumber_omnibox?.toggle) { | |||
| 360 | - | console.log("✅ Using Svelte omnibox toggle"); | |||
| 361 | - | window.__dumber_omnibox.toggle(); | |||
| 362 | - | return; | |||
| 363 | - | } | |||
| 364 | - | ||||
| 365 | - | throw new Error("Omnibox toggle requested but API is unavailable"); | |||
| 366 | - | } catch (error) { | |||
| 367 | - | console.error("❌ Error in __dumber_toggle:", error); | |||
| 368 | - | } | |||
| 369 | - | }; | |||
| 370 | - | ||||
| 371 | - | window.__dumber_find_open = (query?: string) => { | |||
| 372 | - | try { | |||
| 373 | - | if (window.__dumber_omnibox?.open) { | |||
| 374 | - | window.__dumber_omnibox.open("find", query); | |||
| 375 | - | return; | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | throw new Error("Omnibox find requested but API is unavailable"); | |||
| 379 | - | } catch (error) { | |||
| 380 | - | console.error("❌ Error in __dumber_find_open:", error); | |||
| 381 | - | } | |||
| 382 | - | }; | |||
| 383 | - | ||||
| 384 | - | window.__dumber_find_close = () => { | |||
| 385 | - | if (window.__dumber_omnibox?.close) { | |||
| 386 | - | window.__dumber_omnibox.close(); | |||
| 387 | - | return; | |||
| 388 | - | } | |||
| 389 | - | ||||
| 390 | - | const error = new Error("Omnibox close requested but API is unavailable"); | |||
| 391 | - | console.error("❌ Error in __dumber_find_close:", error); | |||
| 392 | - | }; | |||
| 393 | - | ||||
| 394 | - | // Find query function that Go uses to set find query | |||
| 395 | - | window.__dumber_find_query = (query: string) => { | |||
| 396 | - | if (window.__dumber_omnibox?.findQuery) { | |||
| 397 | - | window.__dumber_omnibox.findQuery(query); | |||
| 398 | - | } | |||
| 399 | - | }; | |||
| 400 | - | ||||
| 401 | - | // Create the global GUI object | |||
| 402 | - | window.__dumber_gui = { | |||
| 403 | - | initializeToast: async (config?: ToastConfig) => { | |||
| 404 | - | return await initializeToast(config); | |||
| 405 | - | }, | |||
| 406 | - | ||||
| 407 | - | initializeOmnibox: async (config?: OmniboxInitConfig) => { | |||
| 408 | - | return await initializeOmnibox(config); | |||
| 409 | - | }, | |||
| 410 | - | ||||
| 411 | - | keyboard: keyboardService, | |||
| 412 | - | ||||
| 413 | - | isReady: true, | |||
| 414 | - | }; | |||
| 415 | - | ||||
| 416 | - | // Bootstrap function for lazy GUI initialization by workspace manager | |||
| 417 | - | window.__dumber_gui_bootstrap = function () { | |||
| 418 | - | console.log("[gui-bootstrap] Initializing GUI for workspace"); | |||
| 419 | - | ||||
| 420 | - | // Pane ID will be updated via focus events | |||
| 421 | 13 | ||||
| 422 | - | // Ensure omnibox is available for this pane | 14 | + | const isDumbProtocol = protocol === "dumb:" || href.startsWith("dumb://"); |
| 423 | - | if (!window.__dumber_omnibox) { | 15 | + | const dumbHost = host.toLowerCase(); |
| 424 | - | console.log( | 16 | + | const shouldSkip = |
| 425 | - | `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`, | 17 | + | isDumbProtocol && |
| 426 | - | ); | 18 | + | (href.toLowerCase().startsWith("dumb://home") || |
| 427 | - | } else { | 19 | + | href.toLowerCase().startsWith("dumb://homepage") || |
| 428 | - | console.log( | 20 | + | dumbHost === "home" || |
| 429 | - | `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`, | 21 | + | dumbHost === "homepage"); |
| 430 | - | ); | |||
| 431 | - | // Ensure omnibox is aware of the current pane | |||
| 432 | - | window.__dumber_omnibox.setActive(workspaceHasFocus); | |||
| 433 | - | } | |||
| 434 | 22 | ||||
| 435 | - | console.log( | 23 | + | if (!shouldSkip) { |
| 436 | - | `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`, | 24 | + | bootstrapGUI(); |
| 437 | - | ); | |||
| 438 | - | }; | |||
| 439 | - | } | |||
| 440 | } | 25 | } | ||
M gui/src/injected/main-world.ts +1 -1
| @@ -32,7 +32,7 @@ declare global { | |||
| 32 | 32 | ) => number | void; | |
| 33 | 33 | __dumber_showZoomToast?: (level: number) => void; | |
| 34 | 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | |
| 35 | - | __dumber_webview_id?: string; | |
| 35 | + | __dumber_webview_id?: string | number; | |
| 36 | 36 | __dumber_is_active?: boolean; | |
| 37 | 37 | __dumber_teardown?: () => void; | |
| 38 | 38 | webkit?: { | |
| @@ -32,7 +32,7 @@ declare global { | |||||
| 32 | ) => number | void; | 32 | ) => number | void; | ||
| 33 | __dumber_showZoomToast?: (level: number) => void; | 33 | __dumber_showZoomToast?: (level: number) => void; | ||
| 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | ||
| 35 | - | __dumber_webview_id?: string; | 35 | + | __dumber_webview_id?: string | number; |
| 36 | __dumber_is_active?: boolean; | 36 | __dumber_is_active?: boolean; | ||
| 37 | __dumber_teardown?: () => void; | 37 | __dumber_teardown?: () => void; | ||
| 38 | webkit?: { | 38 | webkit?: { | ||
M gui/src/injected/modules/workspace.ts +4 -459
| @@ -1,463 +1,8 @@ | |||
| 1 | 1 | /** | |
| 2 | - | * Workspace Controller Module | |
| 2 | + | * Workspace UI Notifications Module | |
| 3 | 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. | |
| 4 | + | * Pane mode is now handled entirely in the Go backend with visual border feedback. | |
| 5 | + | * This module is kept for potential future UI enhancements. | |
| 7 | 6 | */ | |
| 8 | 7 | ||
| 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 | - | s: "stack-pane", | |
| 84 | - | x: "close-pane", | |
| 85 | - | enter: "confirm", | |
| 86 | - | escape: "cancel", | |
| 87 | - | }, | |
| 88 | - | }, | |
| 89 | - | tabs: { | |
| 90 | - | newTab: "cmdorctrl+t", | |
| 91 | - | closeTab: "cmdorctrl+w", | |
| 92 | - | nextTab: "cmdorctrl+tab", | |
| 93 | - | previousTab: "cmdorctrl+shift+tab", | |
| 94 | - | }, | |
| 95 | - | popups: { | |
| 96 | - | placement: "right", | |
| 97 | - | openInNewPane: true, | |
| 98 | - | followPaneContext: true, | |
| 99 | - | }, | |
| 100 | - | }; | |
| 101 | - | ||
| 102 | - | function normalizeWorkspaceConfig( | |
| 103 | - | payload?: WorkspaceConfigPayload | null, | |
| 104 | - | ): WorkspaceConfigNormalized { | |
| 105 | - | const paneModePayload = payload?.pane_mode ?? {}; | |
| 106 | - | const tabsPayload = payload?.tabs ?? {}; | |
| 107 | - | const popupsPayload = payload?.popups ?? {}; | |
| 108 | - | ||
| 109 | - | return { | |
| 110 | - | enableZellijControls: | |
| 111 | - | payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls, | |
| 112 | - | paneMode: { | |
| 113 | - | activationShortcut: | |
| 114 | - | paneModePayload.activation_shortcut ?? | |
| 115 | - | DEFAULT_CONFIG.paneMode.activationShortcut, | |
| 116 | - | timeoutMs: | |
| 117 | - | paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs, | |
| 118 | - | actionBindings: { | |
| 119 | - | ...DEFAULT_CONFIG.paneMode.actionBindings, | |
| 120 | - | ...(paneModePayload.action_bindings ?? {}), | |
| 121 | - | }, | |
| 122 | - | }, | |
| 123 | - | tabs: { | |
| 124 | - | newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab, | |
| 125 | - | closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab, | |
| 126 | - | nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab, | |
| 127 | - | previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab, | |
| 128 | - | }, | |
| 129 | - | popups: { | |
| 130 | - | placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement, | |
| 131 | - | openInNewPane: | |
| 132 | - | popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane, | |
| 133 | - | followPaneContext: | |
| 134 | - | popupsPayload.follow_pane_context ?? | |
| 135 | - | DEFAULT_CONFIG.popups.followPaneContext, | |
| 136 | - | }, | |
| 137 | - | }; | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | class WorkspaceController implements WorkspaceRuntime { | |
| 141 | - | private config: WorkspaceConfigNormalized = DEFAULT_CONFIG; | |
| 142 | - | private paneModeActive = false; | |
| 143 | - | private paneModeTimer: ReturnType<typeof setTimeout> | null = null; | |
| 144 | - | private instanceId: string; | |
| 145 | - | private lastActiveCheckTime = 0; | |
| 146 | - | private workspaceHasFocus = false; | |
| 147 | - | private focusEventReceived = false; | |
| 148 | - | ||
| 149 | - | constructor() { | |
| 150 | - | // Generate unique instance ID for this webview | |
| 151 | - | this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`; | |
| 152 | - | console.log( | |
| 153 | - | `[workspace] Created WorkspaceController instance: ${this.instanceId}`, | |
| 154 | - | ); | |
| 155 | - | ||
| 156 | - | document.addEventListener( | |
| 157 | - | "dumber:workspace-focus", | |
| 158 | - | (event: Event) => { | |
| 159 | - | const detail = (event as CustomEvent).detail ?? {}; | |
| 160 | - | this.focusEventReceived = true; | |
| 161 | - | ||
| 162 | - | if (typeof detail?.active === "boolean") { | |
| 163 | - | this.workspaceHasFocus = detail.active; | |
| 164 | - | } else { | |
| 165 | - | this.workspaceHasFocus = detail !== false; | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | if (!this.workspaceHasFocus) { | |
| 169 | - | console.log( | |
| 170 | - | `[workspace] Instance ${this.instanceId} marked inactive via focus event`, | |
| 171 | - | ); | |
| 172 | - | } | |
| 173 | - | }, | |
| 174 | - | { passive: true }, | |
| 175 | - | ); | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | getInstanceId(): string { | |
| 179 | - | return this.instanceId; | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | isActiveInstance(): boolean { | |
| 183 | - | // Cache active check for 50ms to avoid excessive checks | |
| 184 | - | const now = Date.now(); | |
| 185 | - | if (now - this.lastActiveCheckTime < 50) { | |
| 186 | - | return this.workspaceHasFocus; | |
| 187 | - | } | |
| 188 | - | this.lastActiveCheckTime = now; | |
| 189 | - | ||
| 190 | - | if (this.focusEventReceived) { | |
| 191 | - | if (!this.workspaceHasFocus) { | |
| 192 | - | console.log( | |
| 193 | - | `[workspace] Instance ${this.instanceId} not active (focus event state)`, | |
| 194 | - | ); | |
| 195 | - | } | |
| 196 | - | return this.workspaceHasFocus; | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // Check if this webview has focus by attempting to query document.hasFocus() | |
| 200 | - | // In a multi-pane environment, only the focused webview should handle pane mode | |
| 201 | - | try { | |
| 202 | - | const hasFocus = document.hasFocus(); | |
| 203 | - | const isVisible = document.visibilityState === "visible"; | |
| 204 | - | const isActive = hasFocus && isVisible; | |
| 205 | - | ||
| 206 | - | this.workspaceHasFocus = isActive; | |
| 207 | - | ||
| 208 | - | if (!isActive) { | |
| 209 | - | console.log( | |
| 210 | - | `[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`, | |
| 211 | - | ); | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | return isActive; | |
| 215 | - | } catch (error) { | |
| 216 | - | console.warn( | |
| 217 | - | "[workspace] Failed to check active state, assuming active:", | |
| 218 | - | error, | |
| 219 | - | ); | |
| 220 | - | return true; // Fallback to active if check fails | |
| 221 | - | } | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | configure(payload?: WorkspaceConfigPayload | null): void { | |
| 225 | - | const normalized = normalizeWorkspaceConfig(payload); | |
| 226 | - | this.unregisterShortcuts(); | |
| 227 | - | this.config = normalized; | |
| 228 | - | ||
| 229 | - | if (!normalized.enableZellijControls) { | |
| 230 | - | console.log("[workspace] Zellij controls disabled via config"); | |
| 231 | - | window.__dumber_workspace = this.exportRuntime(); | |
| 232 | - | return; | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | this.registerShortcuts(); | |
| 236 | - | window.__dumber_workspace = this.exportRuntime(); | |
| 237 | - | console.log( | |
| 238 | - | "[workspace] Zellij controls enabled with config:", | |
| 239 | - | this.config, | |
| 240 | - | ); | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | private bridge(message: Record<string, unknown>): void { | |
| 244 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 245 | - | if (!bridge || typeof bridge.postMessage !== "function") { | |
| 246 | - | return; | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | try { | |
| 250 | - | bridge.postMessage( | |
| 251 | - | JSON.stringify({ | |
| 252 | - | type: "workspace", | |
| 253 | - | ...message, | |
| 254 | - | }), | |
| 255 | - | ); | |
| 256 | - | } catch (error) { | |
| 257 | - | console.error("[workspace] Failed to post workspace message", error); | |
| 258 | - | } | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | enterPaneMode(): void { | |
| 262 | - | if (!this.config.enableZellijControls) return; | |
| 263 | - | ||
| 264 | - | // Only allow the active instance to enter pane mode | |
| 265 | - | if (!this.isActiveInstance()) { | |
| 266 | - | console.log( | |
| 267 | - | `[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`, | |
| 268 | - | ); | |
| 269 | - | return; | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | console.log(`[workspace] Instance ${this.instanceId} entering pane mode`); | |
| 273 | - | this.paneModeActive = true; | |
| 274 | - | this.restartPaneModeTimer(); | |
| 275 | - | this.emitWorkspaceEvent("pane-mode-entered", { | |
| 276 | - | action: "enter", | |
| 277 | - | config: this.config, | |
| 278 | - | instanceId: this.instanceId, | |
| 279 | - | }); | |
| 280 | - | this.bridge({ event: "pane-mode-entered", instanceId: this.instanceId }); | |
| 281 | - | this.showToast("Pane mode: awaiting direction"); | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | exitPaneMode(reason: string = "exit"): void { | |
| 285 | - | if (!this.paneModeActive) return; | |
| 286 | - | ||
| 287 | - | this.paneModeActive = false; | |
| 288 | - | if (this.paneModeTimer) { | |
| 289 | - | clearTimeout(this.paneModeTimer); | |
| 290 | - | this.paneModeTimer = null; | |
| 291 | - | } | |
| 292 | - | this.bridge({ event: "pane-mode-exited", reason }); | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | isPaneModeActive(): boolean { | |
| 296 | - | return this.paneModeActive; | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | getConfig(): WorkspaceConfigNormalized { | |
| 300 | - | return this.config; | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | private registerShortcuts(): void { | |
| 304 | - | keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [ | |
| 305 | - | { | |
| 306 | - | key: this.config.paneMode.activationShortcut, | |
| 307 | - | handler: () => this.enterPaneMode(), | |
| 308 | - | description: "Enter pane mode", | |
| 309 | - | preventDefault: true, | |
| 310 | - | stopPropagation: true, | |
| 311 | - | whenFocused: false, | |
| 312 | - | }, | |
| 313 | - | ]); | |
| 314 | - | ||
| 315 | - | const paneShortcuts = Object.entries( | |
| 316 | - | this.config.paneMode.actionBindings, | |
| 317 | - | ).map(([key, action]) => ({ | |
| 318 | - | key, | |
| 319 | - | handler: () => this.handlePaneAction(action, key), | |
| 320 | - | description: `Pane action: ${action}`, | |
| 321 | - | preventDefault: true, | |
| 322 | - | stopPropagation: true, | |
| 323 | - | whenFocused: false, | |
| 324 | - | })); | |
| 325 | - | ||
| 326 | - | keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts); | |
| 327 | - | } | |
| 328 | - | ||
| 329 | - | private unregisterShortcuts(): void { | |
| 330 | - | keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID); | |
| 331 | - | keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID); | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | private handlePaneAction(action: string, key?: string): void { | |
| 335 | - | if (!this.paneModeActive) { | |
| 336 | - | return; | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | switch (action) { | |
| 340 | - | case "split-right": | |
| 341 | - | this.emitWorkspaceEvent("pane-split", { | |
| 342 | - | direction: "right", | |
| 343 | - | config: this.config, | |
| 344 | - | }); | |
| 345 | - | this.bridge({ event: "pane-split", direction: "right", action: "split-right" }); | |
| 346 | - | this.showToast("Split pane to the right"); | |
| 347 | - | this.exitPaneMode("split-right"); | |
| 348 | - | break; | |
| 349 | - | case "split-left": | |
| 350 | - | this.emitWorkspaceEvent("pane-split", { | |
| 351 | - | direction: "left", | |
| 352 | - | config: this.config, | |
| 353 | - | }); | |
| 354 | - | this.bridge({ event: "pane-split", direction: "left", action: "split-left" }); | |
| 355 | - | this.showToast("Split pane to the left"); | |
| 356 | - | this.exitPaneMode("split-left"); | |
| 357 | - | break; | |
| 358 | - | case "split-up": | |
| 359 | - | this.emitWorkspaceEvent("pane-split", { | |
| 360 | - | direction: "up", | |
| 361 | - | config: this.config, | |
| 362 | - | }); | |
| 363 | - | this.bridge({ event: "pane-split", direction: "up", action: "split-up" }); | |
| 364 | - | this.showToast("Split pane upwards"); | |
| 365 | - | this.exitPaneMode("split-up"); | |
| 366 | - | break; | |
| 367 | - | case "split-down": | |
| 368 | - | this.emitWorkspaceEvent("pane-split", { | |
| 369 | - | direction: "down", | |
| 370 | - | config: this.config, | |
| 371 | - | }); | |
| 372 | - | this.bridge({ event: "pane-split", direction: "down", action: "split-down" }); | |
| 373 | - | this.showToast("Split pane downwards"); | |
| 374 | - | this.exitPaneMode("split-down"); | |
| 375 | - | break; | |
| 376 | - | case "stack-pane": | |
| 377 | - | this.bridge({ event: "pane-stack", action: "stack-pane" }); | |
| 378 | - | this.showToast("Stacked pane"); | |
| 379 | - | this.exitPaneMode("stack-pane"); | |
| 380 | - | break; | |
| 381 | - | case "close-pane": | |
| 382 | - | this.emitWorkspaceEvent("pane-closed", { key, config: this.config }); | |
| 383 | - | this.bridge({ event: "pane-close", action }); | |
| 384 | - | this.showToast("Closed pane"); | |
| 385 | - | this.exitPaneMode("close-pane"); | |
| 386 | - | break; | |
| 387 | - | case "confirm": | |
| 388 | - | this.bridge({ event: "pane-confirmed" }); | |
| 389 | - | this.showToast("Pane mode confirmed"); | |
| 390 | - | this.exitPaneMode("confirm"); | |
| 391 | - | break; | |
| 392 | - | case "cancel": | |
| 393 | - | this.bridge({ event: "pane-cancelled" }); | |
| 394 | - | this.showToast("Pane mode cancelled"); | |
| 395 | - | this.exitPaneMode("cancel"); | |
| 396 | - | break; | |
| 397 | - | default: | |
| 398 | - | console.warn("[workspace] Unhandled pane action:", action); | |
| 399 | - | } | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | private restartPaneModeTimer(): void { | |
| 403 | - | if (this.paneModeTimer) { | |
| 404 | - | clearTimeout(this.paneModeTimer); | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | if (this.config.paneMode.timeoutMs > 0) { | |
| 408 | - | this.paneModeTimer = setTimeout(() => { | |
| 409 | - | this.showToast("Pane mode timed out"); | |
| 410 | - | this.exitPaneMode("timeout"); | |
| 411 | - | }, this.config.paneMode.timeoutMs); | |
| 412 | - | } | |
| 413 | - | } | |
| 414 | - | ||
| 415 | - | private showToast(message: string): void { | |
| 416 | - | if (typeof window.__dumber_showToast === "function") { | |
| 417 | - | window.__dumber_showToast(message, 2000, "info"); | |
| 418 | - | } else { | |
| 419 | - | console.log("[workspace toast]", message); | |
| 420 | - | } | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | private emitWorkspaceEvent( | |
| 424 | - | event: string, | |
| 425 | - | detail: Record<string, unknown>, | |
| 426 | - | ): void { | |
| 427 | - | document.dispatchEvent( | |
| 428 | - | new CustomEvent(`dumber:workspace-${event}`, { detail }), | |
| 429 | - | ); | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | private exportRuntime(): WorkspaceRuntime { | |
| 433 | - | return { | |
| 434 | - | enterPaneMode: () => this.enterPaneMode(), | |
| 435 | - | exitPaneMode: (reason?: string) => | |
| 436 | - | this.exitPaneMode(reason ?? "external"), | |
| 437 | - | isPaneModeActive: () => this.isPaneModeActive(), | |
| 438 | - | getConfig: () => this.getConfig(), | |
| 439 | - | getInstanceId: () => this.getInstanceId(), | |
| 440 | - | isActiveInstance: () => this.isActiveInstance(), | |
| 441 | - | }; | |
| 442 | - | } | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | const workspaceController = new WorkspaceController(); | |
| 446 | - | let listenersAttached = false; | |
| 447 | - | ||
| 448 | - | export function initializeWorkspace( | |
| 449 | - | payload?: WorkspaceConfigPayload | null, | |
| 450 | - | ): void { | |
| 451 | - | if (!listenersAttached) { | |
| 452 | - | document.addEventListener("dumber:workspace-config", (event: Event) => { | |
| 453 | - | const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail; | |
| 454 | - | workspaceController.configure(detail); | |
| 455 | - | }); | |
| 456 | - | listenersAttached = true; | |
| 457 | - | } | |
| 458 | - | ||
| 459 | - | const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null; | |
| 460 | - | workspaceController.configure(bootstrapPayload); | |
| 461 | - | } | |
| 462 | - | ||
| 463 | - | export { workspaceController }; | |
| 8 | + | console.log("[workspace] UI notification handlers initialized"); | |
| @@ -1,463 +1,8 @@ | |||||
| 1 | /** | 1 | /** | ||
| 2 | - | * Workspace Controller Module | 2 | + | * Workspace UI Notifications Module |
| 3 | * | 3 | * | ||
| 4 | - | * Provides the initial scaffolding for Zellij-inspired pane management. | 4 | + | * Pane mode is now handled entirely in the Go backend with visual border feedback. |
| 5 | - | * Currently tracks pane mode state, exposes configuration to the page, | 5 | + | * This module is kept for potential future UI enhancements. |
| 6 | - | * and wires keyboard service shortcuts based on configuration defaults. | |||
| 7 | */ | 6 | */ | ||
| 8 | 7 | ||||
| 9 | - | import { keyboardService } from "$lib/keyboard"; | 8 | + | console.log("[workspace] UI notification handlers initialized"); |
| 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 | - | s: "stack-pane", | |||
| 84 | - | x: "close-pane", | |||
| 85 | - | enter: "confirm", | |||
| 86 | - | escape: "cancel", | |||
| 87 | - | }, | |||
| 88 | - | }, | |||
| 89 | - | tabs: { | |||
| 90 | - | newTab: "cmdorctrl+t", | |||
| 91 | - | closeTab: "cmdorctrl+w", | |||
| 92 | - | nextTab: "cmdorctrl+tab", | |||
| 93 | - | previousTab: "cmdorctrl+shift+tab", | |||
| 94 | - | }, | |||
| 95 | - | popups: { | |||
| 96 | - | placement: "right", | |||
| 97 | - | openInNewPane: true, | |||
| 98 | - | followPaneContext: true, | |||
| 99 | - | }, | |||
| 100 | - | }; | |||
| 101 | - | ||||
| 102 | - | function normalizeWorkspaceConfig( | |||
| 103 | - | payload?: WorkspaceConfigPayload | null, | |||
| 104 | - | ): WorkspaceConfigNormalized { | |||
| 105 | - | const paneModePayload = payload?.pane_mode ?? {}; | |||
| 106 | - | const tabsPayload = payload?.tabs ?? {}; | |||
| 107 | - | const popupsPayload = payload?.popups ?? {}; | |||
| 108 | - | ||||
| 109 | - | return { | |||
| 110 | - | enableZellijControls: | |||
| 111 | - | payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls, | |||
| 112 | - | paneMode: { | |||
| 113 | - | activationShortcut: | |||
| 114 | - | paneModePayload.activation_shortcut ?? | |||
| 115 | - | DEFAULT_CONFIG.paneMode.activationShortcut, | |||
| 116 | - | timeoutMs: | |||
| 117 | - | paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs, | |||
| 118 | - | actionBindings: { | |||
| 119 | - | ...DEFAULT_CONFIG.paneMode.actionBindings, | |||
| 120 | - | ...(paneModePayload.action_bindings ?? {}), | |||
| 121 | - | }, | |||
| 122 | - | }, | |||
| 123 | - | tabs: { | |||
| 124 | - | newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab, | |||
| 125 | - | closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab, | |||
| 126 | - | nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab, | |||
| 127 | - | previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab, | |||
| 128 | - | }, | |||
| 129 | - | popups: { | |||
| 130 | - | placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement, | |||
| 131 | - | openInNewPane: | |||
| 132 | - | popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane, | |||
| 133 | - | followPaneContext: | |||
| 134 | - | popupsPayload.follow_pane_context ?? | |||
| 135 | - | DEFAULT_CONFIG.popups.followPaneContext, | |||
| 136 | - | }, | |||
| 137 | - | }; | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | class WorkspaceController implements WorkspaceRuntime { | |||
| 141 | - | private config: WorkspaceConfigNormalized = DEFAULT_CONFIG; | |||
| 142 | - | private paneModeActive = false; | |||
| 143 | - | private paneModeTimer: ReturnType<typeof setTimeout> | null = null; | |||
| 144 | - | private instanceId: string; | |||
| 145 | - | private lastActiveCheckTime = 0; | |||
| 146 | - | private workspaceHasFocus = false; | |||
| 147 | - | private focusEventReceived = false; | |||
| 148 | - | ||||
| 149 | - | constructor() { | |||
| 150 | - | // Generate unique instance ID for this webview | |||
| 151 | - | this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`; | |||
| 152 | - | console.log( | |||
| 153 | - | `[workspace] Created WorkspaceController instance: ${this.instanceId}`, | |||
| 154 | - | ); | |||
| 155 | - | ||||
| 156 | - | document.addEventListener( | |||
| 157 | - | "dumber:workspace-focus", | |||
| 158 | - | (event: Event) => { | |||
| 159 | - | const detail = (event as CustomEvent).detail ?? {}; | |||
| 160 | - | this.focusEventReceived = true; | |||
| 161 | - | ||||
| 162 | - | if (typeof detail?.active === "boolean") { | |||
| 163 | - | this.workspaceHasFocus = detail.active; | |||
| 164 | - | } else { | |||
| 165 | - | this.workspaceHasFocus = detail !== false; | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | if (!this.workspaceHasFocus) { | |||
| 169 | - | console.log( | |||
| 170 | - | `[workspace] Instance ${this.instanceId} marked inactive via focus event`, | |||
| 171 | - | ); | |||
| 172 | - | } | |||
| 173 | - | }, | |||
| 174 | - | { passive: true }, | |||
| 175 | - | ); | |||
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | getInstanceId(): string { | |||
| 179 | - | return this.instanceId; | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | isActiveInstance(): boolean { | |||
| 183 | - | // Cache active check for 50ms to avoid excessive checks | |||
| 184 | - | const now = Date.now(); | |||
| 185 | - | if (now - this.lastActiveCheckTime < 50) { | |||
| 186 | - | return this.workspaceHasFocus; | |||
| 187 | - | } | |||
| 188 | - | this.lastActiveCheckTime = now; | |||
| 189 | - | ||||
| 190 | - | if (this.focusEventReceived) { | |||
| 191 | - | if (!this.workspaceHasFocus) { | |||
| 192 | - | console.log( | |||
| 193 | - | `[workspace] Instance ${this.instanceId} not active (focus event state)`, | |||
| 194 | - | ); | |||
| 195 | - | } | |||
| 196 | - | return this.workspaceHasFocus; | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | // Check if this webview has focus by attempting to query document.hasFocus() | |||
| 200 | - | // In a multi-pane environment, only the focused webview should handle pane mode | |||
| 201 | - | try { | |||
| 202 | - | const hasFocus = document.hasFocus(); | |||
| 203 | - | const isVisible = document.visibilityState === "visible"; | |||
| 204 | - | const isActive = hasFocus && isVisible; | |||
| 205 | - | ||||
| 206 | - | this.workspaceHasFocus = isActive; | |||
| 207 | - | ||||
| 208 | - | if (!isActive) { | |||
| 209 | - | console.log( | |||
| 210 | - | `[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`, | |||
| 211 | - | ); | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | return isActive; | |||
| 215 | - | } catch (error) { | |||
| 216 | - | console.warn( | |||
| 217 | - | "[workspace] Failed to check active state, assuming active:", | |||
| 218 | - | error, | |||
| 219 | - | ); | |||
| 220 | - | return true; // Fallback to active if check fails | |||
| 221 | - | } | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | configure(payload?: WorkspaceConfigPayload | null): void { | |||
| 225 | - | const normalized = normalizeWorkspaceConfig(payload); | |||
| 226 | - | this.unregisterShortcuts(); | |||
| 227 | - | this.config = normalized; | |||
| 228 | - | ||||
| 229 | - | if (!normalized.enableZellijControls) { | |||
| 230 | - | console.log("[workspace] Zellij controls disabled via config"); | |||
| 231 | - | window.__dumber_workspace = this.exportRuntime(); | |||
| 232 | - | return; | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | this.registerShortcuts(); | |||
| 236 | - | window.__dumber_workspace = this.exportRuntime(); | |||
| 237 | - | console.log( | |||
| 238 | - | "[workspace] Zellij controls enabled with config:", | |||
| 239 | - | this.config, | |||
| 240 | - | ); | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | private bridge(message: Record<string, unknown>): void { | |||
| 244 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |||
| 245 | - | if (!bridge || typeof bridge.postMessage !== "function") { | |||
| 246 | - | return; | |||
| 247 | - | } | |||
| 248 | - | ||||
| 249 | - | try { | |||
| 250 | - | bridge.postMessage( | |||
| 251 | - | JSON.stringify({ | |||
| 252 | - | type: "workspace", | |||
| 253 | - | ...message, | |||
| 254 | - | }), | |||
| 255 | - | ); | |||
| 256 | - | } catch (error) { | |||
| 257 | - | console.error("[workspace] Failed to post workspace message", error); | |||
| 258 | - | } | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | - | enterPaneMode(): void { | |||
| 262 | - | if (!this.config.enableZellijControls) return; | |||
| 263 | - | ||||
| 264 | - | // Only allow the active instance to enter pane mode | |||
| 265 | - | if (!this.isActiveInstance()) { | |||
| 266 | - | console.log( | |||
| 267 | - | `[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`, | |||
| 268 | - | ); | |||
| 269 | - | return; | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | - | console.log(`[workspace] Instance ${this.instanceId} entering pane mode`); | |||
| 273 | - | this.paneModeActive = true; | |||
| 274 | - | this.restartPaneModeTimer(); | |||
| 275 | - | this.emitWorkspaceEvent("pane-mode-entered", { | |||
| 276 | - | action: "enter", | |||
| 277 | - | config: this.config, | |||
| 278 | - | instanceId: this.instanceId, | |||
| 279 | - | }); | |||
| 280 | - | this.bridge({ event: "pane-mode-entered", instanceId: this.instanceId }); | |||
| 281 | - | this.showToast("Pane mode: awaiting direction"); | |||
| 282 | - | } | |||
| 283 | - | ||||
| 284 | - | exitPaneMode(reason: string = "exit"): void { | |||
| 285 | - | if (!this.paneModeActive) return; | |||
| 286 | - | ||||
| 287 | - | this.paneModeActive = false; | |||
| 288 | - | if (this.paneModeTimer) { | |||
| 289 | - | clearTimeout(this.paneModeTimer); | |||
| 290 | - | this.paneModeTimer = null; | |||
| 291 | - | } | |||
| 292 | - | this.bridge({ event: "pane-mode-exited", reason }); | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | isPaneModeActive(): boolean { | |||
| 296 | - | return this.paneModeActive; | |||
| 297 | - | } | |||
| 298 | - | ||||
| 299 | - | getConfig(): WorkspaceConfigNormalized { | |||
| 300 | - | return this.config; | |||
| 301 | - | } | |||
| 302 | - | ||||
| 303 | - | private registerShortcuts(): void { | |||
| 304 | - | keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [ | |||
| 305 | - | { | |||
| 306 | - | key: this.config.paneMode.activationShortcut, | |||
| 307 | - | handler: () => this.enterPaneMode(), | |||
| 308 | - | description: "Enter pane mode", | |||
| 309 | - | preventDefault: true, | |||
| 310 | - | stopPropagation: true, | |||
| 311 | - | whenFocused: false, | |||
| 312 | - | }, | |||
| 313 | - | ]); | |||
| 314 | - | ||||
| 315 | - | const paneShortcuts = Object.entries( | |||
| 316 | - | this.config.paneMode.actionBindings, | |||
| 317 | - | ).map(([key, action]) => ({ | |||
| 318 | - | key, | |||
| 319 | - | handler: () => this.handlePaneAction(action, key), | |||
| 320 | - | description: `Pane action: ${action}`, | |||
| 321 | - | preventDefault: true, | |||
| 322 | - | stopPropagation: true, | |||
| 323 | - | whenFocused: false, | |||
| 324 | - | })); | |||
| 325 | - | ||||
| 326 | - | keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts); | |||
| 327 | - | } | |||
| 328 | - | ||||
| 329 | - | private unregisterShortcuts(): void { | |||
| 330 | - | keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID); | |||
| 331 | - | keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID); | |||
| 332 | - | } | |||
| 333 | - | ||||
| 334 | - | private handlePaneAction(action: string, key?: string): void { | |||
| 335 | - | if (!this.paneModeActive) { | |||
| 336 | - | return; | |||
| 337 | - | } | |||
| 338 | - | ||||
| 339 | - | switch (action) { | |||
| 340 | - | case "split-right": | |||
| 341 | - | this.emitWorkspaceEvent("pane-split", { | |||
| 342 | - | direction: "right", | |||
| 343 | - | config: this.config, | |||
| 344 | - | }); | |||
| 345 | - | this.bridge({ event: "pane-split", direction: "right", action: "split-right" }); | |||
| 346 | - | this.showToast("Split pane to the right"); | |||
| 347 | - | this.exitPaneMode("split-right"); | |||
| 348 | - | break; | |||
| 349 | - | case "split-left": | |||
| 350 | - | this.emitWorkspaceEvent("pane-split", { | |||
| 351 | - | direction: "left", | |||
| 352 | - | config: this.config, | |||
| 353 | - | }); | |||
| 354 | - | this.bridge({ event: "pane-split", direction: "left", action: "split-left" }); | |||
| 355 | - | this.showToast("Split pane to the left"); | |||
| 356 | - | this.exitPaneMode("split-left"); | |||
| 357 | - | break; | |||
| 358 | - | case "split-up": | |||
| 359 | - | this.emitWorkspaceEvent("pane-split", { | |||
| 360 | - | direction: "up", | |||
| 361 | - | config: this.config, | |||
| 362 | - | }); | |||
| 363 | - | this.bridge({ event: "pane-split", direction: "up", action: "split-up" }); | |||
| 364 | - | this.showToast("Split pane upwards"); | |||
| 365 | - | this.exitPaneMode("split-up"); | |||
| 366 | - | break; | |||
| 367 | - | case "split-down": | |||
| 368 | - | this.emitWorkspaceEvent("pane-split", { | |||
| 369 | - | direction: "down", | |||
| 370 | - | config: this.config, | |||
| 371 | - | }); | |||
| 372 | - | this.bridge({ event: "pane-split", direction: "down", action: "split-down" }); | |||
| 373 | - | this.showToast("Split pane downwards"); | |||
| 374 | - | this.exitPaneMode("split-down"); | |||
| 375 | - | break; | |||
| 376 | - | case "stack-pane": | |||
| 377 | - | this.bridge({ event: "pane-stack", action: "stack-pane" }); | |||
| 378 | - | this.showToast("Stacked pane"); | |||
| 379 | - | this.exitPaneMode("stack-pane"); | |||
| 380 | - | break; | |||
| 381 | - | case "close-pane": | |||
| 382 | - | this.emitWorkspaceEvent("pane-closed", { key, config: this.config }); | |||
| 383 | - | this.bridge({ event: "pane-close", action }); | |||
| 384 | - | this.showToast("Closed pane"); | |||
| 385 | - | this.exitPaneMode("close-pane"); | |||
| 386 | - | break; | |||
| 387 | - | case "confirm": | |||
| 388 | - | this.bridge({ event: "pane-confirmed" }); | |||
| 389 | - | this.showToast("Pane mode confirmed"); | |||
| 390 | - | this.exitPaneMode("confirm"); | |||
| 391 | - | break; | |||
| 392 | - | case "cancel": | |||
| 393 | - | this.bridge({ event: "pane-cancelled" }); | |||
| 394 | - | this.showToast("Pane mode cancelled"); | |||
| 395 | - | this.exitPaneMode("cancel"); | |||
| 396 | - | break; | |||
| 397 | - | default: | |||
| 398 | - | console.warn("[workspace] Unhandled pane action:", action); | |||
| 399 | - | } | |||
| 400 | - | } | |||
| 401 | - | ||||
| 402 | - | private restartPaneModeTimer(): void { | |||
| 403 | - | if (this.paneModeTimer) { | |||
| 404 | - | clearTimeout(this.paneModeTimer); | |||
| 405 | - | } | |||
| 406 | - | ||||
| 407 | - | if (this.config.paneMode.timeoutMs > 0) { | |||
| 408 | - | this.paneModeTimer = setTimeout(() => { | |||
| 409 | - | this.showToast("Pane mode timed out"); | |||
| 410 | - | this.exitPaneMode("timeout"); | |||
| 411 | - | }, this.config.paneMode.timeoutMs); | |||
| 412 | - | } | |||
| 413 | - | } | |||
| 414 | - | ||||
| 415 | - | private showToast(message: string): void { | |||
| 416 | - | if (typeof window.__dumber_showToast === "function") { | |||
| 417 | - | window.__dumber_showToast(message, 2000, "info"); | |||
| 418 | - | } else { | |||
| 419 | - | console.log("[workspace toast]", message); | |||
| 420 | - | } | |||
| 421 | - | } | |||
| 422 | - | ||||
| 423 | - | private emitWorkspaceEvent( | |||
| 424 | - | event: string, | |||
| 425 | - | detail: Record<string, unknown>, | |||
| 426 | - | ): void { | |||
| 427 | - | document.dispatchEvent( | |||
| 428 | - | new CustomEvent(`dumber:workspace-${event}`, { detail }), | |||
| 429 | - | ); | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | - | private exportRuntime(): WorkspaceRuntime { | |||
| 433 | - | return { | |||
| 434 | - | enterPaneMode: () => this.enterPaneMode(), | |||
| 435 | - | exitPaneMode: (reason?: string) => | |||
| 436 | - | this.exitPaneMode(reason ?? "external"), | |||
| 437 | - | isPaneModeActive: () => this.isPaneModeActive(), | |||
| 438 | - | getConfig: () => this.getConfig(), | |||
| 439 | - | getInstanceId: () => this.getInstanceId(), | |||
| 440 | - | isActiveInstance: () => this.isActiveInstance(), | |||
| 441 | - | }; | |||
| 442 | - | } | |||
| 443 | - | } | |||
| 444 | - | ||||
| 445 | - | const workspaceController = new WorkspaceController(); | |||
| 446 | - | let listenersAttached = false; | |||
| 447 | - | ||||
| 448 | - | export function initializeWorkspace( | |||
| 449 | - | payload?: WorkspaceConfigPayload | null, | |||
| 450 | - | ): void { | |||
| 451 | - | if (!listenersAttached) { | |||
| 452 | - | document.addEventListener("dumber:workspace-config", (event: Event) => { | |||
| 453 | - | const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail; | |||
| 454 | - | workspaceController.configure(detail); | |||
| 455 | - | }); | |||
| 456 | - | listenersAttached = true; | |||
| 457 | - | } | |||
| 458 | - | ||||
| 459 | - | const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null; | |||
| 460 | - | workspaceController.configure(bootstrapPayload); | |||
| 461 | - | } | |||
| 462 | - | ||||
| 463 | - | export { workspaceController }; | |||
M gui/src/pages/homepage.ts +3 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | import { mount } from "svelte"; | |
| 2 | 2 | import "../styles/tailwind.css"; | |
| 3 | 3 | import Homepage from "./Homepage.svelte"; | |
| 4 | + | import { bootstrapGUI } from "../injected/bootstrap"; | |
| 4 | 5 | ||
| 5 | 6 | // Proactively unregister any Service Workers and clear caches that may have been | |
| 6 | 7 | // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to | |
| @@ -25,6 +26,8 @@ async function cleanupServiceWorkersAndCaches() { | |||
| 25 | 26 | } | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 29 | + | bootstrapGUI(); | |
| 30 | + | ||
| 28 | 31 | // Mount the Homepage component to the DOM | |
| 29 | 32 | cleanupServiceWorkersAndCaches().finally(() => { | |
| 30 | 33 | console.log("[dumber] Mounting Homepage component to document.body"); | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | import { mount } from "svelte"; | 1 | import { mount } from "svelte"; | ||
| 2 | import "../styles/tailwind.css"; | 2 | import "../styles/tailwind.css"; | ||
| 3 | import Homepage from "./Homepage.svelte"; | 3 | import Homepage from "./Homepage.svelte"; | ||
| 4 | + | import { bootstrapGUI } from "../injected/bootstrap"; | |||
| 4 | 5 | ||||
| 5 | // Proactively unregister any Service Workers and clear caches that may have been | 6 | // Proactively unregister any Service Workers and clear caches that may have been | ||
| 6 | // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to | 7 | // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to | ||
| @@ -25,6 +26,8 @@ async function cleanupServiceWorkersAndCaches() { | |||||
| 25 | } | 26 | } | ||
| 26 | } | 27 | } | ||
| 27 | 28 | ||||
| 29 | + | bootstrapGUI(); | |||
| 30 | + | ||||
| 28 | // Mount the Homepage component to the DOM | 31 | // Mount the Homepage component to the DOM | ||
| 29 | cleanupServiceWorkersAndCaches().finally(() => { | 32 | cleanupServiceWorkersAndCaches().finally(() => { | ||
| 30 | console.log("[dumber] Mounting Homepage component to document.body"); | 33 | console.log("[dumber] Mounting Homepage component to document.body"); | ||