detached 85ff923

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 @@
11 /**
2- * Unified GUI Bundle Entry Point
2+ * Unified GUI bundle entry point.
33 *
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.
66 */
77
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";
199
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 || "";
42113
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");
43422
435- console.log(
436- `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`,
437- );
438- };
439- }
23+if (!shouldSkip) {
24+ bootstrapGUI();
44025 }
@@ -1,440 +1,25 @@
1 /**1 /**
2- * Unified GUI Bundle Entry Point2+ * 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 modules8+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 system10+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 pane14+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 {
3232 ) => number | void;
3333 __dumber_showZoomToast?: (level: number) => void;
3434 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35- __dumber_webview_id?: string;
35+ __dumber_webview_id?: string | number;
3636 __dumber_is_active?: boolean;
3737 __dumber_teardown?: () => void;
3838 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 @@
11 /**
2- * Workspace Controller Module
2+ * Workspace UI Notifications Module
33 *
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.
76 */
87
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 Module2+ * 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 @@
11 import { mount } from "svelte";
22 import "../styles/tailwind.css";
33 import Homepage from "./Homepage.svelte";
4+import { bootstrapGUI } from "../injected/bootstrap";
45
56 // Proactively unregister any Service Workers and clear caches that may have been
67 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to
@@ -25,6 +26,8 @@ async function cleanupServiceWorkersAndCaches() {
2526 }
2627 }
2728
29+bootstrapGUI();
30+
2831 // Mount the Homepage component to the DOM
2932 cleanupServiceWorkersAndCaches().finally(() => {
3033 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 been6 // Proactively unregister any Service Workers and clear caches that may have been
6 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to7 // 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 DOM31 // 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");

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close