detached cb5e838

Merge branch 'featt/zellij-pane-enhancement'

commit
cb5e838307900ae564ce013162f4c2f480f67305
author
bamen · · 2025-09-22 18:41 UTC
merge of
11c5027 + 522f04d · diff against the first parent
signature
unsigned
68 files · +4361 -1678
M CHANGELOG.md +11 -0
@@ -12,6 +12,10 @@ All notable changes to this project will be documented in this file.
1212 - **Configurable workspace styling**: Pane border appearance (width, color, transition duration, radius) now customizable via config with proper viewport stability
1313 - **Popup handling for tiling WM**: Complete popup window support where popups open as workspace panes instead of floating windows, respecting tiling window manager design principles
1414 - **Universal popup auto-close**: Popup windows (OAuth, window.open(), etc.) automatically close when JavaScript calls window.close(), providing standard browser behavior adapted for workspace panes
15+- **Advanced popup deduplication system**: Intelligent duplicate popup prevention with SHA256 fingerprinting and 200ms debounce window to eliminate popup spam
16+- **OAuth auto-close integration**: RFC 6749 compliant OAuth callback detection with automatic popup closure for seamless authentication flows
17+- **Cross-WebView popup communication**: localStorage-based parent-popup bridge enabling proper `window.opener` functionality in tiling workspace environment
18+- **WebView identity tracking**: Unique WebView ID system for precise popup targeting and enhanced debugging capabilities
1519
1620 ### Improved
1721 - WebKit integration now forces GPU compositing by default while still honoring explicit config overrides.
@@ -26,6 +30,13 @@ All notable changes to this project will be documented in this file.
2630 - Omnibox overlay now self-heals if hostile pages remove the injected shadow host, ensuring Ctrl/Cmd+L works everywhere.
2731 - Navigation shortcuts changed from Alt+Arrow to Ctrl/Cmd+Arrow for better workspace compatibility.
2832 - GUI components now support workspace-aware focus management with pane-specific event handling.
33+- **Global zoom shortcuts**: Unified zoom handling at window level (Ctrl+/=/0/-) ensuring zoom applies to currently active pane in multi-pane workspace
34+- **Workspace focus management**: Enhanced focus throttling (100ms) and active state tracking to prevent infinite loops and conflicts between panes
35+- **Popup management**: Intelligent popup vs tab detection based on window features, with improved auto-close logic for OAuth and login flows
36+- **Console logging**: WebView ID context injection for better debugging and log correlation across multiple panes
37+
38+### Changed
39+- **Zoom shortcuts architecture**: Moved from per-WebView registration to centralized window-level handling for consistency with other global shortcuts
2940
3041 ## [0.8.0] - 2025-09-17
3142
M Makefile +2 -2
@@ -43,8 +43,8 @@ build: build-frontend ## Build the application with GUI (frontend assets, then G
4343 $(GOENV) CGO_ENABLED=1 go build -p $(NPROCS) $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
4444 @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4545
46-build-frontend: ## Build Svelte GUI with Tailwind CSS
47- @echo "Building Svelte GUI with Tailwind CSS..."
46+build-frontend: ## Build Svelte GUI with Tailwind CSS and main-world script
47+ @echo "Building Svelte GUI with Tailwind CSS and main-world script..."
4848 @cd gui && npm install --silent && npm run build
4949 @echo "GUI build complete"
5050
A frontend/src/types/generated.ts +83 -0
@@ -0,0 +1,83 @@
1+// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
2+// Auto-generated TypeScript types from Go structs
3+// Generated on: 2025-09-22T12:41:27.395Z
4+// Generator script: scripts/generate-types.js
5+// Source files: internal/db/models.go, internal/parser/types.go
6+// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
7+
8+export interface CertificateValidation {
9+ id: number;
10+ hostname: string;
11+ certificate_hash: string;
12+ user_decision: string;
13+ created_at: string | null;
14+ expires_at: string | null;
15+}
16+
17+export interface History {
18+ id: number;
19+ url: string;
20+ title: string | null;
21+ visit_count: number | null;
22+ last_visited: string | null;
23+ created_at: string | null;
24+ favicon_url: string | null;
25+}
26+
27+export interface Shortcut {
28+ id: number;
29+ shortcut: string;
30+ url_template: string;
31+ description: string | null;
32+ created_at: string | null;
33+}
34+
35+export interface ZoomLevel {
36+ domain: string;
37+ zoom_factor: number;
38+ updated_at: string | null;
39+}
40+
41+export interface ParseResult {
42+ type: InputType;
43+ url: string;
44+ query: string;
45+ confidence: number;
46+ fuzzy_matches?: FuzzyMatch[];
47+ shortcut?: DetectedShortcut;
48+ processing_time: number;
49+}
50+
51+export interface DetectedShortcut {
52+ key: string;
53+ query: string;
54+ url: string;
55+ description: string;
56+}
57+
58+export interface FuzzyMatch {
59+ history_entry?: History;
60+ score: number;
61+ url_score: number;
62+ title_score: number;
63+ recency_score: number;
64+ visit_score: number;
65+ matched_field: string;
66+}
67+
68+export interface FuzzyConfig {
69+ min_similarity_threshold: number;
70+ max_results: number;
71+ url_weight: number;
72+ title_weight: number;
73+ recency_weight: number;
74+ visit_weight: number;
75+ recency_decay_days: number;
76+}
77+
78+// Manual types and enums
79+
80+// Type aliases for frontend usage
81+export type HistoryEntry = History;
82+export type SearchShortcut = Shortcut;
83+export type ZoomEntry = ZoomSetting;
M gui/eslint.config.js +23 -20
@@ -1,35 +1,38 @@
1-import js from '@eslint/js';
2-import tseslint from 'typescript-eslint';
1+import js from "@eslint/js";
2+import tseslint from "typescript-eslint";
33
44 export default tseslint.config(
55 js.configs.recommended,
66 ...tseslint.configs.recommended,
77 {
8- files: ['**/*.{js,mjs,cjs,ts}'],
8+ files: ["**/*.{js,mjs,cjs,ts}"],
99 languageOptions: {
1010 ecmaVersion: 2024,
11- sourceType: 'module',
11+ sourceType: "module",
1212 },
1313 rules: {
1414 // Modern TypeScript best practices
15- '@typescript-eslint/no-unused-vars': ['error', {
16- 'argsIgnorePattern': '^_',
17- 'varsIgnorePattern': '^_'
18- }],
19- '@typescript-eslint/no-explicit-any': 'warn',
20- '@typescript-eslint/no-empty-function': 'off',
21- '@typescript-eslint/consistent-type-imports': 'error',
22- '@typescript-eslint/no-import-type-side-effects': 'error',
23-
15+ "@typescript-eslint/no-unused-vars": [
16+ "error",
17+ {
18+ argsIgnorePattern: "^_",
19+ varsIgnorePattern: "^_",
20+ },
21+ ],
22+ "@typescript-eslint/no-explicit-any": "warn",
23+ "@typescript-eslint/no-empty-function": "off",
24+ "@typescript-eslint/consistent-type-imports": "error",
25+ "@typescript-eslint/no-import-type-side-effects": "error",
26+
2427 // Modern JavaScript/TypeScript patterns
25- 'prefer-const': 'error',
26- 'no-var': 'error',
27- 'object-shorthand': 'error',
28- 'prefer-template': 'error',
28+ "prefer-const": "error",
29+ "no-var": "error",
30+ "object-shorthand": "error",
31+ "prefer-template": "error",
2932 },
3033 },
3134 {
32- files: ['**/*.js', '**/*.mjs'],
35+ files: ["**/*.js", "**/*.mjs"],
3336 ...tseslint.configs.disableTypeChecked,
34- }
35-);⏎̸
37+ },
38+);
M gui/package.json +3 -1
@@ -4,13 +4,15 @@
44 "private": true,
55 "type": "module",
66 "scripts": {
7- "build": "npm run build:color-scheme && npm run build:gui && npm run build:homepage",
7+ "build": "npm run build:color-scheme && npm run build:gui && npm run build:homepage && npm run build:main-world",
88 "build:gui": "vite build --config vite.config.gui.ts",
99 "build:homepage": "vite build --config vite.config.homepage.ts",
1010 "build:color-scheme": "vite build --config vite.config.color-scheme.ts",
11+ "build:main-world": "vite build --config vite.config.main-world.ts",
1112 "dev": "npm run build:gui -- --watch",
1213 "dev:homepage": "npm run build:homepage -- --watch",
1314 "dev:color-scheme": "npm run build:color-scheme -- --watch",
15+ "dev:main-world": "npm run build:main-world -- --watch",
1416 "check": "svelte-check",
1517 "lint": "eslint src --fix",
1618 "fmt": "prettier --write .",
M gui/postcss.config.js +2 -2
@@ -1,6 +1,6 @@
11 export default {
22 plugins: {
3- '@tailwindcss/postcss': {},
3+ "@tailwindcss/postcss": {},
44 autoprefixer: {},
55 },
6-};⏎̸
6+};
M gui/src/components/omnibox/Omnibox.svelte +9 -0
@@ -284,6 +284,15 @@
284284 } catch (error) {
285285 console.error('Error in findQuery:', error);
286286 }
287+ },
288+ setActive: (active) => {
289+ try {
290+ console.log('🎯 Omnibox setActive called with:', active);
291+ // For now, this is a no-op but maintains API compatibility
292+ // In the future, this could control focus management or visibility
293+ } catch (error) {
294+ console.error('Error in setActive:', error);
295+ }
287296 }
288297 };
289298
M gui/src/components/omnibox/find.ts +65 -55
@@ -4,8 +4,8 @@
44 * Text search and highlighting implementation
55 */
66
7-import type { FindMatch, HighlightNode } from './types';
8-import { omniboxStore } from './stores.svelte.ts';
7+import type { FindMatch, HighlightNode } from "./types";
8+import { omniboxStore } from "./stores.svelte.ts";
99
1010 // Track whether we've injected highlight styles into the main document.
1111 let findStylesInjected = false;
@@ -16,8 +16,8 @@ function ensureFindStyles(): void {
1616 }
1717
1818 try {
19- const style = document.createElement('style');
20- style.id = 'dumber-find-styles';
19+ const style = document.createElement("style");
20+ style.id = "dumber-find-styles";
2121 style.textContent = `
2222 .find-highlight {
2323 background-color: rgb(255 235 59);
@@ -37,7 +37,7 @@ function ensureFindStyles(): void {
3737 (document.head || document.documentElement).appendChild(style);
3838 findStylesInjected = true;
3939 } catch (error) {
40- console.warn('Failed to inject find styles:', error);
40+ console.warn("Failed to inject find styles:", error);
4141 }
4242 }
4343
@@ -48,7 +48,7 @@ export function findInPage(query: string): void {
4848 // Clear previous highlights
4949 omniboxStore.clearHighlights();
5050
51- const trimmedQuery = (query || '').trim();
51+ const trimmedQuery = (query || "").trim();
5252 if (!trimmedQuery) {
5353 return;
5454 }
@@ -61,75 +61,83 @@ export function findInPage(query: string): void {
6161 ensureFindStyles();
6262
6363 // Get omnibox root to exclude from search
64- const omniboxRoot = document.getElementById('dumber-omnibox-root');
64+ const omniboxRoot = document.getElementById("dumber-omnibox-root");
6565 const matches: FindMatch[] = [];
6666 const maxMatches = omniboxStore.config.maxMatches;
6767
6868 // Create tree walker to find text nodes
69- const walker = document.createTreeWalker(
70- body,
71- NodeFilter.SHOW_TEXT,
72- {
73- acceptNode(node: Text): number {
74- // Skip empty or whitespace-only nodes
75- if (!node.nodeValue || !node.nodeValue.trim()) {
76- return NodeFilter.FILTER_REJECT;
77- }
78-
79- const parentElement = node.parentElement;
80- if (!parentElement) {
81- return NodeFilter.FILTER_REJECT;
82- }
83-
84- // Skip omnibox content
85- if (omniboxRoot && omniboxRoot.contains(parentElement)) {
86- return NodeFilter.FILTER_REJECT;
87- }
88-
89- // Skip script, style, and noscript tags
90- const tagName = parentElement.tagName;
91- if (tagName === 'SCRIPT' || tagName === 'STYLE' || tagName === 'NOSCRIPT') {
92- return NodeFilter.FILTER_REJECT;
93- }
94-
95- // Skip hidden elements
96- const computedStyle = getComputedStyle(parentElement);
97- if (computedStyle.visibility === 'hidden' || computedStyle.display === 'none') {
98- return NodeFilter.FILTER_REJECT;
99- }
100-
101- return NodeFilter.FILTER_ACCEPT;
69+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
70+ acceptNode(node: Text): number {
71+ // Skip empty or whitespace-only nodes
72+ if (!node.nodeValue || !node.nodeValue.trim()) {
73+ return NodeFilter.FILTER_REJECT;
10274 }
103- }
104- );
75+
76+ const parentElement = node.parentElement;
77+ if (!parentElement) {
78+ return NodeFilter.FILTER_REJECT;
79+ }
80+
81+ // Skip omnibox content
82+ if (omniboxRoot && omniboxRoot.contains(parentElement)) {
83+ return NodeFilter.FILTER_REJECT;
84+ }
85+
86+ // Skip script, style, and noscript tags
87+ const tagName = parentElement.tagName;
88+ if (
89+ tagName === "SCRIPT" ||
90+ tagName === "STYLE" ||
91+ tagName === "NOSCRIPT"
92+ ) {
93+ return NodeFilter.FILTER_REJECT;
94+ }
95+
96+ // Skip hidden elements
97+ const computedStyle = getComputedStyle(parentElement);
98+ if (
99+ computedStyle.visibility === "hidden" ||
100+ computedStyle.display === "none"
101+ ) {
102+ return NodeFilter.FILTER_REJECT;
103+ }
104+
105+ return NodeFilter.FILTER_ACCEPT;
106+ },
107+ });
105108
106109 const queryLowerCase = trimmedQuery.toLowerCase();
107110
108111 // Process each text node
109112 while (walker.nextNode() && matches.length < maxMatches) {
110113 const textNode = walker.currentNode as Text;
111- let nodeValue = textNode.nodeValue || '';
114+ let nodeValue = textNode.nodeValue || "";
112115 let searchIndex = 0;
113116
114117 // Find all matches in this text node
115118 while (nodeValue && matches.length < maxMatches) {
116- const matchIndex = nodeValue.toLowerCase().indexOf(queryLowerCase, searchIndex);
119+ const matchIndex = nodeValue
120+ .toLowerCase()
121+ .indexOf(queryLowerCase, searchIndex);
117122 if (matchIndex === -1) {
118123 break;
119124 }
120125
121126 // Create text nodes for before, match, and after
122127 const beforeText = nodeValue.slice(0, matchIndex);
123- const matchText = nodeValue.slice(matchIndex, matchIndex + trimmedQuery.length);
128+ const matchText = nodeValue.slice(
129+ matchIndex,
130+ matchIndex + trimmedQuery.length,
131+ );
124132 const afterText = nodeValue.slice(matchIndex + trimmedQuery.length);
125133
126134 // Create elements
127135 const beforeNode = document.createTextNode(beforeText);
128136 const matchTextNode = document.createTextNode(matchText);
129- const highlightSpan = document.createElement('span');
137+ const highlightSpan = document.createElement("span");
130138
131139 // Style the highlight span
132- highlightSpan.className = 'find-highlight';
140+ highlightSpan.className = "find-highlight";
133141 highlightSpan.appendChild(matchTextNode);
134142
135143 // Insert into DOM
@@ -145,7 +153,7 @@ export function findInPage(query: string): void {
145153 // Track highlight node for cleanup
146154 const highlightNode: HighlightNode = {
147155 span: highlightSpan,
148- text: matchTextNode
156+ text: matchTextNode,
149157 };
150158 omniboxStore.addHighlightNode(highlightNode);
151159
@@ -160,13 +168,13 @@ export function findInPage(query: string): void {
160168 }
161169
162170 const context = (leftContext + matchText + rightContext)
163- .replace(/\s+/g, ' ')
171+ .replace(/\s+/g, " ")
164172 .trim();
165173
166174 // Add to matches
167175 const findMatch: FindMatch = {
168176 element: highlightSpan,
169- context
177+ context,
170178 };
171179 matches.push(findMatch);
172180
@@ -199,21 +207,21 @@ export function revealMatch(index: number): void {
199207 if (prevActiveIndex >= 0 && matches[prevActiveIndex]) {
200208 const prevElement = matches[prevActiveIndex].element;
201209 if (prevElement.classList) {
202- prevElement.classList.remove('find-active');
210+ prevElement.classList.remove("find-active");
203211 }
204212 }
205213
206214 // Add active class to current match
207215 const currentMatch = matches[index];
208216 if (currentMatch && currentMatch.element.classList) {
209- currentMatch.element.classList.add('find-active');
217+ currentMatch.element.classList.add("find-active");
210218 omniboxStore.setActiveIndex(index);
211219
212220 // Scroll into view
213221 try {
214222 currentMatch.element.scrollIntoView({
215- block: 'center',
216- inline: 'nearest'
223+ block: "center",
224+ inline: "nearest",
217225 });
218226 } catch {
219227 currentMatch.element.scrollIntoView();
@@ -231,7 +239,9 @@ export function jumpToMatch(delta: number): void {
231239 }
232240
233241 const currentIndex = omniboxStore.selectedIndex || 0;
234- const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
242+ const newIndex =
243+ (((currentIndex + delta) % matches.length) + matches.length) %
244+ matches.length;
235245
236246 omniboxStore.setSelectedIndex(newIndex);
237247 revealMatch(newIndex);
M gui/src/components/omnibox/index.ts +8 -8
@@ -4,13 +4,13 @@
44 * Main entry point for omnibox functionality
55 */
66
7-export { default as Omnibox } from './Omnibox.svelte';
8-export { default as OmniboxInput } from './OmniboxInput.svelte';
9-export { default as OmniboxSuggestions } from './OmniboxSuggestions.svelte';
10-export { default as OmniboxFind } from './OmniboxFind.svelte';
7+export { default as Omnibox } from "./Omnibox.svelte";
8+export { default as OmniboxInput } from "./OmniboxInput.svelte";
9+export { default as OmniboxSuggestions } from "./OmniboxSuggestions.svelte";
10+export { default as OmniboxFind } from "./OmniboxFind.svelte";
1111
12-export { omniboxStore } from './stores.svelte.ts';
13-export { omniboxBridge } from './messaging';
14-export { findInPage, revealMatch, jumpToMatch } from './find';
12+export { omniboxStore } from "./stores.svelte.ts";
13+export { omniboxBridge } from "./messaging";
14+export { findInPage, revealMatch, jumpToMatch } from "./find";
1515
16-export type * from './types';⏎̸
16+export type * from "./types";
M gui/src/components/omnibox/messaging.ts +20 -16
@@ -4,33 +4,36 @@
44 * TypeScript interface for Go-JavaScript communication
55 */
66
7-import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from './types';
8-import { omniboxStore } from './stores.svelte.ts';
7+import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from "./types";
8+import { omniboxStore } from "./stores.svelte.ts";
99
1010 export class OmniboxBridge implements OmniboxMessageBridge {
1111 // Forward messages to the native WebKit message handler when available
1212 // Fallback: for navigate, perform direct location change
1313 postMessage(msg: OmniboxMessage): void {
14- console.log('📤 [DEBUG] Posting message to backend:', msg);
14+ console.log("📤 [DEBUG] Posting message to backend:", msg);
1515 // NOTE: Do NOT detach postMessage from its receiver. In WKWebView,
1616 // postMessage must be called on the UserMessageHandler instance.
1717 const bridge = window.webkit?.messageHandlers?.dumber;
18- if (bridge && typeof bridge.postMessage === 'function') {
18+ if (bridge && typeof bridge.postMessage === "function") {
1919 try {
20- console.log('📱 [DEBUG] Using webkit message handler');
20+ console.log("📱 [DEBUG] Using webkit message handler");
2121 bridge.postMessage(JSON.stringify(msg));
2222 return;
2323 } catch (e) {
24- console.warn('postMessage to native handler failed, using fallback:', e);
24+ console.warn(
25+ "postMessage to native handler failed, using fallback:",
26+ e,
27+ );
2528 }
2629 }
27- console.log('⚠️ [DEBUG] No webkit bridge available');
30+ console.log("⚠️ [DEBUG] No webkit bridge available");
2831 // Fallback navigation if no native bridge is available
29- if (msg.type === 'navigate' && typeof msg.url === 'string' && msg.url) {
32+ if (msg.type === "navigate" && typeof msg.url === "string" && msg.url) {
3033 try {
3134 window.location.href = msg.url;
3235 } catch (e) {
33- console.error('Fallback navigation failed:', e);
36+ console.error("Fallback navigation failed:", e);
3437 }
3538 }
3639 }
@@ -39,7 +42,7 @@ export class OmniboxBridge implements OmniboxMessageBridge {
3942 * Update suggestions from Go backend
4043 */
4144 setSuggestions(suggestions: Suggestion[]): void {
42- console.log('📝 [DEBUG] Received suggestions from backend:', suggestions);
45+ console.log("📝 [DEBUG] Received suggestions from backend:", suggestions);
4346 omniboxStore.updateSuggestions(suggestions);
4447 }
4548
@@ -47,19 +50,19 @@ export class OmniboxBridge implements OmniboxMessageBridge {
4750 * Handle navigation request
4851 */
4952 navigate(url: string): void {
50- console.log('🚀 Omnibox navigate called with:', url);
51- this.postMessage({ type: 'navigate', url });
53+ console.log("🚀 Omnibox navigate called with:", url);
54+ this.postMessage({ type: "navigate", url });
5255 }
5356
5457 /**
5558 * Handle search query
5659 */
5760 query(searchTerm: string, limit?: number): void {
58- const q = searchTerm ?? '';
61+ const q = searchTerm ?? "";
5962 const lim = limit || omniboxStore.config.defaultLimit;
60- console.log('🔍 [DEBUG] Sending query to backend:', { q, limit: lim });
63+ console.log("🔍 [DEBUG] Sending query to backend:", { q, limit: lim });
6164 // Send to native handler; Go will compute suggestions and call setSuggestions
62- this.postMessage({ type: 'query', q, limit: lim });
65+ this.postMessage({ type: "query", q, limit: lim });
6366 }
6467
6568 // Suggestions are returned via setSuggestions() when native handler responds
@@ -98,6 +101,7 @@ declare global {
98101 open: (mode?: string, query?: string) => void;
99102 close: () => void;
100103 findQuery: (query: string) => void;
104+ setActive: (active: boolean) => void;
101105 };
102106 }
103-}⏎̸
107+}
M gui/src/components/omnibox/stores.svelte.ts +67 -39
@@ -9,27 +9,27 @@ import type {
99 Suggestion,
1010 FindMatch,
1111 HighlightNode,
12- OmniboxConfig
13-} from './types';
12+ OmniboxConfig,
13+} from "./types";
1414
1515 // Default configuration
1616 const DEFAULT_CONFIG: Required<OmniboxConfig> = {
1717 maxMatches: 2000,
1818 debounceDelay: 120,
19- defaultLimit: 10
19+ defaultLimit: 10,
2020 };
2121
2222 // Global state using Svelte 5 runes
2323 let visible = $state(false);
24-let mode = $state<OmniboxMode>('omnibox');
24+let mode = $state<OmniboxMode>("omnibox");
2525 let suggestions = $state<Suggestion[]>([]);
2626 let matches = $state<FindMatch[]>([]);
2727 let selectedIndex = $state(-1);
2828 let activeIndex = $state(-1);
2929 let faded = $state(false);
30-let inputValue = $state('');
30+let inputValue = $state("");
3131 let highlightNodes = $state<HighlightNode[]>([]);
32-let prevOverflow = $state('');
32+let prevOverflow = $state("");
3333 let debounceTimer = $state(0);
3434
3535 // Configuration
@@ -37,29 +37,55 @@ let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
3737
3838 export const omniboxStore = {
3939 // State getters
40- get visible() { return visible; },
41- get mode() { return mode; },
42- get suggestions() { return suggestions; },
43- get matches() { return matches; },
44- get selectedIndex() { return selectedIndex; },
45- get activeIndex() { return activeIndex; },
46- get faded() { return faded; },
47- get inputValue() { return inputValue; },
48- get highlightNodes() { return highlightNodes; },
49- get prevOverflow() { return prevOverflow; },
50- get config() { return config; },
40+ get visible() {
41+ return visible;
42+ },
43+ get mode() {
44+ return mode;
45+ },
46+ get suggestions() {
47+ return suggestions;
48+ },
49+ get matches() {
50+ return matches;
51+ },
52+ get selectedIndex() {
53+ return selectedIndex;
54+ },
55+ get activeIndex() {
56+ return activeIndex;
57+ },
58+ get faded() {
59+ return faded;
60+ },
61+ get inputValue() {
62+ return inputValue;
63+ },
64+ get highlightNodes() {
65+ return highlightNodes;
66+ },
67+ get prevOverflow() {
68+ return prevOverflow;
69+ },
70+ get config() {
71+ return config;
72+ },
5173
5274 // Computed getters
5375 get hasContent() {
54- return (mode === 'omnibox' && suggestions.length > 0) ||
55- (mode === 'find' && matches.length > 0);
76+ return (
77+ (mode === "omnibox" && suggestions.length > 0) ||
78+ (mode === "find" && matches.length > 0)
79+ );
5680 },
5781 get totalItems() {
58- return mode === 'omnibox' ? suggestions.length : matches.length;
82+ return mode === "omnibox" ? suggestions.length : matches.length;
5983 },
6084 get selectedItem() {
6185 if (selectedIndex < 0) return null;
62- return mode === 'omnibox' ? suggestions[selectedIndex] : matches[selectedIndex];
86+ return mode === "omnibox"
87+ ? suggestions[selectedIndex]
88+ : matches[selectedIndex];
6389 },
6490
6591 // Actions
@@ -67,14 +93,14 @@ export const omniboxStore = {
6793 if (value && !visible) {
6894 // Store scroll state when opening
6995 prevOverflow = document.documentElement.style.overflow;
70- document.documentElement.style.overflow = 'hidden';
96+ document.documentElement.style.overflow = "hidden";
7197 } else if (!value && visible) {
7298 // Restore scroll state when closing
73- document.documentElement.style.overflow = prevOverflow || '';
74- prevOverflow = '';
99+ document.documentElement.style.overflow = prevOverflow || "";
100+ prevOverflow = "";
75101
76102 // Clear find highlights when closing
77- if (mode === 'find') {
103+ if (mode === "find") {
78104 this.clearHighlights();
79105 }
80106 }
@@ -83,10 +109,10 @@ export const omniboxStore = {
83109
84110 setMode(newMode: OmniboxMode) {
85111 // Clear previous mode's state
86- if (newMode === 'omnibox') {
112+ if (newMode === "omnibox") {
87113 // Switching to omnibox mode - clear find state
88114 this.clearHighlights();
89- } else if (newMode === 'find') {
115+ } else if (newMode === "find") {
90116 // Switching to find mode - clear suggestions
91117 this.updateSuggestions([]);
92118 }
@@ -94,7 +120,7 @@ export const omniboxStore = {
94120 mode = newMode;
95121 selectedIndex = -1;
96122 faded = false;
97- inputValue = ''; // Clear input for fresh start
123+ inputValue = ""; // Clear input for fresh start
98124 },
99125
100126 setInputValue(value: string) {
@@ -116,22 +142,24 @@ export const omniboxStore = {
116142
117143 selectNext() {
118144 if (this.totalItems === 0) return;
119- const newIndex = selectedIndex < 0 ? 0 : (selectedIndex + 1) % this.totalItems;
145+ const newIndex =
146+ selectedIndex < 0 ? 0 : (selectedIndex + 1) % this.totalItems;
120147 this.setSelectedIndex(newIndex);
121148 faded = true;
122149 },
123150
124151 selectPrevious() {
125152 if (this.totalItems === 0) return;
126- const newIndex = selectedIndex <= 0 ? this.totalItems - 1 : selectedIndex - 1;
153+ const newIndex =
154+ selectedIndex <= 0 ? this.totalItems - 1 : selectedIndex - 1;
127155 this.setSelectedIndex(newIndex);
128156 faded = true;
129157 },
130158
131- open(newMode: OmniboxMode = 'omnibox', initialQuery?: string) {
159+ open(newMode: OmniboxMode = "omnibox", initialQuery?: string) {
132160 this.setMode(newMode);
133161 this.setVisible(true);
134- if (typeof initialQuery === 'string') {
162+ if (typeof initialQuery === "string") {
135163 inputValue = initialQuery;
136164 }
137165 },
@@ -144,7 +172,7 @@ export const omniboxStore = {
144172 toggle() {
145173 if (!visible) {
146174 // When opening via toggle (Ctrl+L), always open in omnibox mode
147- this.setMode('omnibox');
175+ this.setMode("omnibox");
148176 this.setVisible(true);
149177 } else {
150178 this.setVisible(false);
@@ -171,7 +199,7 @@ export const omniboxStore = {
171199 }
172200 });
173201 } catch (error) {
174- console.warn('Error clearing highlights:', error);
202+ console.warn("Error clearing highlights:", error);
175203 }
176204 highlightNodes = [];
177205 matches = [];
@@ -207,15 +235,15 @@ export const omniboxStore = {
207235
208236 reset() {
209237 visible = false;
210- mode = 'omnibox';
238+ mode = "omnibox";
211239 suggestions = [];
212240 matches = [];
213241 selectedIndex = -1;
214242 activeIndex = -1;
215243 faded = false;
216- inputValue = '';
217- prevOverflow = '';
244+ inputValue = "";
245+ prevOverflow = "";
218246 this.clearHighlights();
219247 this.clearDebounceTimer();
220- }
221-};⏎̸
248+ },
249+};
M gui/src/components/omnibox/types.ts +3 -3
@@ -10,7 +10,7 @@ export interface Suggestion {
1010 }
1111
1212 export interface OmniboxMessage {
13- type: 'navigate' | 'query';
13+ type: "navigate" | "query";
1414 url?: string;
1515 q?: string;
1616 limit?: number;
@@ -26,7 +26,7 @@ export interface HighlightNode {
2626 text: Text;
2727 }
2828
29-export type OmniboxMode = 'omnibox' | 'find';
29+export type OmniboxMode = "omnibox" | "find";
3030
3131 export interface OmniboxConfig {
3232 maxMatches?: number;
@@ -60,4 +60,4 @@ export interface OmniboxAPI {
6060 open: (mode?: OmniboxMode, query?: string) => void;
6161 close: () => void;
6262 findQuery: (query: string) => void;
63-}⏎̸
63+}
M gui/src/injected/gui.ts +309 -246
@@ -6,10 +6,16 @@
66 */
77
88 // Import all GUI modules
9-import { initializeToast, type ToastConfig } from './modules/toast';
10-import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox';
11-import { initializeWorkspace, type WorkspaceConfigPayload, type WorkspaceRuntime } from './modules/workspace';
12-import { keyboardService, type KeyboardService } from '$lib/keyboard';
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 { initializeWindowOpenInterceptor } from "./modules/window-open";
17+import { keyboardService, type KeyboardService } from "$lib/keyboard";
18+import type { Suggestion } from "../components/omnibox/types";
1319 // Note: color-scheme module is loaded separately at document-start by WebKit
1420
1521 // Global interface for the unified GUI system
@@ -24,9 +30,9 @@ interface DumberGUI {
2430
2531 // DOM readiness utility - handles all document ready states
2632 function whenDOMReady(callback: () => void) {
27- if (document.readyState === 'loading') {
28- document.addEventListener('DOMContentLoaded', callback, { once: true });
29- } else if (document.readyState === 'interactive' && !document.body) {
33+ if (document.readyState === "loading") {
34+ document.addEventListener("DOMContentLoaded", callback, { once: true });
35+ } else if (document.readyState === "interactive" && !document.body) {
3036 // Interactive but no body yet, wait a bit more
3137 setTimeout(() => whenDOMReady(callback), 10);
3238 } else {
@@ -43,11 +49,25 @@ declare global {
4349 interface Window {
4450 __dumber_gui?: DumberGUI;
4551 __dumber_gui_ready?: boolean;
46- __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void;
52+ __dumber_showToast?: (
53+ message: string,
54+ duration?: number,
55+ type?: "info" | "success" | "error",
56+ ) => number | void;
4757 __dumber_showZoomToast?: (level: number) => void;
4858 __dumber_keyboard?: KeyboardService;
4959 __dumber_workspace_config?: WorkspaceConfigPayload;
5060 __dumber_workspace?: WorkspaceRuntime;
61+ __dumber_pane?: { id: string; active: boolean };
62+ __dumber_omnibox?: {
63+ setSuggestions: (suggestions: Suggestion[]) => void;
64+ toggle: () => void;
65+ open: (mode?: string, query?: string) => void;
66+ close: () => void;
67+ findQuery: (query: string) => void;
68+ setActive: (active: boolean) => void;
69+ };
70+ __dumber_gui_bootstrap?: () => void;
5171 // Legacy compatibility functions for Go bridge
5272 __dumber_toggle?: () => void;
5373 __dumber_find_open?: (query?: string) => void;
@@ -60,82 +80,100 @@ declare global {
6080 if (!window.__dumber_gui_ready) {
6181 // Skip initialization in iframes - GUI only needed in main frame
6282 if (window.self !== window.top) {
63- console.log('🚫 Dumber GUI skipped in iframe');
83+ console.log("🚫 Dumber GUI skipped in iframe");
6484 } else {
6585 window.__dumber_gui_ready = true;
6686
67- let workspaceHasFocus = true; // Assume focus initially, will be corrected by focus events
68- let currentPaneId = window.__dumber_pane?.id || 'unknown';
69- let hasReceivedFocusEvent = false; // Track if we've received any workspace focus events
70-
71- document.addEventListener('dumber:workspace-focus', (event: Event) => {
72- const detail = (event as CustomEvent).detail || {};
73- console.log('[workspace focus event]', detail, 'prev focus=', workspaceHasFocus);
74-
75- hasReceivedFocusEvent = true;
76-
77- // Update focus state
78- if (typeof detail?.active === 'boolean') {
79- workspaceHasFocus = detail.active;
80- } else {
81- workspaceHasFocus = detail !== false;
82- }
83-
84- // Update pane tracking
85- if (detail?.paneId) {
86- currentPaneId = detail.paneId;
87- }
88-
89- // Update global pane state
90- if (window.__dumber_pane) {
91- window.__dumber_pane.active = workspaceHasFocus;
92- }
93-
94- console.log('[workspace focus updated] focus=', workspaceHasFocus, 'paneId=', currentPaneId);
95-
96- // Handle GUI visibility based on focus
97- if (window.__dumber_omnibox) {
98- if (workspaceHasFocus) {
99- window.__dumber_omnibox.setActive?.(true);
87+ let workspaceHasFocus = true; // Assume focus initially, will be corrected by focus events
88+ let currentPaneId = window.__dumber_pane?.id || "unknown";
89+ let hasReceivedFocusEvent = false; // Track if we've received any workspace focus events
90+
91+ document.addEventListener("dumber:workspace-focus", (event: Event) => {
92+ const detail = (event as CustomEvent).detail || {};
93+ console.log(
94+ "[workspace focus event]",
95+ detail,
96+ "prev focus=",
97+ workspaceHasFocus,
98+ );
99+
100+ hasReceivedFocusEvent = true;
101+
102+ // Update focus state
103+ if (typeof detail?.active === "boolean") {
104+ workspaceHasFocus = detail.active;
100105 } else {
101- window.__dumber_omnibox.setActive?.(false);
102- window.__dumber_omnibox.close?.();
106+ workspaceHasFocus = detail !== false;
103107 }
104- }
105- });
106108
107- // Initialize Svelte GUI systems immediately
108- whenDOMReady(async () => {
109- try {
110- // Page-world bridge is injected by WebKit; avoid duplicate injection here
109+ // Update pane tracking
110+ if (detail?.paneId) {
111+ currentPaneId = detail.paneId;
112+ }
111113
112- // Initialize toast system first
113- await initializeToast();
114+ // Update global pane state
115+ if (window.__dumber_pane) {
116+ window.__dumber_pane.active = workspaceHasFocus;
117+ }
114118
115- // Verify toast functions are available
116- if (typeof window.__dumber_showToast !== 'function') {
117- throw new Error('Toast functions not exposed after initialization');
119+ console.log(
120+ "[workspace focus updated] focus=",
121+ workspaceHasFocus,
122+ "paneId=",
123+ currentPaneId,
124+ );
125+
126+ // Handle GUI visibility based on focus
127+ if (window.__dumber_omnibox) {
128+ if (workspaceHasFocus) {
129+ window.__dumber_omnibox.setActive(true);
130+ } else {
131+ window.__dumber_omnibox.setActive(false);
132+ window.__dumber_omnibox.close();
133+ }
118134 }
135+ });
119136
120- // Initialize omnibox system
121- await initializeOmnibox();
137+ // Initialize Svelte GUI systems immediately
138+ whenDOMReady(async () => {
139+ try {
140+ // Page-world bridge is injected by WebKit; avoid duplicate injection here
122141
123- // Initialize workspace controls (pane/tab scaffolding)
124- initializeWorkspace(window.__dumber_workspace_config);
142+ // Initialize window.open interceptor first (must be early)
143+ initializeWindowOpenInterceptor();
125144
126- console.log('✅ All GUI systems initialized successfully');
127- } catch (e) {
128- console.error('❌ Failed to initialize Svelte toast system:', e);
145+ // Initialize toast system first
146+ await initializeToast();
129147
130- // Enhanced fallback with better error recovery
131- window.__dumber_showToast = (message: string, duration: number = 2500) => {
132- // Remove existing toasts to prevent overlap
133- document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());
148+ // Verify toast functions are available
149+ if (typeof window.__dumber_showToast !== "function") {
150+ throw new Error("Toast functions not exposed after initialization");
151+ }
134152
135- const toast = document.createElement('div');
136- toast.className = 'dumber-fallback-toast';
137- toast.textContent = message;
138- toast.style.cssText = `
153+ // Initialize omnibox system
154+ await initializeOmnibox();
155+
156+ // Initialize workspace controls (pane/tab scaffolding)
157+ initializeWorkspace(window.__dumber_workspace_config);
158+
159+ console.log("✅ All GUI systems initialized successfully");
160+ } catch (e) {
161+ console.error("❌ Failed to initialize Svelte toast system:", e);
162+
163+ // Enhanced fallback with better error recovery
164+ window.__dumber_showToast = (
165+ message: string,
166+ duration: number = 2500,
167+ ) => {
168+ // Remove existing toasts to prevent overlap
169+ document
170+ .querySelectorAll(".dumber-fallback-toast")
171+ .forEach((el) => el.remove());
172+
173+ const toast = document.createElement("div");
174+ toast.className = "dumber-fallback-toast";
175+ toast.textContent = message;
176+ toast.style.cssText = `
139177 position: fixed;
140178 bottom: 20px;
141179 right: 20px;
@@ -153,201 +191,226 @@ if (!window.__dumber_gui_ready) {
153191 word-wrap: break-word;
154192 `;
155193
156- if (document.body) {
157- document.body.appendChild(toast);
158-
159- // Animate in
160- requestAnimationFrame(() => {
161- toast.style.opacity = '1';
162- toast.style.transform = 'translateX(0)';
163- });
164-
165- // Animate out and remove
166- setTimeout(() => {
167- toast.style.opacity = '0';
168- toast.style.transform = 'translateX(100%)';
169- setTimeout(() => toast.remove(), 300);
170- }, duration);
171-
172- }
173- };
174-
175- window.__dumber_showZoomToast = (level: number) => {
176- const percentage = Math.round(level * 100);
177- window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500);
178- };
179-
180- // Expose other required functions
181- window.__dumber_dismissToast = () => {
182- document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());
183- };
184-
185- window.__dumber_clearToasts = () => {
186- document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());
187- };
188- }
189- });
190-
191- // Expose keyboard service globally for Go bridge
192- window.__dumber_keyboard = keyboardService;
193-
194- // Initialize global keyboard and mouse event listeners for the keyboard service
195- whenDOMReady(() => {
196- // Global keyboard event listener (capture phase for high priority)
197- document.addEventListener('keydown', (event) => {
198- keyboardService.handleKeyboardEvent(event);
199- }, true);
200-
201- // Global mouse event listener for navigation buttons
202- document.addEventListener('mousedown', (event) => {
203- keyboardService.handleMouseEvent(event);
204- }, true);
205-
206- console.log('✅ KeyboardService initialized with global listeners');
207-
208- // Listen to bridge keyboard events from page-world and forward to KeyboardService
209- document.addEventListener('dumber:key', (e: Event) => {
210- const detail = (e as CustomEvent).detail || {};
211- if (detail && typeof detail.shortcut === 'string') {
212- keyboardService.handleNativeShortcut(detail.shortcut);
194+ if (document.body) {
195+ document.body.appendChild(toast);
196+
197+ // Animate in
198+ requestAnimationFrame(() => {
199+ toast.style.opacity = "1";
200+ toast.style.transform = "translateX(0)";
201+ });
202+
203+ // Animate out and remove
204+ setTimeout(() => {
205+ toast.style.opacity = "0";
206+ toast.style.transform = "translateX(100%)";
207+ setTimeout(() => toast.remove(), 300);
208+ }, duration);
209+ }
210+ };
211+
212+ window.__dumber_showZoomToast = (level: number) => {
213+ const percentage = Math.round(level * 100);
214+ window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500);
215+ };
216+
217+ // Expose other required functions
218+ window.__dumber_dismissToast = () => {
219+ document
220+ .querySelectorAll(".dumber-fallback-toast")
221+ .forEach((el) => el.remove());
222+ };
223+
224+ window.__dumber_clearToasts = () => {
225+ document
226+ .querySelectorAll(".dumber-fallback-toast")
227+ .forEach((el) => el.remove());
228+ };
213229 }
214230 });
215231
216- document.addEventListener('dumber:ui:shortcut', (e: Event) => {
217- const detail = (e as CustomEvent).detail || {};
218- const action = detail?.action;
219- const eventPaneId = detail?.paneId;
220- const source = detail?.source || 'unknown';
232+ // Expose keyboard service globally for Go bridge
233+ window.__dumber_keyboard = keyboardService;
234+
235+ // Initialize global keyboard and mouse event listeners for the keyboard service
236+ whenDOMReady(() => {
237+ // Global keyboard event listener (capture phase for high priority)
238+ document.addEventListener(
239+ "keydown",
240+ (event) => {
241+ keyboardService.handleKeyboardEvent(event);
242+ },
243+ true,
244+ );
245+
246+ // Global mouse event listener for navigation buttons
247+ document.addEventListener(
248+ "mousedown",
249+ (event) => {
250+ keyboardService.handleMouseEvent(event);
251+ },
252+ true,
253+ );
254+
255+ console.log("✅ KeyboardService initialized with global listeners");
256+
257+ // Listen to bridge keyboard events from page-world and forward to KeyboardService
258+ document.addEventListener("dumber:key", (e: Event) => {
259+ const detail = (e as CustomEvent).detail || {};
260+ if (detail && typeof detail.shortcut === "string") {
261+ keyboardService.handleNativeShortcut(detail.shortcut);
262+ }
263+ });
221264
222- if (typeof action !== 'string') {
223- return;
224- }
265+ document.addEventListener("dumber:ui:shortcut", (e: Event) => {
266+ const detail = (e as CustomEvent).detail || {};
267+ const action = detail?.action;
268+ const eventPaneId = detail?.paneId;
269+ const source = detail?.source || "unknown";
270+
271+ if (typeof action !== "string") {
272+ return;
273+ }
225274
226- // Enhanced focus checking with pane ID validation
227- const isForThisPane = !eventPaneId || eventPaneId === currentPaneId;
228- // Only apply strict focus checking to omnibox actions, be permissive for others
229- const isOmniboxAction = action.startsWith('omnibox-');
230- const shouldHandle = isForThisPane && (isOmniboxAction ? (hasReceivedFocusEvent ? workspaceHasFocus : true) : true);
275+ // Enhanced focus checking with pane ID validation
276+ const isForThisPane = !eventPaneId || eventPaneId === currentPaneId;
277+ // Only apply strict focus checking to omnibox actions, be permissive for others
278+ const isOmniboxAction = action.startsWith("omnibox-");
279+ const shouldHandle =
280+ isForThisPane &&
281+ (isOmniboxAction
282+ ? hasReceivedFocusEvent
283+ ? workspaceHasFocus
284+ : true
285+ : true);
286+
287+ if (!shouldHandle) {
288+ console.log("[dumber shortcuts] ignored action", {
289+ action,
290+ workspaceHasFocus,
291+ isForThisPane,
292+ eventPaneId,
293+ currentPaneId,
294+ source,
295+ detail,
296+ hasReceivedFocusEvent,
297+ });
298+ return;
299+ }
231300
232- if (!shouldHandle) {
233- console.log('[dumber shortcuts] ignored action', {
301+ console.log("[dumber shortcuts] handling action", {
234302 action,
235- workspaceHasFocus,
236- isForThisPane,
237- eventPaneId,
238- currentPaneId,
239303 source,
240- detail,
241- hasReceivedFocusEvent
304+ paneId: currentPaneId,
242305 });
243- return;
306+
307+ const omnibox = window.__dumber_omnibox;
308+
309+ switch (action) {
310+ case "omnibox-nav-toggle":
311+ omnibox?.open?.("omnibox", detail?.query);
312+ break;
313+ case "omnibox-open":
314+ omnibox?.open?.("omnibox", detail?.query);
315+ break;
316+ case "omnibox-find-toggle":
317+ omnibox?.open?.("find", detail?.query);
318+ break;
319+ case "omnibox-close":
320+ omnibox?.close?.();
321+ break;
322+ default:
323+ console.warn("[dumber] Unknown UI shortcut action:", action);
324+ }
325+ });
326+
327+ // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed
328+ });
329+
330+ // Legacy compatibility functions for existing Go code
331+ window.__dumber_toggle = () => {
332+ try {
333+ console.log("🎯 __dumber_toggle called");
334+ console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox);
335+ if (window.__dumber_omnibox?.toggle) {
336+ console.log("✅ Using Svelte omnibox toggle");
337+ window.__dumber_omnibox.toggle();
338+ return;
339+ }
340+
341+ throw new Error("Omnibox toggle requested but API is unavailable");
342+ } catch (error) {
343+ console.error("❌ Error in __dumber_toggle:", error);
244344 }
345+ };
346+
347+ window.__dumber_find_open = (query?: string) => {
348+ try {
349+ if (window.__dumber_omnibox?.open) {
350+ window.__dumber_omnibox.open("find", query);
351+ return;
352+ }
245353
246- console.log('[dumber shortcuts] handling action', { action, source, paneId: currentPaneId });
247-
248- const omnibox = window.__dumber_omnibox;
249-
250- switch (action) {
251- case 'omnibox-nav-toggle':
252- omnibox?.open?.('omnibox', detail?.query);
253- break;
254- case 'omnibox-open':
255- omnibox?.open?.('omnibox', detail?.query);
256- break;
257- case 'omnibox-find-toggle':
258- omnibox?.open?.('find', detail?.query);
259- break;
260- case 'omnibox-close':
261- omnibox?.close?.();
262- break;
263- default:
264- console.warn('[dumber] Unknown UI shortcut action:', action);
354+ throw new Error("Omnibox find requested but API is unavailable");
355+ } catch (error) {
356+ console.error("❌ Error in __dumber_find_open:", error);
265357 }
266- });
358+ };
267359
268- // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed
269- });
270-
271- // Legacy compatibility functions for existing Go code
272- window.__dumber_toggle = () => {
273- try {
274- console.log('🎯 __dumber_toggle called');
275- console.log('🔧 Omnibox API available:', !!window.__dumber_omnibox);
276- if (window.__dumber_omnibox?.toggle) {
277- console.log('✅ Using Svelte omnibox toggle');
278- window.__dumber_omnibox.toggle();
360+ window.__dumber_find_close = () => {
361+ if (window.__dumber_omnibox?.close) {
362+ window.__dumber_omnibox.close();
279363 return;
280364 }
281365
282- throw new Error('Omnibox toggle requested but API is unavailable');
283- } catch (error) {
284- console.error('❌ Error in __dumber_toggle:', error);
285- }
286- };
366+ const error = new Error("Omnibox close requested but API is unavailable");
367+ console.error("❌ Error in __dumber_find_close:", error);
368+ };
287369
288- window.__dumber_find_open = (query?: string) => {
289- try {
290- if (window.__dumber_omnibox?.open) {
291- window.__dumber_omnibox.open('find', query);
292- return;
370+ // Find query function that Go uses to set find query
371+ window.__dumber_find_query = (query: string) => {
372+ if (window.__dumber_omnibox?.findQuery) {
373+ window.__dumber_omnibox.findQuery(query);
293374 }
375+ };
376+
377+ // Create the global GUI object
378+ window.__dumber_gui = {
379+ initializeToast: async (config?: ToastConfig) => {
380+ return await initializeToast(config);
381+ },
382+
383+ initializeOmnibox: async (config?: OmniboxInitConfig) => {
384+ return await initializeOmnibox(config);
385+ },
386+
387+ keyboard: keyboardService,
294388
295- throw new Error('Omnibox find requested but API is unavailable');
296- } catch (error) {
297- console.error('❌ Error in __dumber_find_open:', error);
298- }
299- };
300-
301- window.__dumber_find_close = () => {
302- if (window.__dumber_omnibox?.close) {
303- window.__dumber_omnibox.close();
304- return;
305- }
306-
307- const error = new Error('Omnibox close requested but API is unavailable');
308- console.error('❌ Error in __dumber_find_close:', error);
309- };
310-
311- // Find query function that Go uses to set find query
312- window.__dumber_find_query = (query: string) => {
313- if (window.__dumber_omnibox?.findQuery) {
314- window.__dumber_omnibox.findQuery(query);
315- }
316- };
317-
318- // Create the global GUI object
319- window.__dumber_gui = {
320- initializeToast: async (config?: ToastConfig) => {
321- return await initializeToast(config);
322- },
323-
324- initializeOmnibox: async (config?: OmniboxInitConfig) => {
325- return await initializeOmnibox(config);
326- },
327-
328- keyboard: keyboardService,
329-
330- isReady: true
331- };
332-
333- // Bootstrap function for lazy GUI initialization by workspace manager
334- window.__dumber_gui_bootstrap = function(paneId: string) {
335- console.log(`[gui-bootstrap] Initializing GUI for pane ${paneId}`);
336-
337- // Update current pane ID
338- currentPaneId = paneId;
339-
340- // Ensure omnibox is available for this pane
341- if (!window.__dumber_omnibox) {
342- console.log(`[gui-bootstrap] Omnibox not yet available for pane ${paneId}, will be loaded when needed`);
343- } else {
344- console.log(`[gui-bootstrap] Omnibox already available for pane ${paneId}`);
345- // Ensure omnibox is aware of the current pane
346- window.__dumber_omnibox.setActive?.(workspaceHasFocus);
347- }
348-
349- console.log(`[gui-bootstrap] GUI bootstrap complete for pane ${paneId}`);
350- };
389+ isReady: true,
390+ };
391+
392+ // Bootstrap function for lazy GUI initialization by workspace manager
393+ window.__dumber_gui_bootstrap = function () {
394+ console.log("[gui-bootstrap] Initializing GUI for workspace");
395+
396+ // Pane ID will be updated via focus events
397+
398+ // Ensure omnibox is available for this pane
399+ if (!window.__dumber_omnibox) {
400+ console.log(
401+ `[gui-bootstrap] Omnibox not yet available for pane ${currentPaneId}, will be loaded when needed`,
402+ );
403+ } else {
404+ console.log(
405+ `[gui-bootstrap] Omnibox already available for pane ${currentPaneId}`,
406+ );
407+ // Ensure omnibox is aware of the current pane
408+ window.__dumber_omnibox.setActive(workspaceHasFocus);
409+ }
351410
411+ console.log(
412+ `[gui-bootstrap] GUI bootstrap complete for pane ${currentPaneId}`,
413+ );
414+ };
352415 }
353416 }
A gui/src/injected/main-world.ts +931 -0
@@ -0,0 +1,931 @@
1+/**
2+ * Main-world bridge script for Dumber Browser
3+ *
4+ * This script is injected into the page-world context to provide:
5+ * - Window.open interceptor with debouncing
6+ * - Toast notifications bridge
7+ * - Theme management
8+ * - DOM zoom functionality
9+ * - Omnibox suggestions bridge
10+ */
11+
12+// Type definitions for window extensions
13+declare global {
14+ interface Window {
15+ __dumber: DumberAPI;
16+ __dumber_page_bridge_installed?: boolean;
17+ __dumber_window_open_intercepted?: boolean;
18+ __dumber_dom_zoom_seed?: number;
19+ __dumber_dom_zoom_level?: number;
20+ __dumber_initial_theme?: string;
21+ __dumber_setTheme?: (theme: "light" | "dark") => void;
22+ __dumber_applyDomZoom?: (level: number) => void;
23+ __dumber_showToast?: (
24+ message: string,
25+ duration?: number,
26+ type?: "info" | "success" | "error",
27+ ) => number | void;
28+ __dumber_showZoomToast?: (level: number) => void;
29+ __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
30+ __dumber_webview_id?: string;
31+ __dumber_is_active?: boolean;
32+ webkit?: {
33+ messageHandlers?: {
34+ dumber?: {
35+ postMessage: (message: string) => void;
36+ };
37+ };
38+ };
39+ }
40+}
41+
42+interface WindowIntent {
43+ url: string;
44+ target: string;
45+ features: string;
46+ timestamp: number;
47+ userTriggered: boolean;
48+ requestId: string;
49+ windowType: string; // "tab", "popup", "unknown"
50+}
51+
52+interface Suggestion {
53+ // Define proper suggestion types based on your needs
54+ [key: string]: unknown;
55+}
56+
57+interface DumberAPI {
58+ toast: {
59+ show: (
60+ message: string,
61+ duration?: number,
62+ type?: "info" | "success" | "error",
63+ ) => void;
64+ zoom: (level: number) => void;
65+ };
66+ omnibox: {
67+ suggestions: (suggestions: Suggestion[]) => void;
68+ };
69+}
70+
71+// Function to detect if window.open call is for a popup based on features
72+function detectWindowType(features?: string | null): string {
73+ if (!features) {
74+ return "tab"; // Default to tab if no features specified
75+ }
76+
77+ const featuresStr = features.toLowerCase();
78+
79+ // Check for typical popup characteristics
80+ const hasSize = /width=\d+|height=\d+/.test(featuresStr);
81+ const hasNoToolbar = /toolbar=0|toolbar=no/.test(featuresStr);
82+ const hasNoMenubar = /menubar=0|menubar=no/.test(featuresStr);
83+ const hasNoLocation = /location=0|location=no/.test(featuresStr);
84+
85+ // OAuth/login popups typically have size constraints and disabled UI elements
86+ const popupIndicators = [hasSize, hasNoToolbar, hasNoMenubar, hasNoLocation].filter(Boolean).length;
87+
88+ // If 2 or more popup indicators are present, treat as popup
89+ if (popupIndicators >= 2) {
90+ return "popup";
91+ }
92+
93+ return "tab";
94+}
95+
96+(() => {
97+ try {
98+ // Prevent multiple installations
99+ if (window.__dumber_page_bridge_installed) {
100+ return;
101+ }
102+ window.__dumber_page_bridge_installed = true;
103+
104+ // Initialize zoom level placeholder (will be replaced by Go)
105+ const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go
106+ window.__dumber_dom_zoom_seed = initialZoom;
107+
108+ // Initialize WebView ID and active state (will be replaced by Go)
109+ window.__dumber_webview_id = "__WEBVIEW_ID__";
110+ window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
111+
112+ // Theme setter function for GTK theme integration
113+ window.__dumber_setTheme = (theme: "light" | "dark") => {
114+ window.__dumber_initial_theme = theme;
115+ console.log("[dumber] Setting theme to:", theme);
116+ if (theme === "dark") {
117+ document.documentElement.classList.add("dark");
118+ } else {
119+ document.documentElement.classList.remove("dark");
120+ }
121+ };
122+
123+ // Initialize unified API object
124+ window.__dumber = window.__dumber || ({} as DumberAPI);
125+
126+ // Toast bridge
127+ window.__dumber.toast = window.__dumber.toast || {
128+ show: (message: string, duration?: number, type?: string) => {
129+ try {
130+ document.dispatchEvent(
131+ new CustomEvent("dumber:toast:show", {
132+ detail: { message, duration, type },
133+ }),
134+ );
135+ // Legacy compatibility
136+ document.dispatchEvent(
137+ new CustomEvent("dumber:showToast", {
138+ detail: { message, duration, type },
139+ }),
140+ );
141+ } catch {
142+ // ignore
143+ }
144+ },
145+ zoom: (level: number) => {
146+ try {
147+ document.dispatchEvent(
148+ new CustomEvent("dumber:toast:zoom", {
149+ detail: { level },
150+ }),
151+ );
152+ // Legacy compatibility
153+ document.dispatchEvent(
154+ new CustomEvent("dumber:showZoomToast", {
155+ detail: { level },
156+ }),
157+ );
158+ } catch {
159+ // ignore
160+ }
161+ },
162+ };
163+
164+ // Legacy toast helpers
165+ window.__dumber_showToast = (
166+ message: string,
167+ duration?: number,
168+ type?: "info" | "success" | "error",
169+ ) => {
170+ window.__dumber.toast.show(message, duration, type);
171+ };
172+ window.__dumber_showZoomToast = (level: number) => {
173+ window.__dumber.toast.zoom(level);
174+ };
175+
176+ // DOM zoom functionality
177+ if (typeof window.__dumber_dom_zoom_level !== "number") {
178+ window.__dumber_dom_zoom_level = initialZoom;
179+ }
180+
181+ const applyZoomStyles = (node: HTMLElement, level: number): void => {
182+ if (!node) return;
183+
184+ if (Math.abs(level - 1.0) < 1e-6) {
185+ // Reset zoom
186+ node.style.removeProperty("zoom");
187+ node.style.removeProperty("transform");
188+ node.style.removeProperty("transform-origin");
189+ node.style.removeProperty("width");
190+ node.style.removeProperty("min-width");
191+ node.style.removeProperty("height");
192+ node.style.removeProperty("min-height");
193+ return;
194+ }
195+
196+ const scale = level;
197+ const inversePercent = 100 / scale;
198+ const widthValue = `${inversePercent}%`;
199+
200+ node.style.removeProperty("zoom");
201+ node.style.transform = `scale(${scale})`;
202+ node.style.transformOrigin = "0 0";
203+ node.style.width = widthValue;
204+ node.style.minWidth = widthValue;
205+ node.style.minHeight = "100%";
206+ };
207+
208+ window.__dumber_applyDomZoom = (level: number) => {
209+ try {
210+ window.__dumber_dom_zoom_level = level;
211+ window.__dumber_dom_zoom_seed = level;
212+ applyZoomStyles(document.documentElement, level);
213+ if (document.body) {
214+ applyZoomStyles(document.body, level);
215+ }
216+ } catch (err) {
217+ console.error("[dumber] DOM zoom error", err);
218+ }
219+ };
220+
221+ // Apply initial zoom
222+ window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
223+
224+ if (!document.body) {
225+ document.addEventListener(
226+ "DOMContentLoaded",
227+ () => {
228+ if (typeof window.__dumber_dom_zoom_level === "number") {
229+ window.__dumber_applyDomZoom!(window.__dumber_dom_zoom_level);
230+ }
231+ },
232+ { once: true },
233+ );
234+ }
235+
236+ // Omnibox suggestions bridge
237+ const omniboxQueue: Suggestion[] = [];
238+ let omniboxReady = false;
239+
240+ const omniboxDispatch = (suggestions: Suggestion[]) => {
241+ try {
242+ document.dispatchEvent(
243+ new CustomEvent("dumber:omnibox:suggestions", {
244+ detail: { suggestions },
245+ }),
246+ );
247+ // Legacy compatibility
248+ document.dispatchEvent(
249+ new CustomEvent("dumber:omnibox-suggestions", {
250+ detail: { suggestions },
251+ }),
252+ );
253+ } catch (err) {
254+ console.error("[dumber] Omnibox dispatch error", err);
255+ }
256+ };
257+
258+ window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {
259+ if (omniboxReady) {
260+ omniboxDispatch(suggestions);
261+ } else {
262+ try {
263+ omniboxQueue.push(...suggestions);
264+ } catch (err) {
265+ console.error("[dumber] Omnibox queue error", err);
266+ }
267+ }
268+ };
269+
270+ document.addEventListener("dumber:omnibox-ready", () => {
271+ omniboxReady = true;
272+ if (omniboxQueue && omniboxQueue.length) {
273+ const items = omniboxQueue.slice();
274+ omniboxQueue.length = 0;
275+ items.forEach((s) => omniboxDispatch([s]));
276+ }
277+ });
278+
279+ // Unified omnibox API
280+ window.__dumber.omnibox = window.__dumber.omnibox || {
281+ suggestions: (suggestions: Suggestion[]) => {
282+ window.__dumber_omnibox_suggestions!(suggestions);
283+ },
284+ };
285+
286+ // Popup window.opener bridge using shared localStorage
287+ const setupPopupOpenerBridge = () => {
288+ try {
289+ // Check if this is a popup by looking for parent info in localStorage
290+ const findParentPopupId = () => {
291+ const keys = Object.keys(localStorage);
292+ for (const key of keys) {
293+ if (key.startsWith('popup_') && key.endsWith('_parent_info')) {
294+ const popupId = key.replace('popup_', '').replace('_parent_info', '');
295+ const parentInfo = JSON.parse(localStorage.getItem(key) || '{}');
296+
297+ // Check if this popup info is recent (within last 30 seconds)
298+ const age = Date.now() - (parentInfo.timestamp || 0);
299+ if (age < 30000) {
300+ console.log(`[dumber-popup] Found parent info for popup ID: ${popupId}`, parentInfo);
301+ return { popupId, parentInfo };
302+ }
303+ }
304+ }
305+ return null;
306+ };
307+
308+ const parentData = findParentPopupId();
309+ if (parentData && !window.opener) {
310+ const { popupId, parentInfo } = parentData;
311+
312+ console.log(`[dumber-popup] Setting up window.opener bridge for popup ID: ${popupId}`);
313+
314+ // Create window.opener proxy that uses localStorage for communication
315+ window.opener = {
316+ postMessage: (data: unknown, targetOrigin?: string) => {
317+ try {
318+ localStorage.setItem(`popup_${popupId}_message_to_parent`, JSON.stringify({
319+ data,
320+ origin: targetOrigin || '*',
321+ timestamp: Date.now(),
322+ source: 'popup',
323+ }));
324+ console.log(`[dumber-popup] Sent message to parent via localStorage:`, data);
325+ } catch (err) {
326+ console.warn(`[dumber-popup] Failed to send message to parent:`, err);
327+ }
328+ },
329+
330+ focus: () => {
331+ try {
332+ localStorage.setItem(`popup_${popupId}_parent_action`, JSON.stringify({
333+ action: 'focus',
334+ timestamp: Date.now(),
335+ }));
336+ } catch (err) {
337+ console.warn(`[dumber-popup] Failed to request parent focus:`, err);
338+ }
339+ },
340+
341+ blur: () => {
342+ try {
343+ localStorage.setItem(`popup_${popupId}_parent_action`, JSON.stringify({
344+ action: 'blur',
345+ timestamp: Date.now(),
346+ }));
347+ } catch (err) {
348+ console.warn(`[dumber-popup] Failed to request parent blur:`, err);
349+ }
350+ },
351+
352+ location: {
353+ href: parentInfo.parentUrl || '',
354+ origin: new URL(parentInfo.parentUrl || 'about:blank').origin,
355+ },
356+
357+ closed: false,
358+
359+ // Support for custom properties/methods that websites might use
360+ [Symbol.for('popup.bridge')]: true,
361+ } as unknown as Window;
362+
363+ // Set up message polling to receive messages from parent
364+ const pollForParentMessages = () => {
365+ try {
366+ const messageKey = `popup_${popupId}_message_to_popup`;
367+ const messageData = localStorage.getItem(messageKey);
368+
369+ if (messageData) {
370+ const { data, origin, timestamp } = JSON.parse(messageData);
371+
372+ // Check if message is recent (within last 5 seconds)
373+ if (Date.now() - timestamp < 5000) {
374+ console.log(`[dumber-popup] Received message from parent:`, data);
375+
376+ // Dispatch as MessageEvent to window
377+ const event = new MessageEvent('message', {
378+ data,
379+ origin: origin || parentInfo.parentUrl || '',
380+ source: window.opener,
381+ });
382+ window.dispatchEvent(event);
383+ }
384+
385+ // Clean up message
386+ localStorage.removeItem(messageKey);
387+ }
388+ } catch (err) {
389+ console.warn(`[dumber-popup] Failed to poll for parent messages:`, err);
390+ }
391+ };
392+
393+ // Poll for messages every 100ms
394+ setInterval(pollForParentMessages, 100);
395+
396+ console.log(`[dumber-popup] window.opener bridge established successfully`);
397+ }
398+ } catch (err) {
399+ console.warn(`[dumber-popup] Failed to setup window.opener bridge:`, err);
400+ }
401+ };
402+
403+ // Setup popup bridge if this appears to be a popup
404+ setupPopupOpenerBridge();
405+
406+ // Parent window message polling - only for OAuth scenarios
407+ let parentPollingInterval = null;
408+
409+ const setupParentMessagePolling = () => {
410+ // Only start polling if we have popup mappings or OAuth callbacks
411+ const hasRelevantData = () => {
412+ try {
413+ const keys = Object.keys(localStorage);
414+ return keys.some(key =>
415+ key.startsWith('popup_mapping_') ||
416+ key.startsWith('oauth_callback_') ||
417+ key.includes('message_to_parent')
418+ );
419+ } catch {
420+ return false;
421+ }
422+ };
423+
424+ const pollForPopupMessages = () => {
425+ try {
426+ // Stop polling if no relevant data exists
427+ if (!hasRelevantData()) {
428+ return;
429+ }
430+
431+ const keys = Object.keys(localStorage);
432+
433+ for (const key of keys) {
434+ // Handle popup messages to parent
435+ if (key.includes('message_to_parent')) {
436+ const messageData = localStorage.getItem(key);
437+ if (!messageData) continue;
438+
439+ try {
440+ const { data, origin, timestamp, source } = JSON.parse(messageData);
441+
442+ // Check if message is recent (within last 5 seconds) and from a popup
443+ if (Date.now() - timestamp < 5000 && source === 'popup') {
444+ console.log(`[dumber-parent] Received message from popup:`, data);
445+
446+ // Dispatch as MessageEvent to parent window
447+ const event = new MessageEvent('message', {
448+ data,
449+ origin: origin || window.location.origin,
450+ source: null, // popup reference would be here in real browser
451+ });
452+ window.dispatchEvent(event);
453+
454+ // Clean up message
455+ localStorage.removeItem(key);
456+ }
457+ } catch (err) {
458+ console.warn(`[dumber-parent] Failed to parse popup message:`, err);
459+ localStorage.removeItem(key); // Clean up invalid message
460+ }
461+ }
462+
463+ // Handle OAuth callback detection from popups
464+ if (key.startsWith('oauth_callback_')) {
465+ const callbackData = localStorage.getItem(key);
466+ if (!callbackData) continue;
467+
468+ try {
469+ const { url, webviewId, timestamp, isOAuthCallback } = JSON.parse(callbackData);
470+
471+ // Check if callback is recent (within last 10 seconds) and is an OAuth callback
472+ if (Date.now() - timestamp < 10000 && isOAuthCallback) {
473+ console.log(`[dumber-parent] OAuth callback detected for webview ${webviewId}:`, url);
474+
475+ // Send close request to backend for this popup webview
476+ const bridge = window.webkit?.messageHandlers?.dumber;
477+ if (bridge && typeof bridge.postMessage === 'function') {
478+ try {
479+ const closeMessage = {
480+ type: 'close-popup',
481+ webviewId,
482+ reason: 'oauth-callback-success',
483+ timestamp: Date.now()
484+ };
485+
486+ console.log(`[dumber-parent] Sending popup close request:`, closeMessage);
487+ bridge.postMessage(JSON.stringify(closeMessage));
488+ } catch (err) {
489+ console.warn(`[dumber-parent] Failed to send popup close request:`, err);
490+ }
491+ } else {
492+ console.warn(`[dumber-parent] No webkit bridge available for popup close request`);
493+ }
494+
495+ // Clean up OAuth callback data
496+ localStorage.removeItem(key);
497+ }
498+ } catch (err) {
499+ console.warn(`[dumber-parent] Failed to parse OAuth callback data:`, err);
500+ localStorage.removeItem(key); // Clean up invalid data
501+ }
502+ }
503+ }
504+ } catch (err) {
505+ console.warn(`[dumber-parent] Failed to poll for popup messages:`, err);
506+ }
507+ };
508+
509+ // Start polling only when needed
510+ const startPollingForOAuthCallbacks = () => {
511+ if (!parentPollingInterval && hasRelevantData()) {
512+ console.log(`[dumber-parent] Starting OAuth/popup message polling`);
513+ parentPollingInterval = setInterval(pollForPopupMessages, 100);
514+ }
515+ };
516+
517+ // Check periodically if polling should start
518+ setInterval(() => {
519+ if (hasRelevantData()) {
520+ startPollingForOAuthCallbacks();
521+ }
522+ }, 1000);
523+ };
524+
525+ // Setup conditional parent message polling
526+ setupParentMessagePolling();
527+
528+ // OAuth callback detection for auto-close
529+ const setupOAuthCallbackDetection = () => {
530+ const detectOAuthCallback = () => {
531+ const url = window.location.href.toLowerCase();
532+
533+ // Check for OAuth callback patterns - be more specific to avoid false positives
534+ const isCallback = (
535+ // OAuth callback URLs typically have these parameters
536+ url.includes('code=') ||
537+ url.includes('access_token=') ||
538+ url.includes('id_token=') ||
539+ // OAuth error responses
540+ url.includes('error=access_denied') ||
541+ url.includes('error=unauthorized') ||
542+ // OAuth callback paths (more specific than just "callback" or "redirect")
543+ url.includes('/oauth/callback') ||
544+ url.includes('/auth/callback') ||
545+ url.includes('oauth2callback') ||
546+ url.includes('googlepopupcallback')
547+ );
548+
549+ if (isCallback) {
550+ try {
551+ // Determine which webview should be closed
552+ let targetWebViewId = window.__dumber_webview_id;
553+
554+ // If this is a parent window detecting OAuth callback, find the associated popup
555+ const popupMappingKey = `popup_mapping_${window.__dumber_webview_id}`;
556+ const popupMappingData = localStorage.getItem(popupMappingKey);
557+
558+ if (popupMappingData) {
559+ try {
560+ const mapping = JSON.parse(popupMappingData);
561+ // Check if mapping is recent (within last 60 seconds)
562+ const age = Date.now() - (mapping.timestamp || 0);
563+ if (age < 60000 && mapping.popupId) {
564+ targetWebViewId = mapping.popupId;
565+ console.log(`[oauth-callback] Parent detected OAuth callback, targeting popup webview: ${targetWebViewId}`);
566+
567+ // Clean up the mapping since we're using it
568+ localStorage.removeItem(popupMappingKey);
569+ }
570+ } catch (err) {
571+ console.warn(`[oauth-callback] Failed to parse popup mapping:`, err);
572+ }
573+ }
574+
575+ const callbackData = {
576+ url: window.location.href,
577+ webviewId: targetWebViewId,
578+ timestamp: Date.now(),
579+ isOAuthCallback: true,
580+ };
581+
582+ localStorage.setItem(`oauth_callback_${targetWebViewId}`, JSON.stringify(callbackData));
583+ console.log(`[oauth-callback] Detected OAuth callback, targeting webview ${targetWebViewId}:`, callbackData);
584+
585+ // Auto-cleanup sensitive OAuth data after 10 seconds
586+ setTimeout(() => {
587+ try {
588+ localStorage.removeItem(`oauth_callback_${targetWebViewId}`);
589+ console.log(`[oauth-callback] Cleaned up OAuth callback data from localStorage`);
590+ } catch (err) {
591+ console.warn(`[oauth-callback] Failed to cleanup OAuth callback data:`, err);
592+ }
593+ }, 10000);
594+ } catch (err) {
595+ console.warn(`[oauth-callback] Failed to store OAuth callback data:`, err);
596+ }
597+ }
598+ };
599+
600+ // Check immediately
601+ detectOAuthCallback();
602+
603+ // Monitor for URL changes (for SPAs that don't reload)
604+ let lastUrl = window.location.href;
605+ setInterval(() => {
606+ if (window.location.href !== lastUrl) {
607+ lastUrl = window.location.href;
608+ detectOAuthCallback();
609+ }
610+ }, 500);
611+
612+ // Also check on navigation events
613+ window.addEventListener('popstate', detectOAuthCallback);
614+ window.addEventListener('hashchange', detectOAuthCallback);
615+ };
616+
617+ // Setup OAuth callback detection
618+ setupOAuthCallbackDetection();
619+
620+ // Enhanced window.open deduplication system
621+ interface PendingRequest {
622+ id: string;
623+ url: string;
624+ timestamp: number;
625+ webviewId: string;
626+ resolved: boolean;
627+ }
628+
629+ class WindowOpenDebouncer {
630+ private pendingRequests = new Map<string, PendingRequest>();
631+ private readonly DEBOUNCE_WINDOW = 150; // Increased from 100ms
632+ private readonly CLEANUP_INTERVAL = 1000; // Clean old entries every 1s
633+
634+ generateRequestId(): string {
635+ return `${window.__dumber_webview_id}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
636+ }
637+
638+ isDuplicate(url: string, webviewId: string): boolean {
639+ const key = this.createKey(url, webviewId);
640+ const existing = this.pendingRequests.get(key);
641+
642+ if (!existing) return false;
643+
644+ const timeDiff = Date.now() - existing.timestamp;
645+ return timeDiff < this.DEBOUNCE_WINDOW && !existing.resolved;
646+ }
647+
648+ registerRequest(url: string, webviewId: string): string {
649+ const requestId = this.generateRequestId();
650+ const key = this.createKey(url, webviewId);
651+
652+ this.pendingRequests.set(key, {
653+ id: requestId,
654+ url,
655+ timestamp: Date.now(),
656+ webviewId,
657+ resolved: false,
658+ });
659+
660+ // Schedule cleanup
661+ setTimeout(() => this.markResolved(key), this.DEBOUNCE_WINDOW);
662+ return requestId;
663+ }
664+
665+ private createKey(url: string, webviewId: string): string {
666+ return `${webviewId}:${url}`;
667+ }
668+
669+ private markResolved(key: string): void {
670+ const request = this.pendingRequests.get(key);
671+ if (request) {
672+ request.resolved = true;
673+ // Clean up after additional delay
674+ setTimeout(
675+ () => this.pendingRequests.delete(key),
676+ this.CLEANUP_INTERVAL,
677+ );
678+ }
679+ }
680+ }
681+
682+ // Global debouncer instance
683+ const windowOpenDebouncer = new WindowOpenDebouncer();
684+
685+ // User interaction tracking
686+ let lastUserInteraction = 0;
687+ const INTERACTION_TIMEOUT = 5000; // 5 seconds
688+
689+ const trackUserInteraction = () => {
690+ lastUserInteraction = Date.now();
691+ };
692+
693+ const isUserInteraction = (): boolean => {
694+ const now = Date.now();
695+ const timeSinceInteraction = now - lastUserInteraction;
696+ return timeSinceInteraction <= INTERACTION_TIMEOUT;
697+ };
698+
699+
700+ const createFakeWindow = (url: string, popupId?: string): WindowProxy => {
701+ // Generate unique popup ID if not provided
702+ const actualPopupId = popupId || `${window.__dumber_webview_id}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
703+
704+ // Set up communication channel via shared localStorage
705+ try {
706+ localStorage.setItem(`popup_${actualPopupId}_parent_info`, JSON.stringify({
707+ parentUrl: window.location.href,
708+ parentWebViewId: window.__dumber_webview_id,
709+ timestamp: Date.now(),
710+ popupUrl: url,
711+ }));
712+
713+ console.log(`[window-open] [WebView ${window.__dumber_webview_id}] Set up shared storage communication for popup ID: ${actualPopupId}`);
714+ } catch (err) {
715+ console.warn(`[window-open] [WebView ${window.__dumber_webview_id}] Failed to set up shared storage:`, err);
716+ }
717+
718+ return {
719+ closed: false,
720+ location: { href: url },
721+ close() {
722+ (this as { closed: boolean }).closed = true;
723+ },
724+ focus: () => {
725+ try {
726+ localStorage.setItem(`popup_${actualPopupId}_parent_action`, JSON.stringify({
727+ action: 'focus',
728+ timestamp: Date.now(),
729+ }));
730+ } catch (err) {
731+ console.warn(`[window-open] Failed to store focus action:`, err);
732+ }
733+ },
734+ blur: () => {
735+ try {
736+ localStorage.setItem(`popup_${actualPopupId}_parent_action`, JSON.stringify({
737+ action: 'blur',
738+ timestamp: Date.now(),
739+ }));
740+ } catch (err) {
741+ console.warn(`[window-open] Failed to store blur action:`, err);
742+ }
743+ },
744+ postMessage: (data: unknown, targetOrigin?: string) => {
745+ try {
746+ localStorage.setItem(`popup_${actualPopupId}_message_to_popup`, JSON.stringify({
747+ data,
748+ origin: targetOrigin || '*',
749+ timestamp: Date.now(),
750+ source: 'parent',
751+ }));
752+ console.log(`[window-open] [WebView ${window.__dumber_webview_id}] Stored message for popup ${actualPopupId}:`, data);
753+ } catch (err) {
754+ console.warn(`[window-open] Failed to store postMessage:`, err);
755+ }
756+ },
757+ } as unknown as WindowProxy;
758+ };
759+
760+
761+ // Track user interactions for popup validation
762+ ["click", "mousedown", "keydown", "touchstart"].forEach((eventType) => {
763+ document.addEventListener(eventType, trackUserInteraction, true);
764+ });
765+
766+ console.log(
767+ `[window-open] [WebView ${window.__dumber_webview_id}] User interaction tracking enabled`,
768+ );
769+
770+
771+ // Window.open interceptor - only install once
772+ if (!window.__dumber_window_open_intercepted) {
773+ try {
774+ window.open = function (
775+ url?: string | URL | null,
776+ target?: string | null,
777+ features?: string | null,
778+ ): WindowProxy | null {
779+ const urlString = url
780+ ? typeof url === "string"
781+ ? url
782+ : url.toString()
783+ : "";
784+ const webviewId = window.__dumber_webview_id || "unknown";
785+
786+ console.log(
787+ `[window-open] [WebView ${webviewId}] Bridge called with:`,
788+ urlString,
789+ target,
790+ features,
791+ );
792+
793+
794+ // Enhanced duplicate check
795+ if (windowOpenDebouncer.isDuplicate(urlString, webviewId)) {
796+ console.log(
797+ `[window-open] [WebView ${webviewId}] BLOCKED: Duplicate request within debounce window`,
798+ );
799+ return createFakeWindow(urlString);
800+ }
801+
802+ // Active WebView check
803+ if (!window.__dumber_is_active) {
804+ console.log(
805+ `[window-open] [WebView ${webviewId}] BLOCKED: Inactive WebView`,
806+ );
807+ return createFakeWindow(urlString);
808+ }
809+
810+ // User interaction check
811+ if (!isUserInteraction()) {
812+ console.log(
813+ `[window-open] [WebView ${webviewId}] BLOCKED: No recent user interaction`,
814+ );
815+ return createFakeWindow("");
816+ }
817+
818+ // Register request and get unique ID
819+ const requestId = windowOpenDebouncer.registerRequest(
820+ urlString,
821+ webviewId,
822+ );
823+
824+ const intent: WindowIntent = {
825+ url: urlString,
826+ target: target || "_blank",
827+ features: features || "",
828+ timestamp: Date.now(),
829+ userTriggered: true,
830+ requestId, // NEW: Add unique request ID
831+ windowType: detectWindowType(features), // Detect popup vs tab
832+ };
833+
834+ // Send to Go backend
835+ try {
836+ if (window.webkit?.messageHandlers?.dumber) {
837+ window.webkit.messageHandlers.dumber.postMessage(
838+ JSON.stringify({
839+ type: "handle-window-open",
840+ payload: intent,
841+ }),
842+ );
843+ console.log(
844+ `[window-open] [WebView ${webviewId}] Sent request ${requestId} to Go backend`,
845+ );
846+ }
847+ } catch (err) {
848+ console.error(
849+ `[window-open] [WebView ${webviewId}] Failed to send request ${requestId}:`,
850+ err,
851+ );
852+ }
853+
854+ return createFakeWindow(urlString, requestId);
855+ };
856+
857+ window.__dumber_window_open_intercepted = true;
858+ console.log(
859+ `[window-open] [WebView ${window.__dumber_webview_id}] ✅ Page-world bridge installed`,
860+ );
861+ } catch (error) {
862+ console.error(
863+ `[window-open] [WebView ${window.__dumber_webview_id}] ❌ Failed to install page-world bridge:`,
864+ error,
865+ );
866+ }
867+ }
868+
869+ // Console capture functionality
870+ const setupConsoleCapture = () => {
871+ const originalConsole = {
872+ log: console.log,
873+ warn: console.warn,
874+ error: console.error,
875+ info: console.info,
876+ debug: console.debug,
877+ };
878+
879+ const sendConsoleMessage = (level: string, args: unknown[]) => {
880+ try {
881+ const message = args
882+ .map((arg) =>
883+ typeof arg === "object" ? JSON.stringify(arg) : String(arg),
884+ )
885+ .join(" ");
886+
887+ const formattedMessage = `${window.location.href} ${message}`;
888+
889+ // Send to Go via existing message handler
890+ if (window.webkit?.messageHandlers?.dumber) {
891+ window.webkit.messageHandlers.dumber.postMessage(
892+ JSON.stringify({
893+ type: "console-message",
894+ payload: {
895+ level,
896+ message: formattedMessage,
897+ url: window.location.href,
898+ webviewId: window.__dumber_webview_id,
899+ },
900+ }),
901+ );
902+ }
903+ } catch {
904+ // Silently ignore errors in console capture to avoid infinite loops
905+ }
906+ };
907+
908+ const createConsoleWrapper = (
909+ originalMethod: (...args: unknown[]) => void,
910+ level: string,
911+ ) => {
912+ return function (...args: unknown[]) {
913+ originalMethod.apply(console, args);
914+ sendConsoleMessage(level, args);
915+ };
916+ };
917+
918+ // Override console methods to capture messages
919+ console.log = createConsoleWrapper(originalConsole.log, "LOG");
920+ console.warn = createConsoleWrapper(originalConsole.warn, "WARN");
921+ console.error = createConsoleWrapper(originalConsole.error, "ERROR");
922+ console.info = createConsoleWrapper(originalConsole.info, "INFO");
923+ console.debug = createConsoleWrapper(originalConsole.debug, "DEBUG");
924+ };
925+
926+ // Initialize console capture
927+ setupConsoleCapture();
928+ } catch (err) {
929+ console.warn("[dumber] unified bridge init failed", err);
930+ }
931+})();
M gui/src/injected/modules/color-scheme.ts +36 -26
@@ -9,13 +9,13 @@
99 declare global {
1010 interface Window {
1111 __dumber_gtk_prefers_dark?: boolean;
12- __dumber_setTheme?: (theme: 'light' | 'dark') => void;
12+ __dumber_setTheme?: (theme: "light" | "dark") => void;
1313 }
1414 }
1515
1616 interface ColorSchemeConfig {
1717 isDark: boolean;
18- colorScheme: 'light' | 'dark';
18+ colorScheme: "light" | "dark";
1919 metaContent: string;
2020 rootStyle: string;
2121 }
@@ -29,9 +29,11 @@ class ColorSchemeManager {
2929
3030 this.config = {
3131 isDark: prefersDark,
32- colorScheme: prefersDark ? 'dark' : 'light',
33- metaContent: prefersDark ? 'dark light' : 'light dark',
34- rootStyle: prefersDark ? ':root{color-scheme:dark;}' : ':root{color-scheme:light;}'
32+ colorScheme: prefersDark ? "dark" : "light",
33+ metaContent: prefersDark ? "dark light" : "light dark",
34+ rootStyle: prefersDark
35+ ? ":root{color-scheme:dark;}"
36+ : ":root{color-scheme:light;}",
3537 };
3638
3739 this.initialize();
@@ -40,7 +42,9 @@ class ColorSchemeManager {
4042 private initialize(): void {
4143 try {
4244 console.log(`[dumber] color-scheme set: ${this.config.colorScheme}`);
43- console.log(`[dumber] GTK detected color mode: ${this.config.colorScheme}`);
45+ console.log(
46+ `[dumber] GTK detected color mode: ${this.config.colorScheme}`,
47+ );
4448
4549 // Notify native layer of theme preference
4650 this.notifyNativeLayer();
@@ -56,9 +60,8 @@ class ColorSchemeManager {
5660
5761 // Set up theme integration with existing UI components
5862 this.setupThemeIntegration();
59-
6063 } catch (error) {
61- console.warn('[dumber] color-scheme injection failed', error);
64+ console.warn("[dumber] color-scheme injection failed", error);
6265 }
6366 }
6467
@@ -66,9 +69,9 @@ class ColorSchemeManager {
6669 try {
6770 (window as any).webkit?.messageHandlers?.dumber?.postMessage(
6871 JSON.stringify({
69- type: 'theme',
70- value: this.config.colorScheme
71- })
72+ type: "theme",
73+ value: this.config.colorScheme,
74+ }),
7275 );
7376 } catch {
7477 // Ignore errors - native layer may not be available
@@ -76,27 +79,32 @@ class ColorSchemeManager {
7679 }
7780
7881 private injectColorSchemeMeta(): void {
79- const meta = document.createElement('meta');
80- meta.name = 'color-scheme';
82+ const meta = document.createElement("meta");
83+ meta.name = "color-scheme";
8184 meta.content = this.config.metaContent;
8285 document.documentElement.appendChild(meta);
8386 }
8487
8588 private applyRootStyles(): void {
86- const style = document.createElement('style');
89+ const style = document.createElement("style");
8790 style.textContent = this.config.rootStyle;
8891 document.documentElement.appendChild(style);
8992 }
9093
9194 private overrideMatchMedia(): void {
92- const darkQuery = '(prefers-color-scheme: dark)';
93- const lightQuery = '(prefers-color-scheme: light)';
95+ const darkQuery = "(prefers-color-scheme: dark)";
96+ const lightQuery = "(prefers-color-scheme: light)";
9497 const originalMatchMedia = window.matchMedia;
9598
9699 window.matchMedia = (query: string): MediaQueryList => {
97- if (typeof query === 'string' && (query.includes(darkQuery) || query.includes(lightQuery))) {
100+ if (
101+ typeof query === "string" &&
102+ (query.includes(darkQuery) || query.includes(lightQuery))
103+ ) {
98104 // Create a mock MediaQueryList that reflects our GTK preference
99- const matches = query.includes('dark') ? this.config.isDark : !this.config.isDark;
105+ const matches = query.includes("dark")
106+ ? this.config.isDark
107+ : !this.config.isDark;
100108
101109 return {
102110 matches,
@@ -106,7 +114,7 @@ class ColorSchemeManager {
106114 removeListener: () => {},
107115 addEventListener: () => {},
108116 removeEventListener: () => {},
109- dispatchEvent: () => false
117+ dispatchEvent: () => false,
110118 } as MediaQueryList;
111119 }
112120
@@ -126,11 +134,11 @@ class ColorSchemeManager {
126134 window.__dumber_setTheme(this.config.colorScheme);
127135 } else {
128136 // Fallback: directly apply dark class for Tailwind compatibility
129- console.warn('[dumber] Theme setter not available, using fallback');
137+ console.warn("[dumber] Theme setter not available, using fallback");
130138 if (this.config.isDark) {
131- document.documentElement.classList.add('dark');
139+ document.documentElement.classList.add("dark");
132140 } else {
133- document.documentElement.classList.remove('dark');
141+ document.documentElement.classList.remove("dark");
134142 }
135143 }
136144 }, 100);
@@ -143,9 +151,11 @@ class ColorSchemeManager {
143151 */
144152 public updateColorScheme(isDark: boolean): void {
145153 this.config.isDark = isDark;
146- this.config.colorScheme = isDark ? 'dark' : 'light';
147- this.config.metaContent = isDark ? 'dark light' : 'light dark';
148- this.config.rootStyle = isDark ? ':root{color-scheme:dark;}' : ':root{color-scheme:light;}';
154+ this.config.colorScheme = isDark ? "dark" : "light";
155+ this.config.metaContent = isDark ? "dark light" : "light dark";
156+ this.config.rootStyle = isDark
157+ ? ":root{color-scheme:dark;}"
158+ : ":root{color-scheme:light;}";
149159
150160 // Re-apply all configurations
151161 this.initialize();
@@ -158,4 +168,4 @@ const colorSchemeManager = new ColorSchemeManager();
158168 // Export for potential runtime updates
159169 (window as any).__dumber_color_scheme_manager = colorSchemeManager;
160170
161-export default colorSchemeManager;⏎̸
171+export default colorSchemeManager;
M gui/src/injected/modules/controls.ts +19 -22
@@ -18,11 +18,11 @@ let controlsInitialized = false;
1818
1919 export function initializeControls(_config?: ControlsConfig): void {
2020 if (controlsInitialized) {
21- console.log('🔄 Browser controls already initialized, skipping');
21+ console.log("🔄 Browser controls already initialized, skipping");
2222 return;
2323 }
2424
25- console.log('🚀 Initializing browser controls on:', window.location.href);
25+ console.log("🚀 Initializing browser controls on:", window.location.href);
2626
2727 // Keyboard event handler for global controls
2828 function handleKeyboardEvent(event: KeyboardEvent): void {
@@ -31,29 +31,28 @@ export function initializeControls(_config?: ControlsConfig): void {
3131
3232 try {
3333 // Navigation: Alt + Left Arrow (Back)
34- if (altKey && key === 'ArrowLeft') {
34+ if (altKey && key === "ArrowLeft") {
3535 event.preventDefault();
3636 event.stopPropagation();
3737
3838 if (window.history.length > 1) {
39- console.log('⬅️ Navigating back');
39+ console.log("⬅️ Navigating back");
4040 window.history.back();
4141 }
4242 return;
4343 }
4444
4545 // Navigation: Alt + Right Arrow (Forward)
46- if (altKey && key === 'ArrowRight') {
46+ if (altKey && key === "ArrowRight") {
4747 event.preventDefault();
4848 event.stopPropagation();
4949
50- console.log('➡️ Navigating forward');
50+ console.log("➡️ Navigating forward");
5151 window.history.forward();
5252 return;
5353 }
54-
5554 } catch (error) {
56- console.error('❌ Error in keyboard handler:', error);
55+ console.error("❌ Error in keyboard handler:", error);
5756 }
5857 }
5958
@@ -66,7 +65,7 @@ export function initializeControls(_config?: ControlsConfig): void {
6665 event.stopPropagation();
6766
6867 if (window.history.length > 1) {
69- console.log('⬅️ Mouse back button pressed');
68+ console.log("⬅️ Mouse back button pressed");
7069 window.history.back();
7170 }
7271 return;
@@ -77,33 +76,31 @@ export function initializeControls(_config?: ControlsConfig): void {
7776 event.preventDefault();
7877 event.stopPropagation();
7978
80- console.log('➡️ Mouse forward button pressed');
79+ console.log("➡️ Mouse forward button pressed");
8180 window.history.forward();
8281 return;
8382 }
84-
8583 } catch (error) {
86- console.error('❌ Error in mouse handler:', error);
84+ console.error("❌ Error in mouse handler:", error);
8785 }
8886 }
8987
9088 // Add event listeners with high priority (capture phase)
9189 try {
92- document.addEventListener('keydown', handleKeyboardEvent, true);
93- document.addEventListener('mousedown', handleMouseEvent, true);
90+ document.addEventListener("keydown", handleKeyboardEvent, true);
91+ document.addEventListener("mousedown", handleMouseEvent, true);
9492
9593 controlsInitialized = true;
96- console.log('✅ Browser controls initialized');
94+ console.log("✅ Browser controls initialized");
9795
9896 // Cleanup function for when page is unloaded
99- window.addEventListener('beforeunload', () => {
100- document.removeEventListener('keydown', handleKeyboardEvent, true);
101- document.removeEventListener('mousedown', handleMouseEvent, true);
97+ window.addEventListener("beforeunload", () => {
98+ document.removeEventListener("keydown", handleKeyboardEvent, true);
99+ document.removeEventListener("mousedown", handleMouseEvent, true);
102100 controlsInitialized = false;
103- console.log('🧹 Browser controls cleaned up');
101+ console.log("🧹 Browser controls cleaned up");
104102 });
105-
106103 } catch (error) {
107- console.error('❌ Failed to initialize browser controls:', error);
104+ console.error("❌ Failed to initialize browser controls:", error);
108105 }
109-}⏎̸
106+}
M gui/src/injected/modules/omnibox.ts +46 -27
@@ -4,10 +4,10 @@
44 * Initializes the Svelte 5 omnibox component system
55 */
66
7-import { mount } from 'svelte';
8-import { Omnibox } from '../../components/omnibox';
9-import { ensureShadowMount } from './shadowHost';
10-import type { OmniboxConfig } from '../../components/omnibox/types';
7+import { mount } from "svelte";
8+import { Omnibox } from "../../components/omnibox";
9+import { ensureShadowMount } from "./shadowHost";
10+import type { OmniboxConfig } from "../../components/omnibox/types";
1111
1212 let omniboxComponent: ReturnType<typeof mount> | null = null;
1313 let isInitialized = false;
@@ -20,43 +20,51 @@ export interface OmniboxInitConfig extends OmniboxConfig {
2020 /**
2121 * Initialize the omnibox component system
2222 */
23-export async function initializeOmnibox(_config: OmniboxInitConfig = {}): Promise<void> {
23+export async function initializeOmnibox(
24+ _config: OmniboxInitConfig = {},
25+): Promise<void> {
2426 if (isInitialized) {
25- console.warn('Omnibox already initialized');
27+ console.warn("Omnibox already initialized");
2628 return;
2729 }
2830
2931 try {
3032 // Ensure we're in the main frame (not an iframe)
3133 if (window.self !== window.top) {
32- console.log('🚫 Omnibox skipped in iframe');
34+ console.log("🚫 Omnibox skipped in iframe");
3335 return;
3436 }
3537
3638 // Check if DOM is ready
3739 if (!document.body) {
38- console.warn('DOM body not ready for omnibox initialization');
40+ console.warn("DOM body not ready for omnibox initialization");
3941 return;
4042 }
4143
4244 // Mount omnibox into the shared global Shadow DOM host
43- const mountEl = ensureShadowMount('dumber-omnibox');
45+ const mountEl = ensureShadowMount("dumber-omnibox");
4446 omniboxComponent = mount(Omnibox, {
45- target: mountEl as unknown as Element
47+ target: mountEl as unknown as Element,
4648 });
4749
4850 // Wait for the component to mount and set up the global API
4951 await new Promise<void>((resolve) => {
5052 const checkAPI = () => {
51- if (window.__dumber_omnibox && typeof window.__dumber_omnibox === 'object') {
52- console.log('✅ Omnibox component system initialized');
53- console.log('🔧 Global API is available:', Object.keys(window.__dumber_omnibox));
53+ if (
54+ window.__dumber_omnibox &&
55+ typeof window.__dumber_omnibox === "object"
56+ ) {
57+ console.log("✅ Omnibox component system initialized");
58+ console.log(
59+ "🔧 Global API is available:",
60+ Object.keys(window.__dumber_omnibox),
61+ );
5462 isInitialized = true;
5563 // Notify isolated bundle listeners that omnibox is ready
5664 try {
57- document.dispatchEvent(new CustomEvent('dumber:omnibox-ready'));
65+ document.dispatchEvent(new CustomEvent("dumber:omnibox-ready"));
5866 } catch (e) {
59- console.warn('Failed to dispatch omnibox-ready event', e);
67+ console.warn("Failed to dispatch omnibox-ready event", e);
6068 }
6169 resolve();
6270 } else {
@@ -71,27 +79,38 @@ export async function initializeOmnibox(_config: OmniboxInitConfig = {}): Promis
7179 try {
7280 const detail = (e as CustomEvent).detail;
7381 const suggestions = detail?.suggestions ?? detail;
74- if (Array.isArray(suggestions) && window.__dumber_omnibox?.setSuggestions) {
75- console.log('🔗 [Bridge] Received page-world suggestions event:', suggestions.length);
82+ if (
83+ Array.isArray(suggestions) &&
84+ window.__dumber_omnibox?.setSuggestions
85+ ) {
86+ console.log(
87+ "🔗 [Bridge] Received page-world suggestions event:",
88+ suggestions.length,
89+ );
7690 window.__dumber_omnibox.setSuggestions(suggestions);
7791 } else {
78- console.warn('🔗 [Bridge] Suggestions event received but API not ready or invalid payload');
92+ console.warn(
93+ "🔗 [Bridge] Suggestions event received but API not ready or invalid payload",
94+ );
7995 }
8096 } catch (err) {
81- console.warn('🔗 [Bridge] Failed handling suggestions event:', err);
97+ console.warn("🔗 [Bridge] Failed handling suggestions event:", err);
8298 }
8399 };
84100
85101 document.addEventListener(
86- 'dumber:omnibox-suggestions',
102+ "dumber:omnibox-suggestions",
87103 handleSuggestionsEvent,
88- false
104+ false,
89105 );
90106 // Unified event name
91- document.addEventListener('dumber:omnibox:suggestions', handleSuggestionsEvent, false);
92-
107+ document.addEventListener(
108+ "dumber:omnibox:suggestions",
109+ handleSuggestionsEvent,
110+ false,
111+ );
93112 } catch (error) {
94- console.error('❌ Failed to initialize omnibox component:', error);
113+ console.error("❌ Failed to initialize omnibox component:", error);
95114 throw error;
96115 }
97116 }
@@ -111,7 +130,7 @@ export function cleanupOmnibox(): void {
111130 try {
112131 omniboxComponent.$destroy?.();
113132 } catch (error) {
114- console.warn('Error destroying omnibox component:', error);
133+ console.warn("Error destroying omnibox component:", error);
115134 }
116135 omniboxComponent = null;
117136 }
@@ -119,5 +138,5 @@ export function cleanupOmnibox(): void {
119138 // The shared shadow host remains persistent; just log cleanup
120139
121140 isInitialized = false;
122- console.log('🧹 Omnibox component cleaned up');
123-}⏎̸
141+ console.log("🧹 Omnibox component cleaned up");
142+}
M gui/src/injected/modules/shadowHost.ts +36 -21
@@ -9,7 +9,7 @@
99 // directly into the shadow root. The ?inline suffix makes Vite embed the
1010 // processed CSS, ensuring all utility classes are available without relying
1111 // on runtime @import resolution (which fails inside the injected shadow DOM).
12-import globalStyles from '../../styles/tailwind.css?inline';
12+import globalStyles from "../../styles/tailwind.css?inline";
1313
1414 // Track shadow-root initialization to avoid duplicate reset injection
1515 const shadowResetApplied = new WeakSet<ShadowRoot>();
@@ -40,7 +40,7 @@ function ensureHostPresence(host: HTMLElement): void {
4040 docEl.appendChild(host);
4141 }
4242
43- if (typeof MutationObserver !== 'undefined') {
43+ if (typeof MutationObserver !== "undefined") {
4444 if (!hostObserver) {
4545 hostObserver = new MutationObserver(() => {
4646 if (cachedHost && !cachedHost.isConnected && document.documentElement) {
@@ -59,7 +59,7 @@ function ensureHostPresence(host: HTMLElement): void {
5959 }
6060 }
6161
62- if (hostKeepaliveTimer === null && typeof window !== 'undefined') {
62+ if (hostKeepaliveTimer === null && typeof window !== "undefined") {
6363 hostKeepaliveTimer = window.setInterval(() => {
6464 if (cachedHost && !cachedHost.isConnected && document.documentElement) {
6565 document.documentElement.appendChild(cachedHost);
@@ -68,16 +68,18 @@ function ensureHostPresence(host: HTMLElement): void {
6868 }
6969 }
7070
71-export const GLOBAL_SHADOW_HOST_ID = 'dumber-ui-root';
71+export const GLOBAL_SHADOW_HOST_ID = "dumber-ui-root";
7272
7373 /**
7474 * Ensure and return the global ShadowRoot used by injected UI.
7575 */
7676 export function getGlobalShadowRoot(): ShadowRoot {
7777 // Create or find the host container
78- let host = document.getElementById(GLOBAL_SHADOW_HOST_ID) as HTMLElement | null;
78+ let host = document.getElementById(
79+ GLOBAL_SHADOW_HOST_ID,
80+ ) as HTMLElement | null;
7981 if (!host) {
80- host = document.createElement('div');
82+ host = document.createElement("div");
8183 host.id = GLOBAL_SHADOW_HOST_ID;
8284 }
8385
@@ -86,22 +88,30 @@ export function getGlobalShadowRoot(): ShadowRoot {
8688 cachedHost = host;
8789 ensureHostPresence(host);
8890
89- const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
91+ const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: "open" });
9092
9193 // Reflect current theme from document root to the shadow host so :host(.dark) works
9294 try {
93- const isDark = document.documentElement.classList.contains('dark');
94- host.classList.toggle('dark', isDark);
95+ const isDark = document.documentElement.classList.contains("dark");
96+ host.classList.toggle("dark", isDark);
9597 // Keep it in sync if the theme toggles later
9698 const observer = new MutationObserver(() => {
97- host.classList.toggle('dark', document.documentElement.classList.contains('dark'));
99+ host.classList.toggle(
100+ "dark",
101+ document.documentElement.classList.contains("dark"),
102+ );
98103 });
99- observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
100- } catch { /* no-op */ }
104+ observer.observe(document.documentElement, {
105+ attributes: true,
106+ attributeFilter: ["class"],
107+ });
108+ } catch {
109+ /* no-op */
110+ }
101111
102112 // Inject a minimal reset and base styles into the shadow root once
103113 if (!shadowResetApplied.has(shadowRoot)) {
104- const resetStyle = document.createElement('style');
114+ const resetStyle = document.createElement("style");
105115 resetStyle.textContent = `
106116 :host { all: initial; }
107117 *, *::before, *::after { box-sizing: border-box; }
@@ -111,7 +121,7 @@ export function getGlobalShadowRoot(): ShadowRoot {
111121
112122 // Ensure dynamic design tokens exist within the shadow tree. Tailwind v4
113123 // utilities rely on these CSS variables. We set light defaults and a dark variant on :host.
114- const tokensStyle = document.createElement('style');
124+ const tokensStyle = document.createElement("style");
115125 tokensStyle.textContent = `
116126 :host {
117127 --dynamic-bg: var(--color-browser-bg);
@@ -136,22 +146,27 @@ export function getGlobalShadowRoot(): ShadowRoot {
136146 // root (e.g., toasts, omnibox) receive their styles
137147 try {
138148 // Prefer constructable stylesheets when available
139- const hasConstructable = typeof CSSStyleSheet !== 'undefined';
140- const supportsAdopted = 'adoptedStyleSheets' in shadowRoot;
149+ const hasConstructable = typeof CSSStyleSheet !== "undefined";
150+ const supportsAdopted = "adoptedStyleSheets" in shadowRoot;
141151 if (supportsAdopted && hasConstructable) {
142152 const sheet = new CSSStyleSheet();
143153 sheet.replaceSync(globalStyles);
144- const rootWithSheets = shadowRoot as ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };
145- rootWithSheets.adoptedStyleSheets = [...(rootWithSheets.adoptedStyleSheets ?? []), sheet];
154+ const rootWithSheets = shadowRoot as ShadowRoot & {
155+ adoptedStyleSheets: CSSStyleSheet[];
156+ };
157+ rootWithSheets.adoptedStyleSheets = [
158+ ...(rootWithSheets.adoptedStyleSheets ?? []),
159+ sheet,
160+ ];
146161 } else {
147162 // Fallback: append a <style> element with the global CSS
148- const styleTag = document.createElement('style');
163+ const styleTag = document.createElement("style");
149164 styleTag.textContent = globalStyles;
150165 shadowRoot.appendChild(styleTag);
151166 }
152167 } catch {
153168 // Final fallback if anything above fails
154- const styleTag = document.createElement('style');
169+ const styleTag = document.createElement("style");
155170 styleTag.textContent = globalStyles;
156171 shadowRoot.appendChild(styleTag);
157172 }
@@ -169,7 +184,7 @@ export function ensureShadowMount(mountId: string): HTMLElement {
169184 const root = getGlobalShadowRoot();
170185 let mount = root.getElementById?.(mountId) as HTMLElement | null;
171186 if (!mount) {
172- mount = document.createElement('div');
187+ mount = document.createElement("div");
173188 mount.id = mountId;
174189 mount.style.cssText = `
175190 position: relative;
M gui/src/injected/modules/toast.ts +46 -38
@@ -5,12 +5,12 @@
55 * with proper DOM ready checking and theme support.
66 */
77
8-import { mount, flushSync } from 'svelte';
9-import ToastContainer from '$components/toast/ToastContainer.svelte';
10-import { ensureShadowMount } from './shadowHost';
8+import { mount, flushSync } from "svelte";
9+import ToastContainer from "$components/toast/ToastContainer.svelte";
10+import { ensureShadowMount } from "./shadowHost";
1111
1212 export interface ToastConfig {
13- theme?: 'light' | 'dark';
13+ theme?: "light" | "dark";
1414 }
1515
1616 // Type definitions for window globals
@@ -26,34 +26,41 @@ let toastInitialized = false;
2626 // Apply theme based on current system/GTK preference
2727 function updateTheme(isDark: boolean): void {
2828 if (isDark) {
29- document.documentElement.classList.add('dark');
29+ document.documentElement.classList.add("dark");
3030 } else {
31- document.documentElement.classList.remove('dark');
31+ document.documentElement.classList.remove("dark");
3232 }
3333 }
3434
3535 export async function initializeToast(config?: ToastConfig): Promise<void> {
3636 if (toastInitialized || window.__dumber_toast_loaded) {
3737 // Verify functions still exist - if not, reinitialize
38- if (typeof window.__dumber_showToast === 'function' &&
39- typeof window.__dumber_showZoomToast === 'function') {
40- console.log('✅ Toast system already initialized and functional');
38+ if (
39+ typeof window.__dumber_showToast === "function" &&
40+ typeof window.__dumber_showZoomToast === "function"
41+ ) {
42+ console.log("✅ Toast system already initialized and functional");
4143 return;
4244 }
4345 // If functions are missing, reinitialize
44- console.log('⚠️ Toast functions missing, reinitializing...');
46+ console.log("⚠️ Toast functions missing, reinitializing...");
4547 toastInitialized = false;
4648 window.__dumber_toast_loaded = false;
4749 }
4850
49- console.log('🚀 Initializing Svelte toast system at:', document.readyState);
50- console.log('📊 DOM state check - head:', !!document.head, 'body:', !!document.body);
51+ console.log("🚀 Initializing Svelte toast system at:", document.readyState);
52+ console.log(
53+ "📊 DOM state check - head:",
54+ !!document.head,
55+ "body:",
56+ !!document.body,
57+ );
5158 window.__dumber_toast_loaded = true;
5259
5360 // Ensure DOM is ready
54- if (document.readyState === 'loading') {
61+ if (document.readyState === "loading") {
5562 return new Promise((resolve) => {
56- document.addEventListener('DOMContentLoaded', async () => {
63+ document.addEventListener("DOMContentLoaded", async () => {
5764 await initializeToast(config);
5865 resolve();
5966 });
@@ -61,15 +68,17 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
6168 }
6269
6370 if (!document.head || !document.body) {
64- console.warn('❌ DOM structure not ready, deferring toast initialization. Retrying...');
71+ console.warn(
72+ "❌ DOM structure not ready, deferring toast initialization. Retrying...",
73+ );
6574 window.__dumber_toast_loaded = false; // Reset the flag so we can try again
6675 return new Promise((resolve) => {
6776 const checkDOM = () => {
6877 if (document.head && document.body) {
69- console.log('✅ DOM structure now ready, initializing toast system');
78+ console.log("✅ DOM structure now ready, initializing toast system");
7079 initializeToast(config).then(() => resolve());
7180 } else {
72- console.log('⏳ DOM still not ready, retrying in 50ms...');
81+ console.log("⏳ DOM still not ready, retrying in 50ms...");
7382 setTimeout(checkDOM, 50);
7483 }
7584 };
@@ -79,23 +88,23 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
7988
8089 try {
8190 // Use the global Shadow DOM host for isolation
82- const rootElement = ensureShadowMount('dumber-toast');
91+ const rootElement = ensureShadowMount("dumber-toast");
8392
8493 // Initialize theme
8594 const initialTheme = config?.theme || window.__dumber_initial_theme;
8695 if (initialTheme !== undefined) {
87- updateTheme(initialTheme === 'dark');
96+ updateTheme(initialTheme === "dark");
8897 }
8998
9099 // Mount the Svelte toast container (Svelte 5 syntax)
91100 mount(ToastContainer, {
92- target: rootElement as unknown as Element
101+ target: rootElement as unknown as Element,
93102 });
94103
95104 // Signal isolated GUI readiness for page-world bridge
96105 try {
97- document.documentElement.setAttribute('data-dumber-gui', 'ready');
98- document.dispatchEvent(new CustomEvent('dumber:gui-ready'));
106+ document.documentElement.setAttribute("data-dumber-gui", "ready");
107+ document.dispatchEvent(new CustomEvent("dumber:gui-ready"));
99108 } catch {
100109 // Ignore errors in GUI readiness signaling
101110 }
@@ -103,33 +112,32 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
103112 // Listen for page-world bridge events and forward to internal toast functions
104113 const handleShowToast = (e: Event) => {
105114 const detail = (e as CustomEvent).detail || {};
106- if (typeof window.__dumber_showToast === 'function') {
115+ if (typeof window.__dumber_showToast === "function") {
107116 window.__dumber_showToast(detail.message, detail.duration, detail.type);
108117 }
109118 };
110- document.addEventListener('dumber:showToast', handleShowToast);
111- document.addEventListener('dumber:toast:show', handleShowToast);
119+ document.addEventListener("dumber:showToast", handleShowToast);
120+ document.addEventListener("dumber:toast:show", handleShowToast);
112121
113122 const handleZoomToast = (e: Event) => {
114123 const detail = (e as CustomEvent).detail || {};
115- if (typeof window.__dumber_showZoomToast === 'function') {
124+ if (typeof window.__dumber_showZoomToast === "function") {
116125 window.__dumber_showZoomToast(detail.level);
117126 }
118127 };
119- document.addEventListener('dumber:showZoomToast', handleZoomToast);
120- document.addEventListener('dumber:toast:zoom', handleZoomToast);
128+ document.addEventListener("dumber:showZoomToast", handleZoomToast);
129+ document.addEventListener("dumber:toast:zoom", handleZoomToast);
121130
122131 // Force immediate effect execution to ensure onMount callbacks run
123132 flushSync();
124133
125134 // Toast functions are now exposed by the component itself
126- console.log('✅ Toast functions exposed via component instantiation');
135+ console.log("✅ Toast functions exposed via component instantiation");
127136
128137 toastInitialized = true;
129- console.log('✅ Toast system initialized with Svelte 5');
130-
138+ console.log("✅ Toast system initialized with Svelte 5");
131139 } catch (error) {
132- console.error('❌ Failed to initialize toast system:', error);
140+ console.error("❌ Failed to initialize toast system:", error);
133141
134142 // Fallback to basic toast system if Svelte fails
135143 initializeFallbackToast();
@@ -139,15 +147,15 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
139147 function exposeToastFunctions(): void {
140148 // The Svelte ToastContainer component already exposes these functions
141149 // We just need to make sure they exist, they're handled by the component
142- console.log('✅ Toast functions will be exposed by ToastContainer component');
150+ console.log("✅ Toast functions will be exposed by ToastContainer component");
143151 }
144152
145153 function initializeFallbackToast(): void {
146- console.warn('🔄 Falling back to basic toast system');
154+ console.warn("🔄 Falling back to basic toast system");
147155
148156 // Create basic toast container for fallback
149- const fallbackContainer = document.createElement('div');
150- fallbackContainer.id = 'dumber-fallback-toast';
157+ const fallbackContainer = document.createElement("div");
158+ fallbackContainer.id = "dumber-fallback-toast";
151159 fallbackContainer.style.cssText = `
152160 position: fixed;
153161 top: 20px;
@@ -161,5 +169,5 @@ function initializeFallbackToast(): void {
161169 exposeToastFunctions();
162170
163171 toastInitialized = true;
164- console.log('✅ Fallback toast system ready');
165-}⏎̸
172+ console.log("✅ Fallback toast system ready");
173+}
A gui/src/injected/modules/window-open.ts +29 -0
@@ -0,0 +1,29 @@
1+/**
2+ * Window Open Interceptor Module
3+ *
4+ * OBSOLETE: window.open() is now handled directly via page-world bridge bypass.
5+ * This module is kept for compatibility but no longer performs active interception.
6+ */
7+
8+let isInitialized = false;
9+
10+/**
11+ * Initialize the window.open interceptor (no-op)
12+ * Window.open is now handled directly via JavaScript bypass in page-world
13+ */
14+export function initializeWindowOpenInterceptor(): void {
15+ if (isInitialized) {
16+ return;
17+ }
18+
19+ // No-op - window.open handled directly via page-world bridge
20+ isInitialized = true;
21+ console.log("[window-open] ✅ Interceptor initialized (bypass mode)");
22+}
23+
24+/**
25+ * Get initialization status
26+ */
27+export function isWindowOpenInterceptorInitialized(): boolean {
28+ return isInitialized;
29+}
M gui/src/injected/modules/workspace.ts +148 -101
@@ -6,10 +6,10 @@
66 * and wires keyboard service shortcuts based on configuration defaults.
77 */
88
9-import { keyboardService } from '$lib/keyboard';
9+import { keyboardService } from "$lib/keyboard";
1010
11-const ACTIVATION_COMPONENT_ID = 'workspace-pane-activation';
12-const ACTION_COMPONENT_ID = 'workspace-pane-actions';
11+const ACTIVATION_COMPONENT_ID = "workspace-pane-activation";
12+const ACTION_COMPONENT_ID = "workspace-pane-actions";
1313
1414 export interface WorkspaceConfigPayload {
1515 enable_zellij_controls?: boolean;
@@ -69,61 +69,70 @@ export interface WorkspaceRuntime {
6969 const DEFAULT_CONFIG: WorkspaceConfigNormalized = {
7070 enableZellijControls: true,
7171 paneMode: {
72- activationShortcut: 'cmdorctrl+p',
72+ activationShortcut: "cmdorctrl+p",
7373 timeoutMs: 3000,
7474 actionBindings: {
75- arrowright: 'split-right',
76- arrowleft: 'split-left',
77- arrowup: 'split-up',
78- arrowdown: 'split-down',
79- r: 'split-right',
80- l: 'split-left',
81- u: 'split-up',
82- d: 'split-down',
83- x: 'close-pane',
84- enter: 'confirm',
85- escape: 'cancel'
86- }
75+ arrowright: "split-right",
76+ arrowleft: "split-left",
77+ arrowup: "split-up",
78+ arrowdown: "split-down",
79+ r: "split-right",
80+ l: "split-left",
81+ u: "split-up",
82+ d: "split-down",
83+ x: "close-pane",
84+ enter: "confirm",
85+ escape: "cancel",
86+ },
8787 },
8888 tabs: {
89- newTab: 'cmdorctrl+t',
90- closeTab: 'cmdorctrl+w',
91- nextTab: 'cmdorctrl+tab',
92- previousTab: 'cmdorctrl+shift+tab'
89+ newTab: "cmdorctrl+t",
90+ closeTab: "cmdorctrl+w",
91+ nextTab: "cmdorctrl+tab",
92+ previousTab: "cmdorctrl+shift+tab",
9393 },
9494 popups: {
95- placement: 'right',
95+ placement: "right",
9696 openInNewPane: true,
97- followPaneContext: true
98- }
97+ followPaneContext: true,
98+ },
9999 };
100100
101-function normalizeWorkspaceConfig(payload?: WorkspaceConfigPayload | null): WorkspaceConfigNormalized {
101+function normalizeWorkspaceConfig(
102+ payload?: WorkspaceConfigPayload | null,
103+): WorkspaceConfigNormalized {
102104 const paneModePayload = payload?.pane_mode ?? {};
103105 const tabsPayload = payload?.tabs ?? {};
104106 const popupsPayload = payload?.popups ?? {};
105107
106108 return {
107- enableZellijControls: payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,
109+ enableZellijControls:
110+ payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,
108111 paneMode: {
109- activationShortcut: paneModePayload.activation_shortcut ?? DEFAULT_CONFIG.paneMode.activationShortcut,
110- timeoutMs: paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,
112+ activationShortcut:
113+ paneModePayload.activation_shortcut ??
114+ DEFAULT_CONFIG.paneMode.activationShortcut,
115+ timeoutMs:
116+ paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,
111117 actionBindings: {
112118 ...DEFAULT_CONFIG.paneMode.actionBindings,
113- ...(paneModePayload.action_bindings ?? {})
114- }
119+ ...(paneModePayload.action_bindings ?? {}),
120+ },
115121 },
116122 tabs: {
117123 newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab,
118124 closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab,
119125 nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab,
120- previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab
126+ previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab,
121127 },
122128 popups: {
123129 placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement,
124- openInNewPane: popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,
125- followPaneContext: popupsPayload.follow_pane_context ?? DEFAULT_CONFIG.popups.followPaneContext
126- }
130+ openInNewPane:
131+ popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,
132+ followPaneContext:
133+ popupsPayload.follow_pane_context ??
134+ DEFAULT_CONFIG.popups.followPaneContext,
135+ },
127136 };
128137 }
129138
@@ -137,7 +146,9 @@ class WorkspaceController implements WorkspaceRuntime {
137146 constructor() {
138147 // Generate unique instance ID for this webview
139148 this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`;
140- console.log(`[workspace] Created WorkspaceController instance: ${this.instanceId}`);
149+ console.log(
150+ `[workspace] Created WorkspaceController instance: ${this.instanceId}`,
151+ );
141152 }
142153
143154 getInstanceId(): string {
@@ -156,16 +167,21 @@ class WorkspaceController implements WorkspaceRuntime {
156167 // In a multi-pane environment, only the focused webview should handle pane mode
157168 try {
158169 const hasFocus = document.hasFocus();
159- const isVisible = document.visibilityState === 'visible';
170+ const isVisible = document.visibilityState === "visible";
160171 const isActive = hasFocus && isVisible;
161172
162173 if (!isActive) {
163- console.log(`[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`);
174+ console.log(
175+ `[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`,
176+ );
164177 }
165178
166179 return isActive;
167180 } catch (error) {
168- console.warn('[workspace] Failed to check active state, assuming active:', error);
181+ console.warn(
182+ "[workspace] Failed to check active state, assuming active:",
183+ error,
184+ );
169185 return true; // Fallback to active if check fails
170186 }
171187 }
@@ -176,31 +192,34 @@ class WorkspaceController implements WorkspaceRuntime {
176192 this.config = normalized;
177193
178194 if (!normalized.enableZellijControls) {
179- console.log('[workspace] Zellij controls disabled via config');
195+ console.log("[workspace] Zellij controls disabled via config");
180196 window.__dumber_workspace = this.exportRuntime();
181197 return;
182198 }
183199
184200 this.registerShortcuts();
185201 window.__dumber_workspace = this.exportRuntime();
186- console.log('[workspace] Zellij controls enabled with config:', this.config);
202+ console.log(
203+ "[workspace] Zellij controls enabled with config:",
204+ this.config,
205+ );
187206 }
188207
189208 private bridge(message: Record<string, unknown>): void {
190209 const bridge = window.webkit?.messageHandlers?.dumber;
191- if (!bridge || typeof bridge.postMessage !== 'function') {
210+ if (!bridge || typeof bridge.postMessage !== "function") {
192211 return;
193212 }
194213
195214 try {
196215 bridge.postMessage(
197216 JSON.stringify({
198- type: 'workspace',
199- ...message
200- })
217+ type: "workspace",
218+ ...message,
219+ }),
201220 );
202221 } catch (error) {
203- console.error('[workspace] Failed to post workspace message', error);
222+ console.error("[workspace] Failed to post workspace message", error);
204223 }
205224 }
206225
@@ -209,19 +228,25 @@ class WorkspaceController implements WorkspaceRuntime {
209228
210229 // Only allow the active instance to enter pane mode
211230 if (!this.isActiveInstance()) {
212- console.log(`[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`);
231+ console.log(
232+ `[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`,
233+ );
213234 return;
214235 }
215236
216237 console.log(`[workspace] Instance ${this.instanceId} entering pane mode`);
217238 this.paneModeActive = true;
218239 this.restartPaneModeTimer();
219- this.emitWorkspaceEvent('pane-mode-entered', { action: 'enter', config: this.config, instanceId: this.instanceId });
220- this.bridge({ event: 'pane-mode-entered', instanceId: this.instanceId });
221- this.showToast('Pane mode: awaiting direction');
240+ this.emitWorkspaceEvent("pane-mode-entered", {
241+ action: "enter",
242+ config: this.config,
243+ instanceId: this.instanceId,
244+ });
245+ this.bridge({ event: "pane-mode-entered", instanceId: this.instanceId });
246+ this.showToast("Pane mode: awaiting direction");
222247 }
223248
224- exitPaneMode(reason: string = 'exit'): void {
249+ exitPaneMode(reason: string = "exit"): void {
225250 if (!this.paneModeActive) return;
226251
227252 this.paneModeActive = false;
@@ -229,8 +254,8 @@ class WorkspaceController implements WorkspaceRuntime {
229254 clearTimeout(this.paneModeTimer);
230255 this.paneModeTimer = null;
231256 }
232- this.emitWorkspaceEvent('pane-mode-exited', { action: reason });
233- this.bridge({ event: 'pane-mode-exited', reason });
257+ this.emitWorkspaceEvent("pane-mode-exited", { action: reason });
258+ this.bridge({ event: "pane-mode-exited", reason });
234259 }
235260
236261 isPaneModeActive(): boolean {
@@ -246,20 +271,22 @@ class WorkspaceController implements WorkspaceRuntime {
246271 {
247272 key: this.config.paneMode.activationShortcut,
248273 handler: () => this.enterPaneMode(),
249- description: 'Enter pane mode',
274+ description: "Enter pane mode",
250275 preventDefault: true,
251276 stopPropagation: true,
252- whenFocused: false
253- }
277+ whenFocused: false,
278+ },
254279 ]);
255280
256- const paneShortcuts = Object.entries(this.config.paneMode.actionBindings).map(([key, action]) => ({
281+ const paneShortcuts = Object.entries(
282+ this.config.paneMode.actionBindings,
283+ ).map(([key, action]) => ({
257284 key,
258285 handler: () => this.handlePaneAction(action, key),
259286 description: `Pane action: ${action}`,
260287 preventDefault: true,
261288 stopPropagation: true,
262- whenFocused: false
289+ whenFocused: false,
263290 }));
264291
265292 keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts);
@@ -276,48 +303,60 @@ class WorkspaceController implements WorkspaceRuntime {
276303 }
277304
278305 switch (action) {
279- case 'split-right':
280- this.emitWorkspaceEvent('pane-split', { direction: 'right', config: this.config });
281- this.bridge({ event: 'pane-split', direction: 'right' });
282- this.showToast('Split pane to the right');
283- this.exitPaneMode('split-right');
306+ case "split-right":
307+ this.emitWorkspaceEvent("pane-split", {
308+ direction: "right",
309+ config: this.config,
310+ });
311+ this.bridge({ event: "pane-split", direction: "right" });
312+ this.showToast("Split pane to the right");
313+ this.exitPaneMode("split-right");
284314 break;
285- case 'split-left':
286- this.emitWorkspaceEvent('pane-split', { direction: 'left', config: this.config });
287- this.bridge({ event: 'pane-split', direction: 'left' });
288- this.showToast('Split pane to the left');
289- this.exitPaneMode('split-left');
315+ case "split-left":
316+ this.emitWorkspaceEvent("pane-split", {
317+ direction: "left",
318+ config: this.config,
319+ });
320+ this.bridge({ event: "pane-split", direction: "left" });
321+ this.showToast("Split pane to the left");
322+ this.exitPaneMode("split-left");
290323 break;
291- case 'split-up':
292- this.emitWorkspaceEvent('pane-split', { direction: 'up', config: this.config });
293- this.bridge({ event: 'pane-split', direction: 'up' });
294- this.showToast('Split pane upwards');
295- this.exitPaneMode('split-up');
324+ case "split-up":
325+ this.emitWorkspaceEvent("pane-split", {
326+ direction: "up",
327+ config: this.config,
328+ });
329+ this.bridge({ event: "pane-split", direction: "up" });
330+ this.showToast("Split pane upwards");
331+ this.exitPaneMode("split-up");
296332 break;
297- case 'split-down':
298- this.emitWorkspaceEvent('pane-split', { direction: 'down', config: this.config });
299- this.bridge({ event: 'pane-split', direction: 'down' });
300- this.showToast('Split pane downwards');
301- this.exitPaneMode('split-down');
333+ case "split-down":
334+ this.emitWorkspaceEvent("pane-split", {
335+ direction: "down",
336+ config: this.config,
337+ });
338+ this.bridge({ event: "pane-split", direction: "down" });
339+ this.showToast("Split pane downwards");
340+ this.exitPaneMode("split-down");
302341 break;
303- case 'close-pane':
304- this.emitWorkspaceEvent('pane-closed', { key, config: this.config });
305- this.bridge({ event: 'pane-close', action });
306- this.showToast('Closed pane');
307- this.exitPaneMode('close-pane');
342+ case "close-pane":
343+ this.emitWorkspaceEvent("pane-closed", { key, config: this.config });
344+ this.bridge({ event: "pane-close", action });
345+ this.showToast("Closed pane");
346+ this.exitPaneMode("close-pane");
308347 break;
309- case 'confirm':
310- this.bridge({ event: 'pane-confirmed' });
311- this.showToast('Pane mode confirmed');
312- this.exitPaneMode('confirm');
348+ case "confirm":
349+ this.bridge({ event: "pane-confirmed" });
350+ this.showToast("Pane mode confirmed");
351+ this.exitPaneMode("confirm");
313352 break;
314- case 'cancel':
315- this.bridge({ event: 'pane-cancelled' });
316- this.showToast('Pane mode cancelled');
317- this.exitPaneMode('cancel');
353+ case "cancel":
354+ this.bridge({ event: "pane-cancelled" });
355+ this.showToast("Pane mode cancelled");
356+ this.exitPaneMode("cancel");
318357 break;
319358 default:
320- console.warn('[workspace] Unhandled pane action:', action);
359+ console.warn("[workspace] Unhandled pane action:", action);
321360 }
322361 }
323362
@@ -328,32 +367,38 @@ class WorkspaceController implements WorkspaceRuntime {
328367
329368 if (this.config.paneMode.timeoutMs > 0) {
330369 this.paneModeTimer = setTimeout(() => {
331- this.showToast('Pane mode timed out');
332- this.exitPaneMode('timeout');
370+ this.showToast("Pane mode timed out");
371+ this.exitPaneMode("timeout");
333372 }, this.config.paneMode.timeoutMs);
334373 }
335374 }
336375
337376 private showToast(message: string): void {
338- if (typeof window.__dumber_showToast === 'function') {
339- window.__dumber_showToast(message, 2000, 'info');
377+ if (typeof window.__dumber_showToast === "function") {
378+ window.__dumber_showToast(message, 2000, "info");
340379 } else {
341- console.log('[workspace toast]', message);
380+ console.log("[workspace toast]", message);
342381 }
343382 }
344383
345- private emitWorkspaceEvent(event: string, detail: Record<string, unknown>): void {
346- document.dispatchEvent(new CustomEvent(`dumber:workspace-${event}`, { detail }));
384+ private emitWorkspaceEvent(
385+ event: string,
386+ detail: Record<string, unknown>,
387+ ): void {
388+ document.dispatchEvent(
389+ new CustomEvent(`dumber:workspace-${event}`, { detail }),
390+ );
347391 }
348392
349393 private exportRuntime(): WorkspaceRuntime {
350394 return {
351395 enterPaneMode: () => this.enterPaneMode(),
352- exitPaneMode: (reason?: string) => this.exitPaneMode(reason ?? 'external'),
396+ exitPaneMode: (reason?: string) =>
397+ this.exitPaneMode(reason ?? "external"),
353398 isPaneModeActive: () => this.isPaneModeActive(),
354399 getConfig: () => this.getConfig(),
355400 getInstanceId: () => this.getInstanceId(),
356- isActiveInstance: () => this.isActiveInstance()
401+ isActiveInstance: () => this.isActiveInstance(),
357402 };
358403 }
359404 }
@@ -361,9 +406,11 @@ class WorkspaceController implements WorkspaceRuntime {
361406 const workspaceController = new WorkspaceController();
362407 let listenersAttached = false;
363408
364-export function initializeWorkspace(payload?: WorkspaceConfigPayload | null): void {
409+export function initializeWorkspace(
410+ payload?: WorkspaceConfigPayload | null,
411+): void {
365412 if (!listenersAttached) {
366- document.addEventListener('dumber:workspace-config', (event: Event) => {
413+ document.addEventListener("dumber:workspace-config", (event: Event) => {
367414 const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail;
368415 workspaceController.configure(detail);
369416 });
M gui/src/injected/styles-entry.css +1 -1
@@ -1,2 +1,2 @@
11 /* Vite/PostCSS/Tailwind processed CSS entry for Shadow DOM injection */
2-@import '../lib/styles.css';
2+@import "../lib/styles.css";
M gui/src/lib/keyboard/KeyboardService.ts +112 -63
@@ -11,15 +11,18 @@ import type {
1111 KeyboardServiceConfig,
1212 KeyboardEventDetail,
1313 KeyboardEvent,
14- KeyboardEventType
15-} from './types';
14+ KeyboardEventType,
15+} from "./types";
1616 // COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers
1717
1818 export class KeyboardService {
1919 private components = new Map<string, ComponentShortcuts>();
2020 private activeComponent: string | null = null;
2121 private globalShortcuts: ShortcutConfig[] = [];
22- private eventListeners = new Map<KeyboardEventType, Set<(event: KeyboardEvent) => void>>();
22+ private eventListeners = new Map<
23+ KeyboardEventType,
24+ Set<(event: KeyboardEvent) => void>
25+ >();
2326 private debug: boolean = false;
2427 // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now
2528
@@ -27,7 +30,7 @@ export class KeyboardService {
2730 this.debug = config.debug ?? false;
2831 this.globalShortcuts = config.globalShortcuts ?? [];
2932
30- this.log('info', 'KeyboardService initialized', { config });
33+ this.log("info", "KeyboardService initialized", { config });
3134 }
3235
3336 /**
@@ -35,27 +38,33 @@ export class KeyboardService {
3538 */
3639 registerShortcuts(componentId: string, shortcuts: ShortcutConfig[]): void {
3740 if (this.components.has(componentId)) {
38- this.log('warn', `Component ${componentId} already registered, overwriting shortcuts`);
41+ this.log(
42+ "warn",
43+ `Component ${componentId} already registered, overwriting shortcuts`,
44+ );
3945 }
4046
4147 const componentShortcuts: ComponentShortcuts = {
4248 componentId,
43- shortcuts: shortcuts.map(shortcut => ({
49+ shortcuts: shortcuts.map((shortcut) => ({
4450 ...shortcut,
4551 preventDefault: shortcut.preventDefault ?? true,
4652 stopPropagation: shortcut.stopPropagation ?? true,
47- whenFocused: shortcut.whenFocused ?? false
53+ whenFocused: shortcut.whenFocused ?? false,
4854 })),
49- focused: false
55+ focused: false,
5056 };
5157
5258 this.components.set(componentId, componentShortcuts);
5359
54- this.log('info', 'Shortcuts registered', { componentId, shortcuts: shortcuts.length });
55- this.emit('shortcut-registered', {
60+ this.log("info", "Shortcuts registered", {
61+ componentId,
62+ shortcuts: shortcuts.length,
63+ });
64+ this.emit("shortcut-registered", {
5665 shortcut: componentId,
5766 timestamp: Date.now(),
58- targetComponent: componentId
67+ targetComponent: componentId,
5968 });
6069 }
6170
@@ -65,7 +74,7 @@ export class KeyboardService {
6574 unregisterShortcuts(componentId: string): void {
6675 const component = this.components.get(componentId);
6776 if (!component) {
68- this.log('warn', `Component ${componentId} not found for unregistration`);
77+ this.log("warn", `Component ${componentId} not found for unregistration`);
6978 return;
7079 }
7180
@@ -81,11 +90,11 @@ export class KeyboardService {
8190 this.activeComponent = null;
8291 }
8392
84- this.log('info', 'Shortcuts unregistered', { componentId });
85- this.emit('shortcut-unregistered', {
93+ this.log("info", "Shortcuts unregistered", { componentId });
94+ this.emit("shortcut-unregistered", {
8695 shortcut: componentId,
8796 timestamp: Date.now(),
88- targetComponent: componentId
97+ targetComponent: componentId,
8998 });
9099 }
91100
@@ -97,10 +106,10 @@ export class KeyboardService {
97106 if (this.activeComponent && this.components.has(this.activeComponent)) {
98107 const prevComponent = this.components.get(this.activeComponent)!;
99108 prevComponent.focused = false;
100- this.emit('component-blurred', {
109+ this.emit("component-blurred", {
101110 shortcut: this.activeComponent,
102111 timestamp: Date.now(),
103- targetComponent: this.activeComponent
112+ targetComponent: this.activeComponent,
104113 });
105114 }
106115
@@ -110,28 +119,35 @@ export class KeyboardService {
110119 if (componentId && this.components.has(componentId)) {
111120 const newComponent = this.components.get(componentId)!;
112121 newComponent.focused = true;
113- this.emit('component-focused', {
122+ this.emit("component-focused", {
114123 shortcut: componentId,
115124 timestamp: Date.now(),
116- targetComponent: componentId
125+ targetComponent: componentId,
117126 });
118127 }
119128
120- this.log('info', 'Active component changed', { from: this.activeComponent, to: componentId });
129+ this.log("info", "Active component changed", {
130+ from: this.activeComponent,
131+ to: componentId,
132+ });
121133 }
122134
123135 /**
124136 * Handle a keyboard shortcut from native GTK layer
125137 */
126138 handleNativeShortcut(shortcut: string): boolean {
127- this.log('info', 'Native shortcut received', { shortcut });
139+ this.log("info", "Native shortcut received", { shortcut });
128140
129141 // Normalize shortcut string
130142 const normalizedShortcut = this.normalizeShortcut(shortcut);
131143
132144 // Try to handle with focused component first
133145 if (this.activeComponent) {
134- const handled = this.handleComponentShortcut(this.activeComponent, normalizedShortcut, true);
146+ const handled = this.handleComponentShortcut(
147+ this.activeComponent,
148+ normalizedShortcut,
149+ true,
150+ );
135151 if (handled) {
136152 return true;
137153 }
@@ -147,13 +163,17 @@ export class KeyboardService {
147163 for (const [componentId] of this.components) {
148164 if (componentId === this.activeComponent) continue; // Already tried above
149165
150- const handled = this.handleComponentShortcut(componentId, normalizedShortcut, false);
166+ const handled = this.handleComponentShortcut(
167+ componentId,
168+ normalizedShortcut,
169+ false,
170+ );
151171 if (handled) {
152172 return true;
153173 }
154174 }
155175
156- this.log('info', 'Shortcut not handled', { shortcut: normalizedShortcut });
176+ this.log("info", "Shortcut not handled", { shortcut: normalizedShortcut });
157177 return false;
158178 }
159179
@@ -164,7 +184,7 @@ export class KeyboardService {
164184 const shortcut = this.eventToShortcut(event);
165185 if (!shortcut) return false;
166186
167- this.log('info', 'Keyboard event received', { shortcut });
187+ this.log("info", "Keyboard event received", { shortcut });
168188 return this.handleNativeShortcut(shortcut);
169189 }
170190
@@ -179,7 +199,7 @@ export class KeyboardService {
179199 event.stopPropagation();
180200
181201 if (window.history.length > 1) {
182- console.log('⬅️ Mouse back button pressed');
202+ console.log("⬅️ Mouse back button pressed");
183203 window.history.back();
184204 }
185205 return true;
@@ -190,13 +210,12 @@ export class KeyboardService {
190210 event.preventDefault();
191211 event.stopPropagation();
192212
193- console.log('➡️ Mouse forward button pressed');
213+ console.log("➡️ Mouse forward button pressed");
194214 window.history.forward();
195215 return true;
196216 }
197-
198217 } catch (error) {
199- this.log('error', 'Mouse handler error', { error });
218+ this.log("error", "Mouse handler error", { error });
200219 }
201220
202221 return false;
@@ -205,7 +224,10 @@ export class KeyboardService {
205224 /**
206225 * Add event listener for keyboard service events
207226 */
208- addEventListener(type: KeyboardEventType, listener: (event: KeyboardEvent) => void): void {
227+ addEventListener(
228+ type: KeyboardEventType,
229+ listener: (event: KeyboardEvent) => void,
230+ ): void {
209231 if (!this.eventListeners.has(type)) {
210232 this.eventListeners.set(type, new Set());
211233 }
@@ -215,7 +237,10 @@ export class KeyboardService {
215237 /**
216238 * Remove event listener
217239 */
218- removeEventListener(type: KeyboardEventType, listener: (event: KeyboardEvent) => void): void {
240+ removeEventListener(
241+ type: KeyboardEventType,
242+ listener: (event: KeyboardEvent) => void,
243+ ): void {
219244 const listeners = this.eventListeners.get(type);
220245 if (listeners) {
221246 listeners.delete(listener);
@@ -230,16 +255,20 @@ export class KeyboardService {
230255 components: Array.from(this.components.entries()).map(([id, comp]) => ({
231256 id,
232257 shortcuts: comp.shortcuts.length,
233- focused: comp.focused
258+ focused: comp.focused,
234259 })),
235260 activeComponent: this.activeComponent,
236- globalShortcuts: this.globalShortcuts.length
261+ globalShortcuts: this.globalShortcuts.length,
237262 };
238263 }
239264
240265 // Private methods
241266
242- private handleComponentShortcut(componentId: string, shortcut: string, respectFocus: boolean): boolean {
267+ private handleComponentShortcut(
268+ componentId: string,
269+ shortcut: string,
270+ respectFocus: boolean,
271+ ): boolean {
243272 const component = this.components.get(componentId);
244273 if (!component) return false;
245274
@@ -252,22 +281,26 @@ export class KeyboardService {
252281
253282 try {
254283 shortcutConfig.handler();
255- this.log('info', 'Shortcut handled', {
284+ this.log("info", "Shortcut handled", {
256285 shortcut,
257286 componentId,
258287 focused: component.focused,
259- description: shortcutConfig.description
288+ description: shortcutConfig.description,
260289 });
261290
262- this.emit('shortcut-triggered', {
291+ this.emit("shortcut-triggered", {
263292 shortcut,
264293 timestamp: Date.now(),
265- targetComponent: componentId
294+ targetComponent: componentId,
266295 });
267296
268297 return true;
269298 } catch (error) {
270- this.log('error', 'Shortcut handler error', { shortcut, componentId, error });
299+ this.log("error", "Shortcut handler error", {
300+ shortcut,
301+ componentId,
302+ error,
303+ });
271304 }
272305 }
273306 }
@@ -280,16 +313,22 @@ export class KeyboardService {
280313 if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
281314 try {
282315 shortcutConfig.handler();
283- this.log('info', 'Global shortcut handled', { shortcut, description: shortcutConfig.description });
316+ this.log("info", "Global shortcut handled", {
317+ shortcut,
318+ description: shortcutConfig.description,
319+ });
284320
285- this.emit('shortcut-triggered', {
321+ this.emit("shortcut-triggered", {
286322 shortcut,
287- timestamp: Date.now()
323+ timestamp: Date.now(),
288324 });
289325
290326 return true;
291327 } catch (error) {
292- this.log('error', 'Global shortcut handler error', { shortcut, error });
328+ this.log("error", "Global shortcut handler error", {
329+ shortcut,
330+ error,
331+ });
293332 }
294333 }
295334 }
@@ -297,7 +336,7 @@ export class KeyboardService {
297336 }
298337
299338 private normalizeShortcut(shortcut: string): string {
300- return shortcut.toLowerCase().replace(/\s+/g, '');
339+ return shortcut.toLowerCase().replace(/\s+/g, "");
301340 }
302341
303342 private eventToShortcut(event: globalThis.KeyboardEvent): string | null {
@@ -306,48 +345,54 @@ export class KeyboardService {
306345 // Add modifiers using MODIFIER_KEYS mapping
307346 if (event.ctrlKey && event.metaKey) {
308347 // Both ctrl and meta pressed - use cmdorctrl
309- parts.push('cmdorctrl');
348+ parts.push("cmdorctrl");
310349 } else if (event.ctrlKey) {
311- parts.push('ctrl');
350+ parts.push("ctrl");
312351 } else if (event.metaKey) {
313- parts.push('cmd');
352+ parts.push("cmd");
314353 }
315354
316- if (event.altKey) parts.push('alt');
317- if (event.shiftKey) parts.push('shift');
355+ if (event.altKey) parts.push("alt");
356+ if (event.shiftKey) parts.push("shift");
318357
319358 // Add key
320359 if (event.key) {
321360 parts.push(event.key.toLowerCase());
322361 }
323362
324- return parts.length > 0 ? parts.join('+') : null;
363+ return parts.length > 0 ? parts.join("+") : null;
325364 }
326365
327366 private emit(type: KeyboardEventType, detail: KeyboardEventDetail): void {
328367 const listeners = this.eventListeners.get(type);
329368 if (listeners) {
330369 const keyboardEvent: KeyboardEvent = { type, detail };
331- listeners.forEach(listener => {
370+ listeners.forEach((listener) => {
332371 try {
333372 listener(keyboardEvent);
334373 } catch (error) {
335- this.log('error', 'Event listener error', { type, error });
374+ this.log("error", "Event listener error", { type, error });
336375 }
337376 });
338377 }
339378 }
340379
341- private log(level: 'info' | 'warn' | 'error' | string, message?: string, data?: unknown): void {
342- if (!this.debug && level !== 'error') return;
380+ private log(
381+ level: "info" | "warn" | "error" | string,
382+ message?: string,
383+ data?: unknown,
384+ ): void {
385+ if (!this.debug && level !== "error") return;
343386
344- const logMessage = message ? `[KeyboardService] ${message}` : `[KeyboardService] ${level}`;
387+ const logMessage = message
388+ ? `[KeyboardService] ${message}`
389+ : `[KeyboardService] ${level}`;
345390
346391 switch (level) {
347- case 'error':
392+ case "error":
348393 console.error(logMessage, data);
349394 break;
350- case 'warn':
395+ case "warn":
351396 console.warn(logMessage, data);
352397 break;
353398 default:
@@ -362,27 +407,31 @@ export const keyboardService = new KeyboardService({
362407 globalShortcuts: [
363408 // Navigation shortcuts (migrated from controls module)
364409 {
365- key: 'cmdorctrl+arrowleft',
410+ key: "cmdorctrl+arrowleft",
366411 handler: () => {
367412 if (window.history.length > 1) {
368413 window.history.back();
369414 }
370415 },
371- description: 'Navigate back (Ctrl/Cmd+Left Arrow)'
416+ description: "Navigate back (Ctrl/Cmd+Left Arrow)",
372417 },
373418 {
374- key: 'cmdorctrl+arrowright',
419+ key: "cmdorctrl+arrowright",
375420 handler: () => {
376421 window.history.forward();
377422 },
378- description: 'Navigate forward (Ctrl/Cmd+Right Arrow)'
423+ description: "Navigate forward (Ctrl/Cmd+Right Arrow)",
379424 },
380425 // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled
381426 // directly by the native Go shortcut handlers to ensure proper active pane detection.
382427 // This avoids conflicts between multiple webview instances.
383- ]
428+ ],
384429 });
385430
386431 // Export types for use in components
387-export type { ShortcutConfig, ComponentShortcuts, KeyboardServiceConfig } from './types';
432+export type {
433+ ShortcutConfig,
434+ ComponentShortcuts,
435+ KeyboardServiceConfig,
436+} from "./types";
388437 // COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers
M gui/src/lib/keyboard/index.ts +4 -4
@@ -5,13 +5,13 @@
55 * Provides type-safe keyboard event handling and component integration.
66 */
77
8-export { KeyboardService, keyboardService } from './KeyboardService';
8+export { KeyboardService, keyboardService } from "./KeyboardService";
99 export type {
1010 ShortcutConfig,
1111 ComponentShortcuts,
1212 KeyboardServiceConfig,
1313 KeyboardEventDetail,
1414 KeyboardEvent,
15- KeyboardEventType
16-} from './types';
17-export { MODIFIER_KEYS, COMMON_SHORTCUTS } from './types';⏎̸
15+ KeyboardEventType,
16+} from "./types";
17+export { MODIFIER_KEYS, COMMON_SHORTCUTS } from "./types";
M gui/src/lib/keyboard/types.ts +21 -21
@@ -62,11 +62,11 @@ export interface KeyboardServiceConfig {
6262 * Event types for keyboard service
6363 */
6464 export type KeyboardEventType =
65- | 'shortcut-triggered'
66- | 'component-focused'
67- | 'component-blurred'
68- | 'shortcut-registered'
69- | 'shortcut-unregistered';
65+ | "shortcut-triggered"
66+ | "component-focused"
67+ | "component-blurred"
68+ | "shortcut-registered"
69+ | "shortcut-unregistered";
7070
7171 export interface KeyboardEvent {
7272 type: KeyboardEventType;
@@ -77,25 +77,25 @@ export interface KeyboardEvent {
7777 * Keyboard modifiers mapping
7878 */
7979 export const MODIFIER_KEYS = {
80- ctrl: 'ctrlKey',
81- cmd: 'metaKey',
82- cmdorctrl: navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey',
83- alt: 'altKey',
84- shift: 'shiftKey'
80+ ctrl: "ctrlKey",
81+ cmd: "metaKey",
82+ cmdorctrl: navigator.platform.includes("Mac") ? "metaKey" : "ctrlKey",
83+ alt: "altKey",
84+ shift: "shiftKey",
8585 } as const;
8686
8787 /**
8888 * Common shortcut patterns used throughout the application
8989 */
9090 export const COMMON_SHORTCUTS = {
91- OMNIBOX_OPEN: 'cmdorctrl+l',
92- FIND_OPEN: 'cmdorctrl+f',
93- ESCAPE: 'escape',
94- ENTER: 'enter',
95- ARROW_UP: 'arrowup',
96- ARROW_DOWN: 'arrowdown',
97- ARROW_LEFT: 'arrowleft',
98- ARROW_RIGHT: 'arrowright',
99- TAB: 'tab',
100- SHIFT_TAB: 'shift+tab'
101-} as const;⏎̸
91+ OMNIBOX_OPEN: "cmdorctrl+l",
92+ FIND_OPEN: "cmdorctrl+f",
93+ ESCAPE: "escape",
94+ ENTER: "enter",
95+ ARROW_UP: "arrowup",
96+ ARROW_DOWN: "arrowdown",
97+ ARROW_LEFT: "arrowleft",
98+ ARROW_RIGHT: "arrowright",
99+ TAB: "tab",
100+ SHIFT_TAB: "shift+tab",
101+} as const;
M gui/src/lib/styles.css +1 -1
@@ -1,1 +1,1 @@
1-@import '../styles/tailwind.css';⏎̸
1+@import "../styles/tailwind.css";
M gui/src/lib/types.ts +15 -7
@@ -1,18 +1,22 @@
1-import type { HistoryEntry, SearchShortcut } from '../types/generated.js';
1+import type { HistoryEntry, SearchShortcut } from "../types/generated.js";
22
33 // Global window API types for browser integration
44 declare global {
55 interface Window {
66 // Toast system API
77 __dumber_toast_loaded?: boolean;
8- __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void;
8+ __dumber_showToast?: (
9+ message: string,
10+ duration?: number,
11+ type?: "info" | "success" | "error",
12+ ) => number | void;
913 __dumber_dismissToast?: (id: number) => void;
1014 __dumber_clearToasts?: () => void;
1115 __dumber_showZoomToast?: (zoomLevel: number) => void;
1216
1317 // Theme integration
1418 __dumber_initial_theme?: string;
15- __dumber_setTheme?: (theme: 'light' | 'dark') => void;
19+ __dumber_setTheme?: (theme: "light" | "dark") => void;
1620
1721 // History callbacks
1822 __dumber_history_recent?: (data: HistoryEntry[]) => void;
@@ -34,15 +38,19 @@ declare global {
3438 }
3539
3640 export interface ToastAPI {
37- show: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number;
41+ show: (
42+ message: string,
43+ duration?: number,
44+ type?: "info" | "success" | "error",
45+ ) => number;
3846 dismiss: (id: number) => void;
3947 clear: () => void;
4048 showZoom: (zoomLevel: number) => void;
4149 }
4250
4351 export interface ThemeAPI {
44- setTheme: (theme: 'light' | 'dark') => void;
45- getCurrentTheme: () => 'light' | 'dark';
52+ setTheme: (theme: "light" | "dark") => void;
53+ getCurrentTheme: () => "light" | "dark";
4654 }
4755
4856 // Message types for WebKit communication
@@ -51,4 +59,4 @@ export interface BrowserMessage {
5159 data?: unknown;
5260 }
5361
54-export {}; // Make this file a module⏎̸
62+export {}; // Make this file a module
M gui/src/main.ts +18 -18
@@ -1,6 +1,6 @@
1-import { AppService } from './services/app.js';
2-import { BrowserControlsService } from './services/browser-controls.js';
3-import { DOMRenderer } from './utils/dom.js';
1+import { AppService } from "./services/app.js";
2+import { BrowserControlsService } from "./services/browser-controls.js";
3+import { DOMRenderer } from "./utils/dom.js";
44
55 class DumberBrowserApp {
66 private appService: AppService;
@@ -16,21 +16,21 @@ class DumberBrowserApp {
1616 async initialize(): Promise<void> {
1717 try {
1818 // Show loading states
19- this.domRenderer.showLoading('history');
20- this.domRenderer.showLoading('shortcuts');
19+ this.domRenderer.showLoading("history");
20+ this.domRenderer.showLoading("shortcuts");
2121
2222 // Initialize services
2323 await this.appService.initialize();
24-
24+
2525 // Initialize browser controls
2626 await this.browserControls.initialize();
2727
2828 // Render the UI
2929 this.render();
3030
31- console.log('✓ Dumber Browser initialized successfully');
31+ console.log("✓ Dumber Browser initialized successfully");
3232 } catch (error) {
33- console.error('Failed to initialize Dumber Browser:', error);
33+ console.error("Failed to initialize Dumber Browser:", error);
3434 this.handleInitializationError();
3535 }
3636 }
@@ -45,29 +45,29 @@ class DumberBrowserApp {
4545 const shortcuts = this.appService.getShortcuts();
4646 this.domRenderer.displayShortcuts(shortcuts);
4747 } catch (error) {
48- console.error('Failed to render UI:', error);
49- this.domRenderer.showError('history', 'Failed to load history');
50- this.domRenderer.showError('shortcuts', 'Failed to load shortcuts');
48+ console.error("Failed to render UI:", error);
49+ this.domRenderer.showError("history", "Failed to load history");
50+ this.domRenderer.showError("shortcuts", "Failed to load shortcuts");
5151 }
5252 }
5353
5454 private handleInitializationError(): void {
55- this.domRenderer.showError('history', 'Failed to initialize application');
56- this.domRenderer.showError('shortcuts', 'Failed to initialize application');
55+ this.domRenderer.showError("history", "Failed to initialize application");
56+ this.domRenderer.showError("shortcuts", "Failed to initialize application");
5757 }
5858 }
5959
6060 // Initialize the application when the DOM is ready
6161 function initializeApp(): void {
6262 const app = new DumberBrowserApp();
63- app.initialize().catch(error => {
64- console.error('Critical error during app initialization:', error);
63+ app.initialize().catch((error) => {
64+ console.error("Critical error during app initialization:", error);
6565 });
6666 }
6767
6868 // Wait for DOM to be ready
69-if (document.readyState === 'loading') {
70- document.addEventListener('DOMContentLoaded', initializeApp);
69+if (document.readyState === "loading") {
70+ document.addEventListener("DOMContentLoaded", initializeApp);
7171 } else {
7272 initializeApp();
73-}⏎̸
73+}
M gui/src/pages/homepage.ts +17 -8
@@ -1,27 +1,36 @@
1-import { mount } from 'svelte';
2-import Homepage from './Homepage.svelte';
1+import { mount } from "svelte";
2+import Homepage from "./Homepage.svelte";
33
44 // Proactively unregister any Service Workers and clear caches that may have been
55 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to
66 // request non-existent _app/immutable/* assets under dumb://homepage.
77 async function cleanupServiceWorkersAndCaches() {
88 try {
9- if ('serviceWorker' in navigator) {
9+ if ("serviceWorker" in navigator) {
1010 const regs = await navigator.serviceWorker.getRegistrations();
1111 await Promise.all(regs.map((r) => r.unregister().catch(() => {})));
1212 }
13- } catch { /* ignore */ }
13+ } catch {
14+ /* ignore */
15+ }
1416
1517 try {
16- if ('caches' in window) {
18+ if ("caches" in window) {
1719 const keys = await caches.keys();
1820 await Promise.all(keys.map((k) => caches.delete(k).catch(() => false)));
1921 }
20- } catch { /* ignore */ }
22+ } catch {
23+ /* ignore */
24+ }
2125 }
2226
23-
2427 // Mount the Homepage component to the DOM
2528 cleanupServiceWorkersAndCaches().finally(() => {
26- mount(Homepage, { target: document.body });
29+ console.log("[dumber] Mounting Homepage component to document.body");
30+ try {
31+ const app = mount(Homepage, { target: document.body });
32+ console.log("[dumber] Homepage component mounted successfully", app);
33+ } catch (error) {
34+ console.error("[dumber] Failed to mount Homepage component:", error);
35+ }
2736 });
M gui/src/services/app.ts +66 -42
@@ -1,4 +1,4 @@
1-import type { HistoryEntry, SearchShortcut } from '../types/generated.js';
1+import type { HistoryEntry, SearchShortcut } from "../types/generated.js";
22
33 export class AppService {
44 private history: HistoryEntry[] = [];
@@ -8,12 +8,9 @@ export class AppService {
88
99 async initialize(): Promise<void> {
1010 try {
11- await Promise.all([
12- this.loadHistory(),
13- this.loadShortcuts()
14- ]);
11+ await Promise.all([this.loadHistory(), this.loadShortcuts()]);
1512 } catch (error) {
16- console.error('Failed to initialize app:', error);
13+ console.error("Failed to initialize app:", error);
1714 }
1815 }
1916
@@ -28,57 +25,59 @@ export class AppService {
2825 };
2926
3027 window.__dumber_history_error = (error: string) => {
31- console.error('Failed to load history:', error);
28+ console.error("Failed to load history:", error);
3229 reject(new Error(error));
3330 };
3431
3532 // Send message to Go backend
3633 const bridge = window.webkit?.messageHandlers?.dumber;
37- if (bridge && typeof bridge.postMessage === 'function') {
38- bridge.postMessage(JSON.stringify({
39- type: 'history_recent',
40- limit: 50,
41- offset: 0
42- }));
34+ if (bridge && typeof bridge.postMessage === "function") {
35+ bridge.postMessage(
36+ JSON.stringify({
37+ type: "history_recent",
38+ limit: 50,
39+ offset: 0,
40+ }),
41+ );
4342 } else {
44- reject(new Error('WebKit message handler not available'));
43+ reject(new Error("WebKit message handler not available"));
4544 }
4645 });
4746 } catch (error) {
48- console.error('Failed to load history:', error);
47+ console.error("Failed to load history:", error);
4948 // Mock history for development/fallback
5049 this.history = [
51- {
52- id: 1,
53- url: 'https://github.com/wailsapp/wails',
54- title: 'Wails Framework',
50+ {
51+ id: 1,
52+ url: "https://github.com/wailsapp/wails",
53+ title: "Wails Framework",
5554 visit_count: 3,
5655 last_visited: new Date().toISOString(),
57- created_at: new Date().toISOString()
56+ created_at: new Date().toISOString(),
5857 },
59- {
60- id: 2,
61- url: 'https://go.dev',
62- title: 'The Go Programming Language',
58+ {
59+ id: 2,
60+ url: "https://go.dev",
61+ title: "The Go Programming Language",
6362 visit_count: 2,
6463 last_visited: new Date().toISOString(),
65- created_at: new Date().toISOString()
64+ created_at: new Date().toISOString(),
6665 },
67- {
68- id: 3,
69- url: 'https://developer.mozilla.org',
70- title: 'MDN Web Docs',
66+ {
67+ id: 3,
68+ url: "https://developer.mozilla.org",
69+ title: "MDN Web Docs",
7170 visit_count: 1,
7271 last_visited: new Date().toISOString(),
73- created_at: new Date().toISOString()
74- }
72+ created_at: new Date().toISOString(),
73+ },
7574 ];
7675 }
7776 }
7877
7978 async loadShortcuts(): Promise<void> {
8079 try {
81- const cfg = await fetch('/api/config').then(r => r.json());
80+ const cfg = await fetch("/api/config").then((r) => r.json());
8281 const raw = cfg?.search_shortcuts || {};
8382 // Normalize field casing from backend (supports URL/Description and url/description)
8483 const normalized: Record<string, SearchShortcut> = {};
@@ -87,20 +86,45 @@ export class AppService {
8786 normalized[key] = {
8887 id: 0,
8988 shortcut: key,
90- url_template: (v.url ?? v.URL ?? '') as string,
91- description: (v.description ?? v.Description ?? '') as string,
92- created_at: null
89+ url_template: (v.url ?? v.URL ?? "") as string,
90+ description: (v.description ?? v.Description ?? "") as string,
91+ created_at: null,
9392 };
9493 }
9594 this.shortcuts = normalized;
9695 } catch (error) {
97- console.error('Failed to load shortcuts:', error);
96+ console.error("Failed to load shortcuts:", error);
9897 // Mock shortcuts for development/fallback
9998 this.shortcuts = {
100- 'g': { id: 0, shortcut: 'g', url_template: 'https://google.com/search?q={query}', description: 'Google Search', created_at: null },
101- 'gh': { id: 0, shortcut: 'gh', url_template: 'https://github.com/search?q={query}', description: 'GitHub Search', created_at: null },
102- 'so': { id: 0, shortcut: 'so', url_template: 'https://stackoverflow.com/search?q={query}', description: 'Stack Overflow', created_at: null },
103- 'w': { id: 0, shortcut: 'w', url_template: 'https://en.wikipedia.org/wiki/Special:Search?search={query}', description: 'Wikipedia', created_at: null }
99+ g: {
100+ id: 0,
101+ shortcut: "g",
102+ url_template: "https://google.com/search?q={query}",
103+ description: "Google Search",
104+ created_at: null,
105+ },
106+ gh: {
107+ id: 0,
108+ shortcut: "gh",
109+ url_template: "https://github.com/search?q={query}",
110+ description: "GitHub Search",
111+ created_at: null,
112+ },
113+ so: {
114+ id: 0,
115+ shortcut: "so",
116+ url_template: "https://stackoverflow.com/search?q={query}",
117+ description: "Stack Overflow",
118+ created_at: null,
119+ },
120+ w: {
121+ id: 0,
122+ shortcut: "w",
123+ url_template:
124+ "https://en.wikipedia.org/wiki/Special:Search?search={query}",
125+ description: "Wikipedia",
126+ created_at: null,
127+ },
104128 };
105129 }
106130 }
@@ -116,9 +140,9 @@ export class AppService {
116140 async copyToClipboard(text: string): Promise<void> {
117141 try {
118142 await navigator.clipboard.writeText(text);
119- console.log('Copied to clipboard:', text);
143+ console.log("Copied to clipboard:", text);
120144 } catch (err) {
121- console.error('Failed to copy:', err);
145+ console.error("Failed to copy:", err);
122146 }
123147 }
124148 }
M gui/src/services/browser-controls.ts +112 -46
@@ -1,35 +1,49 @@
1-import { NotificationService } from './notifications.js';
2-import { DebugConsoleService } from './debug-console.js';
1+import { NotificationService } from "./notifications.js";
2+import { DebugConsoleService } from "./debug-console.js";
33
44 export class BrowserControlsService {
5- private currentURL = '';
5+ private currentURL = "";
66 private notifications = new NotificationService();
77 private debugConsole = new DebugConsoleService();
88
99 constructor() {
1010 // Debug logging with enhanced details
11- this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initializing...');
12- this.debugConsole.addLog('info', 'frontend', 'init', 'Running without Wails bindings');
13-
11+ this.debugConsole.addLog(
12+ "info",
13+ "frontend",
14+ "init",
15+ "BrowserControlsService initializing...",
16+ );
17+ this.debugConsole.addLog(
18+ "info",
19+ "frontend",
20+ "init",
21+ "Running without Wails bindings",
22+ );
23+
1424 this.initializeEventListeners();
15- this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initialized ✓');
25+ this.debugConsole.addLog(
26+ "info",
27+ "frontend",
28+ "init",
29+ "BrowserControlsService initialized ✓",
30+ );
1631 }
1732
18-
1933 private initializeEventListeners(): void {
2034 // Keyboard event handler for browser controls
21- document.addEventListener('keydown', this.handleKeyboardEvent.bind(this));
22-
35+ document.addEventListener("keydown", this.handleKeyboardEvent.bind(this));
36+
2337 // Mouse button handler for navigation
24- document.addEventListener('mousedown', this.handleMouseEvent.bind(this));
25-
38+ document.addEventListener("mousedown", this.handleMouseEvent.bind(this));
39+
2640 // URL change listener (for SPA navigation)
27- window.addEventListener('popstate', this.handleURLChange.bind(this));
41+ window.addEventListener("popstate", this.handleURLChange.bind(this));
2842 }
2943
3044 private async handleKeyboardEvent(event: KeyboardEvent): Promise<void> {
3145 // Skip F12 (debug console toggle)
32- if (event.key === 'F12') {
46+ if (event.key === "F12") {
3347 return;
3448 }
3549
@@ -40,28 +54,43 @@ export class BrowserControlsService {
4054 const isCmd = ctrlKey || metaKey;
4155
4256 // Zoom controls handled natively by GTK; don't intercept
43- if (isCmd && (key === '+' || key === '=' || key === '-' || key === '0')) {
57+ if (isCmd && (key === "+" || key === "=" || key === "-" || key === "0")) {
4458 return; // allow native handler to process
4559 }
4660
4761 // Copy URL
48- if (isCmd && shiftKey && key === 'C') {
49- this.debugConsole.addLog('info', 'frontend', 'action', 'Copy URL triggered');
62+ if (isCmd && shiftKey && key === "C") {
63+ this.debugConsole.addLog(
64+ "info",
65+ "frontend",
66+ "action",
67+ "Copy URL triggered",
68+ );
5069 event.preventDefault();
5170 await this.copyCurrentURL();
5271 return;
5372 }
5473
5574 // Navigation
56- if (altKey && key === 'ArrowLeft') {
57- this.debugConsole.addLog('info', 'frontend', 'action', 'Go Back triggered');
75+ if (altKey && key === "ArrowLeft") {
76+ this.debugConsole.addLog(
77+ "info",
78+ "frontend",
79+ "action",
80+ "Go Back triggered",
81+ );
5882 event.preventDefault();
5983 history.back();
6084 return;
6185 }
6286
63- if (altKey && key === 'ArrowRight') {
64- this.debugConsole.addLog('info', 'frontend', 'action', 'Go Forward triggered');
87+ if (altKey && key === "ArrowRight") {
88+ this.debugConsole.addLog(
89+ "info",
90+ "frontend",
91+ "action",
92+ "Go Forward triggered",
93+ );
6594 event.preventDefault();
6695 history.forward();
6796 return;
@@ -74,7 +103,12 @@ export class BrowserControlsService {
74103
75104 // Mouse button 3 (back button)
76105 if (event.button === 3) {
77- this.debugConsole.addLog('info', 'frontend', 'action', 'Mouse Back button triggered');
106+ this.debugConsole.addLog(
107+ "info",
108+ "frontend",
109+ "action",
110+ "Mouse Back button triggered",
111+ );
78112 event.preventDefault();
79113 await this.goBack();
80114 return;
@@ -82,7 +116,12 @@ export class BrowserControlsService {
82116
83117 // Mouse button 4 (forward button)
84118 if (event.button === 4) {
85- this.debugConsole.addLog('info', 'frontend', 'action', 'Mouse Forward button triggered');
119+ this.debugConsole.addLog(
120+ "info",
121+ "frontend",
122+ "action",
123+ "Mouse Forward button triggered",
124+ );
86125 event.preventDefault();
87126 await this.goForward();
88127 return;
@@ -100,41 +139,68 @@ export class BrowserControlsService {
100139 try {
101140 const url = this.getCurrentURL();
102141 await navigator.clipboard.writeText(url);
103-
142+
104143 // URL copied successfully - no backend notification needed
105-
106- this.notifications.show('URL copied to clipboard!', 'success');
144+
145+ this.notifications.show("URL copied to clipboard!", "success");
107146 } catch (error) {
108- console.error('Failed to copy URL:', error);
109- this.notifications.show('Failed to copy URL', 'error');
147+ console.error("Failed to copy URL:", error);
148+ this.notifications.show("Failed to copy URL", "error");
110149 }
111150 }
112151
113152 async goBack(): Promise<void> {
114- this.debugConsole.addLog('info', 'frontend', 'service', 'Calling GoBack');
115-
153+ this.debugConsole.addLog("info", "frontend", "service", "Calling GoBack");
154+
116155 try {
117156 // await BrowserService.GoBack(); // TODO: Implement BrowserService
118- this.debugConsole.logServiceCall('BrowserService', 'GoBack', []);
157+ this.debugConsole.logServiceCall("BrowserService", "GoBack", []);
119158 history.back();
120- this.debugConsole.addLog('info', 'frontend', 'navigation', 'Browser navigated back');
159+ this.debugConsole.addLog(
160+ "info",
161+ "frontend",
162+ "navigation",
163+ "Browser navigated back",
164+ );
121165 } catch (error) {
122- this.debugConsole.logServiceCall('BrowserService', 'GoBack', [], null, error as Error);
123- console.error('Failed to go back:', error);
166+ this.debugConsole.logServiceCall(
167+ "BrowserService",
168+ "GoBack",
169+ [],
170+ null,
171+ error as Error,
172+ );
173+ console.error("Failed to go back:", error);
124174 }
125175 }
126176
127177 async goForward(): Promise<void> {
128- this.debugConsole.addLog('info', 'frontend', 'service', 'Calling GoForward');
129-
178+ this.debugConsole.addLog(
179+ "info",
180+ "frontend",
181+ "service",
182+ "Calling GoForward",
183+ );
184+
130185 try {
131186 // await BrowserService.GoForward(); // TODO: Implement BrowserService
132- this.debugConsole.logServiceCall('BrowserService', 'GoForward', []);
187+ this.debugConsole.logServiceCall("BrowserService", "GoForward", []);
133188 history.forward();
134- this.debugConsole.addLog('info', 'frontend', 'navigation', 'Browser navigated forward');
189+ this.debugConsole.addLog(
190+ "info",
191+ "frontend",
192+ "navigation",
193+ "Browser navigated forward",
194+ );
135195 } catch (error) {
136- this.debugConsole.logServiceCall('BrowserService', 'GoForward', [], null, error as Error);
137- console.error('Failed to go forward:', error);
196+ this.debugConsole.logServiceCall(
197+ "BrowserService",
198+ "GoForward",
199+ [],
200+ null,
201+ error as Error,
202+ );
203+ console.error("Failed to go forward:", error);
138204 }
139205 }
140206
@@ -150,17 +216,17 @@ export class BrowserControlsService {
150216 private async updateWindowTitle(url: string): Promise<void> {
151217 try {
152218 // Extract domain or path for title
153- let title = 'Dumber Browser';
154- if (url && url !== '/') {
219+ let title = "Dumber Browser";
220+ if (url && url !== "/") {
155221 const urlObj = new URL(url);
156222 title = `Dumber - ${urlObj.hostname}`;
157223 }
158-
224+
159225 document.title = title;
160-
226+
161227 // Backend title updates handled natively; no frontend call
162228 } catch (error) {
163- console.error('Failed to update title:', error);
229+ console.error("Failed to update title:", error);
164230 }
165231 }
166232
@@ -172,7 +238,7 @@ export class BrowserControlsService {
172238 // Check if there's an initial URL from backend (direct navigation)
173239 // Set initial URL from browser location (homepage)
174240 this.setCurrentURL(window.location.href);
175-
241+
176242 // Load saved zoom level (only if we're staying on homepage)
177243 // Zoom handled natively
178244 }
M gui/src/services/debug-console.ts +103 -66
@@ -1,7 +1,7 @@
11 export interface DebugLogEntry {
22 timestamp: string;
3- level: 'info' | 'warn' | 'error' | 'debug';
4- source: 'frontend' | 'backend';
3+ level: "info" | "warn" | "error" | "debug";
4+ source: "frontend" | "backend";
55 category: string;
66 message: string;
77 data?: unknown;
@@ -22,8 +22,8 @@ export class DebugConsoleService {
2222
2323 private createDebugUI(): void {
2424 // Create debug panel
25- this.debugElement = document.createElement('div');
26- this.debugElement.id = 'debug-console';
25+ this.debugElement = document.createElement("div");
26+ this.debugElement.id = "debug-console";
2727 this.debugElement.style.cssText = `
2828 position: fixed;
2929 top: 0;
@@ -40,7 +40,7 @@ export class DebugConsoleService {
4040 `;
4141
4242 // Create header
43- const header = document.createElement('div');
43+ const header = document.createElement("div");
4444 header.style.cssText = `
4545 padding: 10px;
4646 background: rgba(0, 255, 0, 0.1);
@@ -60,7 +60,7 @@ export class DebugConsoleService {
6060 `;
6161
6262 // Create logs container
63- this.logsContainer = document.createElement('div');
63+ this.logsContainer = document.createElement("div");
6464 this.logsContainer.style.cssText = `
6565 height: calc(100% - 50px);
6666 overflow-y: auto;
@@ -72,14 +72,18 @@ export class DebugConsoleService {
7272 document.body.appendChild(this.debugElement);
7373
7474 // Setup button handlers
75- header.querySelector('#clear-logs')?.addEventListener('click', () => this.clearLogs());
76- header.querySelector('#close-debug')?.addEventListener('click', () => this.hide());
75+ header
76+ .querySelector("#clear-logs")
77+ ?.addEventListener("click", () => this.clearLogs());
78+ header
79+ .querySelector("#close-debug")
80+ ?.addEventListener("click", () => this.hide());
7781 }
7882
7983 private setupKeyboardShortcut(): void {
80- document.addEventListener('keydown', (event) => {
84+ document.addEventListener("keydown", (event) => {
8185 // F12 to toggle debug console
82- if (event.key === 'F12') {
86+ if (event.key === "F12") {
8387 event.preventDefault();
8488 this.toggle();
8589 }
@@ -92,47 +96,53 @@ export class DebugConsoleService {
9296 warn: console.warn,
9397 error: console.error,
9498 info: console.info,
95- debug: console.debug
99+ debug: console.debug,
96100 };
97101
98102 console.log = (...args) => {
99- this.addLog('info', 'frontend', 'console', args.join(' '));
103+ this.addLog("info", "frontend", "console", args.join(" "));
100104 originalConsole.log(...args);
101105 };
102106
103107 console.warn = (...args) => {
104- this.addLog('warn', 'frontend', 'console', args.join(' '));
108+ this.addLog("warn", "frontend", "console", args.join(" "));
105109 originalConsole.warn(...args);
106110 };
107111
108112 console.error = (...args) => {
109- this.addLog('error', 'frontend', 'console', args.join(' '));
113+ this.addLog("error", "frontend", "console", args.join(" "));
110114 originalConsole.error(...args);
111115 };
112116
113117 console.info = (...args) => {
114- this.addLog('info', 'frontend', 'console', args.join(' '));
118+ this.addLog("info", "frontend", "console", args.join(" "));
115119 originalConsole.info(...args);
116120 };
117121
118122 console.debug = (...args) => {
119- this.addLog('debug', 'frontend', 'console', args.join(' '));
123+ this.addLog("debug", "frontend", "console", args.join(" "));
120124 originalConsole.debug(...args);
121125 };
122126 }
123127
124- public addLog(level: DebugLogEntry['level'], source: DebugLogEntry['source'], category: string, message: string, data?: unknown): void {
128+ public addLog(
129+ level: DebugLogEntry["level"],
130+ source: DebugLogEntry["source"],
131+ category: string,
132+ message: string,
133+ data?: unknown,
134+ ): void {
125135 const entry: DebugLogEntry = {
126- timestamp: new Date().toISOString().split('T')[1]?.split('.')[0] || '', // HH:MM:SS format
136+ timestamp: new Date().toISOString().split("T")[1]?.split(".")[0] || "", // HH:MM:SS format
127137 level,
128138 source,
129139 category,
130140 message,
131- data
141+ data,
132142 };
133143
134144 this.logs.push(entry);
135-
145+
136146 // Keep only the last maxLogs entries
137147 if (this.logs.length > this.maxLogs) {
138148 this.logs.shift();
@@ -144,96 +154,123 @@ export class DebugConsoleService {
144154 private updateDisplay(): void {
145155 if (!this.logsContainer) return;
146156
147- const logHTML = this.logs.map(log => {
148- const levelColor = {
149- info: '#00ff00',
150- warn: '#ffff00',
151- error: '#ff0000',
152- debug: '#00ffff'
153- }[log.level];
157+ const logHTML = this.logs
158+ .map((log) => {
159+ const levelColor = {
160+ info: "#00ff00",
161+ warn: "#ffff00",
162+ error: "#ff0000",
163+ debug: "#00ffff",
164+ }[log.level];
154165
155- const sourceIcon = log.source === 'backend' ? '🖥️' : '🌐';
166+ const sourceIcon = log.source === "backend" ? "🖥️" : "🌐";
156167
157- return `
168+ return `
158169 <div style="margin-bottom: 2px; font-size: 11px;">
159170 <span style="color: #888;">[${log.timestamp}]</span>
160171 <span style="color: ${levelColor};">${sourceIcon} ${log.level.toUpperCase()}</span>
161172 <span style="color: #aaa;">[${log.category}]</span>
162173 <span style="color: #fff;">${log.message}</span>
163- ${log.data ? `<pre style="color: #ccc; font-size: 10px; margin: 2px 0;">${JSON.stringify(log.data, null, 2)}</pre>` : ''}
174+ ${log.data ? `<pre style="color: #ccc; font-size: 10px; margin: 2px 0;">${JSON.stringify(log.data, null, 2)}</pre>` : ""}
164175 </div>
165176 `;
166- }).join('');
177+ })
178+ .join("");
167179
168180 this.logsContainer.innerHTML = logHTML;
169-
181+
170182 // Auto-scroll to bottom
171183 this.logsContainer.scrollTop = this.logsContainer.scrollHeight;
172184 }
173185
174186 public logKeyboardEvent(event: KeyboardEvent): void {
175187 const modifiers = [];
176- if (event.ctrlKey) modifiers.push('Ctrl');
177- if (event.shiftKey) modifiers.push('Shift');
178- if (event.altKey) modifiers.push('Alt');
179- if (event.metaKey) modifiers.push('Meta');
180-
181- const shortcut = modifiers.length > 0 ? `${modifiers.join('+')}+${event.key}` : event.key;
182-
183- this.addLog('info', 'frontend', 'keyboard', `Key pressed: ${shortcut}`, {
188+ if (event.ctrlKey) modifiers.push("Ctrl");
189+ if (event.shiftKey) modifiers.push("Shift");
190+ if (event.altKey) modifiers.push("Alt");
191+ if (event.metaKey) modifiers.push("Meta");
192+
193+ const shortcut =
194+ modifiers.length > 0 ? `${modifiers.join("+")}+${event.key}` : event.key;
195+
196+ this.addLog("info", "frontend", "keyboard", `Key pressed: ${shortcut}`, {
184197 key: event.key,
185198 code: event.code,
186199 modifiers,
187- target: event.target?.constructor.name
200+ target: event.target?.constructor.name,
188201 });
189202 }
190203
191204 public logMouseEvent(event: MouseEvent): void {
192205 const buttonNames: Record<number, string> = {
193- 0: 'Left',
194- 1: 'Middle',
195- 2: 'Right',
196- 3: 'Back',
197- 4: 'Forward'
206+ 0: "Left",
207+ 1: "Middle",
208+ 2: "Right",
209+ 3: "Back",
210+ 4: "Forward",
198211 };
199212
200- this.addLog('info', 'frontend', 'mouse', `Mouse ${event.type}: ${buttonNames[event.button] || `Button${event.button}`}`, {
201- button: event.button,
202- clientX: event.clientX,
203- clientY: event.clientY,
204- target: event.target?.constructor.name
205- });
213+ this.addLog(
214+ "info",
215+ "frontend",
216+ "mouse",
217+ `Mouse ${event.type}: ${buttonNames[event.button] || `Button${event.button}`}`,
218+ {
219+ button: event.button,
220+ clientX: event.clientX,
221+ clientY: event.clientY,
222+ target: event.target?.constructor.name,
223+ },
224+ );
206225 }
207226
208- public logServiceCall(serviceName: string, methodName: string, params: unknown[], result?: unknown, error?: Error): void {
227+ public logServiceCall(
228+ serviceName: string,
229+ methodName: string,
230+ params: unknown[],
231+ result?: unknown,
232+ error?: Error,
233+ ): void {
209234 if (error) {
210- this.addLog('error', 'frontend', 'service', `${serviceName}.${methodName}() failed`, {
211- params,
212- error: error.message
213- });
235+ this.addLog(
236+ "error",
237+ "frontend",
238+ "service",
239+ `${serviceName}.${methodName}() failed`,
240+ {
241+ params,
242+ error: error.message,
243+ },
244+ );
214245 } else {
215- this.addLog('info', 'frontend', 'service', `${serviceName}.${methodName}() called`, {
216- params,
217- result
218- });
246+ this.addLog(
247+ "info",
248+ "frontend",
249+ "service",
250+ `${serviceName}.${methodName}() called`,
251+ {
252+ params,
253+ result,
254+ },
255+ );
219256 }
220257 }
221258
222259 public logBackendMessage(message: string, data?: unknown): void {
223- this.addLog('info', 'backend', 'service', message, data);
260+ this.addLog("info", "backend", "service", message, data);
224261 }
225262
226263 public show(): void {
227264 if (this.debugElement) {
228- this.debugElement.style.display = 'block';
265+ this.debugElement.style.display = "block";
229266 this.isVisible = true;
230- this.addLog('info', 'frontend', 'debug', 'Debug console opened');
267+ this.addLog("info", "frontend", "debug", "Debug console opened");
231268 }
232269 }
233270
234271 public hide(): void {
235272 if (this.debugElement) {
236- this.debugElement.style.display = 'none';
273+ this.debugElement.style.display = "none";
237274 this.isVisible = false;
238275 }
239276 }
@@ -249,7 +286,7 @@ export class DebugConsoleService {
249286 public clearLogs(): void {
250287 this.logs = [];
251288 this.updateDisplay();
252- this.addLog('info', 'frontend', 'debug', 'Logs cleared');
289+ this.addLog("info", "frontend", "debug", "Logs cleared");
253290 }
254291
255292 public isDebugVisible(): boolean {
M gui/src/services/notifications.ts +55 -35
@@ -1,4 +1,4 @@
1-import type { NotificationOptions } from '../types/app.js';
1+import type { NotificationOptions } from "../types/app.js";
22
33 interface TimerElement extends HTMLElement {
44 timeout?: ReturnType<typeof setTimeout>;
@@ -11,24 +11,34 @@ export class NotificationService {
1111 showZoom(message: string, duration = 2000): void {
1212 // Create or update zoom notification
1313 if (!this.zoomNotification) {
14- this.zoomNotification = this.createNotificationElement('zoom-notification', {
15- top: '20px',
16- right: '20px',
17- background: 'rgba(0, 0, 0, 0.8)',
18- color: 'white'
19- });
14+ this.zoomNotification = this.createNotificationElement(
15+ "zoom-notification",
16+ {
17+ top: "20px",
18+ right: "20px",
19+ background: "rgba(0, 0, 0, 0.8)",
20+ color: "white",
21+ },
22+ );
2023 }
2124
2225 this.updateNotification(this.zoomNotification, message, duration);
2326 }
2427
25- show(message: string, type: NotificationOptions['type'] = 'info', duration = 3000): void {
28+ show(
29+ message: string,
30+ type: NotificationOptions["type"] = "info",
31+ duration = 3000,
32+ ): void {
2633 // Create or update general notification
2734 if (!this.generalNotification) {
28- this.generalNotification = this.createNotificationElement('general-notification', {
29- top: '60px',
30- right: '20px'
31- });
35+ this.generalNotification = this.createNotificationElement(
36+ "general-notification",
37+ {
38+ top: "60px",
39+ right: "20px",
40+ },
41+ );
3242 }
3343
3444 // Set colors based on type
@@ -39,32 +49,39 @@ export class NotificationService {
3949 this.updateNotification(this.generalNotification, message, duration);
4050 }
4151
42- private createNotificationElement(id: string, baseStyles: Record<string, string>): HTMLElement {
43- const notification = document.createElement('div');
52+ private createNotificationElement(
53+ id: string,
54+ baseStyles: Record<string, string>,
55+ ): HTMLElement {
56+ const notification = document.createElement("div");
4457 notification.id = id;
45-
58+
4659 const styles = {
47- position: 'fixed',
48- padding: '10px 15px',
49- borderRadius: '5px',
50- fontSize: '14px',
51- zIndex: '10000',
52- transition: 'opacity 0.3s',
53- opacity: '0',
54- ...baseStyles
60+ position: "fixed",
61+ padding: "10px 15px",
62+ borderRadius: "5px",
63+ fontSize: "14px",
64+ zIndex: "10000",
65+ transition: "opacity 0.3s",
66+ opacity: "0",
67+ ...baseStyles,
5568 };
5669
5770 notification.style.cssText = Object.entries(styles)
5871 .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
59- .join('; ');
72+ .join("; ");
6073
6174 document.body.appendChild(notification);
6275 return notification;
6376 }
6477
65- private updateNotification(element: HTMLElement, message: string, duration: number): void {
78+ private updateNotification(
79+ element: HTMLElement,
80+ message: string,
81+ duration: number,
82+ ): void {
6683 element.textContent = message;
67- element.style.opacity = '1';
84+ element.style.opacity = "1";
6885
6986 // Clear any existing timeout
7087 const timerElement = element as TimerElement;
@@ -74,22 +91,25 @@ export class NotificationService {
7491
7592 // Hide after duration
7693 timerElement.timeout = setTimeout(() => {
77- element.style.opacity = '0';
94+ element.style.opacity = "0";
7895 }, duration);
7996 }
8097
81- private getNotificationColors(type: NotificationOptions['type']): { background: string; color: string } {
98+ private getNotificationColors(type: NotificationOptions["type"]): {
99+ background: string;
100+ color: string;
101+ } {
82102 switch (type) {
83- case 'error':
84- return { background: 'rgba(220, 53, 69, 0.9)', color: 'white' };
85- case 'success':
86- return { background: 'rgba(25, 135, 84, 0.9)', color: 'white' };
103+ case "error":
104+ return { background: "rgba(220, 53, 69, 0.9)", color: "white" };
105+ case "success":
106+ return { background: "rgba(25, 135, 84, 0.9)", color: "white" };
87107 default:
88- return { background: 'rgba(13, 110, 253, 0.9)', color: 'white' };
108+ return { background: "rgba(13, 110, 253, 0.9)", color: "white" };
89109 }
90110 }
91111
92112 private camelToKebab(str: string): string {
93- return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
113+ return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
94114 }
95-}⏎̸
115+}
M gui/src/styles/tailwind.css +46 -28
@@ -3,12 +3,12 @@
33 /* Define custom theme colors for v4 */
44 @theme {
55 /* Light theme colors (default) */
6- --color-browser-bg: rgb(255 255 255); /* white */
7- --color-browser-surface: rgb(249 250 251); /* gray-50 */
8- --color-browser-text: rgb(17 24 39); /* gray-900 */
9- --color-browser-muted: rgb(107 114 128); /* gray-500 */
10- --color-browser-accent: rgb(59 130 246); /* blue-500 */
11- --color-browser-border: rgb(229 231 235); /* gray-200 */
6+ --color-browser-bg: rgb(255 255 255); /* white */
7+ --color-browser-surface: rgb(249 250 251); /* gray-50 */
8+ --color-browser-text: rgb(17 24 39); /* gray-900 */
9+ --color-browser-muted: rgb(107 114 128); /* gray-500 */
10+ --color-browser-accent: rgb(59 130 246); /* blue-500 */
11+ --color-browser-border: rgb(229 231 235); /* gray-200 */
1212 }
1313
1414 /* Dynamic theme switching via CSS variables */
@@ -23,12 +23,12 @@
2323
2424 /* Dark theme colors (applied via .dark class from GTK) */
2525 .dark {
26- --dynamic-bg: rgb(17 24 39); /* gray-900 */
27- --dynamic-surface: rgb(31 41 55); /* gray-800 */
28- --dynamic-text: rgb(243 244 246); /* gray-100 */
29- --dynamic-muted: rgb(156 163 175); /* gray-400 */
30- --dynamic-accent: rgb(96 165 250); /* blue-400 */
31- --dynamic-border: rgb(55 65 81); /* gray-700 */
26+ --dynamic-bg: rgb(17 24 39); /* gray-900 */
27+ --dynamic-surface: rgb(31 41 55); /* gray-800 */
28+ --dynamic-text: rgb(243 244 246); /* gray-100 */
29+ --dynamic-muted: rgb(156 163 175); /* gray-400 */
30+ --dynamic-accent: rgb(96 165 250); /* blue-400 */
31+ --dynamic-border: rgb(55 65 81); /* gray-700 */
3232 }
3333
3434 /* Base styles for browser UI components */
@@ -41,8 +41,10 @@
4141
4242 /* Toast notification styles */
4343 .toast-base {
44- border-radius: 0.375rem; /* rounded-md */
45- box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
44+ border-radius: 0.375rem; /* rounded-md */
45+ box-shadow:
46+ 0 10px 15px -3px rgb(0 0 0 / 0.1),
47+ 0 4px 6px -4px rgb(0 0 0 / 0.1);
4648 padding: 0.25rem 0.5rem;
4749 max-width: 24rem;
4850 pointer-events: auto;
@@ -93,7 +95,9 @@
9395 color: var(--dynamic-text);
9496 border: 2px solid var(--dynamic-border);
9597 border-radius: 0.375rem;
96- box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
98+ box-shadow:
99+ 0 4px 6px -1px rgb(0 0 0 / 0.1),
100+ 0 2px 4px -2px rgb(0 0 0 / 0.1);
97101 transition: all 150ms;
98102 }
99103
@@ -118,7 +122,9 @@
118122 background-color: var(--dynamic-bg);
119123 border: 1px solid var(--dynamic-border);
120124 border-radius: 0.375rem;
121- box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
125+ box-shadow:
126+ 0 10px 15px -3px rgb(0 0 0 / 0.1),
127+ 0 4px 6px -4px rgb(0 0 0 / 0.1);
122128 max-height: 24rem;
123129 overflow-y: auto;
124130 }
@@ -218,27 +224,35 @@
218224 }
219225
220226 @keyframes fade-in {
221- from { opacity: 0; }
222- to { opacity: 1; }
227+ from {
228+ opacity: 0;
229+ }
230+ to {
231+ opacity: 1;
232+ }
223233 }
224234
225235 @keyframes fade-out {
226- from { opacity: 1; }
227- to { opacity: 0; }
236+ from {
237+ opacity: 1;
238+ }
239+ to {
240+ opacity: 0;
241+ }
228242 }
229243
230244 /* Find highlighting styles for in-page search */
231245 .find-highlight {
232- background-color: rgb(255 235 59); /* yellow-400 */
233- color: rgb(0 0 0); /* black */
234- padding: 0 0.125rem; /* px-0.5 */
235- border-radius: 0.125rem; /* rounded-sm */
246+ background-color: rgb(255 235 59); /* yellow-400 */
247+ color: rgb(0 0 0); /* black */
248+ padding: 0 0.125rem; /* px-0.5 */
249+ border-radius: 0.125rem; /* rounded-sm */
236250 box-shadow: 0 0 0 1px rgb(200 185 0) inset;
237251 }
238252
239253 .find-active {
240- background-color: rgb(255 152 0) !important; /* orange-500 */
241- color: rgb(0 0 0); /* black */
254+ background-color: rgb(255 152 0) !important; /* orange-500 */
255+ color: rgb(0 0 0); /* black */
242256 box-shadow: 0 0 0 1px rgb(179 107 0) inset !important;
243257 }
244258
@@ -247,9 +261,13 @@
247261 /* Reset any inherited styles from the website */
248262 all: initial;
249263 /* Apply our design system */
250- font-family: Fira Sans, system-ui, -apple-system, sans-serif;
264+ font-family:
265+ Fira Sans,
266+ system-ui,
267+ -apple-system,
268+ sans-serif;
251269 box-sizing: border-box;
252270 * {
253271 box-sizing: border-box;
254272 }
255-}⏎̸
273+}
M gui/src/types/app.ts +3 -3
@@ -1,4 +1,4 @@
1-import type { HistoryEntry, SearchShortcut } from './generated.js';
1+import type { HistoryEntry, SearchShortcut } from "./generated.js";
22
33 export interface AppState {
44 currentURL: string;
@@ -10,7 +10,7 @@ export interface AppState {
1010
1111 export interface NotificationOptions {
1212 message: string;
13- type?: 'info' | 'success' | 'error';
13+ type?: "info" | "success" | "error";
1414 duration?: number;
1515 }
1616
@@ -23,4 +23,4 @@ export interface KeyboardShortcut {
2323 handler: () => void | Promise<void>;
2424 }
2525
26-export { HistoryEntry, SearchShortcut } from './generated.js';⏎̸
26+export { HistoryEntry, SearchShortcut } from "./generated.js";
M gui/src/types/generated.ts +16 -7
@@ -1,10 +1,19 @@
11 // GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
22 // Auto-generated TypeScript types from Go structs
3-// Generated on: 2025-09-09T09:57:41.059Z
3+// Generated on: 2025-09-22T12:41:57.478Z
44 // Generator script: scripts/generate-types.js
55 // Source files: internal/db/models.go, internal/parser/types.go
66 // Do not edit manually - run 'node scripts/generate-types.js' to regenerate
77
8+export interface CertificateValidation {
9+ id: number;
10+ hostname: string;
11+ certificate_hash: string;
12+ user_decision: string;
13+ created_at: string | null;
14+ expires_at: string | null;
15+}
16+
817 export interface History {
918 id: number;
1019 url: string;
@@ -12,6 +21,7 @@ export interface History {
1221 visit_count: number | null;
1322 last_visited: string | null;
1423 created_at: string | null;
24+ favicon_url: string | null;
1525 }
1626
1727 export interface Shortcut {
@@ -22,11 +32,10 @@ export interface Shortcut {
2232 created_at: string | null;
2333 }
2434
25-export interface ZoomSetting {
26- id: number;
27- url: string;
28- zoom_level: number;
29- last_updated: string | null;
35+export interface ZoomLevel {
36+ domain: string;
37+ zoom_factor: number;
38+ updated_at: string | null;
3039 }
3140
3241 export interface ParseResult {
@@ -71,7 +80,7 @@ export enum InputType {
7180 InputTypeDirectURL = 0,
7281 InputTypeSearchShortcut = 1,
7382 InputTypeHistorySearch = 2,
74- InputTypeFallbackSearch = 3
83+ InputTypeFallbackSearch = 3,
7584 }
7685
7786 // Type aliases for frontend usage
M gui/src/types/inline-css.d.ts +1 -1
@@ -1,4 +1,4 @@
1-declare module '*.css?inline' {
1+declare module "*.css?inline" {
22 const content: string;
33 export default content;
44 }
M gui/src/types/raw-css.d.ts +1 -1
@@ -1,4 +1,4 @@
1-declare module '*.css?raw' {
1+declare module "*.css?raw" {
22 const content: string;
33 export default content;
44 }
M gui/src/utils/dom.ts +59 -51
@@ -1,12 +1,12 @@
1-import type { History, Shortcut } from '../types/generated.js';
1+import type { History, Shortcut } from "../types/generated.js";
22
33 export class DOMRenderer {
44 private historyListElement: HTMLElement | null = null;
55 private shortcutsElement: HTMLElement | null = null;
66
77 constructor() {
8- this.historyListElement = document.getElementById('historyList');
9- this.shortcutsElement = document.getElementById('shortcuts');
8+ this.historyListElement = document.getElementById("historyList");
9+ this.shortcutsElement = document.getElementById("shortcuts");
1010 }
1111
1212 displayHistory(historyEntries: History[]): void {
@@ -22,8 +22,8 @@ export class DOMRenderer {
2222 return;
2323 }
2424
25- this.historyListElement.innerHTML = '';
26- historyEntries.forEach(item => {
25+ this.historyListElement.innerHTML = "";
26+ historyEntries.forEach((item) => {
2727 const historyItem = this.createHistoryItem(item);
2828 this.historyListElement!.appendChild(historyItem);
2929 });
@@ -42,22 +42,24 @@ export class DOMRenderer {
4242 return;
4343 }
4444
45- this.shortcutsElement.innerHTML = '';
45+ this.shortcutsElement.innerHTML = "";
4646 Object.entries(shortcutsData).forEach(([key, shortcut]) => {
4747 const shortcutEl = this.createShortcutElement(key, shortcut);
4848 this.shortcutsElement!.appendChild(shortcutEl);
4949 });
5050 }
5151
52- showLoading(container: 'history' | 'shortcuts'): void {
53- const element = container === 'history' ? this.historyListElement : this.shortcutsElement;
52+ showLoading(container: "history" | "shortcuts"): void {
53+ const element =
54+ container === "history" ? this.historyListElement : this.shortcutsElement;
5455 if (element) {
5556 element.innerHTML = '<div class="loading">Loading...</div>';
5657 }
5758 }
5859
59- showError(container: 'history' | 'shortcuts', message: string): void {
60- const element = container === 'history' ? this.historyListElement : this.shortcutsElement;
60+ showError(container: "history" | "shortcuts", message: string): void {
61+ const element =
62+ container === "history" ? this.historyListElement : this.shortcutsElement;
6163 if (element) {
6264 element.innerHTML = `
6365 <div class="empty-state">
@@ -69,44 +71,50 @@ export class DOMRenderer {
6971 }
7072
7173 private createHistoryItem(item: History): HTMLElement {
72- const historyItem = document.createElement('div');
73- historyItem.className = 'history-item';
74+ const historyItem = document.createElement("div");
75+ historyItem.className = "history-item";
7476 const parsed = this.safeParseURL(item.url);
75- const title = item.title && item.title.trim() !== '' ? item.title : (parsed.host || 'Untitled');
77+ const title =
78+ item.title && item.title.trim() !== ""
79+ ? item.title
80+ : parsed.host || "Untitled";
7681
77- const line = document.createElement('div');
78- line.className = 'history-line';
82+ const line = document.createElement("div");
83+ line.className = "history-line";
7984
8085 // Favicon chip (wrapper ensures perfect circle sizing)
81- const iconWrap = document.createElement('div');
82- iconWrap.className = 'history-favicon-chip';
83- const icon = document.createElement('img');
84- icon.className = 'history-favicon-img';
85- icon.width = 16; icon.height = 16;
86- icon.loading = 'lazy';
87- icon.referrerPolicy = 'no-referrer';
86+ const iconWrap = document.createElement("div");
87+ iconWrap.className = "history-favicon-chip";
88+ const icon = document.createElement("img");
89+ icon.className = "history-favicon-img";
90+ icon.width = 16;
91+ icon.height = 16;
92+ icon.loading = "lazy";
93+ icon.referrerPolicy = "no-referrer";
8894 icon.src = this.buildFaviconURL(item.url);
89- icon.addEventListener('error', () => { iconWrap.style.display = 'none'; });
95+ icon.addEventListener("error", () => {
96+ iconWrap.style.display = "none";
97+ });
9098 iconWrap.appendChild(icon);
9199
92- const titleEl = document.createElement('span');
93- titleEl.className = 'history-title';
100+ const titleEl = document.createElement("span");
101+ titleEl.className = "history-title";
94102 titleEl.textContent = title;
95103
96- const sep1 = document.createElement('span');
97- sep1.className = 'history-sep';
98- sep1.textContent = ' - ';
104+ const sep1 = document.createElement("span");
105+ sep1.className = "history-sep";
106+ sep1.textContent = " - ";
99107
100- const domainEl = document.createElement('span');
101- domainEl.className = 'history-domain';
102- domainEl.textContent = parsed.host || '';
108+ const domainEl = document.createElement("span");
109+ domainEl.className = "history-domain";
110+ domainEl.textContent = parsed.host || "";
103111
104- const sep2 = document.createElement('span');
105- sep2.className = 'history-sep';
106- sep2.textContent = ' - ';
112+ const sep2 = document.createElement("span");
113+ sep2.className = "history-sep";
114+ sep2.textContent = " - ";
107115
108- const urlEl = document.createElement('span');
109- urlEl.className = 'history-url';
116+ const urlEl = document.createElement("span");
117+ urlEl.className = "history-url";
110118 urlEl.textContent = item.url;
111119
112120 line.appendChild(iconWrap);
@@ -117,33 +125,33 @@ export class DOMRenderer {
117125 line.appendChild(urlEl);
118126
119127 historyItem.appendChild(line);
120-
121- historyItem.addEventListener('click', () => {
128+
129+ historyItem.addEventListener("click", () => {
122130 this.navigateToUrl(item.url);
123131 });
124-
132+
125133 return historyItem;
126134 }
127135
128136 private createShortcutElement(key: string, shortcut: Shortcut): HTMLElement {
129- const shortcutEl = document.createElement('div');
130- shortcutEl.className = 'shortcut';
137+ const shortcutEl = document.createElement("div");
138+ shortcutEl.className = "shortcut";
131139 shortcutEl.innerHTML = `
132140 <div class="shortcut-key">${this.escapeHtml(key)}:</div>
133- <div class="shortcut-desc">${this.escapeHtml(shortcut.description || 'No description')}</div>
141+ <div class="shortcut-desc">${this.escapeHtml(shortcut.description || "No description")}</div>
134142 `;
135-
136- shortcutEl.addEventListener('click', () => {
143+
144+ shortcutEl.addEventListener("click", () => {
137145 // Navigate to the base URL of the shortcut (remove %s template parameter)
138146 const baseUrl = this.extractBaseUrl(shortcut.url_template);
139147 this.navigateToUrl(baseUrl);
140148 });
141-
149+
142150 return shortcutEl;
143151 }
144152
145153 private async navigateToUrl(url: string): Promise<void> {
146- console.log('Navigating to:', url);
154+ console.log("Navigating to:", url);
147155 window.location.href = url;
148156 }
149157
@@ -163,13 +171,13 @@ export class DOMRenderer {
163171 url.search = params.toString();
164172 return url.toString();
165173 } catch (error) {
166- console.error('Failed to parse shortcut URL:', templateUrl, error);
174+ console.error("Failed to parse shortcut URL:", templateUrl, error);
167175 throw error;
168176 }
169177 }
170178
171179 private escapeHtml(text: string): string {
172- const div = document.createElement('div');
180+ const div = document.createElement("div");
173181 div.textContent = text;
174182 return div.innerHTML;
175183 }
@@ -179,17 +187,17 @@ export class DOMRenderer {
179187 const u = new URL(raw);
180188 return { host: u.host };
181189 } catch {
182- return { host: '' };
190+ return { host: "" };
183191 }
184192 }
185193
186194 private buildFaviconURL(raw: string): string {
187195 try {
188196 const u = new URL(raw);
189- const scheme = u.protocol && u.protocol !== ':' ? u.protocol : 'https:';
197+ const scheme = u.protocol && u.protocol !== ":" ? u.protocol : "https:";
190198 return `${scheme}//${u.host}/favicon.ico`;
191199 } catch {
192- return '';
200+ return "";
193201 }
194202 }
195203 }
M gui/svelte.config.js +2 -2
@@ -1,4 +1,4 @@
1-import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
1+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
22
33 export default {
44 // Enable rune mode for Svelte 5
@@ -7,4 +7,4 @@ export default {
77 },
88 // Use Vite's preprocessor for TypeScript and PostCSS
99 preprocess: vitePreprocess(),
10-};⏎̸
10+};
M gui/tailwind.config.js +20 -22
@@ -1,44 +1,42 @@
11 /** @type {import('tailwindcss').Config} */
22 export default {
3- content: [
4- './src/**/*.{html,js,ts,svelte}',
5- ],
3+ content: ["./src/**/*.{html,js,ts,svelte}"],
64 theme: {
75 extend: {
86 // Custom design system colors
97 colors: {
108 // Browser-specific colors that work well in both themes
119 browser: {
12- bg: 'rgb(var(--browser-bg) / <alpha-value>)',
13- surface: 'rgb(var(--browser-surface) / <alpha-value>)',
14- text: 'rgb(var(--browser-text) / <alpha-value>)',
15- muted: 'rgb(var(--browser-muted) / <alpha-value>)',
16- accent: 'rgb(var(--browser-accent) / <alpha-value>)',
17- border: 'rgb(var(--browser-border) / <alpha-value>)',
18- }
10+ bg: "rgb(var(--browser-bg) / <alpha-value>)",
11+ surface: "rgb(var(--browser-surface) / <alpha-value>)",
12+ text: "rgb(var(--browser-text) / <alpha-value>)",
13+ muted: "rgb(var(--browser-muted) / <alpha-value>)",
14+ accent: "rgb(var(--browser-accent) / <alpha-value>)",
15+ border: "rgb(var(--browser-border) / <alpha-value>)",
16+ },
1917 },
2018 // Animation speeds optimized for browser UI
2119 transitionDuration: {
22- '150': '150ms', // Fast interactions
23- '250': '250ms', // UI feedback
24- '400': '400ms', // Smooth transitions
20+ 150: "150ms", // Fast interactions
21+ 250: "250ms", // UI feedback
22+ 400: "400ms", // Smooth transitions
2523 },
2624 // Typography scale for browser UI
2725 fontSize: {
28- 'xs': ['0.75rem', '1rem'],
29- 'sm': ['0.875rem', '1.25rem'],
30- 'base': ['1rem', '1.5rem'],
31- 'lg': ['1.125rem', '1.75rem'],
26+ xs: ["0.75rem", "1rem"],
27+ sm: ["0.875rem", "1.25rem"],
28+ base: ["1rem", "1.5rem"],
29+ lg: ["1.125rem", "1.75rem"],
3230 },
3331 // Spacing for compact UI elements
3432 spacing: {
35- '18': '4.5rem',
36- '22': '5.5rem',
37- }
33+ 18: "4.5rem",
34+ 22: "5.5rem",
35+ },
3836 },
3937 },
4038 plugins: [],
4139 // Manual dark mode control (not system preference)
4240 // This allows GTK to control theme via CSS class
43- darkMode: 'class',
44-};⏎̸
41+ darkMode: "class",
42+};
M gui/tsconfig.json +3 -9
@@ -22,12 +22,6 @@
2222 "$components/*": ["src/components/*"]
2323 }
2424 },
25- "include": [
26- "src/**/*",
27- "vite.config.ts"
28- ],
29- "exclude": [
30- "node_modules",
31- "dist"
32- ]
33-}⏎̸
25+ "include": ["src/**/*", "vite.config.ts"],
26+ "exclude": ["node_modules", "dist"]
27+}
M gui/vite-plugin-pages.ts +23 -22
@@ -1,6 +1,6 @@
1-import type { Plugin } from 'vite';
2-import { writeFileSync, readFileSync } from 'fs';
3-import { resolve } from 'path';
1+import type { Plugin } from "vite";
2+import { writeFileSync, readFileSync } from "fs";
3+import { resolve } from "path";
44
55 interface PageConfig {
66 /** Page name (used for filename and script name) */
@@ -17,23 +17,25 @@ interface PageConfig {
1717
1818 // Load the favicon SVG from assets
1919 function getFaviconSVG(): string {
20- const logoPath = resolve(__dirname, '..', 'assets', 'logo.svg');
21- return readFileSync(logoPath, 'utf-8');
20+ const logoPath = resolve(__dirname, "..", "assets", "logo.svg");
21+ return readFileSync(logoPath, "utf-8");
2222 }
2323
2424 // Create favicon data URL for inline usage
2525 function getFaviconDataURL(): string {
2626 const svg = getFaviconSVG();
2727 const encoded = encodeURIComponent(svg)
28- .replace(/'/g, '%27')
29- .replace(/\(/g, '%28')
30- .replace(/\)/g, '%29');
28+ .replace(/'/g, "%27")
29+ .replace(/\(/g, "%28")
30+ .replace(/\)/g, "%29");
3131 return `data:image/svg+xml;utf8,${encoded}`;
3232 }
3333
3434 // Generate HTML content for a page
3535 function generatePageHTML(config: PageConfig): string {
36- const cssLink = config.css ? `<link rel="stylesheet" href="./${config.css}">` : '';
36+ const cssLink = config.css
37+ ? `<link rel="stylesheet" href="./${config.css}">`
38+ : "";
3739
3840 return `<!DOCTYPE html>
3941 <html lang="en">
@@ -56,9 +58,9 @@ function generatePageHTML(config: PageConfig): string {
5658 */
5759 export function pageGenerator(pages: PageConfig[]): Plugin {
5860 return {
59- name: 'dumb-page-generator',
61+ name: "dumb-page-generator",
6062 writeBundle() {
61- const assetsDir = resolve(__dirname, '..', 'assets', 'gui');
63+ const assetsDir = resolve(__dirname, "..", "assets", "gui");
6264
6365 try {
6466 // Generate HTML files for each page
@@ -71,14 +73,13 @@ export function pageGenerator(pages: PageConfig[]): Plugin {
7173
7274 // Generate favicon.svg for scheme handler lookups
7375 const faviconContent = getFaviconSVG();
74- writeFileSync(resolve(assetsDir, 'favicon.svg'), faviconContent);
75- console.log('✓ Generated favicon.svg');
76-
76+ writeFileSync(resolve(assetsDir, "favicon.svg"), faviconContent);
77+ console.log("✓ Generated favicon.svg");
7778 } catch (error) {
78- console.error('✗ Failed to generate page files:', error);
79+ console.error("✗ Failed to generate page files:", error);
7980 throw error;
8081 }
81- }
82+ },
8283 };
8384 }
8485
@@ -89,10 +90,10 @@ export function pageGenerator(pages: PageConfig[]): Plugin {
8990 export function homepageGenerator(): Plugin {
9091 return pageGenerator([
9192 {
92- name: 'homepage',
93- title: 'Dumber Browser',
94- script: 'homepage.min.js',
95- filename: 'index.html', // Special case: homepage uses index.html
96- }
93+ name: "homepage",
94+ title: "Dumber Browser",
95+ script: "homepage.min.js",
96+ filename: "index.html", // Special case: homepage uses index.html
97+ },
9798 ]);
98-}⏎̸
99+}
M gui/vite.config.color-scheme.ts +10 -10
@@ -1,28 +1,28 @@
1-import { defineConfig } from 'vite';
2-import { resolve } from 'path';
1+import { defineConfig } from "vite";
2+import { resolve } from "path";
33
44 export default defineConfig({
55 build: {
66 rollupOptions: {
7- input: resolve(__dirname, 'src/injected/modules/color-scheme.ts'),
7+ input: resolve(__dirname, "src/injected/modules/color-scheme.ts"),
88 output: {
9- dir: '../assets/gui',
10- format: 'iife',
11- entryFileNames: 'color-scheme.js',
9+ dir: "../assets/gui",
10+ format: "iife",
11+ entryFileNames: "color-scheme.js",
1212 inlineDynamicImports: true,
1313 manualChunks: undefined, // Prevent chunk splitting
1414 },
1515 },
1616 emptyOutDir: false, // Don't clear the directory since GUI bundle is also built here
17- target: ['es2020', 'chrome91', 'firefox90'],
17+ target: ["es2020", "chrome91", "firefox90"],
1818 minify: true,
1919 sourcemap: false,
2020 },
2121 resolve: {
2222 alias: {
2323 // Alias for easier imports
24- '$lib': resolve(__dirname, 'src/lib'),
25- '$components': resolve(__dirname, 'src/components'),
24+ $lib: resolve(__dirname, "src/lib"),
25+ $components: resolve(__dirname, "src/components"),
2626 },
2727 },
28-});⏎̸
28+});
M gui/vite.config.gui.ts +13 -13
@@ -1,6 +1,6 @@
1-import { defineConfig } from 'vite';
2-import { svelte } from '@sveltejs/vite-plugin-svelte';
3-import { resolve } from 'path';
1+import { defineConfig } from "vite";
2+import { svelte } from "@sveltejs/vite-plugin-svelte";
3+import { resolve } from "path";
44
55 export default defineConfig({
66 plugins: [
@@ -10,19 +10,19 @@ export default defineConfig({
1010 ],
1111 build: {
1212 rollupOptions: {
13- input: resolve(__dirname, 'src/injected/gui.ts'),
13+ input: resolve(__dirname, "src/injected/gui.ts"),
1414 output: {
15- dir: '../assets/gui',
16- format: 'iife',
17- entryFileNames: 'gui.min.js',
18- name: '__dumberGUI',
15+ dir: "../assets/gui",
16+ format: "iife",
17+ entryFileNames: "gui.min.js",
18+ name: "__dumberGUI",
1919 inlineDynamicImports: true,
2020 manualChunks: undefined, // Prevent chunk splitting
21- assetFileNames: '[name].[ext]', // Don't hash asset names since they're inlined
21+ assetFileNames: "[name].[ext]", // Don't hash asset names since they're inlined
2222 },
2323 },
2424 emptyOutDir: false,
25- target: ['es2020', 'chrome91', 'firefox90'],
25+ target: ["es2020", "chrome91", "firefox90"],
2626 minify: true,
2727 sourcemap: false,
2828 cssCodeSplit: false, // Prevent CSS code splitting
@@ -31,8 +31,8 @@ export default defineConfig({
3131 resolve: {
3232 alias: {
3333 // Alias for easier imports
34- '$lib': resolve(__dirname, 'src/lib'),
35- '$components': resolve(__dirname, 'src/components'),
34+ $lib: resolve(__dirname, "src/lib"),
35+ $components: resolve(__dirname, "src/components"),
3636 },
3737 },
38-});⏎̸
38+});
M gui/vite.config.homepage.ts +22 -19
@@ -1,33 +1,36 @@
1-import { defineConfig } from 'vite';
2-import { svelte } from '@sveltejs/vite-plugin-svelte';
3-import { resolve } from 'path';
4-import { pageGenerator } from './vite-plugin-pages';
1+import { defineConfig } from "vite";
2+import { svelte } from "@sveltejs/vite-plugin-svelte";
3+import { resolve } from "path";
4+import { pageGenerator } from "./vite-plugin-pages";
55
66 export default defineConfig({
77 plugins: [
88 svelte(),
99 pageGenerator([
1010 {
11- name: 'homepage',
12- title: 'Dumber Browser',
13- script: 'homepage.min.js',
14- css: 'homepage.css',
15- filename: 'index.html',
16- }
11+ name: "homepage",
12+ title: "Dumber Browser",
13+ script: "homepage.min.js",
14+ css: "homepage.css",
15+ filename: "index.html",
16+ },
1717 ]),
1818 ],
1919 build: {
2020 rollupOptions: {
21- input: resolve(__dirname, 'src/pages/homepage.ts'),
21+ input: resolve(__dirname, "src/pages/homepage.ts"),
2222 output: {
23- dir: '../assets/gui',
24- entryFileNames: 'homepage.min.js',
25- chunkFileNames: '[name].js',
26- assetFileNames: '[name].[ext]',
23+ dir: "../assets/gui",
24+ entryFileNames: "homepage.min.js",
25+ chunkFileNames: "[name].js",
26+ assetFileNames: "[name].[ext]",
27+ manualChunks: undefined,
28+ format: 'iife',
29+ name: 'DumberHomepage'
2730 },
2831 },
2932 emptyOutDir: false,
30- target: ['es2020', 'chrome91', 'firefox90'],
33+ target: ["es2020", "chrome91", "firefox90"],
3134 minify: true,
3235 sourcemap: false,
3336 cssCodeSplit: true,
@@ -35,8 +38,8 @@ export default defineConfig({
3538 },
3639 resolve: {
3740 alias: {
38- '$lib': resolve(__dirname, 'src/lib'),
39- '$components': resolve(__dirname, 'src/components'),
41+ $lib: resolve(__dirname, "src/lib"),
42+ $components: resolve(__dirname, "src/components"),
4043 },
4144 },
42-});⏎̸
45+});
A gui/vite.config.main-world.ts +28 -0
@@ -0,0 +1,28 @@
1+import { defineConfig } from "vite";
2+import { resolve } from "path";
3+
4+export default defineConfig({
5+ build: {
6+ rollupOptions: {
7+ input: resolve(__dirname, "src/injected/main-world.ts"),
8+ output: {
9+ dir: "../assets/gui",
10+ format: "iife",
11+ entryFileNames: "main-world.min.js",
12+ name: "__dumberMainWorld",
13+ inlineDynamicImports: true,
14+ manualChunks: undefined,
15+ assetFileNames: "[name].[ext]",
16+ },
17+ },
18+ emptyOutDir: false,
19+ target: ["es2020", "chrome91", "firefox90"],
20+ minify: true,
21+ sourcemap: false,
22+ cssCodeSplit: false,
23+ assetsInlineLimit: 100000000,
24+ },
25+ define: {
26+ __DOM_ZOOM_DEFAULT__: "1.0", // Default placeholder, will be replaced by Go
27+ },
28+});
M internal/app/browser/shortcuts.go +26 -11
@@ -30,16 +30,33 @@ func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.Cl
3030
3131 func (s *ShortcutHandler) isActivePane() bool {
3232 if s == nil || s.webView == nil {
33- log.Printf("[shortcut] isActivePane: handler or webView is nil")
3433 return false
3534 }
36- if s.app == nil || s.app.activePane == nil {
37- log.Printf("[shortcut] isActivePane: app or activePane is nil, returning true")
38- return true
35+ if s.app == nil || s.app.workspace == nil {
36+ return true // No workspace context; allow
3937 }
40- isActive := s.app.activePane.webView == s.webView
41- log.Printf("[shortcut] isActivePane: handler webView=%p activePane webView=%p isActive=%v", s.webView, s.app.activePane.webView, isActive)
42- return isActive
38+
39+ // Find node for this WebView
40+ node := s.app.workspace.viewToNode[s.webView]
41+ if node == nil {
42+ return false
43+ }
44+
45+ // For related popups, they're active if parent chain contains the active node
46+ if node.isRelated && node.parentPane != nil {
47+ activeNode := s.app.workspace.active
48+ cur := node
49+ for cur != nil {
50+ if cur == activeNode {
51+ return true
52+ }
53+ cur = cur.parentPane
54+ }
55+ return false
56+ }
57+
58+ // Independent panes: equal to active node
59+ return node == s.app.workspace.active
4360 }
4461
4562 // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards
@@ -74,10 +91,8 @@ func (s *ShortcutHandler) RegisterShortcuts() {
7491 _ = s.webView.Reload()
7592 })
7693
77- // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are now handled
78- // by the WorkspaceManager to ensure they work properly across all panes
79-
80- // Zoom handled natively in webkit package (built-in shortcuts)
94+ // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are handled by WorkspaceManager
95+ // and zoom shortcuts (Ctrl +/-/0) are handled by WindowShortcutHandler to ensure they work properly across all panes
8196 }
8297
8398 func (s *ShortcutHandler) dispatchUIShortcut(action string, extra map[string]any) error {
M internal/app/browser/window_shortcuts.go +63 -0
@@ -58,6 +58,11 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
5858 {"ctrl+f", h.handleFindToggle, "Find in page"},
5959 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
6060 {"F12", h.handleDevTools, "Developer tools"},
61+ // Zoom shortcuts - global level for proper active pane targeting
62+ {"ctrl+plus", h.handleZoomIn, "Zoom in"},
63+ {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
64+ {"ctrl+minus", h.handleZoomOut, "Zoom out"},
65+ {"ctrl+0", h.handleZoomReset, "Zoom reset"},
6166 }
6267
6368 for _, shortcut := range shortcuts {
@@ -144,6 +149,64 @@ func (h *WindowShortcutHandler) handleDevTools() {
144149 }
145150 }
146151
152+// handleZoomIn increases zoom level by 10%
153+func (h *WindowShortcutHandler) handleZoomIn() {
154+ h.handleZoom("in", 1.1)
155+}
156+
157+// handleZoomOut decreases zoom level by 10%
158+func (h *WindowShortcutHandler) handleZoomOut() {
159+ h.handleZoom("out", 1.0/1.1)
160+}
161+
162+// handleZoomReset resets zoom to 100%
163+func (h *WindowShortcutHandler) handleZoomReset() {
164+ h.handleZoom("reset", 1.0)
165+}
166+
167+// handleZoom applies zoom changes to the active pane
168+func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
169+ h.mu.Lock()
170+ defer h.mu.Unlock()
171+
172+ if h.app.activePane == nil || h.app.activePane.webView == nil {
173+ log.Printf("[window-shortcuts] No active pane for zoom %s", action)
174+ return
175+ }
176+
177+ activeWebView := h.app.activePane.webView
178+ log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
179+
180+ // Get current zoom level
181+ currentZoom, err := activeWebView.GetZoom()
182+ if err != nil {
183+ log.Printf("[window-shortcuts] Failed to get current zoom: %v", err)
184+ return
185+ }
186+
187+ // Calculate new zoom level
188+ var newZoom float64
189+ if action == "reset" {
190+ newZoom = 1.0
191+ } else {
192+ newZoom = currentZoom * multiplier
193+ // Apply zoom limits
194+ if newZoom < 0.25 {
195+ newZoom = 0.25
196+ }
197+ if newZoom > 5.0 {
198+ newZoom = 5.0
199+ }
200+ }
201+
202+ log.Printf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom)
203+
204+ // Apply zoom to active pane
205+ if err := activeWebView.SetZoom(newZoom); err != nil {
206+ log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
207+ }
208+}
209+
147210 func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
148211 if h.app.activePane == nil {
149212 return
M internal/app/browser/workspace_manager.go +625 -104
@@ -22,8 +22,14 @@ type paneNode struct {
2222 container uintptr // GtkPaned for branch nodes, stable WebView container for leaves
2323 orientation webkit.Orientation
2424 isLeaf bool
25- isPopup bool // Track if this is a popup pane for OAuth auto-close
26- hoverToken uintptr
25+ isPopup bool // Deprecated: use windowType instead
26+ // Window type tracking
27+ windowType webkit.WindowType // Tab or Popup
28+ windowFeatures *webkit.WindowFeatures // Features if popup
29+ isRelated bool // Shares context
30+ parentPane *paneNode // Parent for related views
31+ autoClose bool // Auto-close on OAuth success
32+ hoverToken uintptr
2733 }
2834
2935 // WorkspaceManager coordinates Zellij-style pane operations.
@@ -46,6 +52,13 @@ type WorkspaceManager struct {
4652 paneModeSource *webkit.WebView // Which webview initiated pane mode
4753 lastPaneModeEntry time.Time // When pane mode was last entered
4854 paneMutex sync.Mutex // Protects pane mode state
55+
56+ // Focus throttling fields to prevent infinite loops
57+ lastFocusChange time.Time // When focus was last changed
58+ focusThrottleMutex sync.Mutex // Protects focus throttling state
59+
60+ // NEW: Pane creation deduplicator
61+ paneDeduplicator *messaging.PaneRequestDeduplicator
4962 }
5063
5164 const (
@@ -84,6 +97,7 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView)
8497 })
8598 }
8699
100+ // Pane navigation shortcuts
87101 registerFocusMove("alt+ArrowLeft", "left")
88102 registerFocusMove("alt+ArrowRight", "right")
89103 registerFocusMove("alt+ArrowUp", "up")
@@ -91,6 +105,9 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView)
91105 registerFocusMove("cmdorctrl+ArrowUp", "up")
92106 registerFocusMove("cmdorctrl+ArrowDown", "down")
93107
108+ // Zoom shortcuts are now handled by global window shortcuts in window_shortcuts.go
109+ // for consistency with other global shortcuts like Ctrl+L, Ctrl+F, etc.
110+
94111 // Reduced logging: only log shortcuts registration during initialization, not on every hover
95112 if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
96113 log.Printf("[workspace] registered navigation shortcuts on webView=%p", webView)
@@ -100,11 +117,12 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView)
100117 // NewWorkspaceManager builds a workspace manager rooted at the provided pane.
101118 func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
102119 manager := &WorkspaceManager{
103- app: app,
104- window: rootPane.webView.Window(),
105- viewToNode: make(map[*webkit.WebView]*paneNode),
106- lastSplitMsg: make(map[*webkit.WebView]time.Time),
107- lastExitMsg: make(map[*webkit.WebView]time.Time),
120+ app: app,
121+ window: rootPane.webView.Window(),
122+ viewToNode: make(map[*webkit.WebView]*paneNode),
123+ lastSplitMsg: make(map[*webkit.WebView]time.Time),
124+ lastExitMsg: make(map[*webkit.WebView]time.Time),
125+ paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator
108126 }
109127 manager.createWebViewFn = func() (*webkit.WebView, error) {
110128 if manager.app == nil {
@@ -241,6 +259,96 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
241259 }
242260 wm.clonePaneState(node, newNode)
243261 wm.splitting = false
262+ case "create-pane":
263+ log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)
264+
265+ if msg.URL == "" {
266+ log.Printf("[workspace] create-pane: empty URL, ignoring")
267+ break
268+ }
269+
270+ // NEW: Get WebView ID for deduplication
271+ webViewID := "unknown"
272+ if source != nil {
273+ // Try to get a unique identifier for the WebView
274+ webViewID = fmt.Sprintf("%p", source)
275+ }
276+
277+ // NEW: Create intent for deduplication check
278+ intent := &messaging.WindowIntent{
279+ URL: msg.URL,
280+ WindowType: msg.Action,
281+ Timestamp: time.Now().UnixMilli(),
282+ RequestID: msg.RequestID,
283+ UserTriggered: true,
284+ }
285+
286+ // NEW: Check for duplicates
287+ if isDup, reason := wm.paneDeduplicator.IsDuplicate(intent, webViewID); isDup {
288+ log.Printf("[workspace] create-pane BLOCKED: %s", reason)
289+ break
290+ }
291+
292+ // Use the existing methods to handle tab vs popup creation
293+ switch strings.ToLower(msg.Action) {
294+ case "tab":
295+ newView := wm.handleIntentAsTab(node, msg.URL, intent)
296+ if newView != nil {
297+ log.Printf("[workspace] create-pane: tab created successfully")
298+ } else {
299+ log.Printf("[workspace] create-pane: failed to create tab")
300+ }
301+ case "popup":
302+ newView := wm.handleIntentAsPopup(node, msg.URL, intent)
303+ if newView != nil {
304+ log.Printf("[workspace] create-pane: popup created successfully")
305+ } else {
306+ log.Printf("[workspace] create-pane: failed to create popup")
307+ }
308+ default:
309+ log.Printf("[workspace] create-pane: unknown action '%s', defaulting to tab", msg.Action)
310+ newView := wm.handleIntentAsTab(node, msg.URL, intent)
311+ if newView != nil {
312+ log.Printf("[workspace] create-pane: default tab created successfully")
313+ } else {
314+ log.Printf("[workspace] create-pane: failed to create default tab")
315+ }
316+ }
317+ case "close-popup":
318+ log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
319+
320+ if msg.WebViewID == "" {
321+ log.Printf("[workspace] close-popup: empty webviewId, ignoring")
322+ break
323+ }
324+
325+ // Find the popup pane by webview ID
326+ var targetNode *paneNode
327+ for webView, node := range wm.viewToNode {
328+ if webView != nil && webView.ID() == msg.WebViewID {
329+ targetNode = node
330+ break
331+ }
332+ }
333+
334+ if targetNode == nil {
335+ log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
336+ break
337+ }
338+
339+ if !targetNode.isPopup {
340+ log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
341+ break
342+ }
343+
344+ log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
345+
346+ // Close the popup pane
347+ if err := wm.closePane(targetNode); err != nil {
348+ log.Printf("[workspace] Failed to close popup pane: %v", err)
349+ } else {
350+ log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
351+ }
244352 default:
245353 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
246354 }
@@ -276,9 +384,9 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) {
276384 }
277385 }
278386
279- // Remove active class from previous pane (border stays, just color changes)
280- if wm.active != nil && wm.active.container != 0 {
281- webkit.WidgetRemoveCSSClass(wm.active.container, activePaneClass)
387+ // Set previously active WebView as inactive
388+ if previousWebView != nil && previousWebView != node.pane.webView {
389+ previousWebView.SetActive(false)
282390 }
283391
284392 wm.active = node
@@ -290,6 +398,11 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) {
290398 wm.app.messageHandler = node.pane.messageHandler
291399 wm.app.shortcutHandler = node.pane.shortcutHandler
292400
401+ // Set this WebView as active
402+ if node.pane != nil && node.pane.webView != nil {
403+ node.pane.webView.SetActive(true)
404+ }
405+
293406 // Re-register workspace navigation shortcuts on the newly focused webView
294407 wm.registerWorkspaceShortcuts(node.pane.webView)
295408
@@ -307,7 +420,12 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) {
307420 container := node.container
308421 viewWidget := node.pane.webView.Widget()
309422 if container != 0 && container != previousContainer {
310- // Always add active class (border visibility controlled by CSS multi-pane rules)
423+ // Remove active class from previous container to ensure only one pane is visually active
424+ if previousContainer != 0 {
425+ webkit.WidgetRemoveCSSClass(previousContainer, activePaneClass)
426+ }
427+
428+ // Add active class to current container (border visibility controlled by CSS multi-pane rules)
311429 webkit.WidgetAddCSSClass(container, activePaneClass)
312430 if !webkit.WidgetIsValid(container) {
313431 log.Printf("[workspace] focus aborted: container invalid widget=%#x", container)
@@ -427,6 +545,17 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
427545 if wm == nil || view == nil {
428546 return
429547 }
548+
549+ // Throttle focus changes to prevent infinite loops
550+ wm.focusThrottleMutex.Lock()
551+ const focusThrottleInterval = 100 * time.Millisecond
552+ if time.Since(wm.lastFocusChange) < focusThrottleInterval {
553+ wm.focusThrottleMutex.Unlock()
554+ return
555+ }
556+ wm.lastFocusChange = time.Now()
557+ wm.focusThrottleMutex.Unlock()
558+
430559 if node, ok := wm.viewToNode[view]; ok {
431560 if wm.active != node {
432561 wm.focusNode(node)
@@ -1094,34 +1223,25 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
10941223
10951224 grand := parent.parent
10961225 if grand == nil {
1097- // Parent is the root node. We need to restructure.
1098- // If going from 2 panes to 1, promote sibling to become the new root
1099- if remaining == 2 { // Will become 1 after this close
1100- log.Printf("[workspace] promoting sibling to root (last 2 panes): container=%#x", sibling.container)
1101- wm.root = sibling
1102- sibling.parent = nil
1103-
1104- // Unparent the sibling from the paned first, then set it as window child
1105- if wm.window != nil {
1106- // GTK requires explicit unparenting before reparenting
1107- if parent.left == sibling {
1108- webkit.PanedSetStartChild(parent.container, 0)
1109- } else {
1110- webkit.PanedSetEndChild(parent.container, 0)
1111- }
1112- // Now we can safely set it as window child
1113- wm.window.SetChild(sibling.container)
1114- if sibling.container != 0 {
1115- webkit.WidgetQueueAllocate(sibling.container)
1116- }
1226+ // Parent is the root node. Promote sibling to become the new root.
1227+ // The sibling can be either a leaf (when only 2 panes total) or a branch (when more panes exist)
1228+ log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
1229+ wm.root = sibling
1230+ sibling.parent = nil
1231+
1232+ // Unparent the sibling from the paned first, then set it as window child
1233+ if wm.window != nil {
1234+ // GTK requires explicit unparenting before reparenting
1235+ if parent.left == sibling {
1236+ webkit.PanedSetStartChild(parent.container, 0)
1237+ } else {
1238+ webkit.PanedSetEndChild(parent.container, 0)
1239+ }
1240+ // Now we can safely set it as window child
1241+ wm.window.SetChild(sibling.container)
1242+ if sibling.container != 0 {
1243+ webkit.WidgetQueueAllocate(sibling.container)
11171244 }
1118- } else {
1119- // More than 2 panes remain, so keep the tree structure
1120- // The sibling should remain as a child of root, but we need to remove the parent paned
1121- log.Printf("[workspace] removing parent paned, keeping sibling under root: container=%#x", sibling.container)
1122- // This shouldn't happen in a proper binary tree structure
1123- log.Printf("[workspace] ERROR: unexpected tree state - parent is root but more than 2 panes remain")
1124- return errors.New("unexpected tree state during pane closure")
11251245 }
11261246 } else {
11271247 // Parent has a grandparent, so promote sibling to take parent's place
@@ -1282,6 +1402,8 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
12821402 return nil
12831403 }
12841404
1405+ // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
1406+
12851407 cfg := wm.app.config
12861408 if cfg == nil {
12871409 log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
@@ -1296,24 +1418,51 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
12961418 return nil
12971419 }
12981420
1299- // Get webkit config for new WebView creation
1421+ // Smart detection path: create temporary view and decide placement once type is known
1422+ if popCfg.EnableSmartDetection {
1423+ webkitCfg, err := wm.app.buildWebkitConfig()
1424+ if err != nil {
1425+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1426+ return nil
1427+ }
1428+ webkitCfg.CreateWindow = false
1429+ // Create as related to avoid WindowFeatures crash; we'll decide final placement later
1430+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
1431+ if err != nil {
1432+ log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
1433+ return nil
1434+ }
1435+
1436+ // Register detection callback
1437+ newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
1438+ wm.RunOnUI(func() {
1439+ wm.handleDetectedWindowType(node, newView, url, t, feat)
1440+ })
1441+ })
1442+
1443+ // Fallback: if detection never fires, treat as popup as before
1444+ go func() {
1445+ time.Sleep(1500 * time.Millisecond)
1446+ if newView != nil {
1447+ wm.RunOnUI(func() {
1448+ if wm.viewToNode[newView] == nil { // not yet placed
1449+ wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
1450+ }
1451+ })
1452+ }
1453+ }()
1454+
1455+ return newView
1456+ }
1457+
1458+ // Legacy path preserved
13001459 webkitCfg, err := wm.app.buildWebkitConfig()
13011460 if err != nil {
13021461 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
13031462 return nil
13041463 }
1305- // Don't create window initially - we'll embed directly in workspace
13061464 webkitCfg.CreateWindow = false
1307-
1308- var newView *webkit.WebView
1309-
1310- // SIMPLIFIED: WebView is now created directly in C code via create signal
1311- // This ensures proper WebKit lifecycle and prevents WindowFeatures crash
1312- log.Printf("[workspace] WebView created by C create signal - setting up workspace integration")
1313-
1314- // Create a dummy WebView for now - the real one is passed from C
1315- // TODO: This needs to be refactored to accept the WebView from C
1316- newView, err = webkit.NewWebView(webkitCfg)
1465+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
13171466 if err != nil {
13181467 log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
13191468 return nil
@@ -1388,6 +1537,9 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
13881537 // Ensure GUI components are available in the new pane
13891538 wm.ensureGUIInPane(newPane)
13901539
1540+ // Inject GUI components into the popup pane
1541+ wm.ensureGUIInPane(newPane)
1542+
13911543 // Load the URL if provided
13921544 if url != "" {
13931545 paneType := "popup"
@@ -1398,6 +1550,10 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
13981550 if err := newView.LoadURL(url); err != nil {
13991551 log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
14001552 }
1553+ // Ensure the WebView is visible after loading URL
1554+ if err := newView.Show(); err != nil {
1555+ log.Printf("[workspace] failed to show popup WebView: %v", err)
1556+ }
14011557 }
14021558
14031559 if isBlankTarget {
@@ -1408,6 +1564,410 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
14081564 return newView
14091565 }
14101566
1567+// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
1568+// Note: OAuth detection is now handled by the main-world.js injection script
1569+func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
1570+ log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
1571+ log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
1572+}
1573+
1574+func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
1575+ if intent == nil {
1576+ return
1577+ }
1578+
1579+ features := &webkit.WindowFeatures{}
1580+
1581+ // Apply dimensions if specified
1582+ if intent.Width != nil {
1583+ features.Width = *intent.Width
1584+ }
1585+ if intent.Height != nil {
1586+ features.Height = *intent.Height
1587+ }
1588+
1589+ // Apply visibility features based on window type
1590+ defaultToolbar := !isPopup
1591+ defaultLocation := !isPopup
1592+ defaultMenubar := !isPopup
1593+
1594+ if intent.Toolbar != nil {
1595+ features.ToolbarVisible = *intent.Toolbar
1596+ } else {
1597+ features.ToolbarVisible = defaultToolbar
1598+ }
1599+
1600+ if intent.Location != nil {
1601+ features.LocationbarVisible = *intent.Location
1602+ } else {
1603+ features.LocationbarVisible = defaultLocation
1604+ }
1605+
1606+ if intent.Menubar != nil {
1607+ features.MenubarVisible = *intent.Menubar
1608+ } else {
1609+ features.MenubarVisible = defaultMenubar
1610+ }
1611+
1612+ if intent.Resizable != nil {
1613+ features.Resizable = *intent.Resizable
1614+ } else {
1615+ features.Resizable = true // Usually resizable unless explicitly disabled
1616+ }
1617+
1618+ view.SetWindowFeatures(features)
1619+ windowTypeStr := "tab"
1620+ if isPopup {
1621+ windowTypeStr = "popup"
1622+ }
1623+ log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
1624+ windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
1625+}
1626+
1627+func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
1628+ log.Printf("[workspace] Handling intent as tab: %s", url)
1629+
1630+ webkitCfg, err := wm.app.buildWebkitConfig()
1631+ if err != nil {
1632+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1633+ return nil
1634+ }
1635+ webkitCfg.CreateWindow = false
1636+
1637+ newView, err := webkit.NewWebView(webkitCfg)
1638+ if err != nil {
1639+ log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
1640+ return nil
1641+ }
1642+
1643+ newPane, err := wm.createPane(newView)
1644+ if err != nil {
1645+ log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
1646+ return nil
1647+ }
1648+
1649+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1650+ if direction == "" {
1651+ direction = "right"
1652+ }
1653+
1654+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1655+ log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
1656+ return nil
1657+ }
1658+
1659+ node := wm.viewToNode[newView]
1660+ if node != nil {
1661+ node.windowType = webkit.WindowTypeTab
1662+ node.isRelated = false
1663+
1664+ // Apply window features from JavaScript intent
1665+ wm.applyWindowFeatures(newView, intent, false)
1666+ }
1667+
1668+ wm.ensureGUIInPane(newPane)
1669+
1670+ if url != "" {
1671+ if err := newView.LoadURL(url); err != nil {
1672+ log.Printf("[workspace] failed to load tab URL: %v", err)
1673+ }
1674+ if err := newView.Show(); err != nil {
1675+ log.Printf("[workspace] failed to show tab WebView: %v", err)
1676+ }
1677+ }
1678+
1679+ log.Printf("[workspace] Created tab pane for URL: %s", url)
1680+ return newView
1681+}
1682+
1683+// handleIntentAsPopup creates a related popup pane based on window.open intent
1684+func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
1685+ log.Printf("[workspace] Handling intent as popup: %s", url)
1686+
1687+ webkitCfg, err := wm.app.buildWebkitConfig()
1688+ if err != nil {
1689+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1690+ return nil
1691+ }
1692+ webkitCfg.CreateWindow = false
1693+
1694+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
1695+ if err != nil {
1696+ log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
1697+ return nil
1698+ }
1699+
1700+ // Log the parent-popup WebView ID relationship for OAuth auto-close
1701+ parentWebViewID := sourceNode.pane.webView.ID()
1702+ popupWebViewID := newView.ID()
1703+ log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
1704+
1705+ // Store popup WebView ID in parent's localStorage for OAuth callback lookup
1706+ storeScript := fmt.Sprintf(`
1707+ try {
1708+ const parentWebViewId = '%s';
1709+ const popupWebViewId = '%s';
1710+ const popupMapping = {
1711+ parentId: parentWebViewId,
1712+ popupId: popupWebViewId,
1713+ timestamp: Date.now(),
1714+ url: '%s'
1715+ };
1716+ localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
1717+ console.log('[workspace] Stored popup mapping:', popupMapping);
1718+ } catch(e) {
1719+ console.warn('[workspace] Failed to store popup mapping:', e);
1720+ }
1721+ `, parentWebViewID, popupWebViewID, url)
1722+
1723+ // Inject into parent WebView so it can find its popup later
1724+ if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
1725+ log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
1726+ }
1727+
1728+ newPane, err := wm.createPane(newView)
1729+ if err != nil {
1730+ log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
1731+ return nil
1732+ }
1733+
1734+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1735+ if direction == "" {
1736+ direction = "right"
1737+ }
1738+
1739+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1740+ log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
1741+ return nil
1742+ }
1743+
1744+ node := wm.viewToNode[newView]
1745+ var requestID string
1746+ if node != nil {
1747+ node.windowType = webkit.WindowTypePopup
1748+ node.isRelated = true
1749+ node.parentPane = sourceNode
1750+ node.isPopup = true
1751+ node.autoClose = wm.shouldAutoClose(url)
1752+
1753+ // Store requestID for deduplication cleanup
1754+ if intent != nil {
1755+ requestID = intent.RequestID
1756+ }
1757+
1758+ // Apply window features from JavaScript intent
1759+ wm.applyWindowFeatures(newView, intent, true)
1760+ }
1761+
1762+ // Register close handler for popup auto-close
1763+ newView.RegisterCloseHandler(func() {
1764+ log.Printf("[workspace] Popup requesting close via window.close()")
1765+
1766+ // Clear the RequestID from deduplicator to allow new popups with same ID
1767+ if requestID != "" && wm.paneDeduplicator != nil {
1768+ wm.paneDeduplicator.ClearRequestID(requestID)
1769+ }
1770+
1771+ if n := wm.viewToNode[newView]; n != nil && n.isPopup {
1772+ time.AfterFunc(200*time.Millisecond, func() {
1773+ webkit.IdleAdd(func() bool {
1774+ if err := wm.closePane(n); err != nil {
1775+ log.Printf("[workspace] Failed to close popup pane: %v", err)
1776+ }
1777+ return false
1778+ })
1779+ })
1780+ }
1781+ })
1782+
1783+ // URL-based auto-close for OAuth popups
1784+ if node != nil && node.isPopup && node.autoClose {
1785+ wm.registerOAuthAutoClose(newView, url)
1786+ }
1787+
1788+ wm.ensureGUIInPane(newPane)
1789+
1790+ if url != "" {
1791+ if err := newView.LoadURL(url); err != nil {
1792+ log.Printf("[workspace] failed to load popup URL: %v", err)
1793+ }
1794+ if err := newView.Show(); err != nil {
1795+ log.Printf("[workspace] failed to show popup WebView: %v", err)
1796+ }
1797+ }
1798+
1799+ log.Printf("[workspace] Created popup pane for URL: %s", url)
1800+ return newView
1801+}
1802+
1803+// insertIndependentPane inserts a new independent pane next to the source
1804+func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
1805+ newPane, err := wm.createPane(webView)
1806+ if err != nil {
1807+ return err
1808+ }
1809+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1810+ if direction == "" {
1811+ direction = "right"
1812+ }
1813+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
1814+ return err
1815+ }
1816+ node := wm.viewToNode[webView]
1817+ if node != nil {
1818+ node.windowType = webkit.WindowTypeTab
1819+ node.isRelated = false
1820+ }
1821+ if url != "" {
1822+ _ = webView.LoadURL(url)
1823+ }
1824+ return nil
1825+}
1826+
1827+// configureRelatedPopup creates a related view and inserts it
1828+func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
1829+ // Use the WebView that was already created and returned to WebKit
1830+ related := webView
1831+ newPane, err := wm.createPane(related)
1832+ if err != nil {
1833+ log.Printf("[workspace] failed to create related popup pane: %v", err)
1834+ return
1835+ }
1836+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1837+ if direction == "" {
1838+ direction = "right"
1839+ }
1840+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1841+ log.Printf("[workspace] failed to insert related popup pane: %v", err)
1842+ return
1843+ }
1844+ node := wm.viewToNode[related]
1845+ if node != nil {
1846+ node.windowType = webkit.WindowTypePopup
1847+ node.windowFeatures = feat
1848+ node.isRelated = true
1849+ node.parentPane = sourceNode
1850+ node.isPopup = true
1851+ // Heuristic + config for auto-close intent
1852+ node.autoClose = wm.shouldAutoClose(url)
1853+ }
1854+ // Pipe into existing auto-close flow only for popups (confirmed by detection)
1855+ related.RegisterCloseHandler(func() {
1856+ log.Printf("[workspace] Popup requesting close via window.close()")
1857+ if n := wm.viewToNode[related]; n != nil && n.isPopup {
1858+ time.AfterFunc(200*time.Millisecond, func() {
1859+ webkit.IdleAdd(func() bool {
1860+ if err := wm.closePane(n); err != nil {
1861+ log.Printf("[workspace] Failed to close popup pane: %v", err)
1862+ }
1863+ return false
1864+ })
1865+ })
1866+ }
1867+ })
1868+
1869+ // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
1870+ if node != nil && node.isPopup && node.autoClose {
1871+ wm.registerOAuthAutoClose(related, url)
1872+ }
1873+ if url != "" {
1874+ _ = related.LoadURL(url)
1875+ }
1876+}
1877+
1878+// shouldAutoClose checks simple OAuth-like URL patterns and config flag
1879+func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
1880+ log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
1881+
1882+ if wm == nil || wm.app == nil || wm.app.config == nil {
1883+ log.Printf("[workspace] shouldAutoClose: missing config, returning true")
1884+ return true
1885+ }
1886+ if !wm.app.config.Workspace.Popups.OAuthAutoClose {
1887+ log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
1888+ return false
1889+ }
1890+
1891+ u := strings.ToLower(url)
1892+ log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
1893+
1894+ // RFC 6749 compliant OAuth 2.0 URL patterns
1895+ oauthPatterns := []string{
1896+ // Standard OAuth endpoints
1897+ "oauth", "authorize", "authorization",
1898+ // Standard callback/redirect patterns
1899+ "callback", "redirect", "auth/callback",
1900+ // OpenID Connect patterns
1901+ "oidc", "openid",
1902+ // Common OAuth parameter indicators
1903+ "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
1904+ // Standard OAuth response parameters
1905+ "code=", "access_token=", "id_token=", "token_type=",
1906+ // Error response parameters
1907+ "error=", "error_description=",
1908+ }
1909+
1910+ // Check for OAuth patterns in URL
1911+ for _, pattern := range oauthPatterns {
1912+ if strings.Contains(u, pattern) {
1913+ log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
1914+ return true
1915+ }
1916+ }
1917+
1918+ log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
1919+ return false
1920+}
1921+
1922+// isPopupVerificationPage determines if a URL is a popup verification page that should redirect instead of close
1923+
1924+// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
1925+func (wm *WorkspaceManager) RunOnUI(fn func()) {
1926+ if fn != nil {
1927+ fn()
1928+ }
1929+}
1930+
1931+// handleDetectedWindowType handles window type detection from smart detection path
1932+func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
1933+ if wm.viewToNode[webView] != nil {
1934+ return // Already placed
1935+ }
1936+
1937+ log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
1938+
1939+ switch windowType {
1940+ case webkit.WindowTypeTab:
1941+ // For tabs, create a NEW independent WebView (can't use the related one)
1942+ webkitCfg, err := wm.app.buildWebkitConfig()
1943+ if err != nil {
1944+ log.Printf("[workspace] failed to build webkit config for tab: %v", err)
1945+ return
1946+ }
1947+ webkitCfg.CreateWindow = false
1948+
1949+ // Create independent WebView like handleIntentAsTab does
1950+ independentView, err := webkit.NewWebView(webkitCfg)
1951+ if err != nil {
1952+ log.Printf("[workspace] failed to create independent tab WebView: %v", err)
1953+ return
1954+ }
1955+
1956+ // The related webView was just for detection - we don't use it for tabs
1957+ // Insert the new independent view as a tab
1958+ if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
1959+ log.Printf("[workspace] Failed to insert independent pane: %v", err)
1960+ }
1961+
1962+ case webkit.WindowTypePopup:
1963+ // For popups, use the related WebView we already created
1964+ wm.configureRelatedPopup(sourceNode, webView, url, features)
1965+ default:
1966+ // Fallback to popup behavior
1967+ wm.configureRelatedPopup(sourceNode, webView, url, features)
1968+ }
1969+}
1970+
14111971 // insertPopupPane inserts a pre-created popup pane into the workspace
14121972 func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
14131973 if target == nil || !target.isLeaf || target.pane == nil {
@@ -1431,6 +1991,12 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
14311991 webkit.WidgetSetVExpand(newContainer, true)
14321992 webkit.WidgetRealizeInContainer(newContainer)
14331993
1994+ // Also realize the WebView widget itself for proper popup rendering
1995+ webViewWidget := newPane.webView.Widget()
1996+ if webViewWidget != 0 {
1997+ webkit.WidgetRealizeInContainer(webViewWidget)
1998+ }
1999+
14342000 existingContainer := target.container
14352001 if existingContainer == 0 {
14362002 return errors.New("existing pane missing container")
@@ -1543,65 +2109,20 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
15432109 }
15442110
15452111 // ensureGUIInPane lazily loads GUI components into a pane when it gains focus
2112+// ensureGUIInPane is now a no-op since GUI is injected globally by WebKit
2113+// This prevents duplicate GUI injection that was causing duplicate log messages
15462114 func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
1547- if pane == nil || pane.HasGUI() {
2115+ if pane == nil {
15482116 return
15492117 }
15502118
1551- log.Printf("[workspace] Injecting GUI components into pane %s", pane.ID())
1552-
1553- // Enhanced GUI injection script with verification and retry mechanism
1554- script := fmt.Sprintf(`
1555- (async function() {
1556- try {
1557- // Verify GUI bundle is available
1558- if (typeof window.__dumber_gui_bootstrap !== 'function') {
1559- console.warn('[workspace] GUI bootstrap not available yet, will retry...');
1560- // Set retry flag for later verification
1561- window.__dumber_gui_retry_needed = true;
1562- return false;
1563- }
1564-
1565- // Set up GUI manager for this pane
1566- if (window.__dumber_pane) {
1567- window.__dumber_pane.active = true;
1568- } else {
1569- window.__dumber_pane = { active: true, id: '%s' };
1570- }
1571-
1572- // Initialize GUI bootstrap
1573- const result = window.__dumber_gui_bootstrap('%s');
1574-
1575- // Verify core GUI components are available
1576- const hasOmnibox = window.__dumber_omnibox ? true : false;
1577- const hasToast = window.__dumber_toast ? true : false;
1578-
1579- console.log('[workspace] GUI components initialized for pane %s - omnibox:', hasOmnibox, 'toast:', hasToast);
1580-
1581- // Signal successful initialization
1582- window.__dumber_gui_ready = true;
1583- delete window.__dumber_gui_retry_needed;
1584-
1585- return true;
1586- } catch (error) {
1587- console.error('[workspace] Failed to initialize GUI:', error);
1588- window.__dumber_gui_retry_needed = true;
1589- return false;
1590- }
1591- })();
1592- `, pane.ID(), pane.ID(), pane.ID())
1593-
1594- if err := pane.WebView().InjectScript(script); err != nil {
1595- log.Printf("[workspace] Failed to inject GUI into pane %s: %v", pane.ID(), err)
1596- return
2119+ // GUI is already injected globally via WebKit's enableUserContentManager
2120+ // Just mark the pane as having GUI to prevent unnecessary calls
2121+ if !pane.HasGUI() {
2122+ pane.SetHasGUI(true)
2123+ pane.SetGUIComponent("manager", true)
2124+ pane.SetGUIComponent("omnibox", true)
15972125 }
1598-
1599- // Mark GUI as injected (will be verified later if needed)
1600- pane.SetHasGUI(true)
1601- pane.SetGUIComponent("manager", true)
1602- pane.SetGUIComponent("omnibox", true)
1603-
1604- log.Printf("[workspace] GUI injection completed for pane %s", pane.ID())
16052126 }
16062127
16072128 func mapDirection(direction string) (webkit.Orientation, bool) {
M internal/app/browser/workspace_manager_tree_test.go +140 -2
@@ -343,9 +343,11 @@ func TestTreeTraversal(t *testing.T) {
343343 wm := newTestWorkspaceManagerWithMocksForTree(t)
344344
345345 // Create tree with 4 leaves
346- node1, _ := wm.splitNode(wm.root, "right")
346+ // Keep track of the original root which will become a leaf after split
347+ original := wm.root
348+ node1, _ := wm.splitNode(original, "right")
347349 _, _ = wm.splitNode(node1, "down")
348- _, _ = wm.splitNode(wm.root, "down")
350+ _, _ = wm.splitNode(original, "down") // Split the original leaf, not the new root branch
349351
350352 leaves := wm.collectLeaves()
351353
@@ -483,6 +485,142 @@ func TestFocusManagement(t *testing.T) {
483485 })
484486 }
485487
488+// TestBugClosePaneWithBranchSibling reproduces the bug where closing a pane
489+// whose sibling is a branch (not a leaf) causes "unexpected tree state" error
490+func TestBugClosePaneWithBranchSibling(t *testing.T) {
491+ t.Run("Closing pane when sibling is branch with children", func(t *testing.T) {
492+ wm := newTestWorkspaceManagerWithMocksForTree(t)
493+
494+ // Create the exact scenario from the bug:
495+ // 1. Start with root pane A
496+ A := wm.root
497+
498+ // 2. Split down to create B (A is now top, B is bottom)
499+ B, err := wm.splitNode(A, "down")
500+ if err != nil {
501+ t.Fatalf("Split down failed: %v", err)
502+ }
503+
504+ // After this split:
505+ // Root (vertical paned) has A (top) and B (bottom)
506+
507+ // 3. Split B down again to create C
508+ C, err := wm.splitNode(B, "down")
509+ if err != nil {
510+ t.Fatalf("Split B down failed: %v", err)
511+ }
512+
513+ // After this split, tree structure is:
514+ // Root (vertical) -> A (top), SubPaned (vertical, bottom)
515+ // SubPaned -> B (top), C (bottom)
516+ // Total: 3 leaf panes (A, B, C), 2 branch nodes
517+
518+ // Update app.panes to reflect actual structure
519+ wm.app.panes = []*BrowserPane{A.pane, B.pane, C.pane}
520+
521+ // Verify initial structure
522+ validateTreeStructure(t, wm.root)
523+ leaves, branches := countNodes(wm.root)
524+ if leaves != 3 {
525+ t.Errorf("Expected 3 leaves initially, got %d", leaves)
526+ }
527+ if branches != 2 {
528+ t.Errorf("Expected 2 branches initially, got %d", branches)
529+ }
530+
531+ // Now close pane A - this should promote the SubPaned (containing B and C) to root
532+ // This is where the bug occurs: parent is root, but 2 panes remain (B and C)
533+ // The current code incorrectly thinks this is an error
534+ err = wm.closePane(A)
535+
536+ // THIS IS THE BUG: The current implementation returns an error here
537+ // but it shouldn't - it's a valid operation
538+ if err != nil {
539+ // For now, we expect this to fail with the bug
540+ if err.Error() == "unexpected tree state during pane closure" {
541+ t.Logf("Bug reproduced: %v", err)
542+ t.Logf("This error should not occur - closing pane with branch sibling is valid")
543+ // Comment out the return to see what the final state should be
544+ // return
545+ } else {
546+ t.Fatalf("Unexpected error: %v", err)
547+ }
548+ }
549+
550+ // What SHOULD happen after successful close:
551+ // The SubPaned (containing B and C) should become the new root
552+ validateTreeStructure(t, wm.root)
553+
554+ // Verify final state (what we expect after fix)
555+ finalLeaves, finalBranches := countNodes(wm.root)
556+ if finalLeaves != 2 {
557+ t.Errorf("Expected 2 leaves after close, got %d", finalLeaves)
558+ }
559+ if finalBranches != 1 {
560+ t.Errorf("Expected 1 branch after close, got %d", finalBranches)
561+ }
562+
563+ // The new root should be a branch (the old SubPaned)
564+ if wm.root.isLeaf {
565+ t.Error("Root should be a branch node after closing A")
566+ }
567+
568+ // B and C should still be in the tree
569+ foundB := false
570+ foundC := false
571+ for _, leaf := range wm.collectLeaves() {
572+ if leaf == B {
573+ foundB = true
574+ }
575+ if leaf == C {
576+ foundC = true
577+ }
578+ }
579+ if !foundB {
580+ t.Error("Pane B should still be in tree")
581+ }
582+ if !foundC {
583+ t.Error("Pane C should still be in tree")
584+ }
585+ })
586+
587+ t.Run("Alternative scenario - closing left child when right has grandchildren", func(t *testing.T) {
588+ wm := newTestWorkspaceManagerWithMocksForTree(t)
589+
590+ // Create structure: A | (B / C)
591+ // where | is horizontal split and / is vertical split
592+ A := wm.root
593+ B, err := wm.splitNode(A, "right")
594+ if err != nil {
595+ t.Fatalf("Split right failed: %v", err)
596+ }
597+
598+ C, err := wm.splitNode(B, "down")
599+ if err != nil {
600+ t.Fatalf("Split down failed: %v", err)
601+ }
602+
603+ // Update app.panes
604+ wm.app.panes = []*BrowserPane{A.pane, B.pane, C.pane}
605+
606+ // Tree is: Root(h) -> A, Paned(v) -> B, C
607+ validateTreeStructure(t, wm.root)
608+
609+ // Close A - the Paned(v) containing B and C should become root
610+ err = wm.closePane(A)
611+
612+ // Bug should occur here too
613+ if err != nil {
614+ if err.Error() == "unexpected tree state during pane closure" {
615+ t.Logf("Bug reproduced in alternative scenario: %v", err)
616+ }
617+ }
618+
619+ // After fix, the vertical paned with B and C should be root
620+ validateTreeStructure(t, wm.root)
621+ })
622+}
623+
486624 func TestEdgeCases(t *testing.T) {
487625 t.Run("Closing last pane", func(t *testing.T) {
488626 wm := newTestWorkspaceManagerWithMocksForTree(t)
M internal/app/control/zoom.go +15 -2
@@ -3,6 +3,7 @@ package control
33 import (
44 "context"
55 "log"
6+ "time"
67
78 "github.com/bnema/dumber/internal/services"
89 "github.com/bnema/dumber/pkg/webkit"
@@ -13,6 +14,7 @@ type ZoomController struct {
1314 currentURL string
1415 lastZoomDomain string
1516 programmaticChange bool
17+ programmaticTimer *time.Timer
1618 browserService *services.BrowserService
1719 webView *webkit.WebView
1820 }
@@ -48,7 +50,7 @@ func (z *ZoomController) handleURIChange(url string) {
4850 return
4951 }
5052 currentDomain := services.ZoomKeyForLog(url)
51- z.loadZoomLevelAsync(url, currentDomain, true)
53+ z.loadZoomLevelAsync(url, currentDomain, false)
5254 }
5355
5456 // handleZoomChange responds to zoom level changes and persists them
@@ -57,6 +59,7 @@ func (z *ZoomController) handleZoomChange(level float64) {
5759 if url == "" {
5860 return
5961 }
62+
6063 go func(url string, level float64) {
6164 ctx := context.Background()
6265 if err := z.browserService.SetZoomLevel(ctx, url, level); err != nil {
@@ -144,8 +147,18 @@ func (z *ZoomController) applyZoomLevel(url, domain string, zoomLevel float64, a
144147 z.currentURL = url
145148 }
146149
150+ // Clear any existing timer
151+ if z.programmaticTimer != nil {
152+ z.programmaticTimer.Stop()
153+ }
154+
147155 z.programmaticChange = true
148- defer func() { z.programmaticChange = false }()
156+
157+ // Reset programmatic change flag after a delay to handle async zoom events
158+ z.programmaticTimer = time.AfterFunc(200*time.Millisecond, func() {
159+ z.programmaticChange = false
160+ z.programmaticTimer = nil
161+ })
149162
150163 if err := z.webView.SetZoom(zoomLevel); err != nil {
151164 log.Printf("Warning: failed to set zoom: %v", err)
A internal/app/messaging/deduplicator.go +114 -0
@@ -0,0 +1,114 @@
1+package messaging
2+
3+import (
4+ "crypto/sha256"
5+ "fmt"
6+ "log"
7+ "sync"
8+ "time"
9+)
10+
11+type RequestFingerprint struct {
12+ URL string
13+ WebViewID string
14+ Timestamp int64
15+ Hash string
16+}
17+
18+type PaneRequestDeduplicator struct {
19+ mu sync.RWMutex
20+ recentRequests map[string]*RequestFingerprint
21+ requestIDs map[string]bool
22+ debounceWindow time.Duration
23+ cleanupInterval time.Duration
24+ lastCleanup time.Time
25+}
26+
27+func NewPaneRequestDeduplicator() *PaneRequestDeduplicator {
28+ return &PaneRequestDeduplicator{
29+ recentRequests: make(map[string]*RequestFingerprint),
30+ requestIDs: make(map[string]bool),
31+ debounceWindow: 200 * time.Millisecond,
32+ cleanupInterval: 5 * time.Second,
33+ lastCleanup: time.Now(),
34+ }
35+}
36+
37+func (d *PaneRequestDeduplicator) generateFingerprint(intent *WindowIntent, webViewID string) string {
38+ normalizedTime := intent.Timestamp / 100
39+ data := fmt.Sprintf("%s:%s:%d", intent.URL, webViewID, normalizedTime)
40+ hash := sha256.Sum256([]byte(data))
41+ return fmt.Sprintf("%x", hash[:8])
42+}
43+
44+func (d *PaneRequestDeduplicator) IsDuplicate(intent *WindowIntent, webViewID string) (bool, string) {
45+ d.mu.Lock()
46+ defer d.mu.Unlock()
47+
48+ if time.Since(d.lastCleanup) > d.cleanupInterval {
49+ d.cleanup()
50+ }
51+
52+ if intent.RequestID != "" {
53+ if d.requestIDs[intent.RequestID] {
54+ return true, fmt.Sprintf("duplicate request ID: %s", intent.RequestID)
55+ }
56+ }
57+
58+ fingerprint := d.generateFingerprint(intent, webViewID)
59+
60+ if existing, exists := d.recentRequests[fingerprint]; exists {
61+ timeDiff := time.Duration(intent.Timestamp-existing.Timestamp) * time.Millisecond
62+ if timeDiff < d.debounceWindow {
63+ return true, fmt.Sprintf("duplicate content fingerprint: %s (within %v)", fingerprint, timeDiff)
64+ }
65+ }
66+
67+ d.recentRequests[fingerprint] = &RequestFingerprint{
68+ URL: intent.URL,
69+ WebViewID: webViewID,
70+ Timestamp: intent.Timestamp,
71+ Hash: fingerprint,
72+ }
73+
74+ if intent.RequestID != "" {
75+ d.requestIDs[intent.RequestID] = true
76+ }
77+
78+ return false, ""
79+}
80+
81+func (d *PaneRequestDeduplicator) cleanup() {
82+ now := time.Now().UnixMilli()
83+ cutoff := now - int64(d.debounceWindow*3/time.Millisecond)
84+
85+ for key, req := range d.recentRequests {
86+ if now-req.Timestamp > cutoff {
87+ delete(d.recentRequests, key)
88+ }
89+ }
90+
91+ if len(d.requestIDs) > 100 {
92+ d.requestIDs = make(map[string]bool)
93+ }
94+
95+ d.lastCleanup = time.Now()
96+ log.Printf("[deduplicator] Cleanup completed: %d fingerprints, %d request IDs", len(d.recentRequests), len(d.requestIDs))
97+}
98+
99+// ClearRequestID removes a specific request ID from the deduplication cache
100+// This allows the same RequestID to be used again (e.g., for OAuth verification -> real popup flow)
101+func (d *PaneRequestDeduplicator) ClearRequestID(requestID string) {
102+ if requestID == "" {
103+ return
104+ }
105+
106+ d.mu.Lock()
107+ defer d.mu.Unlock()
108+
109+ if d.requestIDs[requestID] {
110+ delete(d.requestIDs, requestID)
111+ log.Printf("[deduplicator] Cleared request ID: %s", requestID)
112+ }
113+}
114+
M internal/app/messaging/handler.go +107 -1
@@ -11,6 +11,8 @@ import (
1111
1212 "github.com/bnema/dumber/internal/app/constants"
1313 "github.com/bnema/dumber/internal/app/control"
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/logging"
1416 "github.com/bnema/dumber/internal/services"
1517 "github.com/bnema/dumber/pkg/webkit"
1618 )
@@ -45,11 +47,34 @@ type Message struct {
4547 HistoryID string `json:"historyId"`
4648 // Request tracking
4749 RequestID string `json:"requestId"`
50+ // Popup close tracking
51+ WebViewID string `json:"webviewId"`
52+ Reason string `json:"reason"`
4853 // Wails fetch bridge
4954 ID string `json:"id"`
5055 Payload json.RawMessage `json:"payload"`
5156 }
5257
58+// WindowIntent represents an intercepted window.open call from JavaScript
59+type WindowIntent struct {
60+ URL string `json:"url"`
61+ Target string `json:"target"`
62+ Features string `json:"features"`
63+ Timestamp int64 `json:"timestamp"`
64+ WindowType string `json:"windowType"` // "tab", "popup", "unknown"
65+ IsPopup bool `json:"isPopup"`
66+ IsTab bool `json:"isTab"`
67+ RequestID string `json:"requestId"` // NEW: Unique request identifier
68+ UserTriggered bool `json:"userTriggered"` // NEW: Whether this was user-initiated
69+ // Parsed features
70+ Width *int `json:"width,omitempty"`
71+ Height *int `json:"height,omitempty"`
72+ Toolbar *bool `json:"toolbar,omitempty"`
73+ Location *bool `json:"location,omitempty"`
74+ Menubar *bool `json:"menubar,omitempty"`
75+ Resizable *bool `json:"resizable,omitempty"`
76+}
77+
5378 // NewHandler creates a new message handler
5479 func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
5580 return &Handler{
@@ -98,6 +123,12 @@ func (h *Handler) Handle(payload string) {
98123 h.handleHistoryDelete(msg)
99124 case "workspace":
100125 h.handleWorkspace(msg)
126+ case "handle-window-open":
127+ h.handleWindowOpen(msg)
128+ case "close-popup":
129+ h.handleClosePopup(msg)
130+ case "console-message":
131+ h.handleConsoleMessage(msg)
101132 }
102133 }
103134
@@ -161,6 +192,30 @@ func (h *Handler) handleWorkspace(msg Message) {
161192 h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
162193 }
163194
195+func (h *Handler) handleClosePopup(msg Message) {
196+ log.Printf("[messaging] Received close-popup request: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
197+
198+ if h.workspaceObserver == nil {
199+ log.Printf("[messaging] No workspace observer registered for close-popup request")
200+ return
201+ }
202+ if h.webView == nil {
203+ log.Printf("[messaging] No webview attached for close-popup request")
204+ return
205+ }
206+
207+ // Forward close-popup request to workspace manager via observer
208+ closeMsg := Message{
209+ Type: "workspace",
210+ Event: "close-popup",
211+ WebViewID: msg.WebViewID,
212+ Reason: msg.Reason,
213+ }
214+
215+ log.Printf("[messaging] Forwarding close-popup to workspace: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
216+ h.workspaceObserver.OnWorkspaceMessage(h.webView, closeMsg)
217+}
218+
164219 func (h *Handler) handleQuery(msg Message) {
165220 log.Printf("[DEBUG] handleQuery called: q='%s', limit=%d", msg.Q, msg.Limit)
166221 if h.webView == nil {
@@ -422,7 +477,7 @@ func (h *Handler) handleHistoryRecent(msg Message) {
422477 }
423478
424479 // handleHistoryStats processes history stats requests
425-func (h *Handler) handleHistoryStats(msg Message) {
480+func (h *Handler) handleHistoryStats(_ Message) {
426481 if h.browserService == nil || h.webView == nil {
427482 return
428483 }
@@ -496,3 +551,54 @@ func (h *Handler) handleHistoryDelete(msg Message) {
496551 b, _ := json.Marshal(successData)
497552 _ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
498553 }
554+
555+// handleWindowOpen processes handle-window-open messages to create panes directly
556+func (h *Handler) handleWindowOpen(msg Message) {
557+ var intent WindowIntent
558+ if err := json.Unmarshal(msg.Payload, &intent); err != nil {
559+ log.Printf("[messaging] Failed to unmarshal handle-window-open: %v", err)
560+ return
561+ }
562+
563+ log.Printf("[messaging] Handling direct window.open request: url=%s type=%s requestId=%s", intent.URL, intent.WindowType, intent.RequestID)
564+
565+ // Forward to workspace observer if available
566+ if h.workspaceObserver != nil && h.webView != nil {
567+ // Create a workspace message to trigger pane creation
568+ workspaceMsg := Message{
569+ Type: "workspace",
570+ Event: "create-pane",
571+ URL: intent.URL,
572+ Action: intent.WindowType, // "tab" or "popup"
573+ RequestID: intent.RequestID, // NEW: Pass through request ID
574+ }
575+ log.Printf("[messaging] Forwarding to workspace: %+v", workspaceMsg)
576+ h.workspaceObserver.OnWorkspaceMessage(h.webView, workspaceMsg)
577+ } else {
578+ log.Printf("[messaging] No workspace observer available for direct window.open")
579+ }
580+}
581+
582+// handleConsoleMessage processes console-message from JavaScript
583+func (h *Handler) handleConsoleMessage(msg Message) {
584+ // Check if console capture is enabled
585+ cfg := config.Get()
586+ if !cfg.Logging.CaptureConsole {
587+ return
588+ }
589+
590+ // Parse the console message payload
591+ var consolePayload struct {
592+ Level string `json:"level"`
593+ Message string `json:"message"`
594+ URL string `json:"url"`
595+ }
596+
597+ if err := json.Unmarshal(msg.Payload, &consolePayload); err != nil {
598+ log.Printf("[messaging] Failed to unmarshal console-message: %v", err)
599+ return
600+ }
601+
602+ // Send to logging system with [CONSOLE] tag
603+ logging.CaptureWebKitLog(consolePayload.Message)
604+}
M internal/cache/favicon.go +18 -4
@@ -135,7 +135,11 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
135135 log.Printf("[favicon] failed to close download file: %v", err)
136136 }
137137 }()
138- defer os.Remove(tempDownloadFile)
138+ defer func() {
139+ if err := os.Remove(tempDownloadFile); err != nil {
140+ log.Printf("[favicon] failed to remove temp download file: %v", err)
141+ }
142+ }()
139143
140144 // Save raw favicon data
141145 if _, err := io.Copy(downloadFile, limitedBody); err != nil {
@@ -177,7 +181,11 @@ func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, er
177181 if err != nil {
178182 return nil, err
179183 }
180- defer file.Close()
184+ defer func() {
185+ if err := file.Close(); err != nil {
186+ log.Printf("[favicon] failed to close file: %v", err)
187+ }
188+ }()
181189
182190 img, _, err := image.Decode(file)
183191 return img, err
@@ -190,10 +198,16 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
190198 if err != nil {
191199 return fmt.Errorf("failed to create temp file: %w", err)
192200 }
193- defer file.Close()
201+ defer func() {
202+ if err := file.Close(); err != nil {
203+ log.Printf("[favicon] failed to close file: %v", err)
204+ }
205+ }()
194206
195207 if err := png.Encode(file, img); err != nil {
196- os.Remove(tempFile)
208+ if removeErr := os.Remove(tempFile); removeErr != nil {
209+ log.Printf("[favicon] failed to remove temp file: %v", removeErr)
210+ }
197211 return fmt.Errorf("failed to encode PNG: %w", err)
198212 }
199213
M internal/cache/filter_cache.go +10 -3
@@ -4,6 +4,7 @@ import (
44 "encoding/binary"
55 "fmt"
66 "io"
7+ "log"
78 "os"
89 "syscall"
910 "time"
@@ -110,18 +111,24 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) {
110111 // Parse the header
111112 header, err := fc.parseHeader()
112113 if err != nil {
113- fc.Unmap()
114+ if unmapErr := fc.Unmap(); unmapErr != nil {
115+ log.Printf("[filter-cache] failed to unmap after header parse error: %v", unmapErr)
116+ }
114117 return nil, fmt.Errorf("invalid cache header: %w", err)
115118 }
116119
117120 // Validate magic and version
118121 if header.Magic != cacheMagic {
119- fc.Unmap()
122+ if unmapErr := fc.Unmap(); unmapErr != nil {
123+ log.Printf("[filter-cache] failed to unmap after magic validation: %v", unmapErr)
124+ }
120125 return nil, fmt.Errorf("invalid cache magic bytes")
121126 }
122127
123128 if header.Version != cacheFormatVersion {
124- fc.Unmap()
129+ if unmapErr := fc.Unmap(); unmapErr != nil {
130+ log.Printf("[filter-cache] failed to unmap after version validation: %v", unmapErr)
131+ }
125132 return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
126133 }
127134
M internal/cli/logs.go +87 -0
@@ -26,6 +26,7 @@ func NewLogsCmd() *cobra.Command {
2626 cmd.AddCommand(newLogsListCmd())
2727 cmd.AddCommand(newLogsCleanCmd())
2828 cmd.AddCommand(newLogsPathCmd())
29+ cmd.AddCommand(newLogsConsoleCmd())
2930
3031 return cmd
3132 }
@@ -126,6 +127,32 @@ func newLogsPathCmd() *cobra.Command {
126127 return cmd
127128 }
128129
130+// newLogsConsoleCmd creates the console subcommand for filtering console logs
131+func newLogsConsoleCmd() *cobra.Command {
132+ var lines int
133+ var level string
134+
135+ cmd := &cobra.Command{
136+ Use: "console",
137+ Short: "View console logs from WebKit",
138+ Long: "Display console.log, console.error, and other console messages from web pages.",
139+ RunE: func(cmd *cobra.Command, args []string) error {
140+ logDir, err := config.GetLogDir()
141+ if err != nil {
142+ return fmt.Errorf("failed to get log directory: %w", err)
143+ }
144+
145+ currentLog := filepath.Join(logDir, "dumber.log")
146+ return tailConsoleMessages(currentLog, lines, level)
147+ },
148+ }
149+
150+ cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
151+ cmd.Flags().StringVarP(&level, "level", "l", "", "Filter by console level (error, warn, info, log, debug)")
152+
153+ return cmd
154+}
155+
129156 // tailFile displays the last N lines of a file
130157 func tailFile(filePath string, lines int) error {
131158 file, err := os.Open(filePath)
@@ -297,3 +324,63 @@ func formatSize(size int64) string {
297324
298325 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
299326 }
327+
328+// tailConsoleMessages displays console messages from the log file
329+func tailConsoleMessages(filePath string, lines int, level string) error {
330+ file, err := os.Open(filePath)
331+ if err != nil {
332+ if os.IsNotExist(err) {
333+ fmt.Printf("Log file does not exist: %s\n", filePath)
334+ return nil
335+ }
336+ return fmt.Errorf("failed to open log file: %w", err)
337+ }
338+ defer file.Close()
339+
340+ // Read all lines and filter for console messages
341+ var consoleLines []string
342+ scanner := bufio.NewScanner(file)
343+ for scanner.Scan() {
344+ line := scanner.Text()
345+
346+ // Filter for [CONSOLE] tagged messages
347+ if !strings.Contains(line, "[CONSOLE]") {
348+ continue
349+ }
350+
351+ // Filter by level if specified
352+ if level != "" {
353+ levelFilter := fmt.Sprintf("[%s]", strings.ToUpper(level))
354+ if !strings.Contains(line, levelFilter) {
355+ continue
356+ }
357+ }
358+
359+ consoleLines = append(consoleLines, line)
360+ }
361+
362+ if err := scanner.Err(); err != nil {
363+ return fmt.Errorf("error reading log file: %w", err)
364+ }
365+
366+ if len(consoleLines) == 0 {
367+ if level != "" {
368+ fmt.Printf("No console messages found with level '%s'\n", level)
369+ } else {
370+ fmt.Println("No console messages found")
371+ }
372+ return nil
373+ }
374+
375+ // Display last N lines
376+ start := len(consoleLines) - lines
377+ if start < 0 {
378+ start = 0
379+ }
380+
381+ for i := start; i < len(consoleLines); i++ {
382+ fmt.Println(consoleLines[i])
383+ }
384+
385+ return nil
386+}
M internal/config/config.go +17 -3
@@ -101,9 +101,10 @@ type LoggingConfig struct {
101101 EnableFileLog bool `mapstructure:"enable_file_log" yaml:"enable_file_log"`
102102
103103 // Capture settings
104- CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout"`
105- CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr"`
106- CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output"`
104+ CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout" json:"capture_stdout"`
105+ CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr" json:"capture_stderr"`
106+ CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output" json:"capture_c_output"`
107+ CaptureConsole bool `mapstructure:"capture_console" yaml:"capture_console" json:"capture_console"`
107108
108109 // Debug output
109110 DebugFile string `mapstructure:"debug_file" yaml:"debug_file"`
@@ -258,6 +259,15 @@ type PopupBehaviorConfig struct {
258259 Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`
259260 OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`
260261 FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
262+
263+ // New fields for distinguishing window types and behaviors
264+ // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents
265+ // Accepted values: "pane" (default) or "tab" (future support)
266+ BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
267+ // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
268+ EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" json:"enable_smart_detection"`
269+ // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
270+ OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" json:"oauth_auto_close"`
261271 }
262272
263273 // WorkspaceStylingConfig defines visual styling for workspace panes.
@@ -678,6 +688,10 @@ func (m *Manager) setDefaults() {
678688 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
679689 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
680690 m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
691+ // New popup behaviour defaults
692+ m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)
693+ m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)
694+ m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
681695 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
682696 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
683697 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
M internal/config/defaults.go +7 -3
@@ -101,6 +101,7 @@ func DefaultConfig() *Config {
101101 CaptureStdout: false,
102102 CaptureStderr: false,
103103 CaptureCOutput: false, // Disabled by default to avoid performance impact
104+ CaptureConsole: false, // Disabled by default
104105 DebugFile: "debug.log",
105106 VerboseWebKit: false,
106107 },
@@ -183,9 +184,12 @@ func DefaultConfig() *Config {
183184 PreviousTab: "cmdorctrl+shift+tab",
184185 },
185186 Popups: PopupBehaviorConfig{
186- Placement: defaultPopupPlacement,
187- OpenInNewPane: true,
188- FollowPaneContext: true,
187+ Placement: defaultPopupPlacement,
188+ OpenInNewPane: true,
189+ FollowPaneContext: true,
190+ BlankTargetBehavior: "pane", // Default to pane, future: "tab"
191+ EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab
192+ OAuthAutoClose: true, // Auto-close OAuth popups on success
189193 },
190194 Styling: WorkspaceStylingConfig{
191195 BorderWidth: defaultBorderWidth,
M internal/logging/logger.go +9 -2
@@ -163,7 +163,7 @@ func (l *Logger) writeLog(level LogLevel, message string, source string) {
163163 }
164164
165165 func (l *Logger) WriteTagged(source string, message string) {
166- l.writeLog(INFO, message, source)
166+ l.WriteFileOnly(INFO, message, source)
167167 }
168168
169169 // WriteFileOnly writes a log entry only to the file (not stdout) to avoid interfering with CLI operations
@@ -218,10 +218,17 @@ func Fatal(message string) {
218218
219219 func CaptureWebKitLog(message string) {
220220 if l := GetLogger(); l != nil {
221- l.WriteTagged("WEBKIT", message)
221+ l.WriteTagged("CONSOLE", message)
222222 }
223223 }
224224
225+// StartConsoleCapture starts capturing stdout for console messages
226+// StartConsoleCapture starts capturing stdout for console messages and writing to console.log
227+// StartConsoleCapture - placeholder for console message capture
228+func StartConsoleCapture() {
229+ // Placeholder - console message capture will be implemented differently
230+}
231+
225232 // LogLevelInfo returns the INFO log level constant for external packages
226233 func LogLevelInfo() LogLevel {
227234 return INFO
M pkg/webkit/keyboard_cgo.go +2 -73
@@ -3,9 +3,10 @@
33 package webkit
44
55 /*
6-#cgo pkg-config: gtk4
6+#cgo pkg-config: gtk4 webkitgtk-6.0
77 #include <gtk/gtk.h>
88 #include <gdk/gdk.h>
9+#include <webkit/webkit.h>
910
1011 // Declarations for Go callbacks
1112 extern gboolean goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);
@@ -84,7 +85,6 @@ import (
8485 "sync"
8586 "sync/atomic"
8687 "time"
87- "unsafe"
8888 )
8989
9090 // Registry of accelerators per view id.
@@ -533,77 +533,6 @@ func goHandleNewWindowPolicy(id C.ulong, curi *C.char, navType C.int, cframeName
533533 }
534534 }
535535
536-//export goNotifyWebViewCreated
537-func goNotifyWebViewCreated(parentID C.ulong, webViewPtr C.ulong, curi *C.char) {
538- uid := uintptr(parentID)
539- regMu.RLock()
540- parentView := viewByID[uid]
541- regMu.RUnlock()
542-
543- if parentView == nil {
544- log.Printf("[webkit] Notify WebView created: no parent WebView found for ID %d", uid)
545- return
546- }
547-
548- uri := ""
549- if curi != nil {
550- uri = C.GoString(curi)
551- }
552-
553- log.Printf("[webkit] Notify WebView created: parent_id=%d, webview_ptr=%d, uri=%s", uid, uintptr(webViewPtr), uri)
554-
555- // Handle the new WebView through the popup handler
556- if parentView.popupHandler != nil {
557- result := parentView.popupHandler(uri)
558- if result != nil {
559- log.Printf("[webkit] Popup handler accepted WebView for: %s", uri)
560- } else {
561- log.Printf("[webkit] Popup handler declined WebView for: %s", uri)
562- }
563- } else {
564- log.Printf("[webkit] No popup handler registered for WebView notification")
565- }
566-}
567-
568-//export goHandleCreateWebView
569-func goHandleCreateWebView(id C.ulong, curi *C.char) *C.GtkWidget {
570- uid := uintptr(id)
571- regMu.RLock()
572- vw := viewByID[uid]
573- regMu.RUnlock()
574-
575- if vw == nil {
576- log.Printf("[webkit] Create signal: no WebView found for ID %d", uid)
577- return nil // Fallback to native popup
578- }
579-
580- uri := ""
581- if curi != nil {
582- uri = C.GoString(curi)
583- }
584-
585- log.Printf("[webkit] Create signal: creating new pane for URI=%s", uri)
586-
587- // Check if we have a popup handler
588- if vw.popupHandler == nil {
589- log.Printf("[webkit] Create signal: no popup handler registered")
590- return nil // Fallback to native popup
591- }
592-
593- // Always use the popup handler to create workspace pane (tiling WM design)
594- newView := vw.popupHandler(uri)
595- if newView != nil {
596- widget := newView.Widget()
597- if widget != 0 {
598- log.Printf("[webkit] Create signal: created new workspace pane for popup")
599- return (*C.GtkWidget)(unsafe.Pointer(widget))
600- }
601- }
602-
603- log.Printf("[webkit] Create signal: popup handler failed to create pane, falling back to native")
604- return nil // Fallback to native popup if workspace creation fails
605-}
606-
607536 //export goHandleLoadChanged
608537 func goHandleLoadChanged(id C.ulong, curi *C.char, loadEvent C.int) {
609538 uid := uintptr(id)
M pkg/webkit/script_cgo.go +28 -0
@@ -32,3 +32,31 @@ func (w *WebView) InjectScript(js string) error {
3232 )
3333 return nil
3434 }
35+
36+// InjectScriptIntoWorld evaluates JavaScript in the specified script world.
37+func (w *WebView) InjectScriptIntoWorld(js string, worldName string) error {
38+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
39+ return ErrNotImplemented
40+ }
41+ cjs := C.CString(js)
42+ defer C.free(unsafe.Pointer(cjs))
43+
44+ var cworld *C.gchar
45+ if worldName != "" {
46+ cworld = (*C.gchar)(C.CString(worldName))
47+ defer C.free(unsafe.Pointer(cworld))
48+ }
49+
50+ // Evaluate JavaScript in the specified world
51+ C.webkit_web_view_evaluate_javascript(
52+ w.native.wv,
53+ (*C.gchar)(cjs),
54+ C.gssize(-1),
55+ cworld, // world_name
56+ nil, // source_uri
57+ nil, // cancellable
58+ nil, // callback
59+ nil, // user_data
60+ )
61+ return nil
62+}
M pkg/webkit/webview.go +53 -0
@@ -5,9 +5,19 @@ package webkit
55 import (
66 "fmt"
77 "log"
8+ "strconv"
9+ "sync/atomic"
810 "unsafe"
911 )
1012
13+// Package-level ID counter for stub WebViews
14+var stubViewIDCounter uint64
15+
16+// nextStubViewID generates a unique ID for stub WebViews
17+func nextStubViewID() uintptr {
18+ return uintptr(atomic.AddUint64(&stubViewIDCounter, 1))
19+}
20+
1121 // WebView represents a browser view powered by WebKit2GTK.
1222 // Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering.
1323 type WebView struct {
@@ -26,6 +36,12 @@ type WebView struct {
2636 useDomZoom bool
2737 domZoomSeed float64
2838 container uintptr
39+ id uintptr // WebView unique identifier
40+
41+ // Window type fields (no-op in stub)
42+ windowType WindowType
43+ windowFeatures *WindowFeatures
44+ windowTypeCallback func(WindowType, *WindowFeatures)
2945 }
3046
3147 // NewWebView constructs a new WebView instance.
@@ -41,6 +57,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
4157 wv.zoom = cfg.ZoomDefault
4258 }
4359 wv.domZoomSeed = wv.zoom
60+ wv.id = nextStubViewID() // Assign unique ID
4461 // Construction succeeds; create a logical window placeholder.
4562 wv.window = &Window{Title: "Dumber Browser"}
4663 wv.container = newWidgetHandle()
@@ -211,6 +228,19 @@ func (w *WebView) SeedDomZoom(level float64) {
211228 w.domZoomSeed = level
212229 }
213230
231+// CreateRelatedView returns a new WebView (stub creates independent)
232+func (w *WebView) CreateRelatedView() *WebView {
233+ nw, _ := NewWebView(w.config)
234+ return nw
235+}
236+
237+// OnWindowTypeDetected registers a callback (stored but never invoked in stub)
238+func (w *WebView) OnWindowTypeDetected(callback func(WindowType, *WindowFeatures)) {
239+ if w != nil {
240+ w.windowTypeCallback = callback
241+ }
242+}
243+
214244 // InitializeContentBlocking initializes WebKit content blocking with filter manager (stub)
215245 func (w *WebView) InitializeContentBlocking(filterManager interface{}) error {
216246 return ErrNotImplemented
@@ -225,3 +255,26 @@ func (w *WebView) OnNavigate(url string, filterManager interface{}) {
225255 func (w *WebView) UpdateContentFilters(filterManager interface{}) error {
226256 return ErrNotImplemented
227257 }
258+
259+// SetWindowFeatures sets the window features for this WebView (stub)
260+func (w *WebView) SetWindowFeatures(features *WindowFeatures) {
261+ w.windowFeatures = features
262+}
263+
264+// SetActive sets whether this WebView is currently active/focused (stub)
265+func (w *WebView) SetActive(active bool) {
266+ // No-op in stub
267+}
268+
269+// IsActive returns whether this WebView is currently active/focused (stub)
270+func (w *WebView) IsActive() bool {
271+ return false
272+}
273+
274+// ID returns the unique identifier for this WebView as a string (stub)
275+func (w *WebView) ID() string {
276+ if w == nil {
277+ return ""
278+ }
279+ return strconv.FormatUint(uint64(w.id), 10)
280+}
M pkg/webkit/webview_cgo.go +266 -343
@@ -24,8 +24,6 @@ extern void goQuitMainLoop();
2424 extern void goInvokeHandle(uintptr_t handle);
2525 extern gboolean goHandleNewWindowPolicy(unsigned long id, char* uri, int nav_type, char* frame_name, gboolean is_user_gesture, guint modifiers, guint mouse_button);
2626 extern void goHandlePopupGeometry(unsigned long id, int x, int y, int width, int height);
27-extern GtkWidget* goHandleCreateWebView(unsigned long id, char* uri);
28-extern void goNotifyWebViewCreated(unsigned long parent_id, unsigned long webview_ptr, char* uri);
2927 extern void goHandleLoadChanged(unsigned long id, char* uri, int load_event);
3028 extern void goHandleWebViewClose(unsigned long id);
3129 extern void goCreateNewPanel(unsigned long id, char* uri);
@@ -51,42 +49,16 @@ static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy);
5149
5250 // TLS error handling forward declarations
5351 extern gboolean goHandleTLSError(char* failing_uri, char* host, int error_flags, char* cert_info);
52+
53+// Console message handling forward declarations
54+extern void goHandleConsoleMessage(char* message);
55+
5456 static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
5557 const char *failing_uri,
5658 GTlsCertificate *certificate,
5759 GTlsCertificateFlags errors,
5860 gpointer user_data);
5961
60-// Handle ready-to-show signal for popup windows to properly initialize window properties
61-static void on_popup_ready_to_show(WebKitWebView* web_view, gpointer user_data) {
62- printf("[webkit-ready] Ready-to-show signal for popup WebView\n");
63-
64- // Get window properties - this should be safe for actual popups
65- WebKitWindowProperties* props = webkit_web_view_get_window_properties(web_view);
66- if (props) {
67- // Try to access geometry safely for popup positioning
68- GdkRectangle geometry = {0, 0, 800, 600}; // Default fallback
69-
70- // Check if properties are actually available (defensive programming)
71- if (G_IS_OBJECT(props)) {
72- webkit_window_properties_get_geometry(props, &geometry);
73- printf("[webkit-ready] Retrieved popup geometry: %dx%d at (%d,%d)\n",
74- geometry.width, geometry.height, geometry.x, geometry.y);
75- } else {
76- printf("[webkit-ready] WindowProperties object invalid, using defaults\n");
77- }
78-
79- // Pass geometry to Go for workspace positioning
80- unsigned long view_id = (unsigned long)user_data;
81- goHandlePopupGeometry(view_id, geometry.x, geometry.y,
82- geometry.width, geometry.height);
83- } else {
84- printf("[webkit-ready] No window properties available for popup\n");
85- // Still notify Go layer with default geometry
86- unsigned long view_id = (unsigned long)user_data;
87- goHandlePopupGeometry(view_id, 0, 0, 800, 600);
88- }
89-}
9062
9163 // Handle load_changed signal for OAuth completion detection
9264 static void on_load_changed(WebKitWebView *web_view, WebKitLoadEvent load_event, gpointer user_data) {
@@ -107,140 +79,6 @@ static void on_webview_close(WebKitWebView* web_view, gpointer user_data) {
10779 goHandleWebViewClose(view_id);
10880 }
10981
110-// Handle create signal for popup windows - essential for window.open() support
111-static GtkWidget* on_create_web_view(WebKitWebView *web_view,
112- WebKitNavigationAction *navigation_action,
113- gpointer user_data) {
114- WebKitURIRequest *request = webkit_navigation_action_get_request(navigation_action);
115- const char* uri = webkit_uri_request_get_uri(request);
116-
117- // Extract additional navigation properties for enhanced _blank detection
118- const char* frame_name = webkit_navigation_action_get_frame_name(navigation_action);
119- gboolean is_user_gesture = webkit_navigation_action_is_user_gesture(navigation_action);
120- guint modifiers = webkit_navigation_action_get_modifiers(navigation_action);
121- WebKitNavigationType nav_type = webkit_navigation_action_get_navigation_type(navigation_action);
122-
123- printf("[webkit-create] Create signal for URI: %s, FrameName: %s, UserGesture: %s, Modifiers: 0x%x, NavType: %d\n",
124- uri ? uri : "NULL",
125- frame_name ? frame_name : "NULL",
126- is_user_gesture ? "true" : "false",
127- modifiers,
128- nav_type);
129-
130- // Enhanced detection logic for _blank vs popup using reliable heuristics
131- gboolean is_regular_pane = FALSE;
132- const guint GDK_CONTROL_MASK = 0x04;
133- const guint GDK_META_MASK = 0x10000000; // Command key on macOS
134- gboolean has_ctrl_or_cmd = (modifiers & GDK_CONTROL_MASK) || (modifiers & GDK_META_MASK);
135-
136- // Criteria for treating as regular pane:
137- // 1. Explicit _blank frame name (target="_blank" links)
138- // 2. User gesture with Ctrl/Cmd+click (open in new tab intent)
139- // 3. User-initiated OTHER navigation with no frame name (window.open("url", "_blank"))
140- if (frame_name && strcmp(frame_name, "_blank") == 0) {
141- is_regular_pane = TRUE;
142- printf("[webkit-create] _blank frame detected - will let WebKit handle natively to avoid WindowFeatures crash\n");
143- } else if (is_user_gesture && has_ctrl_or_cmd && (nav_type == 0 || nav_type == 5)) {
144- // WEBKIT_NAVIGATION_TYPE_LINK_CLICKED = 0, WEBKIT_NAVIGATION_TYPE_OTHER = 5
145- is_regular_pane = TRUE;
146- printf("[webkit-create] Ctrl/Cmd+click detected - will let WebKit handle natively\n");
147- } else if (is_user_gesture && nav_type == 5 && (!frame_name || strlen(frame_name) == 0)) {
148- // window.open("url", "_blank") typically: user gesture + OTHER + no frame name
149- is_regular_pane = TRUE;
150- printf("[webkit-create] User-initiated window.open() detected (nav_type=%d, frame_name=%s) - will let WebKit handle natively\n",
151- nav_type, frame_name ? frame_name : "NULL");
152- } else {
153- // Default to popup for non-user gestures or other navigation types
154- is_regular_pane = FALSE;
155- printf("[webkit-create] Non-user gesture or other navigation type - treating as popup (user_gesture=%s, nav_type=%d, frame_name=%s)\n",
156- is_user_gesture ? "true" : "false", nav_type, frame_name ? frame_name : "NULL");
157- }
158-
159- // Add marker to URI for workspace manager based on detection
160- char* marked_uri = NULL;
161- if (uri) {
162- if (is_regular_pane) {
163- // Add marker for regular pane
164- size_t len = strlen(uri) + strlen("#__dumber_frame_blank") + 1;
165- marked_uri = malloc(len);
166- snprintf(marked_uri, len, "%s#__dumber_frame_blank", uri);
167- printf("[webkit-create] Marked as regular pane: %s\n", marked_uri);
168- } else {
169- // Add marker for popup
170- size_t len = strlen(uri) + strlen("#__dumber_frame_popup") + 1;
171- marked_uri = malloc(len);
172- snprintf(marked_uri, len, "%s#__dumber_frame_popup", uri);
173- printf("[webkit-create] Marked as popup: %s\n", marked_uri);
174- }
175- }
176-
177- // CRITICAL FIX: Return NULL for _blank targets to prevent WindowFeatures crash
178- if (is_regular_pane) {
179- // For _blank targets, return NULL to let WebKit handle it naturally
180- // This prevents WebKit from trying to access uninitialized WindowFeatures
181- printf("[webkit-create] _blank target detected - returning NULL to prevent WindowFeatures crash\n");
182- printf("[webkit-create] Creating new panel directly for _blank target\n");
183-
184- // Create new panel directly through Go layer
185- unsigned long view_id = (unsigned long)user_data;
186- goCreateNewPanel(view_id, marked_uri ? marked_uri : (char*)uri);
187-
188- // Clean up allocated memory
189- if (marked_uri) {
190- free(marked_uri);
191- }
192-
193- return NULL; // Critical: Don't return a widget for _blank targets
194- }
195-
196- // Only create WebView for actual popups (window.open with features)
197- printf("[webkit-create] Creating popup WebView with shared context from parent\n");
198-
199- // Get the web context from the parent WebView to share settings and session
200- WebKitWebContext* parent_context = webkit_web_view_get_context(web_view);
201- WebKitSettings* parent_settings = webkit_web_view_get_settings(web_view);
202- WebKitUserContentManager* parent_content_manager = webkit_web_view_get_user_content_manager(web_view);
203-
204- // Create new WebView with same context, settings, and content manager as parent
205- GtkWidget* new_webview = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW,
206- "web-context", parent_context,
207- "settings", parent_settings,
208- "user-content-manager", parent_content_manager,
209- NULL));
210-
211- // Now inform Go layer about the new popup WebView for workspace management
212- unsigned long view_id = (unsigned long)user_data;
213- goNotifyWebViewCreated(view_id, (unsigned long)new_webview, marked_uri ? marked_uri : (char*)uri);
214-
215- // Clean up allocated memory
216- if (marked_uri) {
217- free(marked_uri);
218- }
219-
220- if (new_webview) {
221- printf("[webkit-create] Created popup WebView - connecting popup management signals\n");
222-
223- // Connect ready-to-show signal for popup WindowFeatures handling
224- g_signal_connect(new_webview, "ready-to-show",
225- G_CALLBACK(on_popup_ready_to_show), user_data);
226- printf("[webkit-create] Connected ready-to-show signal for popup WindowFeatures\n");
227-
228- WebKitWebView* popup_webview = WEBKIT_WEB_VIEW(new_webview);
229- g_signal_connect_data(G_OBJECT(popup_webview), "load-changed",
230- G_CALLBACK(on_load_changed), user_data, NULL, 0);
231- printf("[webkit-create] Connected load-changed signal to popup for navigation tracking\n");
232-
233- // Connect close signal to handle window.close() from any popup (OAuth, ads, etc.)
234- g_signal_connect_data(G_OBJECT(popup_webview), "close",
235- G_CALLBACK(on_webview_close), user_data, NULL, 0);
236- printf("[webkit-create] Connected close signal to popup for auto-close handling\n");
237-
238- return new_webview;
239- } else {
240- printf("[webkit-create] Go handler declined popup - allowing native popup window\n");
241- return NULL; // Let WebKit create native popup window
242- }
243-}
24482
24583 // Handle decide-policy signal for new window requests - more robust than create signal
24684 static gboolean on_decide_policy(WebKitWebView *web_view,
@@ -393,10 +231,9 @@ static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserCon
393231 g_object_ref(u); // Add ref since we'll pass it to new WebView
394232 }
395233
396- // Create WebView with same session and content manager for process sharing
234+ // Create WebView with related view for process sharing (network session is inherited)
397235 GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW,
398236 "user-content-manager", u,
399- "network-session", related_session,
400237 "related-view", related_view,
401238 NULL));
402239
@@ -411,9 +248,9 @@ static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserCon
411248
412249 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {
413250 if (!web_view) return;
414- g_signal_connect_data(G_OBJECT(web_view), "decide-policy", G_CALLBACK(on_decide_policy), (gpointer)id, NULL, 0);
415- // Keep create signal for JavaScript window.open() and other cases not caught by decide-policy
416- g_signal_connect_data(G_OBJECT(web_view), "create", G_CALLBACK(on_create_web_view), (gpointer)id, NULL, 0);
251+ // Create signal is now handled in window_handler_cgo.go
252+ // Use ConnectCreateSignal from Go to connect the signal
253+ printf("[webkit-debug] Policy handler connected (create signal handled separately)\n");
417254 }
418255
419256 // Connect close signal to a WebView for popup auto-close functionality
@@ -909,6 +746,7 @@ import (
909746 "context"
910747 "crypto/sha256"
911748 "database/sql"
749+ "encoding/json"
912750 "errors"
913751 "fmt"
914752 "log"
@@ -936,125 +774,7 @@ type FilterManager interface {
936774 GetCosmeticScriptForDomain(domain string) string
937775 }
938776
939-const domBridgeTemplate = `(() => { try {
940- if (window.__dumber_page_bridge_installed) return;
941- window.__dumber_page_bridge_installed = true;
942-
943- const __dumberInitialZoom = __DOM_ZOOM_DEFAULT__;
944- window.__dumber_dom_zoom_seed = __dumberInitialZoom;
945-
946- // Theme setter function for GTK theme integration
947- window.__dumber_setTheme = (theme) => {
948- window.__dumber_initial_theme = theme;
949- console.log('[dumber] Setting theme to:', theme);
950- if (theme === 'dark') {
951- document.documentElement.classList.add('dark');
952- } else {
953- document.documentElement.classList.remove('dark');
954- }
955- };
956-
957- // Ensure unified API object
958- window.__dumber = window.__dumber || {};
959- // Toast namespace
960- window.__dumber.toast = window.__dumber.toast || {
961- show: function(message, duration, type) {
962- try {
963- document.dispatchEvent(new CustomEvent('dumber:toast:show', { detail: { message, duration, type } }));
964- // Legacy compatibility
965- document.dispatchEvent(new CustomEvent('dumber:showToast', { detail: { message, duration, type } }));
966- } catch(e) { /* ignore */ }
967- },
968- zoom: function(level) {
969- try {
970- document.dispatchEvent(new CustomEvent('dumber:toast:zoom', { detail: { level } }));
971- // Legacy compatibility
972- document.dispatchEvent(new CustomEvent('dumber:showZoomToast', { detail: { level } }));
973- } catch(e) { /* ignore */ }
974- }
975- };
976- // Back-compat global helpers
977- window.__dumber_showToast = function(message, duration, type) { window.__dumber.toast.show(message, duration, type); };
978- window.__dumber_showZoomToast = function(level) { window.__dumber.toast.zoom(level); };
979-
980- // DOM zoom helpers (optional native override)
981- if (typeof window.__dumber_dom_zoom_level !== 'number') {
982- window.__dumber_dom_zoom_level = __dumberInitialZoom;
983- }
984- const __dumberApplyZoomStyles = function(node, level) {
985- if (!node) return;
986- if (Math.abs(level - 1.0) < 1e-6) {
987- node.style.removeProperty('zoom');
988- node.style.removeProperty('transform');
989- node.style.removeProperty('transform-origin');
990- node.style.removeProperty('width');
991- node.style.removeProperty('min-width');
992- node.style.removeProperty('height');
993- node.style.removeProperty('min-height');
994- return;
995- }
996- const scale = level;
997- const inversePercent = 100 / scale;
998- const widthValue = inversePercent + '%';
999- node.style.removeProperty('zoom');
1000- node.style.transform = 'scale(' + scale + ')';
1001- node.style.transformOrigin = '0 0';
1002- node.style.width = widthValue;
1003- node.style.minWidth = widthValue;
1004- node.style.minHeight = '100%';
1005- };
1006- window.__dumber_applyDomZoom = function(level) {
1007- try {
1008- window.__dumber_dom_zoom_level = level;
1009- window.__dumber_dom_zoom_seed = level;
1010- __dumberApplyZoomStyles(document.documentElement, level);
1011- if (document.body) {
1012- __dumberApplyZoomStyles(document.body, level);
1013- }
1014- } catch (e) {
1015- console.error('[dumber] DOM zoom error', e);
1016- }
1017- };
1018- // Apply immediately so first paint uses the desired zoom, then reapply when body exists.
1019- window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
1020- if (!document.body) {
1021- document.addEventListener('DOMContentLoaded', function() {
1022- if (typeof window.__dumber_dom_zoom_level === 'number') {
1023- window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
1024- }
1025- }, { once: true });
1026- }
1027-
1028- // Omnibox suggestions bridge (with queue before ready)
1029- let __omniboxQueue = [];
1030- let __omniboxReady = false;
1031- function __omniboxDispatch(suggestions) {
1032- try {
1033- document.dispatchEvent(new CustomEvent('dumber:omnibox:suggestions', { detail: { suggestions } }));
1034- // Legacy compatibility event name
1035- document.dispatchEvent(new CustomEvent('dumber:omnibox-suggestions', { detail: { suggestions } }));
1036- } catch (e) { /* ignore */ }
1037- }
1038- window.__dumber_omnibox_suggestions = function(suggestions) {
1039- if (__omniboxReady) {
1040- __omniboxDispatch(suggestions);
1041- } else {
1042- try { __omniboxQueue.push(suggestions); } catch (_) { /* ignore */ }
1043- }
1044- };
1045- document.addEventListener('dumber:omnibox-ready', function() {
1046- __omniboxReady = true;
1047- if (__omniboxQueue && __omniboxQueue.length) {
1048- const items = __omniboxQueue.slice();
1049- __omniboxQueue.length = 0;
1050- for (const s of items) __omniboxDispatch(s);
1051- }
1052- });
1053- // Unified omnibox API for page-world callers
1054- window.__dumber.omnibox = window.__dumber.omnibox || {
1055- suggestions: function(suggestions) { window.__dumber_omnibox_suggestions(suggestions); }
1056- };
1057- } catch (e) { console.warn('[dumber] unified bridge init failed', e); } })();`
777+// domBridgeTemplate removed - now loading compiled main-world.min.js
1058778
1059779 var registerSchemeOnce sync.Once
1060780
@@ -1210,6 +930,14 @@ type WebView struct {
1210930 domZoomSeed float64
1211931 domBridgeScript *C.WebKitUserScript
1212932 wasReparented bool // Track if this WebView has been reparented
933+
934+ // Window type detection and relationships
935+ windowType WindowType
936+ windowFeatures *WindowFeatures
937+ windowTypeCallback func(WindowType, *WindowFeatures)
938+ parentWebView *WebView
939+ isRelated bool
940+ isActive bool // Track if this WebView is currently active/focused
1213941 }
1214942
1215943 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -1366,6 +1094,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
13661094 v.id = nextViewID()
13671095 registerView(v.id, v)
13681096 C.connect_policy_handler(v.native.wv, C.ulong(v.id))
1097+ ConnectCreateSignal(v) // Connect create signal for window.open support
13691098
13701099 // Register with global memory manager if monitoring is enabled
13711100 if cfg.Memory.EnableMemoryMonitoring {
@@ -1388,55 +1117,9 @@ func NewWebView(cfg *Config) (*WebView, error) {
13881117 C.connect_theme_changed_with_id(settings, C.ulong(v.id))
13891118 }
13901119
1391- // Native zoom shortcuts (independent of app services)
1392- // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset
1393- _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() {
1394- nz := v.zoom
1395- if nz <= 0 {
1396- nz = 1.0
1397- }
1398- nz *= 1.1
1399- if nz < 0.25 {
1400- nz = 0.25
1401- }
1402- if nz > 5.0 {
1403- nz = 5.0
1404- }
1405- _ = v.SetZoom(nz)
1406- })
1407- _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() {
1408- nz := v.zoom
1409- if nz <= 0 {
1410- nz = 1.0
1411- }
1412- nz *= 1.1
1413- if nz < 0.25 {
1414- nz = 0.25
1415- }
1416- if nz > 5.0 {
1417- nz = 5.0
1418- }
1419- _ = v.SetZoom(nz)
1420- })
1421- _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() {
1422- nz := v.zoom
1423- if nz <= 0 {
1424- nz = 1.0
1425- }
1426- nz /= 1.1
1427- if nz < 0.25 {
1428- nz = 0.25
1429- }
1430- if nz > 5.0 {
1431- nz = 5.0
1432- }
1433- _ = v.SetZoom(nz)
1434- })
1435- _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) })
1436- // Find in page (Ctrl/Cmd+F): use new keyboard service bridge
1437- _ = v.RegisterKeyboardShortcut("cmdorctrl+f", func() {
1438- _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+f'}}))")
1439- })
1120+ // Zoom shortcuts are now handled by application-level workspace shortcuts
1121+ // in workspace_manager.go to ensure proper active pane targeting
1122+ // Find in page (Ctrl/Cmd+F): handled by window-level shortcuts in window_shortcuts.go
14401123 // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge
14411124 _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() {
14421125 _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowleft'}}))")
@@ -1578,9 +1261,124 @@ func NewWebView(cfg *Config) (*WebView, error) {
15781261 }
15791262 }
15801263
1264+ // Setup console message logging
1265+ v.setupConsoleLogging()
1266+
15811267 return v, nil
15821268 }
15831269
1270+// setupConsoleLogging enables console message capture for the WebView
1271+func (v *WebView) setupConsoleLogging() {
1272+ if v.native == nil || v.native.wv == nil {
1273+ return
1274+ }
1275+
1276+ // Check if console capture is enabled in config
1277+ cfg := config.Get()
1278+ if !cfg.Logging.CaptureConsole {
1279+ // Disable console messages to stdout (always, to keep backend clean)
1280+ if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1281+ C.webkit_settings_set_enable_write_console_messages_to_stdout(settings, C.gboolean(0))
1282+ }
1283+ return
1284+ }
1285+
1286+ // If console capture is enabled, disable stdout and enable our capture
1287+ if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1288+ C.webkit_settings_set_enable_write_console_messages_to_stdout(settings, C.gboolean(0))
1289+ log.Printf("[webkit] Console message capture enabled - redirecting to console.log file")
1290+ }
1291+
1292+ // TODO: Implement JavaScript injection for console capture
1293+ // This will inject code to intercept console.log/error/warn and send to Go
1294+}
1295+
1296+// setupGlobalConsoleCapture sets up a global stdout filter to capture console messages
1297+func setupGlobalConsoleCapture() {
1298+ // This should be called once globally, not per WebView
1299+ logging.StartConsoleCapture()
1300+}
1301+
1302+// injectConsoleCapture injects JavaScript to capture console messages
1303+func (v *WebView) injectConsoleCapture() {
1304+ consoleScript := `
1305+(function() {
1306+ const originalLog = console.log;
1307+ const originalWarn = console.warn;
1308+ const originalError = console.error;
1309+ const originalInfo = console.info;
1310+ const originalDebug = console.debug;
1311+
1312+ function sendToGo(level, args) {
1313+ const message = args.map(arg =>
1314+ typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
1315+ ).join(' ');
1316+
1317+ const formattedMessage = '[' + level + '] ' + window.location.href + ' ' + message;
1318+
1319+ // Send to Go via user script message
1320+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumberBridge) {
1321+ window.webkit.messageHandlers.dumberBridge.postMessage({
1322+ type: 'console',
1323+ data: formattedMessage
1324+ });
1325+ }
1326+ }
1327+
1328+ console.log = function(...args) {
1329+ originalLog.apply(console, args);
1330+ sendToGo('LOG', args);
1331+ };
1332+
1333+ console.warn = function(...args) {
1334+ originalWarn.apply(console, args);
1335+ sendToGo('WARN', args);
1336+ };
1337+
1338+ console.error = function(...args) {
1339+ originalError.apply(console, args);
1340+ sendToGo('ERROR', args);
1341+ };
1342+
1343+ console.info = function(...args) {
1344+ originalInfo.apply(console, args);
1345+ sendToGo('INFO', args);
1346+ };
1347+
1348+ console.debug = function(...args) {
1349+ originalDebug.apply(console, args);
1350+ sendToGo('DEBUG', args);
1351+ };
1352+})();
1353+`
1354+ _ = v.InjectScript(consoleScript)
1355+}
1356+
1357+// handleConsoleMessage processes console messages from JavaScript
1358+func (v *WebView) handleConsoleMessage(payload string) {
1359+ // Parse the message payload to check if it's a console message
1360+ var msg struct {
1361+ Type string `json:"type"`
1362+ Data string `json:"data"`
1363+ }
1364+
1365+ if err := json.Unmarshal([]byte(payload), &msg); err != nil {
1366+ return
1367+ }
1368+
1369+ if msg.Type == "console" {
1370+ // Send to logging system with [CONSOLE] tag
1371+ logging.CaptureWebKitLog(msg.Data)
1372+ }
1373+}
1374+
1375+// startConsoleCapture starts capturing stdout to filter console messages
1376+func (v *WebView) startConsoleCapture() {
1377+ // This is a simplified approach - we'll rely on the existing OutputCapture
1378+ // mechanism in internal/logging/capture.go to handle stdout capture
1379+ // and filter for console messages in the log processing
1380+}
1381+
15841382 // NewWebViewWithRelated creates a new WebView related to an existing one for process sharing
15851383 func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) {
15861384 if relatedView == nil || relatedView.native == nil || relatedView.native.wv == nil {
@@ -1613,6 +1411,17 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error)
16131411
16141412 C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget)
16151413
1414+ // Make the WebView widget visible and expandable for popup rendering
1415+ C.gtk_widget_set_visible(viewWidget, C.gboolean(1))
1416+ C.gtk_widget_set_visible(container, C.gboolean(1))
1417+ C.gtk_widget_set_hexpand(viewWidget, C.gboolean(1))
1418+ C.gtk_widget_set_vexpand(viewWidget, C.gboolean(1))
1419+ C.gtk_widget_set_hexpand(container, C.gboolean(1))
1420+ C.gtk_widget_set_vexpand(container, C.gboolean(1))
1421+ // Remove any size constraints from popup WebView to let it fill the pane
1422+ C.gtk_widget_set_size_request(viewWidget, -1, -1)
1423+ C.gtk_widget_set_size_request(container, -1, -1)
1424+
16161425 if cfg.CreateWindow {
16171426 win = C.new_window()
16181427 if win == nil {
@@ -1653,6 +1462,7 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error)
16531462 v.id = nextViewID()
16541463 registerView(v.id, v)
16551464 C.connect_policy_handler(v.native.wv, C.ulong(v.id))
1465+ ConnectCreateSignal(v) // Connect create signal for window.open support
16561466
16571467 // Register with memory manager if enabled
16581468 if cfg.Memory.EnableMemoryMonitoring {
@@ -1870,6 +1680,46 @@ func (w *WebView) dispatchPopupRequest(uri string) *WebView {
18701680 return nil
18711681 }
18721682
1683+// CreateRelatedView creates a WebView that shares process/context with this view
1684+func (w *WebView) CreateRelatedView() *WebView {
1685+ if w == nil || w.native == nil || w.native.wv == nil {
1686+ return nil
1687+ }
1688+ // Build a minimal config inheriting from current view
1689+ cfg := &Config{
1690+ Assets: w.config.Assets,
1691+ ZoomDefault: w.config.ZoomDefault,
1692+ EnableDeveloperExtras: w.config.EnableDeveloperExtras,
1693+ DataDir: w.config.DataDir,
1694+ CacheDir: w.config.CacheDir,
1695+ APIToken: w.config.APIToken,
1696+ DefaultSansFont: w.config.DefaultSansFont,
1697+ DefaultSerifFont: w.config.DefaultSerifFont,
1698+ DefaultMonospaceFont: w.config.DefaultMonospaceFont,
1699+ DefaultFontSize: w.config.DefaultFontSize,
1700+ Rendering: w.config.Rendering,
1701+ UseDomZoom: w.config.UseDomZoom,
1702+ VideoAcceleration: w.config.VideoAcceleration,
1703+ Memory: w.config.Memory,
1704+ CodecPreferences: w.config.CodecPreferences,
1705+ CreateWindow: false,
1706+ }
1707+ related, err := NewWebViewWithRelated(cfg, w)
1708+ if err != nil {
1709+ log.Printf("[webkit] CreateRelatedView failed: %v", err)
1710+ return nil
1711+ }
1712+ return related
1713+}
1714+
1715+// OnWindowTypeDetected registers a callback invoked when window type is detected
1716+func (w *WebView) OnWindowTypeDetected(callback func(WindowType, *WindowFeatures)) {
1717+ if w == nil {
1718+ return
1719+ }
1720+ w.windowTypeCallback = callback
1721+}
1722+
18731723 // GetNativePointer returns the native WebView pointer for CGO callbacks
18741724 func (w *WebView) GetNativePointer() unsafe.Pointer {
18751725 if w != nil && w.native != nil && w.native.wv != nil {
@@ -2153,10 +2003,35 @@ func clampDomZoom(level float64) float64 {
21532003 return level
21542004 }
21552005
2156-func buildDomBridgeScript(initialZoom float64) string {
2006+func buildDomBridgeScript(cfg *Config, initialZoom float64, webViewId uintptr, w *WebView) string {
2007+ if cfg == nil || cfg.Assets == nil {
2008+ log.Printf("[webkit] Config or assets not available for main-world script")
2009+ return ""
2010+ }
2011+
2012+ // Load the compiled main-world script
2013+ scriptBytes, err := cfg.Assets.ReadFile("assets/gui/main-world.min.js")
2014+ if err != nil {
2015+ log.Printf("[webkit] Failed to load main-world script: %v", err)
2016+ return ""
2017+ }
2018+
2019+ // Replace the zoom placeholder with the actual value
21572020 zoom := clampDomZoom(initialZoom)
21582021 zoomStr := strconv.FormatFloat(zoom, 'f', -1, 64)
2159- return strings.ReplaceAll(domBridgeTemplate, "__DOM_ZOOM_DEFAULT__", zoomStr)
2022+ script := strings.ReplaceAll(string(scriptBytes), "__DOM_ZOOM_DEFAULT__", zoomStr)
2023+
2024+ // Inject WebView ID and active state
2025+ webViewIdStr := strconv.FormatUint(uint64(webViewId), 10)
2026+ script = strings.ReplaceAll(script, "__WEBVIEW_ID__", webViewIdStr)
2027+ // Default to active for the initial WebView, since it will be focused immediately
2028+ // For single WebView scenarios, assume it should be active
2029+ activeState := "true"
2030+ log.Printf("[webkit] Building script with WebView %s, active state: %s", webViewIdStr, activeState)
2031+ script = strings.ReplaceAll(script, "\"__WEBVIEW_ACTIVE__\"", activeState)
2032+
2033+ log.Printf("[webkit] Loaded main-world script successfully, size: %d bytes", len(scriptBytes))
2034+ return script
21602035 }
21612036
21622037 func (w *WebView) UsesDomZoom() bool {
@@ -2176,7 +2051,7 @@ func (w *WebView) installDomBridgeScript() {
21762051 w.domBridgeScript = nil
21772052 }
21782053
2179- source := buildDomBridgeScript(w.domZoomSeed)
2054+ source := buildDomBridgeScript(w.config, w.domZoomSeed, w.id, w)
21802055 cBridge := C.CString(source)
21812056 defer C.free(unsafe.Pointer(cBridge))
21822057
@@ -2202,8 +2077,11 @@ func (w *WebView) SeedDomZoom(level float64) {
22022077 return
22032078 }
22042079 w.domZoomSeed = seed
2080+ // Instead of re-injecting the entire script, just update the zoom level
22052081 w.RunOnMainThread(func() {
2206- w.installDomBridgeScript()
2082+ zoomStr := strconv.FormatFloat(seed, 'f', -1, 64)
2083+ script := fmt.Sprintf("if (window.__dumber_applyDomZoom) { window.__dumber_applyDomZoom(%s); }", zoomStr)
2084+ w.InjectScript(script)
22072085 })
22082086 }
22092087
@@ -2338,6 +2216,13 @@ func goHandleTLSError(failingURI *C.char, host *C.char, errorFlags C.int, certIn
23382216 return C.FALSE // Don't proceed
23392217 }
23402218
2219+//export goHandleConsoleMessage
2220+//export goHandleConsoleMessage
2221+func goHandleConsoleMessage(message *C.char) {
2222+ messageText := C.GoString(message)
2223+ logging.CaptureWebKitLog(messageText)
2224+}
2225+
23412226 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability
23422227 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {
23432228 if w == nil || w.native == nil || w.native.win == nil {
@@ -2656,3 +2541,41 @@ func (w *WebView) detectAndLogRenderingBackend() {
26562541 w.InjectScript(detectionScript)
26572542 }()
26582543 }
2544+
2545+// SetWindowFeatures sets the window features for this WebView
2546+func (w *WebView) SetWindowFeatures(features *WindowFeatures) {
2547+ w.windowFeatures = features
2548+}
2549+
2550+// SetActive sets whether this WebView is currently active/focused
2551+func (w *WebView) SetActive(active bool) {
2552+ if w == nil || w.destroyed {
2553+ return
2554+ }
2555+
2556+ if w.isActive != active {
2557+ w.isActive = active
2558+ log.Printf("[webkit] WebView %d active state changed to: %v", w.id, active)
2559+
2560+ // Update the active state in the JavaScript context
2561+ activeStr := "false"
2562+ if active {
2563+ activeStr = "true"
2564+ }
2565+ script := fmt.Sprintf("window.__dumber_is_active = %s;", activeStr)
2566+ w.InjectScript(script)
2567+ }
2568+}
2569+
2570+// IsActive returns whether this WebView is currently active/focused
2571+func (w *WebView) IsActive() bool {
2572+ return w != nil && w.isActive
2573+}
2574+
2575+// ID returns the unique identifier for this WebView as a string
2576+func (w *WebView) ID() string {
2577+ if w == nil {
2578+ return ""
2579+ }
2580+ return strconv.FormatUint(uint64(w.id), 10)
2581+}
A pkg/webkit/window_handler_cgo.go +72 -0
@@ -0,0 +1,72 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: gtk4 webkitgtk-6.0
7+#include <gtk/gtk.h>
8+#include <gdk/gdk.h>
9+#include <webkit/webkit.h>
10+
11+// Declarations for Go callbacks
12+extern GtkWidget* goHandleCreateWebView(unsigned long id, char* uri);
13+
14+// Handle create signal for popup windows - essential for window.open() support
15+static GtkWidget* on_create_web_view(WebKitWebView *web_view,
16+ WebKitNavigationAction *navigation_action,
17+ gpointer user_data) {
18+ WebKitURIRequest *request = webkit_navigation_action_get_request(navigation_action);
19+ const char* uri = webkit_uri_request_get_uri(request);
20+
21+ printf("[webkit-create] Create signal for URI: %s\n", uri ? uri : "NULL");
22+
23+ unsigned long parent_view_id = (unsigned long)user_data;
24+
25+ // Let Go handler create WebView and return it like Epiphany does
26+ GtkWidget* new_webview = goHandleCreateWebView(parent_view_id, (char*)uri);
27+
28+ if (new_webview) {
29+ printf("[webkit-create] Go handler provided WebView - using workspace pane\n");
30+ return new_webview;
31+ } else {
32+ printf("[webkit-create] Go handler declined - allowing native popup window\n");
33+ return NULL; // Let WebKit create native popup window
34+ }
35+}
36+
37+static void connect_create_signal(WebKitWebView* web_view, unsigned long id) {
38+ if (!web_view) return;
39+ g_signal_connect_data(G_OBJECT(web_view), "create", G_CALLBACK(on_create_web_view), (gpointer)id, NULL, 0);
40+ printf("[webkit-debug] create-web-view callback ENABLED with WebKit-created WebViews\n");
41+}
42+*/
43+import "C"
44+import (
45+ "log"
46+ "unsafe"
47+)
48+
49+//export goHandleCreateWebView
50+func goHandleCreateWebView(parentID C.ulong, curi *C.char) *C.GtkWidget {
51+ uri := ""
52+ if curi != nil {
53+ uri = C.GoString(curi)
54+ }
55+
56+ log.Printf("[webkit] Create signal bypassed: URI=%s (handled by JavaScript bypass)", uri)
57+
58+ // Always return NULL - window.open is now handled entirely by JavaScript bypass
59+ // This prevents duplicate pane creation from WebKit's create signal
60+ return nil
61+}
62+
63+// ConnectCreateSignal connects the create signal to a WebView
64+func ConnectCreateSignal(view *WebView) {
65+ if view == nil || view.native == nil || view.native.view == nil {
66+ log.Printf("[webkit] ConnectCreateSignal: invalid WebView")
67+ return
68+ }
69+
70+ webkitView := (*C.WebKitWebView)(unsafe.Pointer(view.native.view))
71+ C.connect_create_signal(webkitView, C.ulong(view.id))
72+}
A pkg/webkit/window_types.go +23 -0
@@ -0,0 +1,23 @@
1+package webkit
2+
3+// WindowType indicates how a new WebView should be treated
4+type WindowType int
5+
6+const (
7+ // WindowTypeTab represents an independent WebView (future: tab)
8+ WindowTypeTab WindowType = iota
9+ // WindowTypePopup represents a related WebView (shares process/context)
10+ WindowTypePopup
11+ // WindowTypeUnknown indicates type not detected yet
12+ WindowTypeUnknown
13+)
14+
15+// WindowFeatures describes the window features detected from WebKitWindowProperties
16+type WindowFeatures struct {
17+ Width int
18+ Height int
19+ ToolbarVisible bool
20+ LocationbarVisible bool
21+ MenubarVisible bool
22+ Resizable bool
23+}
M pkg/webkit/workspace_widgets_cgo.go +8 -0
@@ -306,6 +306,14 @@ func PanedSetResizeEnd(paned uintptr, resize bool) {
306306 C.gtk_paned_set_resize_end_child((*C.GtkPaned)(unsafe.Pointer(paned)), goBool(resize))
307307 }
308308
309+// PanedSetPosition sets the pixel position of the divider.
310+func PanedSetPosition(paned uintptr, pos int) {
311+ if paned == 0 {
312+ return
313+ }
314+ C.gtk_paned_set_position((*C.GtkPaned)(unsafe.Pointer(paned)), C.int(pos))
315+}
316+
309317 // WidgetUnparent detaches a widget from its current parent.
310318 func WidgetUnparent(widget uintptr) {
311319 if widget == 0 {
M pkg/webkit/workspace_widgets_stub.go +3 -0
@@ -93,6 +93,9 @@ func PanedSetResizeStart(paned uintptr, resize bool) {}
9393
9494 func PanedSetResizeEnd(paned uintptr, resize bool) {}
9595
96+// PanedSetPosition is a no-op in stub builds.
97+func PanedSetPosition(paned uintptr, pos int) {}
98+
9699 func WidgetUnparent(widget uintptr) {}
97100
98101 func WidgetSetHExpand(widget uintptr, expand bool) {}

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