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.
12 - **Configurable workspace styling**: Pane border appearance (width, color, transition duration, radius) now customizable via config with proper viewport stability12 - **Configurable workspace styling**: Pane border appearance (width, color, transition duration, radius) now customizable via config with proper viewport stability
13 - **Popup handling for tiling WM**: Complete popup window support where popups open as workspace panes instead of floating windows, respecting tiling window manager design principles13 - **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
14 - **Universal popup auto-close**: Popup windows (OAuth, window.open(), etc.) automatically close when JavaScript calls window.close(), providing standard browser behavior adapted for workspace panes14 - **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
15 19
16 ### Improved20 ### Improved
17 - WebKit integration now forces GPU compositing by default while still honoring explicit config overrides.21 - 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.
26 - Omnibox overlay now self-heals if hostile pages remove the injected shadow host, ensuring Ctrl/Cmd+L works everywhere.30 - Omnibox overlay now self-heals if hostile pages remove the injected shadow host, ensuring Ctrl/Cmd+L works everywhere.
27 - Navigation shortcuts changed from Alt+Arrow to Ctrl/Cmd+Arrow for better workspace compatibility.31 - Navigation shortcuts changed from Alt+Arrow to Ctrl/Cmd+Arrow for better workspace compatibility.
28 - GUI components now support workspace-aware focus management with pane-specific event handling.32 - 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
29 40
30 ## [0.8.0] - 2025-09-1741 ## [0.8.0] - 2025-09-17
31 42
M Makefile +2 -2
@@ -43,8 +43,8 @@ build: build-frontend ## Build the application with GUI (frontend assets, then G
43 $(GOENV) CGO_ENABLED=1 go build -p $(NPROCS) $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)43 $(GOENV) CGO_ENABLED=1 go build -p $(NPROCS) $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
44 @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"44 @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
45 45
46-build-frontend: ## Build Svelte GUI with Tailwind CSS46+build-frontend: ## Build Svelte GUI with Tailwind CSS and main-world script
47- @echo "Building Svelte GUI with Tailwind CSS..."47+ @echo "Building Svelte GUI with Tailwind CSS and main-world script..."
48 @cd gui && npm install --silent && npm run build48 @cd gui && npm install --silent && npm run build
49 @echo "GUI build complete"49 @echo "GUI build complete"
50 50
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';1+import js from "@eslint/js";
2-import tseslint from 'typescript-eslint';2+import tseslint from "typescript-eslint";
3 3
4 export default tseslint.config(4 export default tseslint.config(
5 js.configs.recommended,5 js.configs.recommended,
6 ...tseslint.configs.recommended,6 ...tseslint.configs.recommended,
7 {7 {
8- files: ['**/*.{js,mjs,cjs,ts}'],8+ files: ["**/*.{js,mjs,cjs,ts}"],
9 languageOptions: {9 languageOptions: {
10 ecmaVersion: 2024,10 ecmaVersion: 2024,
11- sourceType: 'module',11+ sourceType: "module",
12 },12 },
13 rules: {13 rules: {
14 // Modern TypeScript best practices14 // Modern TypeScript best practices
15- '@typescript-eslint/no-unused-vars': ['error', { 15+ "@typescript-eslint/no-unused-vars": [
16- 'argsIgnorePattern': '^_',16+ "error",
17- 'varsIgnorePattern': '^_'17+ {
18- }],18+ argsIgnorePattern: "^_",
19- '@typescript-eslint/no-explicit-any': 'warn',19+ varsIgnorePattern: "^_",
20- '@typescript-eslint/no-empty-function': 'off',20+ },
21- '@typescript-eslint/consistent-type-imports': 'error',21+ ],
22- '@typescript-eslint/no-import-type-side-effects': 'error',22+ "@typescript-eslint/no-explicit-any": "warn",
23- 23+ "@typescript-eslint/no-empty-function": "off",
24+ "@typescript-eslint/consistent-type-imports": "error",
25+ "@typescript-eslint/no-import-type-side-effects": "error",
26+
24 // Modern JavaScript/TypeScript patterns27 // Modern JavaScript/TypeScript patterns
25- 'prefer-const': 'error',28+ "prefer-const": "error",
26- 'no-var': 'error',29+ "no-var": "error",
27- 'object-shorthand': 'error',30+ "object-shorthand": "error",
28- 'prefer-template': 'error',31+ "prefer-template": "error",
29 },32 },
30 },33 },
31 {34 {
32- files: ['**/*.js', '**/*.mjs'],35+ files: ["**/*.js", "**/*.mjs"],
33 ...tseslint.configs.disableTypeChecked,36 ...tseslint.configs.disableTypeChecked,
34- }37+ },
35-);⏎̸38+);
M gui/package.json +3 -1
@@ -4,13 +4,15 @@
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "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",
8 "build:gui": "vite build --config vite.config.gui.ts",8 "build:gui": "vite build --config vite.config.gui.ts",
9 "build:homepage": "vite build --config vite.config.homepage.ts",9 "build:homepage": "vite build --config vite.config.homepage.ts",
10 "build:color-scheme": "vite build --config vite.config.color-scheme.ts",10 "build:color-scheme": "vite build --config vite.config.color-scheme.ts",
11+ "build:main-world": "vite build --config vite.config.main-world.ts",
11 "dev": "npm run build:gui -- --watch",12 "dev": "npm run build:gui -- --watch",
12 "dev:homepage": "npm run build:homepage -- --watch",13 "dev:homepage": "npm run build:homepage -- --watch",
13 "dev:color-scheme": "npm run build:color-scheme -- --watch",14 "dev:color-scheme": "npm run build:color-scheme -- --watch",
15+ "dev:main-world": "npm run build:main-world -- --watch",
14 "check": "svelte-check",16 "check": "svelte-check",
15 "lint": "eslint src --fix",17 "lint": "eslint src --fix",
16 "fmt": "prettier --write .",18 "fmt": "prettier --write .",
M gui/postcss.config.js +2 -2
@@ -1,6 +1,6 @@
1 export default {1 export default {
2 plugins: {2 plugins: {
3- '@tailwindcss/postcss': {},3+ "@tailwindcss/postcss": {},
4 autoprefixer: {},4 autoprefixer: {},
5 },5 },
6-};⏎̸6+};
M gui/src/components/omnibox/Omnibox.svelte +9 -0
@@ -284,6 +284,15 @@
284 } catch (error) {284 } catch (error) {
285 console.error('Error in findQuery:', error);285 console.error('Error in findQuery:', error);
286 }286 }
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+ }
287 }296 }
288 };297 };
289 298
M gui/src/components/omnibox/find.ts +65 -55
@@ -4,8 +4,8 @@
4 * Text search and highlighting implementation4 * Text search and highlighting implementation
5 */5 */
6 6
7-import type { FindMatch, HighlightNode } from './types';7+import type { FindMatch, HighlightNode } from "./types";
8-import { omniboxStore } from './stores.svelte.ts';8+import { omniboxStore } from "./stores.svelte.ts";
9 9
10 // Track whether we've injected highlight styles into the main document.10 // Track whether we've injected highlight styles into the main document.
11 let findStylesInjected = false;11 let findStylesInjected = false;
@@ -16,8 +16,8 @@ function ensureFindStyles(): void {
16 }16 }
17 17
18 try {18 try {
19- const style = document.createElement('style');19+ const style = document.createElement("style");
20- style.id = 'dumber-find-styles';20+ style.id = "dumber-find-styles";
21 style.textContent = `21 style.textContent = `
22 .find-highlight {22 .find-highlight {
23 background-color: rgb(255 235 59);23 background-color: rgb(255 235 59);
@@ -37,7 +37,7 @@ function ensureFindStyles(): void {
37 (document.head || document.documentElement).appendChild(style);37 (document.head || document.documentElement).appendChild(style);
38 findStylesInjected = true;38 findStylesInjected = true;
39 } catch (error) {39 } catch (error) {
40- console.warn('Failed to inject find styles:', error);40+ console.warn("Failed to inject find styles:", error);
41 }41 }
42 }42 }
43 43
@@ -48,7 +48,7 @@ export function findInPage(query: string): void {
48 // Clear previous highlights48 // Clear previous highlights
49 omniboxStore.clearHighlights();49 omniboxStore.clearHighlights();
50 50
51- const trimmedQuery = (query || '').trim();51+ const trimmedQuery = (query || "").trim();
52 if (!trimmedQuery) {52 if (!trimmedQuery) {
53 return;53 return;
54 }54 }
@@ -61,75 +61,83 @@ export function findInPage(query: string): void {
61 ensureFindStyles();61 ensureFindStyles();
62 62
63 // Get omnibox root to exclude from search63 // Get omnibox root to exclude from search
64- const omniboxRoot = document.getElementById('dumber-omnibox-root');64+ const omniboxRoot = document.getElementById("dumber-omnibox-root");
65 const matches: FindMatch[] = [];65 const matches: FindMatch[] = [];
66 const maxMatches = omniboxStore.config.maxMatches;66 const maxMatches = omniboxStore.config.maxMatches;
67 67
68 // Create tree walker to find text nodes68 // Create tree walker to find text nodes
69- const walker = document.createTreeWalker(69+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
70- body,70+ acceptNode(node: Text): number {
71- NodeFilter.SHOW_TEXT,71+ // Skip empty or whitespace-only nodes
72- {72+ if (!node.nodeValue || !node.nodeValue.trim()) {
73- acceptNode(node: Text): number {73+ return NodeFilter.FILTER_REJECT;
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;
102 }74 }
103- }75+
104- );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+ });
105 108
106 const queryLowerCase = trimmedQuery.toLowerCase();109 const queryLowerCase = trimmedQuery.toLowerCase();
107 110
108 // Process each text node111 // Process each text node
109 while (walker.nextNode() && matches.length < maxMatches) {112 while (walker.nextNode() && matches.length < maxMatches) {
110 const textNode = walker.currentNode as Text;113 const textNode = walker.currentNode as Text;
111- let nodeValue = textNode.nodeValue || '';114+ let nodeValue = textNode.nodeValue || "";
112 let searchIndex = 0;115 let searchIndex = 0;
113 116
114 // Find all matches in this text node117 // Find all matches in this text node
115 while (nodeValue && matches.length < maxMatches) {118 while (nodeValue && matches.length < maxMatches) {
116- const matchIndex = nodeValue.toLowerCase().indexOf(queryLowerCase, searchIndex);119+ const matchIndex = nodeValue
120+ .toLowerCase()
121+ .indexOf(queryLowerCase, searchIndex);
117 if (matchIndex === -1) {122 if (matchIndex === -1) {
118 break;123 break;
119 }124 }
120 125
121 // Create text nodes for before, match, and after126 // Create text nodes for before, match, and after
122 const beforeText = nodeValue.slice(0, matchIndex);127 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+ );
124 const afterText = nodeValue.slice(matchIndex + trimmedQuery.length);132 const afterText = nodeValue.slice(matchIndex + trimmedQuery.length);
125 133
126 // Create elements134 // Create elements
127 const beforeNode = document.createTextNode(beforeText);135 const beforeNode = document.createTextNode(beforeText);
128 const matchTextNode = document.createTextNode(matchText);136 const matchTextNode = document.createTextNode(matchText);
129- const highlightSpan = document.createElement('span');137+ const highlightSpan = document.createElement("span");
130 138
131 // Style the highlight span139 // Style the highlight span
132- highlightSpan.className = 'find-highlight';140+ highlightSpan.className = "find-highlight";
133 highlightSpan.appendChild(matchTextNode);141 highlightSpan.appendChild(matchTextNode);
134 142
135 // Insert into DOM143 // Insert into DOM
@@ -145,7 +153,7 @@ export function findInPage(query: string): void {
145 // Track highlight node for cleanup153 // Track highlight node for cleanup
146 const highlightNode: HighlightNode = {154 const highlightNode: HighlightNode = {
147 span: highlightSpan,155 span: highlightSpan,
148- text: matchTextNode156+ text: matchTextNode,
149 };157 };
150 omniboxStore.addHighlightNode(highlightNode);158 omniboxStore.addHighlightNode(highlightNode);
151 159
@@ -160,13 +168,13 @@ export function findInPage(query: string): void {
160 }168 }
161 169
162 const context = (leftContext + matchText + rightContext)170 const context = (leftContext + matchText + rightContext)
163- .replace(/\s+/g, ' ')171+ .replace(/\s+/g, " ")
164 .trim();172 .trim();
165 173
166 // Add to matches174 // Add to matches
167 const findMatch: FindMatch = {175 const findMatch: FindMatch = {
168 element: highlightSpan,176 element: highlightSpan,
169- context177+ context,
170 };178 };
171 matches.push(findMatch);179 matches.push(findMatch);
172 180
@@ -199,21 +207,21 @@ export function revealMatch(index: number): void {
199 if (prevActiveIndex >= 0 && matches[prevActiveIndex]) {207 if (prevActiveIndex >= 0 && matches[prevActiveIndex]) {
200 const prevElement = matches[prevActiveIndex].element;208 const prevElement = matches[prevActiveIndex].element;
201 if (prevElement.classList) {209 if (prevElement.classList) {
202- prevElement.classList.remove('find-active');210+ prevElement.classList.remove("find-active");
203 }211 }
204 }212 }
205 213
206 // Add active class to current match214 // Add active class to current match
207 const currentMatch = matches[index];215 const currentMatch = matches[index];
208 if (currentMatch && currentMatch.element.classList) {216 if (currentMatch && currentMatch.element.classList) {
209- currentMatch.element.classList.add('find-active');217+ currentMatch.element.classList.add("find-active");
210 omniboxStore.setActiveIndex(index);218 omniboxStore.setActiveIndex(index);
211 219
212 // Scroll into view220 // Scroll into view
213 try {221 try {
214 currentMatch.element.scrollIntoView({222 currentMatch.element.scrollIntoView({
215- block: 'center',223+ block: "center",
216- inline: 'nearest'224+ inline: "nearest",
217 });225 });
218 } catch {226 } catch {
219 currentMatch.element.scrollIntoView();227 currentMatch.element.scrollIntoView();
@@ -231,7 +239,9 @@ export function jumpToMatch(delta: number): void {
231 }239 }
232 240
233 const currentIndex = omniboxStore.selectedIndex || 0;241 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;
235 245
236 omniboxStore.setSelectedIndex(newIndex);246 omniboxStore.setSelectedIndex(newIndex);
237 revealMatch(newIndex);247 revealMatch(newIndex);
M gui/src/components/omnibox/index.ts +8 -8
@@ -4,13 +4,13 @@
4 * Main entry point for omnibox functionality4 * Main entry point for omnibox functionality
5 */5 */
6 6
7-export { default as Omnibox } from './Omnibox.svelte';7+export { default as Omnibox } from "./Omnibox.svelte";
8-export { default as OmniboxInput } from './OmniboxInput.svelte';8+export { default as OmniboxInput } from "./OmniboxInput.svelte";
9-export { default as OmniboxSuggestions } from './OmniboxSuggestions.svelte';9+export { default as OmniboxSuggestions } from "./OmniboxSuggestions.svelte";
10-export { default as OmniboxFind } from './OmniboxFind.svelte';10+export { default as OmniboxFind } from "./OmniboxFind.svelte";
11 11
12-export { omniboxStore } from './stores.svelte.ts';12+export { omniboxStore } from "./stores.svelte.ts";
13-export { omniboxBridge } from './messaging';13+export { omniboxBridge } from "./messaging";
14-export { findInPage, revealMatch, jumpToMatch } from './find';14+export { findInPage, revealMatch, jumpToMatch } from "./find";
15 15
16-export type * from './types';⏎̸16+export type * from "./types";
M gui/src/components/omnibox/messaging.ts +20 -16
@@ -4,33 +4,36 @@
4 * TypeScript interface for Go-JavaScript communication4 * TypeScript interface for Go-JavaScript communication
5 */5 */
6 6
7-import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from './types';7+import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from "./types";
8-import { omniboxStore } from './stores.svelte.ts';8+import { omniboxStore } from "./stores.svelte.ts";
9 9
10 export class OmniboxBridge implements OmniboxMessageBridge {10 export class OmniboxBridge implements OmniboxMessageBridge {
11 // Forward messages to the native WebKit message handler when available11 // Forward messages to the native WebKit message handler when available
12 // Fallback: for navigate, perform direct location change12 // Fallback: for navigate, perform direct location change
13 postMessage(msg: OmniboxMessage): void {13 postMessage(msg: OmniboxMessage): void {
14- console.log('📤 [DEBUG] Posting message to backend:', msg);14+ console.log("📤 [DEBUG] Posting message to backend:", msg);
15 // NOTE: Do NOT detach postMessage from its receiver. In WKWebView,15 // NOTE: Do NOT detach postMessage from its receiver. In WKWebView,
16 // postMessage must be called on the UserMessageHandler instance.16 // postMessage must be called on the UserMessageHandler instance.
17 const bridge = window.webkit?.messageHandlers?.dumber;17 const bridge = window.webkit?.messageHandlers?.dumber;
18- if (bridge && typeof bridge.postMessage === 'function') {18+ if (bridge && typeof bridge.postMessage === "function") {
19 try {19 try {
20- console.log('📱 [DEBUG] Using webkit message handler');20+ console.log("📱 [DEBUG] Using webkit message handler");
21 bridge.postMessage(JSON.stringify(msg));21 bridge.postMessage(JSON.stringify(msg));
22 return;22 return;
23 } catch (e) {23 } 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+ );
25 }28 }
26 }29 }
27- console.log('⚠️ [DEBUG] No webkit bridge available');30+ console.log("⚠️ [DEBUG] No webkit bridge available");
28 // Fallback navigation if no native bridge is available31 // 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) {
30 try {33 try {
31 window.location.href = msg.url;34 window.location.href = msg.url;
32 } catch (e) {35 } catch (e) {
33- console.error('Fallback navigation failed:', e);36+ console.error("Fallback navigation failed:", e);
34 }37 }
35 }38 }
36 }39 }
@@ -39,7 +42,7 @@ export class OmniboxBridge implements OmniboxMessageBridge {
39 * Update suggestions from Go backend42 * Update suggestions from Go backend
40 */43 */
41 setSuggestions(suggestions: Suggestion[]): void {44 setSuggestions(suggestions: Suggestion[]): void {
42- console.log('📝 [DEBUG] Received suggestions from backend:', suggestions);45+ console.log("📝 [DEBUG] Received suggestions from backend:", suggestions);
43 omniboxStore.updateSuggestions(suggestions);46 omniboxStore.updateSuggestions(suggestions);
44 }47 }
45 48
@@ -47,19 +50,19 @@ export class OmniboxBridge implements OmniboxMessageBridge {
47 * Handle navigation request50 * Handle navigation request
48 */51 */
49 navigate(url: string): void {52 navigate(url: string): void {
50- console.log('🚀 Omnibox navigate called with:', url);53+ console.log("🚀 Omnibox navigate called with:", url);
51- this.postMessage({ type: 'navigate', url });54+ this.postMessage({ type: "navigate", url });
52 }55 }
53 56
54 /**57 /**
55 * Handle search query58 * Handle search query
56 */59 */
57 query(searchTerm: string, limit?: number): void {60 query(searchTerm: string, limit?: number): void {
58- const q = searchTerm ?? '';61+ const q = searchTerm ?? "";
59 const lim = limit || omniboxStore.config.defaultLimit;62 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 });
61 // Send to native handler; Go will compute suggestions and call setSuggestions64 // 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 });
63 }66 }
64 67
65 // Suggestions are returned via setSuggestions() when native handler responds68 // Suggestions are returned via setSuggestions() when native handler responds
@@ -98,6 +101,7 @@ declare global {
98 open: (mode?: string, query?: string) => void;101 open: (mode?: string, query?: string) => void;
99 close: () => void;102 close: () => void;
100 findQuery: (query: string) => void;103 findQuery: (query: string) => void;
104+ setActive: (active: boolean) => void;
101 };105 };
102 }106 }
103-}⏎̸107+}
M gui/src/components/omnibox/stores.svelte.ts +67 -39
@@ -9,27 +9,27 @@ import type {
9 Suggestion,9 Suggestion,
10 FindMatch,10 FindMatch,
11 HighlightNode,11 HighlightNode,
12- OmniboxConfig12+ OmniboxConfig,
13-} from './types';13+} from "./types";
14 14
15 // Default configuration15 // Default configuration
16 const DEFAULT_CONFIG: Required<OmniboxConfig> = {16 const DEFAULT_CONFIG: Required<OmniboxConfig> = {
17 maxMatches: 2000,17 maxMatches: 2000,
18 debounceDelay: 120,18 debounceDelay: 120,
19- defaultLimit: 1019+ defaultLimit: 10,
20 };20 };
21 21
22 // Global state using Svelte 5 runes22 // Global state using Svelte 5 runes
23 let visible = $state(false);23 let visible = $state(false);
24-let mode = $state<OmniboxMode>('omnibox');24+let mode = $state<OmniboxMode>("omnibox");
25 let suggestions = $state<Suggestion[]>([]);25 let suggestions = $state<Suggestion[]>([]);
26 let matches = $state<FindMatch[]>([]);26 let matches = $state<FindMatch[]>([]);
27 let selectedIndex = $state(-1);27 let selectedIndex = $state(-1);
28 let activeIndex = $state(-1);28 let activeIndex = $state(-1);
29 let faded = $state(false);29 let faded = $state(false);
30-let inputValue = $state('');30+let inputValue = $state("");
31 let highlightNodes = $state<HighlightNode[]>([]);31 let highlightNodes = $state<HighlightNode[]>([]);
32-let prevOverflow = $state('');32+let prevOverflow = $state("");
33 let debounceTimer = $state(0);33 let debounceTimer = $state(0);
34 34
35 // Configuration35 // Configuration
@@ -37,29 +37,55 @@ let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
37 37
38 export const omniboxStore = {38 export const omniboxStore = {
39 // State getters39 // State getters
40- get visible() { return visible; },40+ get visible() {
41- get mode() { return mode; },41+ return visible;
42- get suggestions() { return suggestions; },42+ },
43- get matches() { return matches; },43+ get mode() {
44- get selectedIndex() { return selectedIndex; },44+ return mode;
45- get activeIndex() { return activeIndex; },45+ },
46- get faded() { return faded; },46+ get suggestions() {
47- get inputValue() { return inputValue; },47+ return suggestions;
48- get highlightNodes() { return highlightNodes; },48+ },
49- get prevOverflow() { return prevOverflow; },49+ get matches() {
50- get config() { return config; },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+ },
51 73
52 // Computed getters74 // Computed getters
53 get hasContent() {75 get hasContent() {
54- return (mode === 'omnibox' && suggestions.length > 0) ||76+ return (
55- (mode === 'find' && matches.length > 0);77+ (mode === "omnibox" && suggestions.length > 0) ||
78+ (mode === "find" && matches.length > 0)
79+ );
56 },80 },
57 get totalItems() {81 get totalItems() {
58- return mode === 'omnibox' ? suggestions.length : matches.length;82+ return mode === "omnibox" ? suggestions.length : matches.length;
59 },83 },
60 get selectedItem() {84 get selectedItem() {
61 if (selectedIndex < 0) return null;85 if (selectedIndex < 0) return null;
62- return mode === 'omnibox' ? suggestions[selectedIndex] : matches[selectedIndex];86+ return mode === "omnibox"
87+ ? suggestions[selectedIndex]
88+ : matches[selectedIndex];
63 },89 },
64 90
65 // Actions91 // Actions
@@ -67,14 +93,14 @@ export const omniboxStore = {
67 if (value && !visible) {93 if (value && !visible) {
68 // Store scroll state when opening94 // Store scroll state when opening
69 prevOverflow = document.documentElement.style.overflow;95 prevOverflow = document.documentElement.style.overflow;
70- document.documentElement.style.overflow = 'hidden';96+ document.documentElement.style.overflow = "hidden";
71 } else if (!value && visible) {97 } else if (!value && visible) {
72 // Restore scroll state when closing98 // Restore scroll state when closing
73- document.documentElement.style.overflow = prevOverflow || '';99+ document.documentElement.style.overflow = prevOverflow || "";
74- prevOverflow = '';100+ prevOverflow = "";
75 101
76 // Clear find highlights when closing102 // Clear find highlights when closing
77- if (mode === 'find') {103+ if (mode === "find") {
78 this.clearHighlights();104 this.clearHighlights();
79 }105 }
80 }106 }
@@ -83,10 +109,10 @@ export const omniboxStore = {
83 109
84 setMode(newMode: OmniboxMode) {110 setMode(newMode: OmniboxMode) {
85 // Clear previous mode's state111 // Clear previous mode's state
86- if (newMode === 'omnibox') {112+ if (newMode === "omnibox") {
87 // Switching to omnibox mode - clear find state113 // Switching to omnibox mode - clear find state
88 this.clearHighlights();114 this.clearHighlights();
89- } else if (newMode === 'find') {115+ } else if (newMode === "find") {
90 // Switching to find mode - clear suggestions116 // Switching to find mode - clear suggestions
91 this.updateSuggestions([]);117 this.updateSuggestions([]);
92 }118 }
@@ -94,7 +120,7 @@ export const omniboxStore = {
94 mode = newMode;120 mode = newMode;
95 selectedIndex = -1;121 selectedIndex = -1;
96 faded = false;122 faded = false;
97- inputValue = ''; // Clear input for fresh start123+ inputValue = ""; // Clear input for fresh start
98 },124 },
99 125
100 setInputValue(value: string) {126 setInputValue(value: string) {
@@ -116,22 +142,24 @@ export const omniboxStore = {
116 142
117 selectNext() {143 selectNext() {
118 if (this.totalItems === 0) return;144 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;
120 this.setSelectedIndex(newIndex);147 this.setSelectedIndex(newIndex);
121 faded = true;148 faded = true;
122 },149 },
123 150
124 selectPrevious() {151 selectPrevious() {
125 if (this.totalItems === 0) return;152 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;
127 this.setSelectedIndex(newIndex);155 this.setSelectedIndex(newIndex);
128 faded = true;156 faded = true;
129 },157 },
130 158
131- open(newMode: OmniboxMode = 'omnibox', initialQuery?: string) {159+ open(newMode: OmniboxMode = "omnibox", initialQuery?: string) {
132 this.setMode(newMode);160 this.setMode(newMode);
133 this.setVisible(true);161 this.setVisible(true);
134- if (typeof initialQuery === 'string') {162+ if (typeof initialQuery === "string") {
135 inputValue = initialQuery;163 inputValue = initialQuery;
136 }164 }
137 },165 },
@@ -144,7 +172,7 @@ export const omniboxStore = {
144 toggle() {172 toggle() {
145 if (!visible) {173 if (!visible) {
146 // When opening via toggle (Ctrl+L), always open in omnibox mode174 // When opening via toggle (Ctrl+L), always open in omnibox mode
147- this.setMode('omnibox');175+ this.setMode("omnibox");
148 this.setVisible(true);176 this.setVisible(true);
149 } else {177 } else {
150 this.setVisible(false);178 this.setVisible(false);
@@ -171,7 +199,7 @@ export const omniboxStore = {
171 }199 }
172 });200 });
173 } catch (error) {201 } catch (error) {
174- console.warn('Error clearing highlights:', error);202+ console.warn("Error clearing highlights:", error);
175 }203 }
176 highlightNodes = [];204 highlightNodes = [];
177 matches = [];205 matches = [];
@@ -207,15 +235,15 @@ export const omniboxStore = {
207 235
208 reset() {236 reset() {
209 visible = false;237 visible = false;
210- mode = 'omnibox';238+ mode = "omnibox";
211 suggestions = [];239 suggestions = [];
212 matches = [];240 matches = [];
213 selectedIndex = -1;241 selectedIndex = -1;
214 activeIndex = -1;242 activeIndex = -1;
215 faded = false;243 faded = false;
216- inputValue = '';244+ inputValue = "";
217- prevOverflow = '';245+ prevOverflow = "";
218 this.clearHighlights();246 this.clearHighlights();
219 this.clearDebounceTimer();247 this.clearDebounceTimer();
220- }248+ },
221-};⏎̸249+};
M gui/src/components/omnibox/types.ts +3 -3
@@ -10,7 +10,7 @@ export interface Suggestion {
10 }10 }
11 11
12 export interface OmniboxMessage {12 export interface OmniboxMessage {
13- type: 'navigate' | 'query';13+ type: "navigate" | "query";
14 url?: string;14 url?: string;
15 q?: string;15 q?: string;
16 limit?: number;16 limit?: number;
@@ -26,7 +26,7 @@ export interface HighlightNode {
26 text: Text;26 text: Text;
27 }27 }
28 28
29-export type OmniboxMode = 'omnibox' | 'find';29+export type OmniboxMode = "omnibox" | "find";
30 30
31 export interface OmniboxConfig {31 export interface OmniboxConfig {
32 maxMatches?: number;32 maxMatches?: number;
@@ -60,4 +60,4 @@ export interface OmniboxAPI {
60 open: (mode?: OmniboxMode, query?: string) => void;60 open: (mode?: OmniboxMode, query?: string) => void;
61 close: () => void;61 close: () => void;
62 findQuery: (query: string) => void;62 findQuery: (query: string) => void;
63-}⏎̸63+}
M gui/src/injected/gui.ts +309 -246
@@ -6,10 +6,16 @@
6 */6 */
7 7
8 // Import all GUI modules8 // Import all GUI modules
9-import { initializeToast, type ToastConfig } from './modules/toast';9+import { initializeToast, type ToastConfig } from "./modules/toast";
10-import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox';10+import { initializeOmnibox, type OmniboxInitConfig } from "./modules/omnibox";
11-import { initializeWorkspace, type WorkspaceConfigPayload, type WorkspaceRuntime } from './modules/workspace';11+import {
12-import { keyboardService, type KeyboardService } from '$lib/keyboard';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";
13 // Note: color-scheme module is loaded separately at document-start by WebKit19 // Note: color-scheme module is loaded separately at document-start by WebKit
14 20
15 // Global interface for the unified GUI system21 // Global interface for the unified GUI system
@@ -24,9 +30,9 @@ interface DumberGUI {
24 30
25 // DOM readiness utility - handles all document ready states31 // DOM readiness utility - handles all document ready states
26 function whenDOMReady(callback: () => void) {32 function whenDOMReady(callback: () => void) {
27- if (document.readyState === 'loading') {33+ if (document.readyState === "loading") {
28- document.addEventListener('DOMContentLoaded', callback, { once: true });34+ document.addEventListener("DOMContentLoaded", callback, { once: true });
29- } else if (document.readyState === 'interactive' && !document.body) {35+ } else if (document.readyState === "interactive" && !document.body) {
30 // Interactive but no body yet, wait a bit more36 // Interactive but no body yet, wait a bit more
31 setTimeout(() => whenDOMReady(callback), 10);37 setTimeout(() => whenDOMReady(callback), 10);
32 } else {38 } else {
@@ -43,11 +49,25 @@ declare global {
43 interface Window {49 interface Window {
44 __dumber_gui?: DumberGUI;50 __dumber_gui?: DumberGUI;
45 __dumber_gui_ready?: boolean;51 __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;
47 __dumber_showZoomToast?: (level: number) => void;57 __dumber_showZoomToast?: (level: number) => void;
48 __dumber_keyboard?: KeyboardService;58 __dumber_keyboard?: KeyboardService;
49 __dumber_workspace_config?: WorkspaceConfigPayload;59 __dumber_workspace_config?: WorkspaceConfigPayload;
50 __dumber_workspace?: WorkspaceRuntime;60 __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;
51 // Legacy compatibility functions for Go bridge71 // Legacy compatibility functions for Go bridge
52 __dumber_toggle?: () => void;72 __dumber_toggle?: () => void;
53 __dumber_find_open?: (query?: string) => void;73 __dumber_find_open?: (query?: string) => void;
@@ -60,82 +80,100 @@ declare global {
60 if (!window.__dumber_gui_ready) {80 if (!window.__dumber_gui_ready) {
61 // Skip initialization in iframes - GUI only needed in main frame81 // Skip initialization in iframes - GUI only needed in main frame
62 if (window.self !== window.top) {82 if (window.self !== window.top) {
63- console.log('🚫 Dumber GUI skipped in iframe');83+ console.log("🚫 Dumber GUI skipped in iframe");
64 } else {84 } else {
65 window.__dumber_gui_ready = true;85 window.__dumber_gui_ready = true;
66 86
67- let workspaceHasFocus = true; // Assume focus initially, will be corrected by focus events87+ let workspaceHasFocus = true; // Assume focus initially, will be corrected by focus events
68- let currentPaneId = window.__dumber_pane?.id || 'unknown';88+ let currentPaneId = window.__dumber_pane?.id || "unknown";
69- let hasReceivedFocusEvent = false; // Track if we've received any workspace focus events89+ let hasReceivedFocusEvent = false; // Track if we've received any workspace focus events
70-90+
71- document.addEventListener('dumber:workspace-focus', (event: Event) => {91+ document.addEventListener("dumber:workspace-focus", (event: Event) => {
72- const detail = (event as CustomEvent).detail || {};92+ const detail = (event as CustomEvent).detail || {};
73- console.log('[workspace focus event]', detail, 'prev focus=', workspaceHasFocus);93+ console.log(
74- 94+ "[workspace focus event]",
75- hasReceivedFocusEvent = true;95+ detail,
76-96+ "prev focus=",
77- // Update focus state97+ workspaceHasFocus,
78- if (typeof detail?.active === 'boolean') {98+ );
79- workspaceHasFocus = detail.active;99+
80- } else {100+ hasReceivedFocusEvent = true;
81- workspaceHasFocus = detail !== false;101+
82- }102+ // Update focus state
83-103+ if (typeof detail?.active === "boolean") {
84- // Update pane tracking104+ workspaceHasFocus = detail.active;
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);
100 } else {105 } else {
101- window.__dumber_omnibox.setActive?.(false);106+ workspaceHasFocus = detail !== false;
102- window.__dumber_omnibox.close?.();
103 }107 }
104- }
105- });
106 108
107- // Initialize Svelte GUI systems immediately109+ // Update pane tracking
108- whenDOMReady(async () => {110+ if (detail?.paneId) {
109- try {111+ currentPaneId = detail.paneId;
110- // Page-world bridge is injected by WebKit; avoid duplicate injection here112+ }
111 113
112- // Initialize toast system first114+ // Update global pane state
113- await initializeToast();115+ if (window.__dumber_pane) {
116+ window.__dumber_pane.active = workspaceHasFocus;
117+ }
114 118
115- // Verify toast functions are available119+ console.log(
116- if (typeof window.__dumber_showToast !== 'function') {120+ "[workspace focus updated] focus=",
117- throw new Error('Toast functions not exposed after initialization');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+ }
118 }134 }
135+ });
119 136
120- // Initialize omnibox system137+ // Initialize Svelte GUI systems immediately
121- await initializeOmnibox();138+ whenDOMReady(async () => {
139+ try {
140+ // Page-world bridge is injected by WebKit; avoid duplicate injection here
122 141
123- // Initialize workspace controls (pane/tab scaffolding)142+ // Initialize window.open interceptor first (must be early)
124- initializeWorkspace(window.__dumber_workspace_config);143+ initializeWindowOpenInterceptor();
125 144
126- console.log('✅ All GUI systems initialized successfully');145+ // Initialize toast system first
127- } catch (e) {146+ await initializeToast();
128- console.error('❌ Failed to initialize Svelte toast system:', e);
129 147
130- // Enhanced fallback with better error recovery148+ // Verify toast functions are available
131- window.__dumber_showToast = (message: string, duration: number = 2500) => {149+ if (typeof window.__dumber_showToast !== "function") {
132- // Remove existing toasts to prevent overlap150+ throw new Error("Toast functions not exposed after initialization");
133- document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());151+ }
134 152
135- const toast = document.createElement('div');153+ // Initialize omnibox system
136- toast.className = 'dumber-fallback-toast';154+ await initializeOmnibox();
137- toast.textContent = message;155+
138- toast.style.cssText = `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 = `
139 position: fixed;177 position: fixed;
140 bottom: 20px;178 bottom: 20px;
141 right: 20px;179 right: 20px;
@@ -153,201 +191,226 @@ if (!window.__dumber_gui_ready) {
153 word-wrap: break-word;191 word-wrap: break-word;
154 `;192 `;
155 193
156- if (document.body) {194+ if (document.body) {
157- document.body.appendChild(toast);195+ document.body.appendChild(toast);
158-196+
159- // Animate in197+ // Animate in
160- requestAnimationFrame(() => {198+ requestAnimationFrame(() => {
161- toast.style.opacity = '1';199+ toast.style.opacity = "1";
162- toast.style.transform = 'translateX(0)';200+ toast.style.transform = "translateX(0)";
163- });201+ });
164-202+
165- // Animate out and remove203+ // Animate out and remove
166- setTimeout(() => {204+ setTimeout(() => {
167- toast.style.opacity = '0';205+ toast.style.opacity = "0";
168- toast.style.transform = 'translateX(100%)';206+ toast.style.transform = "translateX(100%)";
169- setTimeout(() => toast.remove(), 300);207+ setTimeout(() => toast.remove(), 300);
170- }, duration);208+ }, duration);
171-209+ }
172- }210+ };
173- };211+
174-212+ window.__dumber_showZoomToast = (level: number) => {
175- window.__dumber_showZoomToast = (level: number) => {213+ const percentage = Math.round(level * 100);
176- const percentage = Math.round(level * 100);214+ window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500);
177- window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500);215+ };
178- };216+
179-217+ // Expose other required functions
180- // Expose other required functions218+ window.__dumber_dismissToast = () => {
181- window.__dumber_dismissToast = () => {219+ document
182- document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());220+ .querySelectorAll(".dumber-fallback-toast")
183- };221+ .forEach((el) => el.remove());
184-222+ };
185- window.__dumber_clearToasts = () => {223+
186- document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());224+ window.__dumber_clearToasts = () => {
187- };225+ document
188- }226+ .querySelectorAll(".dumber-fallback-toast")
189- });227+ .forEach((el) => el.remove());
190-228+ };
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);
213 }229 }
214 });230 });
215 231
216- document.addEventListener('dumber:ui:shortcut', (e: Event) => {232+ // Expose keyboard service globally for Go bridge
217- const detail = (e as CustomEvent).detail || {};233+ window.__dumber_keyboard = keyboardService;
218- const action = detail?.action;234+
219- const eventPaneId = detail?.paneId;235+ // Initialize global keyboard and mouse event listeners for the keyboard service
220- const source = detail?.source || 'unknown';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+ });
221 264
222- if (typeof action !== 'string') {265+ document.addEventListener("dumber:ui:shortcut", (e: Event) => {
223- return;266+ const detail = (e as CustomEvent).detail || {};
224- }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+ }
225 274
226- // Enhanced focus checking with pane ID validation275+ // Enhanced focus checking with pane ID validation
227- const isForThisPane = !eventPaneId || eventPaneId === currentPaneId;276+ const isForThisPane = !eventPaneId || eventPaneId === currentPaneId;
228- // Only apply strict focus checking to omnibox actions, be permissive for others277+ // Only apply strict focus checking to omnibox actions, be permissive for others
229- const isOmniboxAction = action.startsWith('omnibox-');278+ const isOmniboxAction = action.startsWith("omnibox-");
230- const shouldHandle = isForThisPane && (isOmniboxAction ? (hasReceivedFocusEvent ? workspaceHasFocus : true) : true);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+ }
231 300
232- if (!shouldHandle) {301+ console.log("[dumber shortcuts] handling action", {
233- console.log('[dumber shortcuts] ignored action', {
234 action,302 action,
235- workspaceHasFocus,
236- isForThisPane,
237- eventPaneId,
238- currentPaneId,
239 source,303 source,
240- detail,304+ paneId: currentPaneId,
241- hasReceivedFocusEvent
242 });305 });
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);
244 }344 }
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+ }
245 353
246- console.log('[dumber shortcuts] handling action', { action, source, paneId: currentPaneId });354+ throw new Error("Omnibox find requested but API is unavailable");
247-355+ } catch (error) {
248- const omnibox = window.__dumber_omnibox;356+ console.error("❌ Error in __dumber_find_open:", error);
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);
265 }357 }
266- });358+ };
267 359
268- // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed360+ window.__dumber_find_close = () => {
269- });361+ if (window.__dumber_omnibox?.close) {
270-362+ window.__dumber_omnibox.close();
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();
279 return;363 return;
280 }364 }
281 365
282- throw new Error('Omnibox toggle requested but API is unavailable');366+ const error = new Error("Omnibox close requested but API is unavailable");
283- } catch (error) {367+ console.error("❌ Error in __dumber_find_close:", error);
284- console.error('❌ Error in __dumber_toggle:', error);368+ };
285- }
286- };
287 369
288- window.__dumber_find_open = (query?: string) => {370+ // Find query function that Go uses to set find query
289- try {371+ window.__dumber_find_query = (query: string) => {
290- if (window.__dumber_omnibox?.open) {372+ if (window.__dumber_omnibox?.findQuery) {
291- window.__dumber_omnibox.open('find', query);373+ window.__dumber_omnibox.findQuery(query);
292- return;
293 }374 }
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,
294 388
295- throw new Error('Omnibox find requested but API is unavailable');389+ isReady: true,
296- } catch (error) {390+ };
297- console.error('❌ Error in __dumber_find_open:', error);391+
298- }392+ // Bootstrap function for lazy GUI initialization by workspace manager
299- };393+ window.__dumber_gui_bootstrap = function () {
300-394+ console.log("[gui-bootstrap] Initializing GUI for workspace");
301- window.__dumber_find_close = () => {395+
302- if (window.__dumber_omnibox?.close) {396+ // Pane ID will be updated via focus events
303- window.__dumber_omnibox.close();397+
304- return;398+ // Ensure omnibox is available for this pane
305- }399+ if (!window.__dumber_omnibox) {
306-400+ console.log(
307- const error = new Error('Omnibox close requested but API is unavailable');401+ `[gui-bootstrap] Omnibox not yet available for pane ${currentPaneId}, will be loaded when needed`,
308- console.error('❌ Error in __dumber_find_close:', error);402+ );
309- };403+ } else {
310-404+ console.log(
311- // Find query function that Go uses to set find query405+ `[gui-bootstrap] Omnibox already available for pane ${currentPaneId}`,
312- window.__dumber_find_query = (query: string) => {406+ );
313- if (window.__dumber_omnibox?.findQuery) {407+ // Ensure omnibox is aware of the current pane
314- window.__dumber_omnibox.findQuery(query);408+ window.__dumber_omnibox.setActive(workspaceHasFocus);
315- }409+ }
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- };
351 410
411+ console.log(
412+ `[gui-bootstrap] GUI bootstrap complete for pane ${currentPaneId}`,
413+ );
414+ };
352 }415 }
353 }416 }
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 @@
9 declare global {9 declare global {
10 interface Window {10 interface Window {
11 __dumber_gtk_prefers_dark?: boolean;11 __dumber_gtk_prefers_dark?: boolean;
12- __dumber_setTheme?: (theme: 'light' | 'dark') => void;12+ __dumber_setTheme?: (theme: "light" | "dark") => void;
13 }13 }
14 }14 }
15 15
16 interface ColorSchemeConfig {16 interface ColorSchemeConfig {
17 isDark: boolean;17 isDark: boolean;
18- colorScheme: 'light' | 'dark';18+ colorScheme: "light" | "dark";
19 metaContent: string;19 metaContent: string;
20 rootStyle: string;20 rootStyle: string;
21 }21 }
@@ -29,9 +29,11 @@ class ColorSchemeManager {
29 29
30 this.config = {30 this.config = {
31 isDark: prefersDark,31 isDark: prefersDark,
32- colorScheme: prefersDark ? 'dark' : 'light',32+ colorScheme: prefersDark ? "dark" : "light",
33- metaContent: prefersDark ? 'dark light' : 'light dark',33+ metaContent: prefersDark ? "dark light" : "light dark",
34- rootStyle: prefersDark ? ':root{color-scheme:dark;}' : ':root{color-scheme:light;}'34+ rootStyle: prefersDark
35+ ? ":root{color-scheme:dark;}"
36+ : ":root{color-scheme:light;}",
35 };37 };
36 38
37 this.initialize();39 this.initialize();
@@ -40,7 +42,9 @@ class ColorSchemeManager {
40 private initialize(): void {42 private initialize(): void {
41 try {43 try {
42 console.log(`[dumber] color-scheme set: ${this.config.colorScheme}`);44 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+ );
44 48
45 // Notify native layer of theme preference49 // Notify native layer of theme preference
46 this.notifyNativeLayer();50 this.notifyNativeLayer();
@@ -56,9 +60,8 @@ class ColorSchemeManager {
56 60
57 // Set up theme integration with existing UI components61 // Set up theme integration with existing UI components
58 this.setupThemeIntegration();62 this.setupThemeIntegration();
59-
60 } catch (error) {63 } catch (error) {
61- console.warn('[dumber] color-scheme injection failed', error);64+ console.warn("[dumber] color-scheme injection failed", error);
62 }65 }
63 }66 }
64 67
@@ -66,9 +69,9 @@ class ColorSchemeManager {
66 try {69 try {
67 (window as any).webkit?.messageHandlers?.dumber?.postMessage(70 (window as any).webkit?.messageHandlers?.dumber?.postMessage(
68 JSON.stringify({71 JSON.stringify({
69- type: 'theme',72+ type: "theme",
70- value: this.config.colorScheme73+ value: this.config.colorScheme,
71- })74+ }),
72 );75 );
73 } catch {76 } catch {
74 // Ignore errors - native layer may not be available77 // Ignore errors - native layer may not be available
@@ -76,27 +79,32 @@ class ColorSchemeManager {
76 }79 }
77 80
78 private injectColorSchemeMeta(): void {81 private injectColorSchemeMeta(): void {
79- const meta = document.createElement('meta');82+ const meta = document.createElement("meta");
80- meta.name = 'color-scheme';83+ meta.name = "color-scheme";
81 meta.content = this.config.metaContent;84 meta.content = this.config.metaContent;
82 document.documentElement.appendChild(meta);85 document.documentElement.appendChild(meta);
83 }86 }
84 87
85 private applyRootStyles(): void {88 private applyRootStyles(): void {
86- const style = document.createElement('style');89+ const style = document.createElement("style");
87 style.textContent = this.config.rootStyle;90 style.textContent = this.config.rootStyle;
88 document.documentElement.appendChild(style);91 document.documentElement.appendChild(style);
89 }92 }
90 93
91 private overrideMatchMedia(): void {94 private overrideMatchMedia(): void {
92- const darkQuery = '(prefers-color-scheme: dark)';95+ const darkQuery = "(prefers-color-scheme: dark)";
93- const lightQuery = '(prefers-color-scheme: light)';96+ const lightQuery = "(prefers-color-scheme: light)";
94 const originalMatchMedia = window.matchMedia;97 const originalMatchMedia = window.matchMedia;
95 98
96 window.matchMedia = (query: string): MediaQueryList => {99 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+ ) {
98 // Create a mock MediaQueryList that reflects our GTK preference104 // 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;
100 108
101 return {109 return {
102 matches,110 matches,
@@ -106,7 +114,7 @@ class ColorSchemeManager {
106 removeListener: () => {},114 removeListener: () => {},
107 addEventListener: () => {},115 addEventListener: () => {},
108 removeEventListener: () => {},116 removeEventListener: () => {},
109- dispatchEvent: () => false117+ dispatchEvent: () => false,
110 } as MediaQueryList;118 } as MediaQueryList;
111 }119 }
112 120
@@ -126,11 +134,11 @@ class ColorSchemeManager {
126 window.__dumber_setTheme(this.config.colorScheme);134 window.__dumber_setTheme(this.config.colorScheme);
127 } else {135 } else {
128 // Fallback: directly apply dark class for Tailwind compatibility136 // 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");
130 if (this.config.isDark) {138 if (this.config.isDark) {
131- document.documentElement.classList.add('dark');139+ document.documentElement.classList.add("dark");
132 } else {140 } else {
133- document.documentElement.classList.remove('dark');141+ document.documentElement.classList.remove("dark");
134 }142 }
135 }143 }
136 }, 100);144 }, 100);
@@ -143,9 +151,11 @@ class ColorSchemeManager {
143 */151 */
144 public updateColorScheme(isDark: boolean): void {152 public updateColorScheme(isDark: boolean): void {
145 this.config.isDark = isDark;153 this.config.isDark = isDark;
146- this.config.colorScheme = isDark ? 'dark' : 'light';154+ this.config.colorScheme = isDark ? "dark" : "light";
147- this.config.metaContent = isDark ? 'dark light' : 'light dark';155+ this.config.metaContent = isDark ? "dark light" : "light dark";
148- this.config.rootStyle = isDark ? ':root{color-scheme:dark;}' : ':root{color-scheme:light;}';156+ this.config.rootStyle = isDark
157+ ? ":root{color-scheme:dark;}"
158+ : ":root{color-scheme:light;}";
149 159
150 // Re-apply all configurations160 // Re-apply all configurations
151 this.initialize();161 this.initialize();
@@ -158,4 +168,4 @@ const colorSchemeManager = new ColorSchemeManager();
158 // Export for potential runtime updates168 // Export for potential runtime updates
159 (window as any).__dumber_color_scheme_manager = colorSchemeManager;169 (window as any).__dumber_color_scheme_manager = colorSchemeManager;
160 170
161-export default colorSchemeManager;⏎̸171+export default colorSchemeManager;
M gui/src/injected/modules/controls.ts +19 -22
@@ -18,11 +18,11 @@ let controlsInitialized = false;
18 18
19 export function initializeControls(_config?: ControlsConfig): void {19 export function initializeControls(_config?: ControlsConfig): void {
20 if (controlsInitialized) {20 if (controlsInitialized) {
21- console.log('🔄 Browser controls already initialized, skipping');21+ console.log("🔄 Browser controls already initialized, skipping");
22 return;22 return;
23 }23 }
24 24
25- console.log('🚀 Initializing browser controls on:', window.location.href);25+ console.log("🚀 Initializing browser controls on:", window.location.href);
26 26
27 // Keyboard event handler for global controls27 // Keyboard event handler for global controls
28 function handleKeyboardEvent(event: KeyboardEvent): void {28 function handleKeyboardEvent(event: KeyboardEvent): void {
@@ -31,29 +31,28 @@ export function initializeControls(_config?: ControlsConfig): void {
31 31
32 try {32 try {
33 // Navigation: Alt + Left Arrow (Back)33 // Navigation: Alt + Left Arrow (Back)
34- if (altKey && key === 'ArrowLeft') {34+ if (altKey && key === "ArrowLeft") {
35 event.preventDefault();35 event.preventDefault();
36 event.stopPropagation();36 event.stopPropagation();
37 37
38 if (window.history.length > 1) {38 if (window.history.length > 1) {
39- console.log('⬅️ Navigating back');39+ console.log("⬅️ Navigating back");
40 window.history.back();40 window.history.back();
41 }41 }
42 return;42 return;
43 }43 }
44 44
45 // Navigation: Alt + Right Arrow (Forward)45 // Navigation: Alt + Right Arrow (Forward)
46- if (altKey && key === 'ArrowRight') {46+ if (altKey && key === "ArrowRight") {
47 event.preventDefault();47 event.preventDefault();
48 event.stopPropagation();48 event.stopPropagation();
49 49
50- console.log('➡️ Navigating forward');50+ console.log("➡️ Navigating forward");
51 window.history.forward();51 window.history.forward();
52 return;52 return;
53 }53 }
54-
55 } catch (error) {54 } catch (error) {
56- console.error('❌ Error in keyboard handler:', error);55+ console.error("❌ Error in keyboard handler:", error);
57 }56 }
58 }57 }
59 58
@@ -66,7 +65,7 @@ export function initializeControls(_config?: ControlsConfig): void {
66 event.stopPropagation();65 event.stopPropagation();
67 66
68 if (window.history.length > 1) {67 if (window.history.length > 1) {
69- console.log('⬅️ Mouse back button pressed');68+ console.log("⬅️ Mouse back button pressed");
70 window.history.back();69 window.history.back();
71 }70 }
72 return;71 return;
@@ -77,33 +76,31 @@ export function initializeControls(_config?: ControlsConfig): void {
77 event.preventDefault();76 event.preventDefault();
78 event.stopPropagation();77 event.stopPropagation();
79 78
80- console.log('➡️ Mouse forward button pressed');79+ console.log("➡️ Mouse forward button pressed");
81 window.history.forward();80 window.history.forward();
82 return;81 return;
83 }82 }
84-
85 } catch (error) {83 } catch (error) {
86- console.error('❌ Error in mouse handler:', error);84+ console.error("❌ Error in mouse handler:", error);
87 }85 }
88 }86 }
89 87
90 // Add event listeners with high priority (capture phase)88 // Add event listeners with high priority (capture phase)
91 try {89 try {
92- document.addEventListener('keydown', handleKeyboardEvent, true);90+ document.addEventListener("keydown", handleKeyboardEvent, true);
93- document.addEventListener('mousedown', handleMouseEvent, true);91+ document.addEventListener("mousedown", handleMouseEvent, true);
94 92
95 controlsInitialized = true;93 controlsInitialized = true;
96- console.log('✅ Browser controls initialized');94+ console.log("✅ Browser controls initialized");
97 95
98 // Cleanup function for when page is unloaded96 // Cleanup function for when page is unloaded
99- window.addEventListener('beforeunload', () => {97+ window.addEventListener("beforeunload", () => {
100- document.removeEventListener('keydown', handleKeyboardEvent, true);98+ document.removeEventListener("keydown", handleKeyboardEvent, true);
101- document.removeEventListener('mousedown', handleMouseEvent, true);99+ document.removeEventListener("mousedown", handleMouseEvent, true);
102 controlsInitialized = false;100 controlsInitialized = false;
103- console.log('🧹 Browser controls cleaned up');101+ console.log("🧹 Browser controls cleaned up");
104 });102 });
105-
106 } catch (error) {103 } catch (error) {
107- console.error('❌ Failed to initialize browser controls:', error);104+ console.error("❌ Failed to initialize browser controls:", error);
108 }105 }
109-}⏎̸106+}
M gui/src/injected/modules/omnibox.ts +46 -27
@@ -4,10 +4,10 @@
4 * Initializes the Svelte 5 omnibox component system4 * Initializes the Svelte 5 omnibox component system
5 */5 */
6 6
7-import { mount } from 'svelte';7+import { mount } from "svelte";
8-import { Omnibox } from '../../components/omnibox';8+import { Omnibox } from "../../components/omnibox";
9-import { ensureShadowMount } from './shadowHost';9+import { ensureShadowMount } from "./shadowHost";
10-import type { OmniboxConfig } from '../../components/omnibox/types';10+import type { OmniboxConfig } from "../../components/omnibox/types";
11 11
12 let omniboxComponent: ReturnType<typeof mount> | null = null;12 let omniboxComponent: ReturnType<typeof mount> | null = null;
13 let isInitialized = false;13 let isInitialized = false;
@@ -20,43 +20,51 @@ export interface OmniboxInitConfig extends OmniboxConfig {
20 /**20 /**
21 * Initialize the omnibox component system21 * Initialize the omnibox component system
22 */22 */
23-export async function initializeOmnibox(_config: OmniboxInitConfig = {}): Promise<void> {23+export async function initializeOmnibox(
24+ _config: OmniboxInitConfig = {},
25+): Promise<void> {
24 if (isInitialized) {26 if (isInitialized) {
25- console.warn('Omnibox already initialized');27+ console.warn("Omnibox already initialized");
26 return;28 return;
27 }29 }
28 30
29 try {31 try {
30 // Ensure we're in the main frame (not an iframe)32 // Ensure we're in the main frame (not an iframe)
31 if (window.self !== window.top) {33 if (window.self !== window.top) {
32- console.log('🚫 Omnibox skipped in iframe');34+ console.log("🚫 Omnibox skipped in iframe");
33 return;35 return;
34 }36 }
35 37
36 // Check if DOM is ready38 // Check if DOM is ready
37 if (!document.body) {39 if (!document.body) {
38- console.warn('DOM body not ready for omnibox initialization');40+ console.warn("DOM body not ready for omnibox initialization");
39 return;41 return;
40 }42 }
41 43
42 // Mount omnibox into the shared global Shadow DOM host44 // Mount omnibox into the shared global Shadow DOM host
43- const mountEl = ensureShadowMount('dumber-omnibox');45+ const mountEl = ensureShadowMount("dumber-omnibox");
44 omniboxComponent = mount(Omnibox, {46 omniboxComponent = mount(Omnibox, {
45- target: mountEl as unknown as Element47+ target: mountEl as unknown as Element,
46 });48 });
47 49
48 // Wait for the component to mount and set up the global API50 // Wait for the component to mount and set up the global API
49 await new Promise<void>((resolve) => {51 await new Promise<void>((resolve) => {
50 const checkAPI = () => {52 const checkAPI = () => {
51- if (window.__dumber_omnibox && typeof window.__dumber_omnibox === 'object') {53+ if (
52- console.log('✅ Omnibox component system initialized');54+ window.__dumber_omnibox &&
53- console.log('🔧 Global API is available:', Object.keys(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+ );
54 isInitialized = true;62 isInitialized = true;
55 // Notify isolated bundle listeners that omnibox is ready63 // Notify isolated bundle listeners that omnibox is ready
56 try {64 try {
57- document.dispatchEvent(new CustomEvent('dumber:omnibox-ready'));65+ document.dispatchEvent(new CustomEvent("dumber:omnibox-ready"));
58 } catch (e) {66 } catch (e) {
59- console.warn('Failed to dispatch omnibox-ready event', e);67+ console.warn("Failed to dispatch omnibox-ready event", e);
60 }68 }
61 resolve();69 resolve();
62 } else {70 } else {
@@ -71,27 +79,38 @@ export async function initializeOmnibox(_config: OmniboxInitConfig = {}): Promis
71 try {79 try {
72 const detail = (e as CustomEvent).detail;80 const detail = (e as CustomEvent).detail;
73 const suggestions = detail?.suggestions ?? detail;81 const suggestions = detail?.suggestions ?? detail;
74- if (Array.isArray(suggestions) && window.__dumber_omnibox?.setSuggestions) {82+ if (
75- console.log('🔗 [Bridge] Received page-world suggestions event:', suggestions.length);83+ Array.isArray(suggestions) &&
84+ window.__dumber_omnibox?.setSuggestions
85+ ) {
86+ console.log(
87+ "🔗 [Bridge] Received page-world suggestions event:",
88+ suggestions.length,
89+ );
76 window.__dumber_omnibox.setSuggestions(suggestions);90 window.__dumber_omnibox.setSuggestions(suggestions);
77 } else {91 } 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+ );
79 }95 }
80 } catch (err) {96 } catch (err) {
81- console.warn('🔗 [Bridge] Failed handling suggestions event:', err);97+ console.warn("🔗 [Bridge] Failed handling suggestions event:", err);
82 }98 }
83 };99 };
84 100
85 document.addEventListener(101 document.addEventListener(
86- 'dumber:omnibox-suggestions',102+ "dumber:omnibox-suggestions",
87 handleSuggestionsEvent,103 handleSuggestionsEvent,
88- false104+ false,
89 );105 );
90 // Unified event name106 // Unified event name
91- document.addEventListener('dumber:omnibox:suggestions', handleSuggestionsEvent, false);107+ document.addEventListener(
92-108+ "dumber:omnibox:suggestions",
109+ handleSuggestionsEvent,
110+ false,
111+ );
93 } catch (error) {112 } catch (error) {
94- console.error('❌ Failed to initialize omnibox component:', error);113+ console.error("❌ Failed to initialize omnibox component:", error);
95 throw error;114 throw error;
96 }115 }
97 }116 }
@@ -111,7 +130,7 @@ export function cleanupOmnibox(): void {
111 try {130 try {
112 omniboxComponent.$destroy?.();131 omniboxComponent.$destroy?.();
113 } catch (error) {132 } catch (error) {
114- console.warn('Error destroying omnibox component:', error);133+ console.warn("Error destroying omnibox component:", error);
115 }134 }
116 omniboxComponent = null;135 omniboxComponent = null;
117 }136 }
@@ -119,5 +138,5 @@ export function cleanupOmnibox(): void {
119 // The shared shadow host remains persistent; just log cleanup138 // The shared shadow host remains persistent; just log cleanup
120 139
121 isInitialized = false;140 isInitialized = false;
122- console.log('🧹 Omnibox component cleaned up');141+ console.log("🧹 Omnibox component cleaned up");
123-}⏎̸142+}
M gui/src/injected/modules/shadowHost.ts +36 -21
@@ -9,7 +9,7 @@
9 // directly into the shadow root. The ?inline suffix makes Vite embed the9 // directly into the shadow root. The ?inline suffix makes Vite embed the
10 // processed CSS, ensuring all utility classes are available without relying10 // processed CSS, ensuring all utility classes are available without relying
11 // on runtime @import resolution (which fails inside the injected shadow DOM).11 // 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";
13 13
14 // Track shadow-root initialization to avoid duplicate reset injection14 // Track shadow-root initialization to avoid duplicate reset injection
15 const shadowResetApplied = new WeakSet<ShadowRoot>();15 const shadowResetApplied = new WeakSet<ShadowRoot>();
@@ -40,7 +40,7 @@ function ensureHostPresence(host: HTMLElement): void {
40 docEl.appendChild(host);40 docEl.appendChild(host);
41 }41 }
42 42
43- if (typeof MutationObserver !== 'undefined') {43+ if (typeof MutationObserver !== "undefined") {
44 if (!hostObserver) {44 if (!hostObserver) {
45 hostObserver = new MutationObserver(() => {45 hostObserver = new MutationObserver(() => {
46 if (cachedHost && !cachedHost.isConnected && document.documentElement) {46 if (cachedHost && !cachedHost.isConnected && document.documentElement) {
@@ -59,7 +59,7 @@ function ensureHostPresence(host: HTMLElement): void {
59 }59 }
60 }60 }
61 61
62- if (hostKeepaliveTimer === null && typeof window !== 'undefined') {62+ if (hostKeepaliveTimer === null && typeof window !== "undefined") {
63 hostKeepaliveTimer = window.setInterval(() => {63 hostKeepaliveTimer = window.setInterval(() => {
64 if (cachedHost && !cachedHost.isConnected && document.documentElement) {64 if (cachedHost && !cachedHost.isConnected && document.documentElement) {
65 document.documentElement.appendChild(cachedHost);65 document.documentElement.appendChild(cachedHost);
@@ -68,16 +68,18 @@ function ensureHostPresence(host: HTMLElement): void {
68 }68 }
69 }69 }
70 70
71-export const GLOBAL_SHADOW_HOST_ID = 'dumber-ui-root';71+export const GLOBAL_SHADOW_HOST_ID = "dumber-ui-root";
72 72
73 /**73 /**
74 * Ensure and return the global ShadowRoot used by injected UI.74 * Ensure and return the global ShadowRoot used by injected UI.
75 */75 */
76 export function getGlobalShadowRoot(): ShadowRoot {76 export function getGlobalShadowRoot(): ShadowRoot {
77 // Create or find the host container77 // 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;
79 if (!host) {81 if (!host) {
80- host = document.createElement('div');82+ host = document.createElement("div");
81 host.id = GLOBAL_SHADOW_HOST_ID;83 host.id = GLOBAL_SHADOW_HOST_ID;
82 }84 }
83 85
@@ -86,22 +88,30 @@ export function getGlobalShadowRoot(): ShadowRoot {
86 cachedHost = host;88 cachedHost = host;
87 ensureHostPresence(host);89 ensureHostPresence(host);
88 90
89- const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: 'open' });91+ const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: "open" });
90 92
91 // Reflect current theme from document root to the shadow host so :host(.dark) works93 // Reflect current theme from document root to the shadow host so :host(.dark) works
92 try {94 try {
93- const isDark = document.documentElement.classList.contains('dark');95+ const isDark = document.documentElement.classList.contains("dark");
94- host.classList.toggle('dark', isDark);96+ host.classList.toggle("dark", isDark);
95 // Keep it in sync if the theme toggles later97 // Keep it in sync if the theme toggles later
96 const observer = new MutationObserver(() => {98 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+ );
98 });103 });
99- observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });104+ observer.observe(document.documentElement, {
100- } catch { /* no-op */ }105+ attributes: true,
106+ attributeFilter: ["class"],
107+ });
108+ } catch {
109+ /* no-op */
110+ }
101 111
102 // Inject a minimal reset and base styles into the shadow root once112 // Inject a minimal reset and base styles into the shadow root once
103 if (!shadowResetApplied.has(shadowRoot)) {113 if (!shadowResetApplied.has(shadowRoot)) {
104- const resetStyle = document.createElement('style');114+ const resetStyle = document.createElement("style");
105 resetStyle.textContent = `115 resetStyle.textContent = `
106 :host { all: initial; }116 :host { all: initial; }
107 *, *::before, *::after { box-sizing: border-box; }117 *, *::before, *::after { box-sizing: border-box; }
@@ -111,7 +121,7 @@ export function getGlobalShadowRoot(): ShadowRoot {
111 121
112 // Ensure dynamic design tokens exist within the shadow tree. Tailwind v4122 // Ensure dynamic design tokens exist within the shadow tree. Tailwind v4
113 // utilities rely on these CSS variables. We set light defaults and a dark variant on :host.123 // 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");
115 tokensStyle.textContent = `125 tokensStyle.textContent = `
116 :host {126 :host {
117 --dynamic-bg: var(--color-browser-bg);127 --dynamic-bg: var(--color-browser-bg);
@@ -136,22 +146,27 @@ export function getGlobalShadowRoot(): ShadowRoot {
136 // root (e.g., toasts, omnibox) receive their styles146 // root (e.g., toasts, omnibox) receive their styles
137 try {147 try {
138 // Prefer constructable stylesheets when available148 // Prefer constructable stylesheets when available
139- const hasConstructable = typeof CSSStyleSheet !== 'undefined';149+ const hasConstructable = typeof CSSStyleSheet !== "undefined";
140- const supportsAdopted = 'adoptedStyleSheets' in shadowRoot;150+ const supportsAdopted = "adoptedStyleSheets" in shadowRoot;
141 if (supportsAdopted && hasConstructable) {151 if (supportsAdopted && hasConstructable) {
142 const sheet = new CSSStyleSheet();152 const sheet = new CSSStyleSheet();
143 sheet.replaceSync(globalStyles);153 sheet.replaceSync(globalStyles);
144- const rootWithSheets = shadowRoot as ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };154+ const rootWithSheets = shadowRoot as ShadowRoot & {
145- rootWithSheets.adoptedStyleSheets = [...(rootWithSheets.adoptedStyleSheets ?? []), sheet];155+ adoptedStyleSheets: CSSStyleSheet[];
156+ };
157+ rootWithSheets.adoptedStyleSheets = [
158+ ...(rootWithSheets.adoptedStyleSheets ?? []),
159+ sheet,
160+ ];
146 } else {161 } else {
147 // Fallback: append a <style> element with the global CSS162 // Fallback: append a <style> element with the global CSS
148- const styleTag = document.createElement('style');163+ const styleTag = document.createElement("style");
149 styleTag.textContent = globalStyles;164 styleTag.textContent = globalStyles;
150 shadowRoot.appendChild(styleTag);165 shadowRoot.appendChild(styleTag);
151 }166 }
152 } catch {167 } catch {
153 // Final fallback if anything above fails168 // Final fallback if anything above fails
154- const styleTag = document.createElement('style');169+ const styleTag = document.createElement("style");
155 styleTag.textContent = globalStyles;170 styleTag.textContent = globalStyles;
156 shadowRoot.appendChild(styleTag);171 shadowRoot.appendChild(styleTag);
157 }172 }
@@ -169,7 +184,7 @@ export function ensureShadowMount(mountId: string): HTMLElement {
169 const root = getGlobalShadowRoot();184 const root = getGlobalShadowRoot();
170 let mount = root.getElementById?.(mountId) as HTMLElement | null;185 let mount = root.getElementById?.(mountId) as HTMLElement | null;
171 if (!mount) {186 if (!mount) {
172- mount = document.createElement('div');187+ mount = document.createElement("div");
173 mount.id = mountId;188 mount.id = mountId;
174 mount.style.cssText = `189 mount.style.cssText = `
175 position: relative;190 position: relative;
M gui/src/injected/modules/toast.ts +46 -38
@@ -5,12 +5,12 @@
5 * with proper DOM ready checking and theme support.5 * with proper DOM ready checking and theme support.
6 */6 */
7 7
8-import { mount, flushSync } from 'svelte';8+import { mount, flushSync } from "svelte";
9-import ToastContainer from '$components/toast/ToastContainer.svelte';9+import ToastContainer from "$components/toast/ToastContainer.svelte";
10-import { ensureShadowMount } from './shadowHost';10+import { ensureShadowMount } from "./shadowHost";
11 11
12 export interface ToastConfig {12 export interface ToastConfig {
13- theme?: 'light' | 'dark';13+ theme?: "light" | "dark";
14 }14 }
15 15
16 // Type definitions for window globals16 // Type definitions for window globals
@@ -26,34 +26,41 @@ let toastInitialized = false;
26 // Apply theme based on current system/GTK preference26 // Apply theme based on current system/GTK preference
27 function updateTheme(isDark: boolean): void {27 function updateTheme(isDark: boolean): void {
28 if (isDark) {28 if (isDark) {
29- document.documentElement.classList.add('dark');29+ document.documentElement.classList.add("dark");
30 } else {30 } else {
31- document.documentElement.classList.remove('dark');31+ document.documentElement.classList.remove("dark");
32 }32 }
33 }33 }
34 34
35 export async function initializeToast(config?: ToastConfig): Promise<void> {35 export async function initializeToast(config?: ToastConfig): Promise<void> {
36 if (toastInitialized || window.__dumber_toast_loaded) {36 if (toastInitialized || window.__dumber_toast_loaded) {
37 // Verify functions still exist - if not, reinitialize37 // Verify functions still exist - if not, reinitialize
38- if (typeof window.__dumber_showToast === 'function' &&38+ if (
39- typeof window.__dumber_showZoomToast === 'function') {39+ typeof window.__dumber_showToast === "function" &&
40- console.log('✅ Toast system already initialized and functional');40+ typeof window.__dumber_showZoomToast === "function"
41+ ) {
42+ console.log("✅ Toast system already initialized and functional");
41 return;43 return;
42 }44 }
43 // If functions are missing, reinitialize45 // If functions are missing, reinitialize
44- console.log('⚠️ Toast functions missing, reinitializing...');46+ console.log("⚠️ Toast functions missing, reinitializing...");
45 toastInitialized = false;47 toastInitialized = false;
46 window.__dumber_toast_loaded = false;48 window.__dumber_toast_loaded = false;
47 }49 }
48 50
49- console.log('🚀 Initializing Svelte toast system at:', document.readyState);51+ console.log("🚀 Initializing Svelte toast system at:", document.readyState);
50- console.log('📊 DOM state check - head:', !!document.head, 'body:', !!document.body);52+ console.log(
53+ "📊 DOM state check - head:",
54+ !!document.head,
55+ "body:",
56+ !!document.body,
57+ );
51 window.__dumber_toast_loaded = true;58 window.__dumber_toast_loaded = true;
52 59
53 // Ensure DOM is ready60 // Ensure DOM is ready
54- if (document.readyState === 'loading') {61+ if (document.readyState === "loading") {
55 return new Promise((resolve) => {62 return new Promise((resolve) => {
56- document.addEventListener('DOMContentLoaded', async () => {63+ document.addEventListener("DOMContentLoaded", async () => {
57 await initializeToast(config);64 await initializeToast(config);
58 resolve();65 resolve();
59 });66 });
@@ -61,15 +68,17 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
61 }68 }
62 69
63 if (!document.head || !document.body) {70 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+ );
65 window.__dumber_toast_loaded = false; // Reset the flag so we can try again74 window.__dumber_toast_loaded = false; // Reset the flag so we can try again
66 return new Promise((resolve) => {75 return new Promise((resolve) => {
67 const checkDOM = () => {76 const checkDOM = () => {
68 if (document.head && document.body) {77 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");
70 initializeToast(config).then(() => resolve());79 initializeToast(config).then(() => resolve());
71 } else {80 } else {
72- console.log('⏳ DOM still not ready, retrying in 50ms...');81+ console.log("⏳ DOM still not ready, retrying in 50ms...");
73 setTimeout(checkDOM, 50);82 setTimeout(checkDOM, 50);
74 }83 }
75 };84 };
@@ -79,23 +88,23 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
79 88
80 try {89 try {
81 // Use the global Shadow DOM host for isolation90 // Use the global Shadow DOM host for isolation
82- const rootElement = ensureShadowMount('dumber-toast');91+ const rootElement = ensureShadowMount("dumber-toast");
83 92
84 // Initialize theme93 // Initialize theme
85 const initialTheme = config?.theme || window.__dumber_initial_theme;94 const initialTheme = config?.theme || window.__dumber_initial_theme;
86 if (initialTheme !== undefined) {95 if (initialTheme !== undefined) {
87- updateTheme(initialTheme === 'dark');96+ updateTheme(initialTheme === "dark");
88 }97 }
89 98
90 // Mount the Svelte toast container (Svelte 5 syntax)99 // Mount the Svelte toast container (Svelte 5 syntax)
91 mount(ToastContainer, {100 mount(ToastContainer, {
92- target: rootElement as unknown as Element101+ target: rootElement as unknown as Element,
93 });102 });
94 103
95 // Signal isolated GUI readiness for page-world bridge104 // Signal isolated GUI readiness for page-world bridge
96 try {105 try {
97- document.documentElement.setAttribute('data-dumber-gui', 'ready');106+ document.documentElement.setAttribute("data-dumber-gui", "ready");
98- document.dispatchEvent(new CustomEvent('dumber:gui-ready'));107+ document.dispatchEvent(new CustomEvent("dumber:gui-ready"));
99 } catch {108 } catch {
100 // Ignore errors in GUI readiness signaling109 // Ignore errors in GUI readiness signaling
101 }110 }
@@ -103,33 +112,32 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
103 // Listen for page-world bridge events and forward to internal toast functions112 // Listen for page-world bridge events and forward to internal toast functions
104 const handleShowToast = (e: Event) => {113 const handleShowToast = (e: Event) => {
105 const detail = (e as CustomEvent).detail || {};114 const detail = (e as CustomEvent).detail || {};
106- if (typeof window.__dumber_showToast === 'function') {115+ if (typeof window.__dumber_showToast === "function") {
107 window.__dumber_showToast(detail.message, detail.duration, detail.type);116 window.__dumber_showToast(detail.message, detail.duration, detail.type);
108 }117 }
109 };118 };
110- document.addEventListener('dumber:showToast', handleShowToast);119+ document.addEventListener("dumber:showToast", handleShowToast);
111- document.addEventListener('dumber:toast:show', handleShowToast);120+ document.addEventListener("dumber:toast:show", handleShowToast);
112 121
113 const handleZoomToast = (e: Event) => {122 const handleZoomToast = (e: Event) => {
114 const detail = (e as CustomEvent).detail || {};123 const detail = (e as CustomEvent).detail || {};
115- if (typeof window.__dumber_showZoomToast === 'function') {124+ if (typeof window.__dumber_showZoomToast === "function") {
116 window.__dumber_showZoomToast(detail.level);125 window.__dumber_showZoomToast(detail.level);
117 }126 }
118 };127 };
119- document.addEventListener('dumber:showZoomToast', handleZoomToast);128+ document.addEventListener("dumber:showZoomToast", handleZoomToast);
120- document.addEventListener('dumber:toast:zoom', handleZoomToast);129+ document.addEventListener("dumber:toast:zoom", handleZoomToast);
121 130
122 // Force immediate effect execution to ensure onMount callbacks run131 // Force immediate effect execution to ensure onMount callbacks run
123 flushSync();132 flushSync();
124 133
125 // Toast functions are now exposed by the component itself134 // 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");
127 136
128 toastInitialized = true;137 toastInitialized = true;
129- console.log('✅ Toast system initialized with Svelte 5');138+ console.log("✅ Toast system initialized with Svelte 5");
130-
131 } catch (error) {139 } catch (error) {
132- console.error('❌ Failed to initialize toast system:', error);140+ console.error("❌ Failed to initialize toast system:", error);
133 141
134 // Fallback to basic toast system if Svelte fails142 // Fallback to basic toast system if Svelte fails
135 initializeFallbackToast();143 initializeFallbackToast();
@@ -139,15 +147,15 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
139 function exposeToastFunctions(): void {147 function exposeToastFunctions(): void {
140 // The Svelte ToastContainer component already exposes these functions148 // The Svelte ToastContainer component already exposes these functions
141 // We just need to make sure they exist, they're handled by the component149 // 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");
143 }151 }
144 152
145 function initializeFallbackToast(): void {153 function initializeFallbackToast(): void {
146- console.warn('🔄 Falling back to basic toast system');154+ console.warn("🔄 Falling back to basic toast system");
147 155
148 // Create basic toast container for fallback156 // Create basic toast container for fallback
149- const fallbackContainer = document.createElement('div');157+ const fallbackContainer = document.createElement("div");
150- fallbackContainer.id = 'dumber-fallback-toast';158+ fallbackContainer.id = "dumber-fallback-toast";
151 fallbackContainer.style.cssText = `159 fallbackContainer.style.cssText = `
152 position: fixed;160 position: fixed;
153 top: 20px;161 top: 20px;
@@ -161,5 +169,5 @@ function initializeFallbackToast(): void {
161 exposeToastFunctions();169 exposeToastFunctions();
162 170
163 toastInitialized = true;171 toastInitialized = true;
164- console.log('✅ Fallback toast system ready');172+ console.log("✅ Fallback toast system ready");
165-}⏎̸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 @@
6 * and wires keyboard service shortcuts based on configuration defaults.6 * and wires keyboard service shortcuts based on configuration defaults.
7 */7 */
8 8
9-import { keyboardService } from '$lib/keyboard';9+import { keyboardService } from "$lib/keyboard";
10 10
11-const ACTIVATION_COMPONENT_ID = 'workspace-pane-activation';11+const ACTIVATION_COMPONENT_ID = "workspace-pane-activation";
12-const ACTION_COMPONENT_ID = 'workspace-pane-actions';12+const ACTION_COMPONENT_ID = "workspace-pane-actions";
13 13
14 export interface WorkspaceConfigPayload {14 export interface WorkspaceConfigPayload {
15 enable_zellij_controls?: boolean;15 enable_zellij_controls?: boolean;
@@ -69,61 +69,70 @@ export interface WorkspaceRuntime {
69 const DEFAULT_CONFIG: WorkspaceConfigNormalized = {69 const DEFAULT_CONFIG: WorkspaceConfigNormalized = {
70 enableZellijControls: true,70 enableZellijControls: true,
71 paneMode: {71 paneMode: {
72- activationShortcut: 'cmdorctrl+p',72+ activationShortcut: "cmdorctrl+p",
73 timeoutMs: 3000,73 timeoutMs: 3000,
74 actionBindings: {74 actionBindings: {
75- arrowright: 'split-right',75+ arrowright: "split-right",
76- arrowleft: 'split-left',76+ arrowleft: "split-left",
77- arrowup: 'split-up',77+ arrowup: "split-up",
78- arrowdown: 'split-down',78+ arrowdown: "split-down",
79- r: 'split-right',79+ r: "split-right",
80- l: 'split-left',80+ l: "split-left",
81- u: 'split-up',81+ u: "split-up",
82- d: 'split-down',82+ d: "split-down",
83- x: 'close-pane',83+ x: "close-pane",
84- enter: 'confirm',84+ enter: "confirm",
85- escape: 'cancel'85+ escape: "cancel",
86- }86+ },
87 },87 },
88 tabs: {88 tabs: {
89- newTab: 'cmdorctrl+t',89+ newTab: "cmdorctrl+t",
90- closeTab: 'cmdorctrl+w',90+ closeTab: "cmdorctrl+w",
91- nextTab: 'cmdorctrl+tab',91+ nextTab: "cmdorctrl+tab",
92- previousTab: 'cmdorctrl+shift+tab'92+ previousTab: "cmdorctrl+shift+tab",
93 },93 },
94 popups: {94 popups: {
95- placement: 'right',95+ placement: "right",
96 openInNewPane: true,96 openInNewPane: true,
97- followPaneContext: true97+ followPaneContext: true,
98- }98+ },
99 };99 };
100 100
101-function normalizeWorkspaceConfig(payload?: WorkspaceConfigPayload | null): WorkspaceConfigNormalized {101+function normalizeWorkspaceConfig(
102+ payload?: WorkspaceConfigPayload | null,
103+): WorkspaceConfigNormalized {
102 const paneModePayload = payload?.pane_mode ?? {};104 const paneModePayload = payload?.pane_mode ?? {};
103 const tabsPayload = payload?.tabs ?? {};105 const tabsPayload = payload?.tabs ?? {};
104 const popupsPayload = payload?.popups ?? {};106 const popupsPayload = payload?.popups ?? {};
105 107
106 return {108 return {
107- enableZellijControls: payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,109+ enableZellijControls:
110+ payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,
108 paneMode: {111 paneMode: {
109- activationShortcut: paneModePayload.activation_shortcut ?? DEFAULT_CONFIG.paneMode.activationShortcut,112+ activationShortcut:
110- timeoutMs: paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,113+ paneModePayload.activation_shortcut ??
114+ DEFAULT_CONFIG.paneMode.activationShortcut,
115+ timeoutMs:
116+ paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,
111 actionBindings: {117 actionBindings: {
112 ...DEFAULT_CONFIG.paneMode.actionBindings,118 ...DEFAULT_CONFIG.paneMode.actionBindings,
113- ...(paneModePayload.action_bindings ?? {})119+ ...(paneModePayload.action_bindings ?? {}),
114- }120+ },
115 },121 },
116 tabs: {122 tabs: {
117 newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab,123 newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab,
118 closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab,124 closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab,
119 nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab,125 nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab,
120- previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab126+ previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab,
121 },127 },
122 popups: {128 popups: {
123 placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement,129 placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement,
124- openInNewPane: popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,130+ openInNewPane:
125- followPaneContext: popupsPayload.follow_pane_context ?? DEFAULT_CONFIG.popups.followPaneContext131+ popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,
126- }132+ followPaneContext:
133+ popupsPayload.follow_pane_context ??
134+ DEFAULT_CONFIG.popups.followPaneContext,
135+ },
127 };136 };
128 }137 }
129 138
@@ -137,7 +146,9 @@ class WorkspaceController implements WorkspaceRuntime {
137 constructor() {146 constructor() {
138 // Generate unique instance ID for this webview147 // Generate unique instance ID for this webview
139 this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`;148 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+ );
141 }152 }
142 153
143 getInstanceId(): string {154 getInstanceId(): string {
@@ -156,16 +167,21 @@ class WorkspaceController implements WorkspaceRuntime {
156 // In a multi-pane environment, only the focused webview should handle pane mode167 // In a multi-pane environment, only the focused webview should handle pane mode
157 try {168 try {
158 const hasFocus = document.hasFocus();169 const hasFocus = document.hasFocus();
159- const isVisible = document.visibilityState === 'visible';170+ const isVisible = document.visibilityState === "visible";
160 const isActive = hasFocus && isVisible;171 const isActive = hasFocus && isVisible;
161 172
162 if (!isActive) {173 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+ );
164 }177 }
165 178
166 return isActive;179 return isActive;
167 } catch (error) {180 } 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+ );
169 return true; // Fallback to active if check fails185 return true; // Fallback to active if check fails
170 }186 }
171 }187 }
@@ -176,31 +192,34 @@ class WorkspaceController implements WorkspaceRuntime {
176 this.config = normalized;192 this.config = normalized;
177 193
178 if (!normalized.enableZellijControls) {194 if (!normalized.enableZellijControls) {
179- console.log('[workspace] Zellij controls disabled via config');195+ console.log("[workspace] Zellij controls disabled via config");
180 window.__dumber_workspace = this.exportRuntime();196 window.__dumber_workspace = this.exportRuntime();
181 return;197 return;
182 }198 }
183 199
184 this.registerShortcuts();200 this.registerShortcuts();
185 window.__dumber_workspace = this.exportRuntime();201 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+ );
187 }206 }
188 207
189 private bridge(message: Record<string, unknown>): void {208 private bridge(message: Record<string, unknown>): void {
190 const bridge = window.webkit?.messageHandlers?.dumber;209 const bridge = window.webkit?.messageHandlers?.dumber;
191- if (!bridge || typeof bridge.postMessage !== 'function') {210+ if (!bridge || typeof bridge.postMessage !== "function") {
192 return;211 return;
193 }212 }
194 213
195 try {214 try {
196 bridge.postMessage(215 bridge.postMessage(
197 JSON.stringify({216 JSON.stringify({
198- type: 'workspace',217+ type: "workspace",
199- ...message218+ ...message,
200- })219+ }),
201 );220 );
202 } catch (error) {221 } catch (error) {
203- console.error('[workspace] Failed to post workspace message', error);222+ console.error("[workspace] Failed to post workspace message", error);
204 }223 }
205 }224 }
206 225
@@ -209,19 +228,25 @@ class WorkspaceController implements WorkspaceRuntime {
209 228
210 // Only allow the active instance to enter pane mode229 // Only allow the active instance to enter pane mode
211 if (!this.isActiveInstance()) {230 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+ );
213 return;234 return;
214 }235 }
215 236
216 console.log(`[workspace] Instance ${this.instanceId} entering pane mode`);237 console.log(`[workspace] Instance ${this.instanceId} entering pane mode`);
217 this.paneModeActive = true;238 this.paneModeActive = true;
218 this.restartPaneModeTimer();239 this.restartPaneModeTimer();
219- this.emitWorkspaceEvent('pane-mode-entered', { action: 'enter', config: this.config, instanceId: this.instanceId });240+ this.emitWorkspaceEvent("pane-mode-entered", {
220- this.bridge({ event: 'pane-mode-entered', instanceId: this.instanceId });241+ action: "enter",
221- this.showToast('Pane mode: awaiting direction');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");
222 }247 }
223 248
224- exitPaneMode(reason: string = 'exit'): void {249+ exitPaneMode(reason: string = "exit"): void {
225 if (!this.paneModeActive) return;250 if (!this.paneModeActive) return;
226 251
227 this.paneModeActive = false;252 this.paneModeActive = false;
@@ -229,8 +254,8 @@ class WorkspaceController implements WorkspaceRuntime {
229 clearTimeout(this.paneModeTimer);254 clearTimeout(this.paneModeTimer);
230 this.paneModeTimer = null;255 this.paneModeTimer = null;
231 }256 }
232- this.emitWorkspaceEvent('pane-mode-exited', { action: reason });257+ this.emitWorkspaceEvent("pane-mode-exited", { action: reason });
233- this.bridge({ event: 'pane-mode-exited', reason });258+ this.bridge({ event: "pane-mode-exited", reason });
234 }259 }
235 260
236 isPaneModeActive(): boolean {261 isPaneModeActive(): boolean {
@@ -246,20 +271,22 @@ class WorkspaceController implements WorkspaceRuntime {
246 {271 {
247 key: this.config.paneMode.activationShortcut,272 key: this.config.paneMode.activationShortcut,
248 handler: () => this.enterPaneMode(),273 handler: () => this.enterPaneMode(),
249- description: 'Enter pane mode',274+ description: "Enter pane mode",
250 preventDefault: true,275 preventDefault: true,
251 stopPropagation: true,276 stopPropagation: true,
252- whenFocused: false277+ whenFocused: false,
253- }278+ },
254 ]);279 ]);
255 280
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]) => ({
257 key,284 key,
258 handler: () => this.handlePaneAction(action, key),285 handler: () => this.handlePaneAction(action, key),
259 description: `Pane action: ${action}`,286 description: `Pane action: ${action}`,
260 preventDefault: true,287 preventDefault: true,
261 stopPropagation: true,288 stopPropagation: true,
262- whenFocused: false289+ whenFocused: false,
263 }));290 }));
264 291
265 keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts);292 keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts);
@@ -276,48 +303,60 @@ class WorkspaceController implements WorkspaceRuntime {
276 }303 }
277 304
278 switch (action) {305 switch (action) {
279- case 'split-right':306+ case "split-right":
280- this.emitWorkspaceEvent('pane-split', { direction: 'right', config: this.config });307+ this.emitWorkspaceEvent("pane-split", {
281- this.bridge({ event: 'pane-split', direction: 'right' });308+ direction: "right",
282- this.showToast('Split pane to the right');309+ config: this.config,
283- this.exitPaneMode('split-right');310+ });
311+ this.bridge({ event: "pane-split", direction: "right" });
312+ this.showToast("Split pane to the right");
313+ this.exitPaneMode("split-right");
284 break;314 break;
285- case 'split-left':315+ case "split-left":
286- this.emitWorkspaceEvent('pane-split', { direction: 'left', config: this.config });316+ this.emitWorkspaceEvent("pane-split", {
287- this.bridge({ event: 'pane-split', direction: 'left' });317+ direction: "left",
288- this.showToast('Split pane to the left');318+ config: this.config,
289- this.exitPaneMode('split-left');319+ });
320+ this.bridge({ event: "pane-split", direction: "left" });
321+ this.showToast("Split pane to the left");
322+ this.exitPaneMode("split-left");
290 break;323 break;
291- case 'split-up':324+ case "split-up":
292- this.emitWorkspaceEvent('pane-split', { direction: 'up', config: this.config });325+ this.emitWorkspaceEvent("pane-split", {
293- this.bridge({ event: 'pane-split', direction: 'up' });326+ direction: "up",
294- this.showToast('Split pane upwards');327+ config: this.config,
295- this.exitPaneMode('split-up');328+ });
329+ this.bridge({ event: "pane-split", direction: "up" });
330+ this.showToast("Split pane upwards");
331+ this.exitPaneMode("split-up");
296 break;332 break;
297- case 'split-down':333+ case "split-down":
298- this.emitWorkspaceEvent('pane-split', { direction: 'down', config: this.config });334+ this.emitWorkspaceEvent("pane-split", {
299- this.bridge({ event: 'pane-split', direction: 'down' });335+ direction: "down",
300- this.showToast('Split pane downwards');336+ config: this.config,
301- this.exitPaneMode('split-down');337+ });
338+ this.bridge({ event: "pane-split", direction: "down" });
339+ this.showToast("Split pane downwards");
340+ this.exitPaneMode("split-down");
302 break;341 break;
303- case 'close-pane':342+ case "close-pane":
304- this.emitWorkspaceEvent('pane-closed', { key, config: this.config });343+ this.emitWorkspaceEvent("pane-closed", { key, config: this.config });
305- this.bridge({ event: 'pane-close', action });344+ this.bridge({ event: "pane-close", action });
306- this.showToast('Closed pane');345+ this.showToast("Closed pane");
307- this.exitPaneMode('close-pane');346+ this.exitPaneMode("close-pane");
308 break;347 break;
309- case 'confirm':348+ case "confirm":
310- this.bridge({ event: 'pane-confirmed' });349+ this.bridge({ event: "pane-confirmed" });
311- this.showToast('Pane mode confirmed');350+ this.showToast("Pane mode confirmed");
312- this.exitPaneMode('confirm');351+ this.exitPaneMode("confirm");
313 break;352 break;
314- case 'cancel':353+ case "cancel":
315- this.bridge({ event: 'pane-cancelled' });354+ this.bridge({ event: "pane-cancelled" });
316- this.showToast('Pane mode cancelled');355+ this.showToast("Pane mode cancelled");
317- this.exitPaneMode('cancel');356+ this.exitPaneMode("cancel");
318 break;357 break;
319 default:358 default:
320- console.warn('[workspace] Unhandled pane action:', action);359+ console.warn("[workspace] Unhandled pane action:", action);
321 }360 }
322 }361 }
323 362
@@ -328,32 +367,38 @@ class WorkspaceController implements WorkspaceRuntime {
328 367
329 if (this.config.paneMode.timeoutMs > 0) {368 if (this.config.paneMode.timeoutMs > 0) {
330 this.paneModeTimer = setTimeout(() => {369 this.paneModeTimer = setTimeout(() => {
331- this.showToast('Pane mode timed out');370+ this.showToast("Pane mode timed out");
332- this.exitPaneMode('timeout');371+ this.exitPaneMode("timeout");
333 }, this.config.paneMode.timeoutMs);372 }, this.config.paneMode.timeoutMs);
334 }373 }
335 }374 }
336 375
337 private showToast(message: string): void {376 private showToast(message: string): void {
338- if (typeof window.__dumber_showToast === 'function') {377+ if (typeof window.__dumber_showToast === "function") {
339- window.__dumber_showToast(message, 2000, 'info');378+ window.__dumber_showToast(message, 2000, "info");
340 } else {379 } else {
341- console.log('[workspace toast]', message);380+ console.log("[workspace toast]", message);
342 }381 }
343 }382 }
344 383
345- private emitWorkspaceEvent(event: string, detail: Record<string, unknown>): void {384+ private emitWorkspaceEvent(
346- document.dispatchEvent(new CustomEvent(`dumber:workspace-${event}`, { detail }));385+ event: string,
386+ detail: Record<string, unknown>,
387+ ): void {
388+ document.dispatchEvent(
389+ new CustomEvent(`dumber:workspace-${event}`, { detail }),
390+ );
347 }391 }
348 392
349 private exportRuntime(): WorkspaceRuntime {393 private exportRuntime(): WorkspaceRuntime {
350 return {394 return {
351 enterPaneMode: () => this.enterPaneMode(),395 enterPaneMode: () => this.enterPaneMode(),
352- exitPaneMode: (reason?: string) => this.exitPaneMode(reason ?? 'external'),396+ exitPaneMode: (reason?: string) =>
397+ this.exitPaneMode(reason ?? "external"),
353 isPaneModeActive: () => this.isPaneModeActive(),398 isPaneModeActive: () => this.isPaneModeActive(),
354 getConfig: () => this.getConfig(),399 getConfig: () => this.getConfig(),
355 getInstanceId: () => this.getInstanceId(),400 getInstanceId: () => this.getInstanceId(),
356- isActiveInstance: () => this.isActiveInstance()401+ isActiveInstance: () => this.isActiveInstance(),
357 };402 };
358 }403 }
359 }404 }
@@ -361,9 +406,11 @@ class WorkspaceController implements WorkspaceRuntime {
361 const workspaceController = new WorkspaceController();406 const workspaceController = new WorkspaceController();
362 let listenersAttached = false;407 let listenersAttached = false;
363 408
364-export function initializeWorkspace(payload?: WorkspaceConfigPayload | null): void {409+export function initializeWorkspace(
410+ payload?: WorkspaceConfigPayload | null,
411+): void {
365 if (!listenersAttached) {412 if (!listenersAttached) {
366- document.addEventListener('dumber:workspace-config', (event: Event) => {413+ document.addEventListener("dumber:workspace-config", (event: Event) => {
367 const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail;414 const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail;
368 workspaceController.configure(detail);415 workspaceController.configure(detail);
369 });416 });
M gui/src/injected/styles-entry.css +1 -1
@@ -1,2 +1,2 @@
1 /* Vite/PostCSS/Tailwind processed CSS entry for Shadow DOM injection */1 /* 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 {
11 KeyboardServiceConfig,11 KeyboardServiceConfig,
12 KeyboardEventDetail,12 KeyboardEventDetail,
13 KeyboardEvent,13 KeyboardEvent,
14- KeyboardEventType14+ KeyboardEventType,
15-} from './types';15+} from "./types";
16 // COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers16 // COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers
17 17
18 export class KeyboardService {18 export class KeyboardService {
19 private components = new Map<string, ComponentShortcuts>();19 private components = new Map<string, ComponentShortcuts>();
20 private activeComponent: string | null = null;20 private activeComponent: string | null = null;
21 private globalShortcuts: ShortcutConfig[] = [];21 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+ >();
23 private debug: boolean = false;26 private debug: boolean = false;
24 // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now27 // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now
25 28
@@ -27,7 +30,7 @@ export class KeyboardService {
27 this.debug = config.debug ?? false;30 this.debug = config.debug ?? false;
28 this.globalShortcuts = config.globalShortcuts ?? [];31 this.globalShortcuts = config.globalShortcuts ?? [];
29 32
30- this.log('info', 'KeyboardService initialized', { config });33+ this.log("info", "KeyboardService initialized", { config });
31 }34 }
32 35
33 /**36 /**
@@ -35,27 +38,33 @@ export class KeyboardService {
35 */38 */
36 registerShortcuts(componentId: string, shortcuts: ShortcutConfig[]): void {39 registerShortcuts(componentId: string, shortcuts: ShortcutConfig[]): void {
37 if (this.components.has(componentId)) {40 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+ );
39 }45 }
40 46
41 const componentShortcuts: ComponentShortcuts = {47 const componentShortcuts: ComponentShortcuts = {
42 componentId,48 componentId,
43- shortcuts: shortcuts.map(shortcut => ({49+ shortcuts: shortcuts.map((shortcut) => ({
44 ...shortcut,50 ...shortcut,
45 preventDefault: shortcut.preventDefault ?? true,51 preventDefault: shortcut.preventDefault ?? true,
46 stopPropagation: shortcut.stopPropagation ?? true,52 stopPropagation: shortcut.stopPropagation ?? true,
47- whenFocused: shortcut.whenFocused ?? false53+ whenFocused: shortcut.whenFocused ?? false,
48 })),54 })),
49- focused: false55+ focused: false,
50 };56 };
51 57
52 this.components.set(componentId, componentShortcuts);58 this.components.set(componentId, componentShortcuts);
53 59
54- this.log('info', 'Shortcuts registered', { componentId, shortcuts: shortcuts.length });60+ this.log("info", "Shortcuts registered", {
55- this.emit('shortcut-registered', {61+ componentId,
62+ shortcuts: shortcuts.length,
63+ });
64+ this.emit("shortcut-registered", {
56 shortcut: componentId,65 shortcut: componentId,
57 timestamp: Date.now(),66 timestamp: Date.now(),
58- targetComponent: componentId67+ targetComponent: componentId,
59 });68 });
60 }69 }
61 70
@@ -65,7 +74,7 @@ export class KeyboardService {
65 unregisterShortcuts(componentId: string): void {74 unregisterShortcuts(componentId: string): void {
66 const component = this.components.get(componentId);75 const component = this.components.get(componentId);
67 if (!component) {76 if (!component) {
68- this.log('warn', `Component ${componentId} not found for unregistration`);77+ this.log("warn", `Component ${componentId} not found for unregistration`);
69 return;78 return;
70 }79 }
71 80
@@ -81,11 +90,11 @@ export class KeyboardService {
81 this.activeComponent = null;90 this.activeComponent = null;
82 }91 }
83 92
84- this.log('info', 'Shortcuts unregistered', { componentId });93+ this.log("info", "Shortcuts unregistered", { componentId });
85- this.emit('shortcut-unregistered', {94+ this.emit("shortcut-unregistered", {
86 shortcut: componentId,95 shortcut: componentId,
87 timestamp: Date.now(),96 timestamp: Date.now(),
88- targetComponent: componentId97+ targetComponent: componentId,
89 });98 });
90 }99 }
91 100
@@ -97,10 +106,10 @@ export class KeyboardService {
97 if (this.activeComponent && this.components.has(this.activeComponent)) {106 if (this.activeComponent && this.components.has(this.activeComponent)) {
98 const prevComponent = this.components.get(this.activeComponent)!;107 const prevComponent = this.components.get(this.activeComponent)!;
99 prevComponent.focused = false;108 prevComponent.focused = false;
100- this.emit('component-blurred', {109+ this.emit("component-blurred", {
101 shortcut: this.activeComponent,110 shortcut: this.activeComponent,
102 timestamp: Date.now(),111 timestamp: Date.now(),
103- targetComponent: this.activeComponent112+ targetComponent: this.activeComponent,
104 });113 });
105 }114 }
106 115
@@ -110,28 +119,35 @@ export class KeyboardService {
110 if (componentId && this.components.has(componentId)) {119 if (componentId && this.components.has(componentId)) {
111 const newComponent = this.components.get(componentId)!;120 const newComponent = this.components.get(componentId)!;
112 newComponent.focused = true;121 newComponent.focused = true;
113- this.emit('component-focused', {122+ this.emit("component-focused", {
114 shortcut: componentId,123 shortcut: componentId,
115 timestamp: Date.now(),124 timestamp: Date.now(),
116- targetComponent: componentId125+ targetComponent: componentId,
117 });126 });
118 }127 }
119 128
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+ });
121 }133 }
122 134
123 /**135 /**
124 * Handle a keyboard shortcut from native GTK layer136 * Handle a keyboard shortcut from native GTK layer
125 */137 */
126 handleNativeShortcut(shortcut: string): boolean {138 handleNativeShortcut(shortcut: string): boolean {
127- this.log('info', 'Native shortcut received', { shortcut });139+ this.log("info", "Native shortcut received", { shortcut });
128 140
129 // Normalize shortcut string141 // Normalize shortcut string
130 const normalizedShortcut = this.normalizeShortcut(shortcut);142 const normalizedShortcut = this.normalizeShortcut(shortcut);
131 143
132 // Try to handle with focused component first144 // Try to handle with focused component first
133 if (this.activeComponent) {145 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+ );
135 if (handled) {151 if (handled) {
136 return true;152 return true;
137 }153 }
@@ -147,13 +163,17 @@ export class KeyboardService {
147 for (const [componentId] of this.components) {163 for (const [componentId] of this.components) {
148 if (componentId === this.activeComponent) continue; // Already tried above164 if (componentId === this.activeComponent) continue; // Already tried above
149 165
150- const handled = this.handleComponentShortcut(componentId, normalizedShortcut, false);166+ const handled = this.handleComponentShortcut(
167+ componentId,
168+ normalizedShortcut,
169+ false,
170+ );
151 if (handled) {171 if (handled) {
152 return true;172 return true;
153 }173 }
154 }174 }
155 175
156- this.log('info', 'Shortcut not handled', { shortcut: normalizedShortcut });176+ this.log("info", "Shortcut not handled", { shortcut: normalizedShortcut });
157 return false;177 return false;
158 }178 }
159 179
@@ -164,7 +184,7 @@ export class KeyboardService {
164 const shortcut = this.eventToShortcut(event);184 const shortcut = this.eventToShortcut(event);
165 if (!shortcut) return false;185 if (!shortcut) return false;
166 186
167- this.log('info', 'Keyboard event received', { shortcut });187+ this.log("info", "Keyboard event received", { shortcut });
168 return this.handleNativeShortcut(shortcut);188 return this.handleNativeShortcut(shortcut);
169 }189 }
170 190
@@ -179,7 +199,7 @@ export class KeyboardService {
179 event.stopPropagation();199 event.stopPropagation();
180 200
181 if (window.history.length > 1) {201 if (window.history.length > 1) {
182- console.log('⬅️ Mouse back button pressed');202+ console.log("⬅️ Mouse back button pressed");
183 window.history.back();203 window.history.back();
184 }204 }
185 return true;205 return true;
@@ -190,13 +210,12 @@ export class KeyboardService {
190 event.preventDefault();210 event.preventDefault();
191 event.stopPropagation();211 event.stopPropagation();
192 212
193- console.log('➡️ Mouse forward button pressed');213+ console.log("➡️ Mouse forward button pressed");
194 window.history.forward();214 window.history.forward();
195 return true;215 return true;
196 }216 }
197-
198 } catch (error) {217 } catch (error) {
199- this.log('error', 'Mouse handler error', { error });218+ this.log("error", "Mouse handler error", { error });
200 }219 }
201 220
202 return false;221 return false;
@@ -205,7 +224,10 @@ export class KeyboardService {
205 /**224 /**
206 * Add event listener for keyboard service events225 * Add event listener for keyboard service events
207 */226 */
208- addEventListener(type: KeyboardEventType, listener: (event: KeyboardEvent) => void): void {227+ addEventListener(
228+ type: KeyboardEventType,
229+ listener: (event: KeyboardEvent) => void,
230+ ): void {
209 if (!this.eventListeners.has(type)) {231 if (!this.eventListeners.has(type)) {
210 this.eventListeners.set(type, new Set());232 this.eventListeners.set(type, new Set());
211 }233 }
@@ -215,7 +237,10 @@ export class KeyboardService {
215 /**237 /**
216 * Remove event listener238 * Remove event listener
217 */239 */
218- removeEventListener(type: KeyboardEventType, listener: (event: KeyboardEvent) => void): void {240+ removeEventListener(
241+ type: KeyboardEventType,
242+ listener: (event: KeyboardEvent) => void,
243+ ): void {
219 const listeners = this.eventListeners.get(type);244 const listeners = this.eventListeners.get(type);
220 if (listeners) {245 if (listeners) {
221 listeners.delete(listener);246 listeners.delete(listener);
@@ -230,16 +255,20 @@ export class KeyboardService {
230 components: Array.from(this.components.entries()).map(([id, comp]) => ({255 components: Array.from(this.components.entries()).map(([id, comp]) => ({
231 id,256 id,
232 shortcuts: comp.shortcuts.length,257 shortcuts: comp.shortcuts.length,
233- focused: comp.focused258+ focused: comp.focused,
234 })),259 })),
235 activeComponent: this.activeComponent,260 activeComponent: this.activeComponent,
236- globalShortcuts: this.globalShortcuts.length261+ globalShortcuts: this.globalShortcuts.length,
237 };262 };
238 }263 }
239 264
240 // Private methods265 // Private methods
241 266
242- private handleComponentShortcut(componentId: string, shortcut: string, respectFocus: boolean): boolean {267+ private handleComponentShortcut(
268+ componentId: string,
269+ shortcut: string,
270+ respectFocus: boolean,
271+ ): boolean {
243 const component = this.components.get(componentId);272 const component = this.components.get(componentId);
244 if (!component) return false;273 if (!component) return false;
245 274
@@ -252,22 +281,26 @@ export class KeyboardService {
252 281
253 try {282 try {
254 shortcutConfig.handler();283 shortcutConfig.handler();
255- this.log('info', 'Shortcut handled', {284+ this.log("info", "Shortcut handled", {
256 shortcut,285 shortcut,
257 componentId,286 componentId,
258 focused: component.focused,287 focused: component.focused,
259- description: shortcutConfig.description288+ description: shortcutConfig.description,
260 });289 });
261 290
262- this.emit('shortcut-triggered', {291+ this.emit("shortcut-triggered", {
263 shortcut,292 shortcut,
264 timestamp: Date.now(),293 timestamp: Date.now(),
265- targetComponent: componentId294+ targetComponent: componentId,
266 });295 });
267 296
268 return true;297 return true;
269 } catch (error) {298 } 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+ });
271 }304 }
272 }305 }
273 }306 }
@@ -280,16 +313,22 @@ export class KeyboardService {
280 if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {313 if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
281 try {314 try {
282 shortcutConfig.handler();315 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+ });
284 320
285- this.emit('shortcut-triggered', {321+ this.emit("shortcut-triggered", {
286 shortcut,322 shortcut,
287- timestamp: Date.now()323+ timestamp: Date.now(),
288 });324 });
289 325
290 return true;326 return true;
291 } catch (error) {327 } 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+ });
293 }332 }
294 }333 }
295 }334 }
@@ -297,7 +336,7 @@ export class KeyboardService {
297 }336 }
298 337
299 private normalizeShortcut(shortcut: string): string {338 private normalizeShortcut(shortcut: string): string {
300- return shortcut.toLowerCase().replace(/\s+/g, '');339+ return shortcut.toLowerCase().replace(/\s+/g, "");
301 }340 }
302 341
303 private eventToShortcut(event: globalThis.KeyboardEvent): string | null {342 private eventToShortcut(event: globalThis.KeyboardEvent): string | null {
@@ -306,48 +345,54 @@ export class KeyboardService {
306 // Add modifiers using MODIFIER_KEYS mapping345 // Add modifiers using MODIFIER_KEYS mapping
307 if (event.ctrlKey && event.metaKey) {346 if (event.ctrlKey && event.metaKey) {
308 // Both ctrl and meta pressed - use cmdorctrl347 // Both ctrl and meta pressed - use cmdorctrl
309- parts.push('cmdorctrl');348+ parts.push("cmdorctrl");
310 } else if (event.ctrlKey) {349 } else if (event.ctrlKey) {
311- parts.push('ctrl');350+ parts.push("ctrl");
312 } else if (event.metaKey) {351 } else if (event.metaKey) {
313- parts.push('cmd');352+ parts.push("cmd");
314 }353 }
315 354
316- if (event.altKey) parts.push('alt');355+ if (event.altKey) parts.push("alt");
317- if (event.shiftKey) parts.push('shift');356+ if (event.shiftKey) parts.push("shift");
318 357
319 // Add key358 // Add key
320 if (event.key) {359 if (event.key) {
321 parts.push(event.key.toLowerCase());360 parts.push(event.key.toLowerCase());
322 }361 }
323 362
324- return parts.length > 0 ? parts.join('+') : null;363+ return parts.length > 0 ? parts.join("+") : null;
325 }364 }
326 365
327 private emit(type: KeyboardEventType, detail: KeyboardEventDetail): void {366 private emit(type: KeyboardEventType, detail: KeyboardEventDetail): void {
328 const listeners = this.eventListeners.get(type);367 const listeners = this.eventListeners.get(type);
329 if (listeners) {368 if (listeners) {
330 const keyboardEvent: KeyboardEvent = { type, detail };369 const keyboardEvent: KeyboardEvent = { type, detail };
331- listeners.forEach(listener => {370+ listeners.forEach((listener) => {
332 try {371 try {
333 listener(keyboardEvent);372 listener(keyboardEvent);
334 } catch (error) {373 } catch (error) {
335- this.log('error', 'Event listener error', { type, error });374+ this.log("error", "Event listener error", { type, error });
336 }375 }
337 });376 });
338 }377 }
339 }378 }
340 379
341- private log(level: 'info' | 'warn' | 'error' | string, message?: string, data?: unknown): void {380+ private log(
342- if (!this.debug && level !== 'error') return;381+ level: "info" | "warn" | "error" | string,
382+ message?: string,
383+ data?: unknown,
384+ ): void {
385+ if (!this.debug && level !== "error") return;
343 386
344- const logMessage = message ? `[KeyboardService] ${message}` : `[KeyboardService] ${level}`;387+ const logMessage = message
388+ ? `[KeyboardService] ${message}`
389+ : `[KeyboardService] ${level}`;
345 390
346 switch (level) {391 switch (level) {
347- case 'error':392+ case "error":
348 console.error(logMessage, data);393 console.error(logMessage, data);
349 break;394 break;
350- case 'warn':395+ case "warn":
351 console.warn(logMessage, data);396 console.warn(logMessage, data);
352 break;397 break;
353 default:398 default:
@@ -362,27 +407,31 @@ export const keyboardService = new KeyboardService({
362 globalShortcuts: [407 globalShortcuts: [
363 // Navigation shortcuts (migrated from controls module)408 // Navigation shortcuts (migrated from controls module)
364 {409 {
365- key: 'cmdorctrl+arrowleft',410+ key: "cmdorctrl+arrowleft",
366 handler: () => {411 handler: () => {
367 if (window.history.length > 1) {412 if (window.history.length > 1) {
368 window.history.back();413 window.history.back();
369 }414 }
370 },415 },
371- description: 'Navigate back (Ctrl/Cmd+Left Arrow)'416+ description: "Navigate back (Ctrl/Cmd+Left Arrow)",
372 },417 },
373 {418 {
374- key: 'cmdorctrl+arrowright',419+ key: "cmdorctrl+arrowright",
375 handler: () => {420 handler: () => {
376 window.history.forward();421 window.history.forward();
377 },422 },
378- description: 'Navigate forward (Ctrl/Cmd+Right Arrow)'423+ description: "Navigate forward (Ctrl/Cmd+Right Arrow)",
379 },424 },
380 // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled425 // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled
381 // directly by the native Go shortcut handlers to ensure proper active pane detection.426 // directly by the native Go shortcut handlers to ensure proper active pane detection.
382 // This avoids conflicts between multiple webview instances.427 // This avoids conflicts between multiple webview instances.
383- ]428+ ],
384 });429 });
385 430
386 // Export types for use in components431 // 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";
388 // COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers437 // 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 @@
5 * Provides type-safe keyboard event handling and component integration.5 * Provides type-safe keyboard event handling and component integration.
6 */6 */
7 7
8-export { KeyboardService, keyboardService } from './KeyboardService';8+export { KeyboardService, keyboardService } from "./KeyboardService";
9 export type {9 export type {
10 ShortcutConfig,10 ShortcutConfig,
11 ComponentShortcuts,11 ComponentShortcuts,
12 KeyboardServiceConfig,12 KeyboardServiceConfig,
13 KeyboardEventDetail,13 KeyboardEventDetail,
14 KeyboardEvent,14 KeyboardEvent,
15- KeyboardEventType15+ KeyboardEventType,
16-} from './types';16+} from "./types";
17-export { MODIFIER_KEYS, COMMON_SHORTCUTS } 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 {
62 * Event types for keyboard service62 * Event types for keyboard service
63 */63 */
64 export type KeyboardEventType =64 export type KeyboardEventType =
65- | 'shortcut-triggered'65+ | "shortcut-triggered"
66- | 'component-focused'66+ | "component-focused"
67- | 'component-blurred'67+ | "component-blurred"
68- | 'shortcut-registered'68+ | "shortcut-registered"
69- | 'shortcut-unregistered';69+ | "shortcut-unregistered";
70 70
71 export interface KeyboardEvent {71 export interface KeyboardEvent {
72 type: KeyboardEventType;72 type: KeyboardEventType;
@@ -77,25 +77,25 @@ export interface KeyboardEvent {
77 * Keyboard modifiers mapping77 * Keyboard modifiers mapping
78 */78 */
79 export const MODIFIER_KEYS = {79 export const MODIFIER_KEYS = {
80- ctrl: 'ctrlKey',80+ ctrl: "ctrlKey",
81- cmd: 'metaKey',81+ cmd: "metaKey",
82- cmdorctrl: navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey',82+ cmdorctrl: navigator.platform.includes("Mac") ? "metaKey" : "ctrlKey",
83- alt: 'altKey',83+ alt: "altKey",
84- shift: 'shiftKey'84+ shift: "shiftKey",
85 } as const;85 } as const;
86 86
87 /**87 /**
88 * Common shortcut patterns used throughout the application88 * Common shortcut patterns used throughout the application
89 */89 */
90 export const COMMON_SHORTCUTS = {90 export const COMMON_SHORTCUTS = {
91- OMNIBOX_OPEN: 'cmdorctrl+l',91+ OMNIBOX_OPEN: "cmdorctrl+l",
92- FIND_OPEN: 'cmdorctrl+f',92+ FIND_OPEN: "cmdorctrl+f",
93- ESCAPE: 'escape',93+ ESCAPE: "escape",
94- ENTER: 'enter',94+ ENTER: "enter",
95- ARROW_UP: 'arrowup',95+ ARROW_UP: "arrowup",
96- ARROW_DOWN: 'arrowdown',96+ ARROW_DOWN: "arrowdown",
97- ARROW_LEFT: 'arrowleft',97+ ARROW_LEFT: "arrowleft",
98- ARROW_RIGHT: 'arrowright',98+ ARROW_RIGHT: "arrowright",
99- TAB: 'tab',99+ TAB: "tab",
100- SHIFT_TAB: 'shift+tab'100+ SHIFT_TAB: "shift+tab",
101-} as const;⏎̸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";
2 2
3 // Global window API types for browser integration3 // Global window API types for browser integration
4 declare global {4 declare global {
5 interface Window {5 interface Window {
6 // Toast system API6 // Toast system API
7 __dumber_toast_loaded?: boolean;7 __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;
9 __dumber_dismissToast?: (id: number) => void;13 __dumber_dismissToast?: (id: number) => void;
10 __dumber_clearToasts?: () => void;14 __dumber_clearToasts?: () => void;
11 __dumber_showZoomToast?: (zoomLevel: number) => void;15 __dumber_showZoomToast?: (zoomLevel: number) => void;
12 16
13 // Theme integration17 // Theme integration
14 __dumber_initial_theme?: string;18 __dumber_initial_theme?: string;
15- __dumber_setTheme?: (theme: 'light' | 'dark') => void;19+ __dumber_setTheme?: (theme: "light" | "dark") => void;
16 20
17 // History callbacks21 // History callbacks
18 __dumber_history_recent?: (data: HistoryEntry[]) => void;22 __dumber_history_recent?: (data: HistoryEntry[]) => void;
@@ -34,15 +38,19 @@ declare global {
34 }38 }
35 39
36 export interface ToastAPI {40 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;
38 dismiss: (id: number) => void;46 dismiss: (id: number) => void;
39 clear: () => void;47 clear: () => void;
40 showZoom: (zoomLevel: number) => void;48 showZoom: (zoomLevel: number) => void;
41 }49 }
42 50
43 export interface ThemeAPI {51 export interface ThemeAPI {
44- setTheme: (theme: 'light' | 'dark') => void;52+ setTheme: (theme: "light" | "dark") => void;
45- getCurrentTheme: () => 'light' | 'dark';53+ getCurrentTheme: () => "light" | "dark";
46 }54 }
47 55
48 // Message types for WebKit communication56 // Message types for WebKit communication
@@ -51,4 +59,4 @@ export interface BrowserMessage {
51 data?: unknown;59 data?: unknown;
52 }60 }
53 61
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';1+import { AppService } from "./services/app.js";
2-import { BrowserControlsService } from './services/browser-controls.js';2+import { BrowserControlsService } from "./services/browser-controls.js";
3-import { DOMRenderer } from './utils/dom.js';3+import { DOMRenderer } from "./utils/dom.js";
4 4
5 class DumberBrowserApp {5 class DumberBrowserApp {
6 private appService: AppService;6 private appService: AppService;
@@ -16,21 +16,21 @@ class DumberBrowserApp {
16 async initialize(): Promise<void> {16 async initialize(): Promise<void> {
17 try {17 try {
18 // Show loading states18 // Show loading states
19- this.domRenderer.showLoading('history');19+ this.domRenderer.showLoading("history");
20- this.domRenderer.showLoading('shortcuts');20+ this.domRenderer.showLoading("shortcuts");
21 21
22 // Initialize services22 // Initialize services
23 await this.appService.initialize();23 await this.appService.initialize();
24- 24+
25 // Initialize browser controls25 // Initialize browser controls
26 await this.browserControls.initialize();26 await this.browserControls.initialize();
27 27
28 // Render the UI28 // Render the UI
29 this.render();29 this.render();
30 30
31- console.log('✓ Dumber Browser initialized successfully');31+ console.log("✓ Dumber Browser initialized successfully");
32 } catch (error) {32 } catch (error) {
33- console.error('Failed to initialize Dumber Browser:', error);33+ console.error("Failed to initialize Dumber Browser:", error);
34 this.handleInitializationError();34 this.handleInitializationError();
35 }35 }
36 }36 }
@@ -45,29 +45,29 @@ class DumberBrowserApp {
45 const shortcuts = this.appService.getShortcuts();45 const shortcuts = this.appService.getShortcuts();
46 this.domRenderer.displayShortcuts(shortcuts);46 this.domRenderer.displayShortcuts(shortcuts);
47 } catch (error) {47 } catch (error) {
48- console.error('Failed to render UI:', error);48+ console.error("Failed to render UI:", error);
49- this.domRenderer.showError('history', 'Failed to load history');49+ this.domRenderer.showError("history", "Failed to load history");
50- this.domRenderer.showError('shortcuts', 'Failed to load shortcuts');50+ this.domRenderer.showError("shortcuts", "Failed to load shortcuts");
51 }51 }
52 }52 }
53 53
54 private handleInitializationError(): void {54 private handleInitializationError(): void {
55- this.domRenderer.showError('history', 'Failed to initialize application');55+ this.domRenderer.showError("history", "Failed to initialize application");
56- this.domRenderer.showError('shortcuts', 'Failed to initialize application');56+ this.domRenderer.showError("shortcuts", "Failed to initialize application");
57 }57 }
58 }58 }
59 59
60 // Initialize the application when the DOM is ready60 // Initialize the application when the DOM is ready
61 function initializeApp(): void {61 function initializeApp(): void {
62 const app = new DumberBrowserApp();62 const app = new DumberBrowserApp();
63- app.initialize().catch(error => {63+ app.initialize().catch((error) => {
64- console.error('Critical error during app initialization:', error);64+ console.error("Critical error during app initialization:", error);
65 });65 });
66 }66 }
67 67
68 // Wait for DOM to be ready68 // Wait for DOM to be ready
69-if (document.readyState === 'loading') {69+if (document.readyState === "loading") {
70- document.addEventListener('DOMContentLoaded', initializeApp);70+ document.addEventListener("DOMContentLoaded", initializeApp);
71 } else {71 } else {
72 initializeApp();72 initializeApp();
73-}⏎̸73+}
M gui/src/pages/homepage.ts +17 -8
@@ -1,27 +1,36 @@
1-import { mount } from 'svelte';1+import { mount } from "svelte";
2-import Homepage from './Homepage.svelte';2+import Homepage from "./Homepage.svelte";
3 3
4 // Proactively unregister any Service Workers and clear caches that may have been4 // Proactively unregister any Service Workers and clear caches that may have been
5 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to5 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to
6 // request non-existent _app/immutable/* assets under dumb://homepage.6 // request non-existent _app/immutable/* assets under dumb://homepage.
7 async function cleanupServiceWorkersAndCaches() {7 async function cleanupServiceWorkersAndCaches() {
8 try {8 try {
9- if ('serviceWorker' in navigator) {9+ if ("serviceWorker" in navigator) {
10 const regs = await navigator.serviceWorker.getRegistrations();10 const regs = await navigator.serviceWorker.getRegistrations();
11 await Promise.all(regs.map((r) => r.unregister().catch(() => {})));11 await Promise.all(regs.map((r) => r.unregister().catch(() => {})));
12 }12 }
13- } catch { /* ignore */ }13+ } catch {
14+ /* ignore */
15+ }
14 16
15 try {17 try {
16- if ('caches' in window) {18+ if ("caches" in window) {
17 const keys = await caches.keys();19 const keys = await caches.keys();
18 await Promise.all(keys.map((k) => caches.delete(k).catch(() => false)));20 await Promise.all(keys.map((k) => caches.delete(k).catch(() => false)));
19 }21 }
20- } catch { /* ignore */ }22+ } catch {
23+ /* ignore */
24+ }
21 }25 }
22 26
23-
24 // Mount the Homepage component to the DOM27 // Mount the Homepage component to the DOM
25 cleanupServiceWorkersAndCaches().finally(() => {28 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+ }
27 });36 });
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";
2 2
3 export class AppService {3 export class AppService {
4 private history: HistoryEntry[] = [];4 private history: HistoryEntry[] = [];
@@ -8,12 +8,9 @@ export class AppService {
8 8
9 async initialize(): Promise<void> {9 async initialize(): Promise<void> {
10 try {10 try {
11- await Promise.all([11+ await Promise.all([this.loadHistory(), this.loadShortcuts()]);
12- this.loadHistory(),
13- this.loadShortcuts()
14- ]);
15 } catch (error) {12 } catch (error) {
16- console.error('Failed to initialize app:', error);13+ console.error("Failed to initialize app:", error);
17 }14 }
18 }15 }
19 16
@@ -28,57 +25,59 @@ export class AppService {
28 };25 };
29 26
30 window.__dumber_history_error = (error: string) => {27 window.__dumber_history_error = (error: string) => {
31- console.error('Failed to load history:', error);28+ console.error("Failed to load history:", error);
32 reject(new Error(error));29 reject(new Error(error));
33 };30 };
34 31
35 // Send message to Go backend32 // Send message to Go backend
36 const bridge = window.webkit?.messageHandlers?.dumber;33 const bridge = window.webkit?.messageHandlers?.dumber;
37- if (bridge && typeof bridge.postMessage === 'function') {34+ if (bridge && typeof bridge.postMessage === "function") {
38- bridge.postMessage(JSON.stringify({35+ bridge.postMessage(
39- type: 'history_recent',36+ JSON.stringify({
40- limit: 50,37+ type: "history_recent",
41- offset: 038+ limit: 50,
42- }));39+ offset: 0,
40+ }),
41+ );
43 } else {42 } else {
44- reject(new Error('WebKit message handler not available'));43+ reject(new Error("WebKit message handler not available"));
45 }44 }
46 });45 });
47 } catch (error) {46 } catch (error) {
48- console.error('Failed to load history:', error);47+ console.error("Failed to load history:", error);
49 // Mock history for development/fallback48 // Mock history for development/fallback
50 this.history = [49 this.history = [
51- { 50+ {
52- id: 1, 51+ id: 1,
53- url: 'https://github.com/wailsapp/wails', 52+ url: "https://github.com/wailsapp/wails",
54- title: 'Wails Framework', 53+ title: "Wails Framework",
55 visit_count: 3,54 visit_count: 3,
56 last_visited: new Date().toISOString(),55 last_visited: new Date().toISOString(),
57- created_at: new Date().toISOString()56+ created_at: new Date().toISOString(),
58 },57 },
59- { 58+ {
60- id: 2, 59+ id: 2,
61- url: 'https://go.dev', 60+ url: "https://go.dev",
62- title: 'The Go Programming Language', 61+ title: "The Go Programming Language",
63 visit_count: 2,62 visit_count: 2,
64 last_visited: new Date().toISOString(),63 last_visited: new Date().toISOString(),
65- created_at: new Date().toISOString()64+ created_at: new Date().toISOString(),
66 },65 },
67- { 66+ {
68- id: 3, 67+ id: 3,
69- url: 'https://developer.mozilla.org', 68+ url: "https://developer.mozilla.org",
70- title: 'MDN Web Docs', 69+ title: "MDN Web Docs",
71 visit_count: 1,70 visit_count: 1,
72 last_visited: new Date().toISOString(),71 last_visited: new Date().toISOString(),
73- created_at: new Date().toISOString()72+ created_at: new Date().toISOString(),
74- }73+ },
75 ];74 ];
76 }75 }
77 }76 }
78 77
79 async loadShortcuts(): Promise<void> {78 async loadShortcuts(): Promise<void> {
80 try {79 try {
81- const cfg = await fetch('/api/config').then(r => r.json());80+ const cfg = await fetch("/api/config").then((r) => r.json());
82 const raw = cfg?.search_shortcuts || {};81 const raw = cfg?.search_shortcuts || {};
83 // Normalize field casing from backend (supports URL/Description and url/description)82 // Normalize field casing from backend (supports URL/Description and url/description)
84 const normalized: Record<string, SearchShortcut> = {};83 const normalized: Record<string, SearchShortcut> = {};
@@ -87,20 +86,45 @@ export class AppService {
87 normalized[key] = {86 normalized[key] = {
88 id: 0,87 id: 0,
89 shortcut: key,88 shortcut: key,
90- url_template: (v.url ?? v.URL ?? '') as string,89+ url_template: (v.url ?? v.URL ?? "") as string,
91- description: (v.description ?? v.Description ?? '') as string,90+ description: (v.description ?? v.Description ?? "") as string,
92- created_at: null91+ created_at: null,
93 };92 };
94 }93 }
95 this.shortcuts = normalized;94 this.shortcuts = normalized;
96 } catch (error) {95 } catch (error) {
97- console.error('Failed to load shortcuts:', error);96+ console.error("Failed to load shortcuts:", error);
98 // Mock shortcuts for development/fallback97 // Mock shortcuts for development/fallback
99 this.shortcuts = {98 this.shortcuts = {
100- 'g': { id: 0, shortcut: 'g', url_template: 'https://google.com/search?q={query}', description: 'Google Search', created_at: null },99+ g: {
101- 'gh': { id: 0, shortcut: 'gh', url_template: 'https://github.com/search?q={query}', description: 'GitHub Search', created_at: null },100+ id: 0,
102- 'so': { id: 0, shortcut: 'so', url_template: 'https://stackoverflow.com/search?q={query}', description: 'Stack Overflow', created_at: null },101+ shortcut: "g",
103- 'w': { id: 0, shortcut: 'w', url_template: 'https://en.wikipedia.org/wiki/Special:Search?search={query}', description: 'Wikipedia', created_at: null }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+ },
104 };128 };
105 }129 }
106 }130 }
@@ -116,9 +140,9 @@ export class AppService {
116 async copyToClipboard(text: string): Promise<void> {140 async copyToClipboard(text: string): Promise<void> {
117 try {141 try {
118 await navigator.clipboard.writeText(text);142 await navigator.clipboard.writeText(text);
119- console.log('Copied to clipboard:', text);143+ console.log("Copied to clipboard:", text);
120 } catch (err) {144 } catch (err) {
121- console.error('Failed to copy:', err);145+ console.error("Failed to copy:", err);
122 }146 }
123 }147 }
124 }148 }
M gui/src/services/browser-controls.ts +112 -46
@@ -1,35 +1,49 @@
1-import { NotificationService } from './notifications.js';1+import { NotificationService } from "./notifications.js";
2-import { DebugConsoleService } from './debug-console.js';2+import { DebugConsoleService } from "./debug-console.js";
3 3
4 export class BrowserControlsService {4 export class BrowserControlsService {
5- private currentURL = '';5+ private currentURL = "";
6 private notifications = new NotificationService();6 private notifications = new NotificationService();
7 private debugConsole = new DebugConsoleService();7 private debugConsole = new DebugConsoleService();
8 8
9 constructor() {9 constructor() {
10 // Debug logging with enhanced details10 // Debug logging with enhanced details
11- this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initializing...');11+ this.debugConsole.addLog(
12- this.debugConsole.addLog('info', 'frontend', 'init', 'Running without Wails bindings');12+ "info",
13- 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+
14 this.initializeEventListeners();24 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+ );
16 }31 }
17 32
18-
19 private initializeEventListeners(): void {33 private initializeEventListeners(): void {
20 // Keyboard event handler for browser controls34 // Keyboard event handler for browser controls
21- document.addEventListener('keydown', this.handleKeyboardEvent.bind(this));35+ document.addEventListener("keydown", this.handleKeyboardEvent.bind(this));
22- 36+
23 // Mouse button handler for navigation37 // Mouse button handler for navigation
24- document.addEventListener('mousedown', this.handleMouseEvent.bind(this));38+ document.addEventListener("mousedown", this.handleMouseEvent.bind(this));
25- 39+
26 // URL change listener (for SPA navigation)40 // URL change listener (for SPA navigation)
27- window.addEventListener('popstate', this.handleURLChange.bind(this));41+ window.addEventListener("popstate", this.handleURLChange.bind(this));
28 }42 }
29 43
30 private async handleKeyboardEvent(event: KeyboardEvent): Promise<void> {44 private async handleKeyboardEvent(event: KeyboardEvent): Promise<void> {
31 // Skip F12 (debug console toggle)45 // Skip F12 (debug console toggle)
32- if (event.key === 'F12') {46+ if (event.key === "F12") {
33 return;47 return;
34 }48 }
35 49
@@ -40,28 +54,43 @@ export class BrowserControlsService {
40 const isCmd = ctrlKey || metaKey;54 const isCmd = ctrlKey || metaKey;
41 55
42 // Zoom controls handled natively by GTK; don't intercept56 // 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")) {
44 return; // allow native handler to process58 return; // allow native handler to process
45 }59 }
46 60
47 // Copy URL61 // Copy URL
48- if (isCmd && shiftKey && key === 'C') {62+ if (isCmd && shiftKey && key === "C") {
49- this.debugConsole.addLog('info', 'frontend', 'action', 'Copy URL triggered');63+ this.debugConsole.addLog(
64+ "info",
65+ "frontend",
66+ "action",
67+ "Copy URL triggered",
68+ );
50 event.preventDefault();69 event.preventDefault();
51 await this.copyCurrentURL();70 await this.copyCurrentURL();
52 return;71 return;
53 }72 }
54 73
55 // Navigation74 // Navigation
56- if (altKey && key === 'ArrowLeft') {75+ if (altKey && key === "ArrowLeft") {
57- this.debugConsole.addLog('info', 'frontend', 'action', 'Go Back triggered');76+ this.debugConsole.addLog(
77+ "info",
78+ "frontend",
79+ "action",
80+ "Go Back triggered",
81+ );
58 event.preventDefault();82 event.preventDefault();
59 history.back();83 history.back();
60 return;84 return;
61 }85 }
62 86
63- if (altKey && key === 'ArrowRight') {87+ if (altKey && key === "ArrowRight") {
64- this.debugConsole.addLog('info', 'frontend', 'action', 'Go Forward triggered');88+ this.debugConsole.addLog(
89+ "info",
90+ "frontend",
91+ "action",
92+ "Go Forward triggered",
93+ );
65 event.preventDefault();94 event.preventDefault();
66 history.forward();95 history.forward();
67 return;96 return;
@@ -74,7 +103,12 @@ export class BrowserControlsService {
74 103
75 // Mouse button 3 (back button)104 // Mouse button 3 (back button)
76 if (event.button === 3) {105 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+ );
78 event.preventDefault();112 event.preventDefault();
79 await this.goBack();113 await this.goBack();
80 return;114 return;
@@ -82,7 +116,12 @@ export class BrowserControlsService {
82 116
83 // Mouse button 4 (forward button)117 // Mouse button 4 (forward button)
84 if (event.button === 4) {118 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+ );
86 event.preventDefault();125 event.preventDefault();
87 await this.goForward();126 await this.goForward();
88 return;127 return;
@@ -100,41 +139,68 @@ export class BrowserControlsService {
100 try {139 try {
101 const url = this.getCurrentURL();140 const url = this.getCurrentURL();
102 await navigator.clipboard.writeText(url);141 await navigator.clipboard.writeText(url);
103- 142+
104 // URL copied successfully - no backend notification needed143 // URL copied successfully - no backend notification needed
105- 144+
106- this.notifications.show('URL copied to clipboard!', 'success');145+ this.notifications.show("URL copied to clipboard!", "success");
107 } catch (error) {146 } catch (error) {
108- console.error('Failed to copy URL:', error);147+ console.error("Failed to copy URL:", error);
109- this.notifications.show('Failed to copy URL', 'error');148+ this.notifications.show("Failed to copy URL", "error");
110 }149 }
111 }150 }
112 151
113 async goBack(): Promise<void> {152 async goBack(): Promise<void> {
114- this.debugConsole.addLog('info', 'frontend', 'service', 'Calling GoBack');153+ this.debugConsole.addLog("info", "frontend", "service", "Calling GoBack");
115- 154+
116 try {155 try {
117 // await BrowserService.GoBack(); // TODO: Implement BrowserService156 // await BrowserService.GoBack(); // TODO: Implement BrowserService
118- this.debugConsole.logServiceCall('BrowserService', 'GoBack', []);157+ this.debugConsole.logServiceCall("BrowserService", "GoBack", []);
119 history.back();158 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+ );
121 } catch (error) {165 } catch (error) {
122- this.debugConsole.logServiceCall('BrowserService', 'GoBack', [], null, error as Error);166+ this.debugConsole.logServiceCall(
123- console.error('Failed to go back:', error);167+ "BrowserService",
168+ "GoBack",
169+ [],
170+ null,
171+ error as Error,
172+ );
173+ console.error("Failed to go back:", error);
124 }174 }
125 }175 }
126 176
127 async goForward(): Promise<void> {177 async goForward(): Promise<void> {
128- this.debugConsole.addLog('info', 'frontend', 'service', 'Calling GoForward');178+ this.debugConsole.addLog(
129- 179+ "info",
180+ "frontend",
181+ "service",
182+ "Calling GoForward",
183+ );
184+
130 try {185 try {
131 // await BrowserService.GoForward(); // TODO: Implement BrowserService186 // await BrowserService.GoForward(); // TODO: Implement BrowserService
132- this.debugConsole.logServiceCall('BrowserService', 'GoForward', []);187+ this.debugConsole.logServiceCall("BrowserService", "GoForward", []);
133 history.forward();188 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+ );
135 } catch (error) {195 } catch (error) {
136- this.debugConsole.logServiceCall('BrowserService', 'GoForward', [], null, error as Error);196+ this.debugConsole.logServiceCall(
137- console.error('Failed to go forward:', error);197+ "BrowserService",
198+ "GoForward",
199+ [],
200+ null,
201+ error as Error,
202+ );
203+ console.error("Failed to go forward:", error);
138 }204 }
139 }205 }
140 206
@@ -150,17 +216,17 @@ export class BrowserControlsService {
150 private async updateWindowTitle(url: string): Promise<void> {216 private async updateWindowTitle(url: string): Promise<void> {
151 try {217 try {
152 // Extract domain or path for title218 // Extract domain or path for title
153- let title = 'Dumber Browser';219+ let title = "Dumber Browser";
154- if (url && url !== '/') {220+ if (url && url !== "/") {
155 const urlObj = new URL(url);221 const urlObj = new URL(url);
156 title = `Dumber - ${urlObj.hostname}`;222 title = `Dumber - ${urlObj.hostname}`;
157 }223 }
158- 224+
159 document.title = title;225 document.title = title;
160- 226+
161 // Backend title updates handled natively; no frontend call227 // Backend title updates handled natively; no frontend call
162 } catch (error) {228 } catch (error) {
163- console.error('Failed to update title:', error);229+ console.error("Failed to update title:", error);
164 }230 }
165 }231 }
166 232
@@ -172,7 +238,7 @@ export class BrowserControlsService {
172 // Check if there's an initial URL from backend (direct navigation)238 // Check if there's an initial URL from backend (direct navigation)
173 // Set initial URL from browser location (homepage)239 // Set initial URL from browser location (homepage)
174 this.setCurrentURL(window.location.href);240 this.setCurrentURL(window.location.href);
175- 241+
176 // Load saved zoom level (only if we're staying on homepage)242 // Load saved zoom level (only if we're staying on homepage)
177 // Zoom handled natively243 // Zoom handled natively
178 }244 }
M gui/src/services/debug-console.ts +103 -66
@@ -1,7 +1,7 @@
1 export interface DebugLogEntry {1 export interface DebugLogEntry {
2 timestamp: string;2 timestamp: string;
3- level: 'info' | 'warn' | 'error' | 'debug';3+ level: "info" | "warn" | "error" | "debug";
4- source: 'frontend' | 'backend';4+ source: "frontend" | "backend";
5 category: string;5 category: string;
6 message: string;6 message: string;
7 data?: unknown;7 data?: unknown;
@@ -22,8 +22,8 @@ export class DebugConsoleService {
22 22
23 private createDebugUI(): void {23 private createDebugUI(): void {
24 // Create debug panel24 // Create debug panel
25- this.debugElement = document.createElement('div');25+ this.debugElement = document.createElement("div");
26- this.debugElement.id = 'debug-console';26+ this.debugElement.id = "debug-console";
27 this.debugElement.style.cssText = `27 this.debugElement.style.cssText = `
28 position: fixed;28 position: fixed;
29 top: 0;29 top: 0;
@@ -40,7 +40,7 @@ export class DebugConsoleService {
40 `;40 `;
41 41
42 // Create header42 // Create header
43- const header = document.createElement('div');43+ const header = document.createElement("div");
44 header.style.cssText = `44 header.style.cssText = `
45 padding: 10px;45 padding: 10px;
46 background: rgba(0, 255, 0, 0.1);46 background: rgba(0, 255, 0, 0.1);
@@ -60,7 +60,7 @@ export class DebugConsoleService {
60 `;60 `;
61 61
62 // Create logs container62 // Create logs container
63- this.logsContainer = document.createElement('div');63+ this.logsContainer = document.createElement("div");
64 this.logsContainer.style.cssText = `64 this.logsContainer.style.cssText = `
65 height: calc(100% - 50px);65 height: calc(100% - 50px);
66 overflow-y: auto;66 overflow-y: auto;
@@ -72,14 +72,18 @@ export class DebugConsoleService {
72 document.body.appendChild(this.debugElement);72 document.body.appendChild(this.debugElement);
73 73
74 // Setup button handlers74 // Setup button handlers
75- header.querySelector('#clear-logs')?.addEventListener('click', () => this.clearLogs());75+ header
76- header.querySelector('#close-debug')?.addEventListener('click', () => this.hide());76+ .querySelector("#clear-logs")
77+ ?.addEventListener("click", () => this.clearLogs());
78+ header
79+ .querySelector("#close-debug")
80+ ?.addEventListener("click", () => this.hide());
77 }81 }
78 82
79 private setupKeyboardShortcut(): void {83 private setupKeyboardShortcut(): void {
80- document.addEventListener('keydown', (event) => {84+ document.addEventListener("keydown", (event) => {
81 // F12 to toggle debug console85 // F12 to toggle debug console
82- if (event.key === 'F12') {86+ if (event.key === "F12") {
83 event.preventDefault();87 event.preventDefault();
84 this.toggle();88 this.toggle();
85 }89 }
@@ -92,47 +96,53 @@ export class DebugConsoleService {
92 warn: console.warn,96 warn: console.warn,
93 error: console.error,97 error: console.error,
94 info: console.info,98 info: console.info,
95- debug: console.debug99+ debug: console.debug,
96 };100 };
97 101
98 console.log = (...args) => {102 console.log = (...args) => {
99- this.addLog('info', 'frontend', 'console', args.join(' '));103+ this.addLog("info", "frontend", "console", args.join(" "));
100 originalConsole.log(...args);104 originalConsole.log(...args);
101 };105 };
102 106
103 console.warn = (...args) => {107 console.warn = (...args) => {
104- this.addLog('warn', 'frontend', 'console', args.join(' '));108+ this.addLog("warn", "frontend", "console", args.join(" "));
105 originalConsole.warn(...args);109 originalConsole.warn(...args);
106 };110 };
107 111
108 console.error = (...args) => {112 console.error = (...args) => {
109- this.addLog('error', 'frontend', 'console', args.join(' '));113+ this.addLog("error", "frontend", "console", args.join(" "));
110 originalConsole.error(...args);114 originalConsole.error(...args);
111 };115 };
112 116
113 console.info = (...args) => {117 console.info = (...args) => {
114- this.addLog('info', 'frontend', 'console', args.join(' '));118+ this.addLog("info", "frontend", "console", args.join(" "));
115 originalConsole.info(...args);119 originalConsole.info(...args);
116 };120 };
117 121
118 console.debug = (...args) => {122 console.debug = (...args) => {
119- this.addLog('debug', 'frontend', 'console', args.join(' '));123+ this.addLog("debug", "frontend", "console", args.join(" "));
120 originalConsole.debug(...args);124 originalConsole.debug(...args);
121 };125 };
122 }126 }
123 127
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 {
125 const entry: DebugLogEntry = {135 const entry: DebugLogEntry = {
126- timestamp: new Date().toISOString().split('T')[1]?.split('.')[0] || '', // HH:MM:SS format136+ timestamp: new Date().toISOString().split("T")[1]?.split(".")[0] || "", // HH:MM:SS format
127 level,137 level,
128 source,138 source,
129 category,139 category,
130 message,140 message,
131- data141+ data,
132 };142 };
133 143
134 this.logs.push(entry);144 this.logs.push(entry);
135- 145+
136 // Keep only the last maxLogs entries146 // Keep only the last maxLogs entries
137 if (this.logs.length > this.maxLogs) {147 if (this.logs.length > this.maxLogs) {
138 this.logs.shift();148 this.logs.shift();
@@ -144,96 +154,123 @@ export class DebugConsoleService {
144 private updateDisplay(): void {154 private updateDisplay(): void {
145 if (!this.logsContainer) return;155 if (!this.logsContainer) return;
146 156
147- const logHTML = this.logs.map(log => {157+ const logHTML = this.logs
148- const levelColor = {158+ .map((log) => {
149- info: '#00ff00',159+ const levelColor = {
150- warn: '#ffff00', 160+ info: "#00ff00",
151- error: '#ff0000',161+ warn: "#ffff00",
152- debug: '#00ffff'162+ error: "#ff0000",
153- }[log.level];163+ debug: "#00ffff",
164+ }[log.level];
154 165
155- const sourceIcon = log.source === 'backend' ? '🖥️' : '🌐';166+ const sourceIcon = log.source === "backend" ? "🖥️" : "🌐";
156 167
157- return `168+ return `
158 <div style="margin-bottom: 2px; font-size: 11px;">169 <div style="margin-bottom: 2px; font-size: 11px;">
159 <span style="color: #888;">[${log.timestamp}]</span>170 <span style="color: #888;">[${log.timestamp}]</span>
160 <span style="color: ${levelColor};">${sourceIcon} ${log.level.toUpperCase()}</span>171 <span style="color: ${levelColor};">${sourceIcon} ${log.level.toUpperCase()}</span>
161 <span style="color: #aaa;">[${log.category}]</span>172 <span style="color: #aaa;">[${log.category}]</span>
162 <span style="color: #fff;">${log.message}</span>173 <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>` : ""}
164 </div>175 </div>
165 `;176 `;
166- }).join('');177+ })
178+ .join("");
167 179
168 this.logsContainer.innerHTML = logHTML;180 this.logsContainer.innerHTML = logHTML;
169- 181+
170 // Auto-scroll to bottom182 // Auto-scroll to bottom
171 this.logsContainer.scrollTop = this.logsContainer.scrollHeight;183 this.logsContainer.scrollTop = this.logsContainer.scrollHeight;
172 }184 }
173 185
174 public logKeyboardEvent(event: KeyboardEvent): void {186 public logKeyboardEvent(event: KeyboardEvent): void {
175 const modifiers = [];187 const modifiers = [];
176- if (event.ctrlKey) modifiers.push('Ctrl');188+ if (event.ctrlKey) modifiers.push("Ctrl");
177- if (event.shiftKey) modifiers.push('Shift');189+ if (event.shiftKey) modifiers.push("Shift");
178- if (event.altKey) modifiers.push('Alt');190+ if (event.altKey) modifiers.push("Alt");
179- if (event.metaKey) modifiers.push('Meta');191+ if (event.metaKey) modifiers.push("Meta");
180-192+
181- const shortcut = modifiers.length > 0 ? `${modifiers.join('+')}+${event.key}` : event.key;193+ const shortcut =
182- 194+ modifiers.length > 0 ? `${modifiers.join("+")}+${event.key}` : event.key;
183- this.addLog('info', 'frontend', 'keyboard', `Key pressed: ${shortcut}`, {195+
196+ this.addLog("info", "frontend", "keyboard", `Key pressed: ${shortcut}`, {
184 key: event.key,197 key: event.key,
185 code: event.code,198 code: event.code,
186 modifiers,199 modifiers,
187- target: event.target?.constructor.name200+ target: event.target?.constructor.name,
188 });201 });
189 }202 }
190 203
191 public logMouseEvent(event: MouseEvent): void {204 public logMouseEvent(event: MouseEvent): void {
192 const buttonNames: Record<number, string> = {205 const buttonNames: Record<number, string> = {
193- 0: 'Left',206+ 0: "Left",
194- 1: 'Middle',207+ 1: "Middle",
195- 2: 'Right',208+ 2: "Right",
196- 3: 'Back',209+ 3: "Back",
197- 4: 'Forward'210+ 4: "Forward",
198 };211 };
199 212
200- this.addLog('info', 'frontend', 'mouse', `Mouse ${event.type}: ${buttonNames[event.button] || `Button${event.button}`}`, {213+ this.addLog(
201- button: event.button,214+ "info",
202- clientX: event.clientX,215+ "frontend",
203- clientY: event.clientY,216+ "mouse",
204- target: event.target?.constructor.name217+ `Mouse ${event.type}: ${buttonNames[event.button] || `Button${event.button}`}`,
205- });218+ {
219+ button: event.button,
220+ clientX: event.clientX,
221+ clientY: event.clientY,
222+ target: event.target?.constructor.name,
223+ },
224+ );
206 }225 }
207 226
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 {
209 if (error) {234 if (error) {
210- this.addLog('error', 'frontend', 'service', `${serviceName}.${methodName}() failed`, {235+ this.addLog(
211- params,236+ "error",
212- error: error.message237+ "frontend",
213- });238+ "service",
239+ `${serviceName}.${methodName}() failed`,
240+ {
241+ params,
242+ error: error.message,
243+ },
244+ );
214 } else {245 } else {
215- this.addLog('info', 'frontend', 'service', `${serviceName}.${methodName}() called`, {246+ this.addLog(
216- params,247+ "info",
217- result248+ "frontend",
218- });249+ "service",
250+ `${serviceName}.${methodName}() called`,
251+ {
252+ params,
253+ result,
254+ },
255+ );
219 }256 }
220 }257 }
221 258
222 public logBackendMessage(message: string, data?: unknown): void {259 public logBackendMessage(message: string, data?: unknown): void {
223- this.addLog('info', 'backend', 'service', message, data);260+ this.addLog("info", "backend", "service", message, data);
224 }261 }
225 262
226 public show(): void {263 public show(): void {
227 if (this.debugElement) {264 if (this.debugElement) {
228- this.debugElement.style.display = 'block';265+ this.debugElement.style.display = "block";
229 this.isVisible = true;266 this.isVisible = true;
230- this.addLog('info', 'frontend', 'debug', 'Debug console opened');267+ this.addLog("info", "frontend", "debug", "Debug console opened");
231 }268 }
232 }269 }
233 270
234 public hide(): void {271 public hide(): void {
235 if (this.debugElement) {272 if (this.debugElement) {
236- this.debugElement.style.display = 'none';273+ this.debugElement.style.display = "none";
237 this.isVisible = false;274 this.isVisible = false;
238 }275 }
239 }276 }
@@ -249,7 +286,7 @@ export class DebugConsoleService {
249 public clearLogs(): void {286 public clearLogs(): void {
250 this.logs = [];287 this.logs = [];
251 this.updateDisplay();288 this.updateDisplay();
252- this.addLog('info', 'frontend', 'debug', 'Logs cleared');289+ this.addLog("info", "frontend", "debug", "Logs cleared");
253 }290 }
254 291
255 public isDebugVisible(): boolean {292 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";
2 2
3 interface TimerElement extends HTMLElement {3 interface TimerElement extends HTMLElement {
4 timeout?: ReturnType<typeof setTimeout>;4 timeout?: ReturnType<typeof setTimeout>;
@@ -11,24 +11,34 @@ export class NotificationService {
11 showZoom(message: string, duration = 2000): void {11 showZoom(message: string, duration = 2000): void {
12 // Create or update zoom notification12 // Create or update zoom notification
13 if (!this.zoomNotification) {13 if (!this.zoomNotification) {
14- this.zoomNotification = this.createNotificationElement('zoom-notification', {14+ this.zoomNotification = this.createNotificationElement(
15- top: '20px',15+ "zoom-notification",
16- right: '20px',16+ {
17- background: 'rgba(0, 0, 0, 0.8)',17+ top: "20px",
18- color: 'white'18+ right: "20px",
19- });19+ background: "rgba(0, 0, 0, 0.8)",
20+ color: "white",
21+ },
22+ );
20 }23 }
21 24
22 this.updateNotification(this.zoomNotification, message, duration);25 this.updateNotification(this.zoomNotification, message, duration);
23 }26 }
24 27
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 {
26 // Create or update general notification33 // Create or update general notification
27 if (!this.generalNotification) {34 if (!this.generalNotification) {
28- this.generalNotification = this.createNotificationElement('general-notification', {35+ this.generalNotification = this.createNotificationElement(
29- top: '60px',36+ "general-notification",
30- right: '20px'37+ {
31- });38+ top: "60px",
39+ right: "20px",
40+ },
41+ );
32 }42 }
33 43
34 // Set colors based on type44 // Set colors based on type
@@ -39,32 +49,39 @@ export class NotificationService {
39 this.updateNotification(this.generalNotification, message, duration);49 this.updateNotification(this.generalNotification, message, duration);
40 }50 }
41 51
42- private createNotificationElement(id: string, baseStyles: Record<string, string>): HTMLElement {52+ private createNotificationElement(
43- const notification = document.createElement('div');53+ id: string,
54+ baseStyles: Record<string, string>,
55+ ): HTMLElement {
56+ const notification = document.createElement("div");
44 notification.id = id;57 notification.id = id;
45- 58+
46 const styles = {59 const styles = {
47- position: 'fixed',60+ position: "fixed",
48- padding: '10px 15px',61+ padding: "10px 15px",
49- borderRadius: '5px',62+ borderRadius: "5px",
50- fontSize: '14px',63+ fontSize: "14px",
51- zIndex: '10000',64+ zIndex: "10000",
52- transition: 'opacity 0.3s',65+ transition: "opacity 0.3s",
53- opacity: '0',66+ opacity: "0",
54- ...baseStyles67+ ...baseStyles,
55 };68 };
56 69
57 notification.style.cssText = Object.entries(styles)70 notification.style.cssText = Object.entries(styles)
58 .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)71 .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
59- .join('; ');72+ .join("; ");
60 73
61 document.body.appendChild(notification);74 document.body.appendChild(notification);
62 return notification;75 return notification;
63 }76 }
64 77
65- private updateNotification(element: HTMLElement, message: string, duration: number): void {78+ private updateNotification(
79+ element: HTMLElement,
80+ message: string,
81+ duration: number,
82+ ): void {
66 element.textContent = message;83 element.textContent = message;
67- element.style.opacity = '1';84+ element.style.opacity = "1";
68 85
69 // Clear any existing timeout86 // Clear any existing timeout
70 const timerElement = element as TimerElement;87 const timerElement = element as TimerElement;
@@ -74,22 +91,25 @@ export class NotificationService {
74 91
75 // Hide after duration92 // Hide after duration
76 timerElement.timeout = setTimeout(() => {93 timerElement.timeout = setTimeout(() => {
77- element.style.opacity = '0';94+ element.style.opacity = "0";
78 }, duration);95 }, duration);
79 }96 }
80 97
81- private getNotificationColors(type: NotificationOptions['type']): { background: string; color: string } {98+ private getNotificationColors(type: NotificationOptions["type"]): {
99+ background: string;
100+ color: string;
101+ } {
82 switch (type) {102 switch (type) {
83- case 'error':103+ case "error":
84- return { background: 'rgba(220, 53, 69, 0.9)', color: 'white' };104+ return { background: "rgba(220, 53, 69, 0.9)", color: "white" };
85- case 'success':105+ case "success":
86- return { background: 'rgba(25, 135, 84, 0.9)', color: 'white' };106+ return { background: "rgba(25, 135, 84, 0.9)", color: "white" };
87 default:107 default:
88- return { background: 'rgba(13, 110, 253, 0.9)', color: 'white' };108+ return { background: "rgba(13, 110, 253, 0.9)", color: "white" };
89 }109 }
90 }110 }
91 111
92 private camelToKebab(str: string): string {112 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()}`);
94 }114 }
95-}⏎̸115+}
M gui/src/styles/tailwind.css +46 -28
@@ -3,12 +3,12 @@
3 /* Define custom theme colors for v4 */3 /* Define custom theme colors for v4 */
4 @theme {4 @theme {
5 /* Light theme colors (default) */5 /* Light theme colors (default) */
6- --color-browser-bg: rgb(255 255 255); /* white */6+ --color-browser-bg: rgb(255 255 255); /* white */
7- --color-browser-surface: rgb(249 250 251); /* gray-50 */7+ --color-browser-surface: rgb(249 250 251); /* gray-50 */
8- --color-browser-text: rgb(17 24 39); /* gray-900 */8+ --color-browser-text: rgb(17 24 39); /* gray-900 */
9- --color-browser-muted: rgb(107 114 128); /* gray-500 */9+ --color-browser-muted: rgb(107 114 128); /* gray-500 */
10- --color-browser-accent: rgb(59 130 246); /* blue-500 */10+ --color-browser-accent: rgb(59 130 246); /* blue-500 */
11- --color-browser-border: rgb(229 231 235); /* gray-200 */11+ --color-browser-border: rgb(229 231 235); /* gray-200 */
12 }12 }
13 13
14 /* Dynamic theme switching via CSS variables */14 /* Dynamic theme switching via CSS variables */
@@ -23,12 +23,12 @@
23 23
24 /* Dark theme colors (applied via .dark class from GTK) */24 /* Dark theme colors (applied via .dark class from GTK) */
25 .dark {25 .dark {
26- --dynamic-bg: rgb(17 24 39); /* gray-900 */26+ --dynamic-bg: rgb(17 24 39); /* gray-900 */
27- --dynamic-surface: rgb(31 41 55); /* gray-800 */27+ --dynamic-surface: rgb(31 41 55); /* gray-800 */
28- --dynamic-text: rgb(243 244 246); /* gray-100 */28+ --dynamic-text: rgb(243 244 246); /* gray-100 */
29- --dynamic-muted: rgb(156 163 175); /* gray-400 */29+ --dynamic-muted: rgb(156 163 175); /* gray-400 */
30- --dynamic-accent: rgb(96 165 250); /* blue-400 */30+ --dynamic-accent: rgb(96 165 250); /* blue-400 */
31- --dynamic-border: rgb(55 65 81); /* gray-700 */31+ --dynamic-border: rgb(55 65 81); /* gray-700 */
32 }32 }
33 33
34 /* Base styles for browser UI components */34 /* Base styles for browser UI components */
@@ -41,8 +41,10 @@
41 41
42 /* Toast notification styles */42 /* Toast notification styles */
43 .toast-base {43 .toast-base {
44- border-radius: 0.375rem; /* rounded-md */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);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);
46 padding: 0.25rem 0.5rem;48 padding: 0.25rem 0.5rem;
47 max-width: 24rem;49 max-width: 24rem;
48 pointer-events: auto;50 pointer-events: auto;
@@ -93,7 +95,9 @@
93 color: var(--dynamic-text);95 color: var(--dynamic-text);
94 border: 2px solid var(--dynamic-border);96 border: 2px solid var(--dynamic-border);
95 border-radius: 0.375rem;97 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);
97 transition: all 150ms;101 transition: all 150ms;
98 }102 }
99 103
@@ -118,7 +122,9 @@
118 background-color: var(--dynamic-bg);122 background-color: var(--dynamic-bg);
119 border: 1px solid var(--dynamic-border);123 border: 1px solid var(--dynamic-border);
120 border-radius: 0.375rem;124 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);
122 max-height: 24rem;128 max-height: 24rem;
123 overflow-y: auto;129 overflow-y: auto;
124 }130 }
@@ -218,27 +224,35 @@
218 }224 }
219 225
220 @keyframes fade-in {226 @keyframes fade-in {
221- from { opacity: 0; }227+ from {
222- to { opacity: 1; }228+ opacity: 0;
229+ }
230+ to {
231+ opacity: 1;
232+ }
223 }233 }
224 234
225 @keyframes fade-out {235 @keyframes fade-out {
226- from { opacity: 1; }236+ from {
227- to { opacity: 0; }237+ opacity: 1;
238+ }
239+ to {
240+ opacity: 0;
241+ }
228 }242 }
229 243
230 /* Find highlighting styles for in-page search */244 /* Find highlighting styles for in-page search */
231 .find-highlight {245 .find-highlight {
232- background-color: rgb(255 235 59); /* yellow-400 */246+ background-color: rgb(255 235 59); /* yellow-400 */
233- color: rgb(0 0 0); /* black */247+ color: rgb(0 0 0); /* black */
234- padding: 0 0.125rem; /* px-0.5 */248+ padding: 0 0.125rem; /* px-0.5 */
235- border-radius: 0.125rem; /* rounded-sm */249+ border-radius: 0.125rem; /* rounded-sm */
236 box-shadow: 0 0 0 1px rgb(200 185 0) inset;250 box-shadow: 0 0 0 1px rgb(200 185 0) inset;
237 }251 }
238 252
239 .find-active {253 .find-active {
240- background-color: rgb(255 152 0) !important; /* orange-500 */254+ background-color: rgb(255 152 0) !important; /* orange-500 */
241- color: rgb(0 0 0); /* black */255+ color: rgb(0 0 0); /* black */
242 box-shadow: 0 0 0 1px rgb(179 107 0) inset !important;256 box-shadow: 0 0 0 1px rgb(179 107 0) inset !important;
243 }257 }
244 258
@@ -247,9 +261,13 @@
247 /* Reset any inherited styles from the website */261 /* Reset any inherited styles from the website */
248 all: initial;262 all: initial;
249 /* Apply our design system */263 /* 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;
251 box-sizing: border-box;269 box-sizing: border-box;
252 * {270 * {
253 box-sizing: border-box;271 box-sizing: border-box;
254 }272 }
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";
2 2
3 export interface AppState {3 export interface AppState {
4 currentURL: string;4 currentURL: string;
@@ -10,7 +10,7 @@ export interface AppState {
10 10
11 export interface NotificationOptions {11 export interface NotificationOptions {
12 message: string;12 message: string;
13- type?: 'info' | 'success' | 'error';13+ type?: "info" | "success" | "error";
14 duration?: number;14 duration?: number;
15 }15 }
16 16
@@ -23,4 +23,4 @@ export interface KeyboardShortcut {
23 handler: () => void | Promise<void>;23 handler: () => void | Promise<void>;
24 }24 }
25 25
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 @@
1 // GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT1 // GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
2 // Auto-generated TypeScript types from Go structs2 // Auto-generated TypeScript types from Go structs
3-// Generated on: 2025-09-09T09:57:41.059Z3+// Generated on: 2025-09-22T12:41:57.478Z
4 // Generator script: scripts/generate-types.js4 // Generator script: scripts/generate-types.js
5 // Source files: internal/db/models.go, internal/parser/types.go5 // Source files: internal/db/models.go, internal/parser/types.go
6 // Do not edit manually - run 'node scripts/generate-types.js' to regenerate6 // Do not edit manually - run 'node scripts/generate-types.js' to regenerate
7 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+
8 export interface History {17 export interface History {
9 id: number;18 id: number;
10 url: string;19 url: string;
@@ -12,6 +21,7 @@ export interface History {
12 visit_count: number | null;21 visit_count: number | null;
13 last_visited: string | null;22 last_visited: string | null;
14 created_at: string | null;23 created_at: string | null;
24+ favicon_url: string | null;
15 }25 }
16 26
17 export interface Shortcut {27 export interface Shortcut {
@@ -22,11 +32,10 @@ export interface Shortcut {
22 created_at: string | null;32 created_at: string | null;
23 }33 }
24 34
25-export interface ZoomSetting {35+export interface ZoomLevel {
26- id: number;36+ domain: string;
27- url: string;37+ zoom_factor: number;
28- zoom_level: number;38+ updated_at: string | null;
29- last_updated: string | null;
30 }39 }
31 40
32 export interface ParseResult {41 export interface ParseResult {
@@ -71,7 +80,7 @@ export enum InputType {
71 InputTypeDirectURL = 0,80 InputTypeDirectURL = 0,
72 InputTypeSearchShortcut = 1,81 InputTypeSearchShortcut = 1,
73 InputTypeHistorySearch = 2,82 InputTypeHistorySearch = 2,
74- InputTypeFallbackSearch = 383+ InputTypeFallbackSearch = 3,
75 }84 }
76 85
77 // Type aliases for frontend usage86 // 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" {
2 const content: string;2 const content: string;
3 export default content;3 export default content;
4 }4 }
M gui/src/types/raw-css.d.ts +1 -1
@@ -1,4 +1,4 @@
1-declare module '*.css?raw' {1+declare module "*.css?raw" {
2 const content: string;2 const content: string;
3 export default content;3 export default content;
4 }4 }
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";
2 2
3 export class DOMRenderer {3 export class DOMRenderer {
4 private historyListElement: HTMLElement | null = null;4 private historyListElement: HTMLElement | null = null;
5 private shortcutsElement: HTMLElement | null = null;5 private shortcutsElement: HTMLElement | null = null;
6 6
7 constructor() {7 constructor() {
8- this.historyListElement = document.getElementById('historyList');8+ this.historyListElement = document.getElementById("historyList");
9- this.shortcutsElement = document.getElementById('shortcuts');9+ this.shortcutsElement = document.getElementById("shortcuts");
10 }10 }
11 11
12 displayHistory(historyEntries: History[]): void {12 displayHistory(historyEntries: History[]): void {
@@ -22,8 +22,8 @@ export class DOMRenderer {
22 return;22 return;
23 }23 }
24 24
25- this.historyListElement.innerHTML = '';25+ this.historyListElement.innerHTML = "";
26- historyEntries.forEach(item => {26+ historyEntries.forEach((item) => {
27 const historyItem = this.createHistoryItem(item);27 const historyItem = this.createHistoryItem(item);
28 this.historyListElement!.appendChild(historyItem);28 this.historyListElement!.appendChild(historyItem);
29 });29 });
@@ -42,22 +42,24 @@ export class DOMRenderer {
42 return;42 return;
43 }43 }
44 44
45- this.shortcutsElement.innerHTML = '';45+ this.shortcutsElement.innerHTML = "";
46 Object.entries(shortcutsData).forEach(([key, shortcut]) => {46 Object.entries(shortcutsData).forEach(([key, shortcut]) => {
47 const shortcutEl = this.createShortcutElement(key, shortcut);47 const shortcutEl = this.createShortcutElement(key, shortcut);
48 this.shortcutsElement!.appendChild(shortcutEl);48 this.shortcutsElement!.appendChild(shortcutEl);
49 });49 });
50 }50 }
51 51
52- showLoading(container: 'history' | 'shortcuts'): void {52+ showLoading(container: "history" | "shortcuts"): void {
53- const element = container === 'history' ? this.historyListElement : this.shortcutsElement;53+ const element =
54+ container === "history" ? this.historyListElement : this.shortcutsElement;
54 if (element) {55 if (element) {
55 element.innerHTML = '<div class="loading">Loading...</div>';56 element.innerHTML = '<div class="loading">Loading...</div>';
56 }57 }
57 }58 }
58 59
59- showError(container: 'history' | 'shortcuts', message: string): void {60+ showError(container: "history" | "shortcuts", message: string): void {
60- const element = container === 'history' ? this.historyListElement : this.shortcutsElement;61+ const element =
62+ container === "history" ? this.historyListElement : this.shortcutsElement;
61 if (element) {63 if (element) {
62 element.innerHTML = `64 element.innerHTML = `
63 <div class="empty-state">65 <div class="empty-state">
@@ -69,44 +71,50 @@ export class DOMRenderer {
69 }71 }
70 72
71 private createHistoryItem(item: History): HTMLElement {73 private createHistoryItem(item: History): HTMLElement {
72- const historyItem = document.createElement('div');74+ const historyItem = document.createElement("div");
73- historyItem.className = 'history-item';75+ historyItem.className = "history-item";
74 const parsed = this.safeParseURL(item.url);76 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";
76 81
77- const line = document.createElement('div');82+ const line = document.createElement("div");
78- line.className = 'history-line';83+ line.className = "history-line";
79 84
80 // Favicon chip (wrapper ensures perfect circle sizing)85 // Favicon chip (wrapper ensures perfect circle sizing)
81- const iconWrap = document.createElement('div');86+ const iconWrap = document.createElement("div");
82- iconWrap.className = 'history-favicon-chip';87+ iconWrap.className = "history-favicon-chip";
83- const icon = document.createElement('img');88+ const icon = document.createElement("img");
84- icon.className = 'history-favicon-img';89+ icon.className = "history-favicon-img";
85- icon.width = 16; icon.height = 16;90+ icon.width = 16;
86- icon.loading = 'lazy';91+ icon.height = 16;
87- icon.referrerPolicy = 'no-referrer';92+ icon.loading = "lazy";
93+ icon.referrerPolicy = "no-referrer";
88 icon.src = this.buildFaviconURL(item.url);94 icon.src = this.buildFaviconURL(item.url);
89- icon.addEventListener('error', () => { iconWrap.style.display = 'none'; });95+ icon.addEventListener("error", () => {
96+ iconWrap.style.display = "none";
97+ });
90 iconWrap.appendChild(icon);98 iconWrap.appendChild(icon);
91 99
92- const titleEl = document.createElement('span');100+ const titleEl = document.createElement("span");
93- titleEl.className = 'history-title';101+ titleEl.className = "history-title";
94 titleEl.textContent = title;102 titleEl.textContent = title;
95 103
96- const sep1 = document.createElement('span');104+ const sep1 = document.createElement("span");
97- sep1.className = 'history-sep';105+ sep1.className = "history-sep";
98- sep1.textContent = ' - ';106+ sep1.textContent = " - ";
99 107
100- const domainEl = document.createElement('span');108+ const domainEl = document.createElement("span");
101- domainEl.className = 'history-domain';109+ domainEl.className = "history-domain";
102- domainEl.textContent = parsed.host || '';110+ domainEl.textContent = parsed.host || "";
103 111
104- const sep2 = document.createElement('span');112+ const sep2 = document.createElement("span");
105- sep2.className = 'history-sep';113+ sep2.className = "history-sep";
106- sep2.textContent = ' - ';114+ sep2.textContent = " - ";
107 115
108- const urlEl = document.createElement('span');116+ const urlEl = document.createElement("span");
109- urlEl.className = 'history-url';117+ urlEl.className = "history-url";
110 urlEl.textContent = item.url;118 urlEl.textContent = item.url;
111 119
112 line.appendChild(iconWrap);120 line.appendChild(iconWrap);
@@ -117,33 +125,33 @@ export class DOMRenderer {
117 line.appendChild(urlEl);125 line.appendChild(urlEl);
118 126
119 historyItem.appendChild(line);127 historyItem.appendChild(line);
120- 128+
121- historyItem.addEventListener('click', () => {129+ historyItem.addEventListener("click", () => {
122 this.navigateToUrl(item.url);130 this.navigateToUrl(item.url);
123 });131 });
124- 132+
125 return historyItem;133 return historyItem;
126 }134 }
127 135
128 private createShortcutElement(key: string, shortcut: Shortcut): HTMLElement {136 private createShortcutElement(key: string, shortcut: Shortcut): HTMLElement {
129- const shortcutEl = document.createElement('div');137+ const shortcutEl = document.createElement("div");
130- shortcutEl.className = 'shortcut';138+ shortcutEl.className = "shortcut";
131 shortcutEl.innerHTML = `139 shortcutEl.innerHTML = `
132 <div class="shortcut-key">${this.escapeHtml(key)}:</div>140 <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>
134 `;142 `;
135- 143+
136- shortcutEl.addEventListener('click', () => {144+ shortcutEl.addEventListener("click", () => {
137 // Navigate to the base URL of the shortcut (remove %s template parameter)145 // Navigate to the base URL of the shortcut (remove %s template parameter)
138 const baseUrl = this.extractBaseUrl(shortcut.url_template);146 const baseUrl = this.extractBaseUrl(shortcut.url_template);
139 this.navigateToUrl(baseUrl);147 this.navigateToUrl(baseUrl);
140 });148 });
141- 149+
142 return shortcutEl;150 return shortcutEl;
143 }151 }
144 152
145 private async navigateToUrl(url: string): Promise<void> {153 private async navigateToUrl(url: string): Promise<void> {
146- console.log('Navigating to:', url);154+ console.log("Navigating to:", url);
147 window.location.href = url;155 window.location.href = url;
148 }156 }
149 157
@@ -163,13 +171,13 @@ export class DOMRenderer {
163 url.search = params.toString();171 url.search = params.toString();
164 return url.toString();172 return url.toString();
165 } catch (error) {173 } catch (error) {
166- console.error('Failed to parse shortcut URL:', templateUrl, error);174+ console.error("Failed to parse shortcut URL:", templateUrl, error);
167 throw error;175 throw error;
168 }176 }
169 }177 }
170 178
171 private escapeHtml(text: string): string {179 private escapeHtml(text: string): string {
172- const div = document.createElement('div');180+ const div = document.createElement("div");
173 div.textContent = text;181 div.textContent = text;
174 return div.innerHTML;182 return div.innerHTML;
175 }183 }
@@ -179,17 +187,17 @@ export class DOMRenderer {
179 const u = new URL(raw);187 const u = new URL(raw);
180 return { host: u.host };188 return { host: u.host };
181 } catch {189 } catch {
182- return { host: '' };190+ return { host: "" };
183 }191 }
184 }192 }
185 193
186 private buildFaviconURL(raw: string): string {194 private buildFaviconURL(raw: string): string {
187 try {195 try {
188 const u = new URL(raw);196 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:";
190 return `${scheme}//${u.host}/favicon.ico`;198 return `${scheme}//${u.host}/favicon.ico`;
191 } catch {199 } catch {
192- return '';200+ return "";
193 }201 }
194 }202 }
195 }203 }
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";
2 2
3 export default {3 export default {
4 // Enable rune mode for Svelte 54 // Enable rune mode for Svelte 5
@@ -7,4 +7,4 @@ export default {
7 },7 },
8 // Use Vite's preprocessor for TypeScript and PostCSS8 // Use Vite's preprocessor for TypeScript and PostCSS
9 preprocess: vitePreprocess(),9 preprocess: vitePreprocess(),
10-};⏎̸10+};
M gui/tailwind.config.js +20 -22
@@ -1,44 +1,42 @@
1 /** @type {import('tailwindcss').Config} */1 /** @type {import('tailwindcss').Config} */
2 export default {2 export default {
3- content: [3+ content: ["./src/**/*.{html,js,ts,svelte}"],
4- './src/**/*.{html,js,ts,svelte}',
5- ],
6 theme: {4 theme: {
7 extend: {5 extend: {
8 // Custom design system colors6 // Custom design system colors
9 colors: {7 colors: {
10 // Browser-specific colors that work well in both themes8 // Browser-specific colors that work well in both themes
11 browser: {9 browser: {
12- bg: 'rgb(var(--browser-bg) / <alpha-value>)',10+ bg: "rgb(var(--browser-bg) / <alpha-value>)",
13- surface: 'rgb(var(--browser-surface) / <alpha-value>)',11+ surface: "rgb(var(--browser-surface) / <alpha-value>)",
14- text: 'rgb(var(--browser-text) / <alpha-value>)',12+ text: "rgb(var(--browser-text) / <alpha-value>)",
15- muted: 'rgb(var(--browser-muted) / <alpha-value>)',13+ muted: "rgb(var(--browser-muted) / <alpha-value>)",
16- accent: 'rgb(var(--browser-accent) / <alpha-value>)',14+ accent: "rgb(var(--browser-accent) / <alpha-value>)",
17- border: 'rgb(var(--browser-border) / <alpha-value>)',15+ border: "rgb(var(--browser-border) / <alpha-value>)",
18- }16+ },
19 },17 },
20 // Animation speeds optimized for browser UI18 // Animation speeds optimized for browser UI
21 transitionDuration: {19 transitionDuration: {
22- '150': '150ms', // Fast interactions20+ 150: "150ms", // Fast interactions
23- '250': '250ms', // UI feedback21+ 250: "250ms", // UI feedback
24- '400': '400ms', // Smooth transitions22+ 400: "400ms", // Smooth transitions
25 },23 },
26 // Typography scale for browser UI24 // Typography scale for browser UI
27 fontSize: {25 fontSize: {
28- 'xs': ['0.75rem', '1rem'],26+ xs: ["0.75rem", "1rem"],
29- 'sm': ['0.875rem', '1.25rem'],27+ sm: ["0.875rem", "1.25rem"],
30- 'base': ['1rem', '1.5rem'],28+ base: ["1rem", "1.5rem"],
31- 'lg': ['1.125rem', '1.75rem'],29+ lg: ["1.125rem", "1.75rem"],
32 },30 },
33 // Spacing for compact UI elements31 // Spacing for compact UI elements
34 spacing: {32 spacing: {
35- '18': '4.5rem',33+ 18: "4.5rem",
36- '22': '5.5rem',34+ 22: "5.5rem",
37- }35+ },
38 },36 },
39 },37 },
40 plugins: [],38 plugins: [],
41 // Manual dark mode control (not system preference)39 // Manual dark mode control (not system preference)
42 // This allows GTK to control theme via CSS class40 // This allows GTK to control theme via CSS class
43- darkMode: 'class',41+ darkMode: "class",
44-};⏎̸42+};
M gui/tsconfig.json +3 -9
@@ -22,12 +22,6 @@
22 "$components/*": ["src/components/*"]22 "$components/*": ["src/components/*"]
23 }23 }
24 },24 },
25- "include": [25+ "include": ["src/**/*", "vite.config.ts"],
26- "src/**/*",26+ "exclude": ["node_modules", "dist"]
27- "vite.config.ts"27+}
28- ],
29- "exclude": [
30- "node_modules",
31- "dist"
32- ]
33-}⏎̸
M gui/vite-plugin-pages.ts +23 -22
@@ -1,6 +1,6 @@
1-import type { Plugin } from 'vite';1+import type { Plugin } from "vite";
2-import { writeFileSync, readFileSync } from 'fs';2+import { writeFileSync, readFileSync } from "fs";
3-import { resolve } from 'path';3+import { resolve } from "path";
4 4
5 interface PageConfig {5 interface PageConfig {
6 /** Page name (used for filename and script name) */6 /** Page name (used for filename and script name) */
@@ -17,23 +17,25 @@ interface PageConfig {
17 17
18 // Load the favicon SVG from assets18 // Load the favicon SVG from assets
19 function getFaviconSVG(): string {19 function getFaviconSVG(): string {
20- const logoPath = resolve(__dirname, '..', 'assets', 'logo.svg');20+ const logoPath = resolve(__dirname, "..", "assets", "logo.svg");
21- return readFileSync(logoPath, 'utf-8');21+ return readFileSync(logoPath, "utf-8");
22 }22 }
23 23
24 // Create favicon data URL for inline usage24 // Create favicon data URL for inline usage
25 function getFaviconDataURL(): string {25 function getFaviconDataURL(): string {
26 const svg = getFaviconSVG();26 const svg = getFaviconSVG();
27 const encoded = encodeURIComponent(svg)27 const encoded = encodeURIComponent(svg)
28- .replace(/'/g, '%27')28+ .replace(/'/g, "%27")
29- .replace(/\(/g, '%28')29+ .replace(/\(/g, "%28")
30- .replace(/\)/g, '%29');30+ .replace(/\)/g, "%29");
31 return `data:image/svg+xml;utf8,${encoded}`;31 return `data:image/svg+xml;utf8,${encoded}`;
32 }32 }
33 33
34 // Generate HTML content for a page34 // Generate HTML content for a page
35 function generatePageHTML(config: PageConfig): string {35 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+ : "";
37 39
38 return `<!DOCTYPE html>40 return `<!DOCTYPE html>
39 <html lang="en">41 <html lang="en">
@@ -56,9 +58,9 @@ function generatePageHTML(config: PageConfig): string {
56 */58 */
57 export function pageGenerator(pages: PageConfig[]): Plugin {59 export function pageGenerator(pages: PageConfig[]): Plugin {
58 return {60 return {
59- name: 'dumb-page-generator',61+ name: "dumb-page-generator",
60 writeBundle() {62 writeBundle() {
61- const assetsDir = resolve(__dirname, '..', 'assets', 'gui');63+ const assetsDir = resolve(__dirname, "..", "assets", "gui");
62 64
63 try {65 try {
64 // Generate HTML files for each page66 // Generate HTML files for each page
@@ -71,14 +73,13 @@ export function pageGenerator(pages: PageConfig[]): Plugin {
71 73
72 // Generate favicon.svg for scheme handler lookups74 // Generate favicon.svg for scheme handler lookups
73 const faviconContent = getFaviconSVG();75 const faviconContent = getFaviconSVG();
74- writeFileSync(resolve(assetsDir, 'favicon.svg'), faviconContent);76+ writeFileSync(resolve(assetsDir, "favicon.svg"), faviconContent);
75- console.log('✓ Generated favicon.svg');77+ console.log("✓ Generated favicon.svg");
76-
77 } catch (error) {78 } catch (error) {
78- console.error('✗ Failed to generate page files:', error);79+ console.error("✗ Failed to generate page files:", error);
79 throw error;80 throw error;
80 }81 }
81- }82+ },
82 };83 };
83 }84 }
84 85
@@ -89,10 +90,10 @@ export function pageGenerator(pages: PageConfig[]): Plugin {
89 export function homepageGenerator(): Plugin {90 export function homepageGenerator(): Plugin {
90 return pageGenerator([91 return pageGenerator([
91 {92 {
92- name: 'homepage',93+ name: "homepage",
93- title: 'Dumber Browser',94+ title: "Dumber Browser",
94- script: 'homepage.min.js',95+ script: "homepage.min.js",
95- filename: 'index.html', // Special case: homepage uses index.html96+ filename: "index.html", // Special case: homepage uses index.html
96- }97+ },
97 ]);98 ]);
98-}⏎̸99+}
M gui/vite.config.color-scheme.ts +10 -10
@@ -1,28 +1,28 @@
1-import { defineConfig } from 'vite';1+import { defineConfig } from "vite";
2-import { resolve } from 'path';2+import { resolve } from "path";
3 3
4 export default defineConfig({4 export default defineConfig({
5 build: {5 build: {
6 rollupOptions: {6 rollupOptions: {
7- input: resolve(__dirname, 'src/injected/modules/color-scheme.ts'),7+ input: resolve(__dirname, "src/injected/modules/color-scheme.ts"),
8 output: {8 output: {
9- dir: '../assets/gui',9+ dir: "../assets/gui",
10- format: 'iife',10+ format: "iife",
11- entryFileNames: 'color-scheme.js',11+ entryFileNames: "color-scheme.js",
12 inlineDynamicImports: true,12 inlineDynamicImports: true,
13 manualChunks: undefined, // Prevent chunk splitting13 manualChunks: undefined, // Prevent chunk splitting
14 },14 },
15 },15 },
16 emptyOutDir: false, // Don't clear the directory since GUI bundle is also built here16 emptyOutDir: false, // Don't clear the directory since GUI bundle is also built here
17- target: ['es2020', 'chrome91', 'firefox90'],17+ target: ["es2020", "chrome91", "firefox90"],
18 minify: true,18 minify: true,
19 sourcemap: false,19 sourcemap: false,
20 },20 },
21 resolve: {21 resolve: {
22 alias: {22 alias: {
23 // Alias for easier imports23 // Alias for easier imports
24- '$lib': resolve(__dirname, 'src/lib'),24+ $lib: resolve(__dirname, "src/lib"),
25- '$components': resolve(__dirname, 'src/components'),25+ $components: resolve(__dirname, "src/components"),
26 },26 },
27 },27 },
28-});⏎̸28+});
M gui/vite.config.gui.ts +13 -13
@@ -1,6 +1,6 @@
1-import { defineConfig } from 'vite';1+import { defineConfig } from "vite";
2-import { svelte } from '@sveltejs/vite-plugin-svelte';2+import { svelte } from "@sveltejs/vite-plugin-svelte";
3-import { resolve } from 'path';3+import { resolve } from "path";
4 4
5 export default defineConfig({5 export default defineConfig({
6 plugins: [6 plugins: [
@@ -10,19 +10,19 @@ export default defineConfig({
10 ],10 ],
11 build: {11 build: {
12 rollupOptions: {12 rollupOptions: {
13- input: resolve(__dirname, 'src/injected/gui.ts'),13+ input: resolve(__dirname, "src/injected/gui.ts"),
14 output: {14 output: {
15- dir: '../assets/gui',15+ dir: "../assets/gui",
16- format: 'iife',16+ format: "iife",
17- entryFileNames: 'gui.min.js',17+ entryFileNames: "gui.min.js",
18- name: '__dumberGUI',18+ name: "__dumberGUI",
19 inlineDynamicImports: true,19 inlineDynamicImports: true,
20 manualChunks: undefined, // Prevent chunk splitting20 manualChunks: undefined, // Prevent chunk splitting
21- assetFileNames: '[name].[ext]', // Don't hash asset names since they're inlined21+ assetFileNames: "[name].[ext]", // Don't hash asset names since they're inlined
22 },22 },
23 },23 },
24 emptyOutDir: false,24 emptyOutDir: false,
25- target: ['es2020', 'chrome91', 'firefox90'],25+ target: ["es2020", "chrome91", "firefox90"],
26 minify: true,26 minify: true,
27 sourcemap: false,27 sourcemap: false,
28 cssCodeSplit: false, // Prevent CSS code splitting28 cssCodeSplit: false, // Prevent CSS code splitting
@@ -31,8 +31,8 @@ export default defineConfig({
31 resolve: {31 resolve: {
32 alias: {32 alias: {
33 // Alias for easier imports33 // Alias for easier imports
34- '$lib': resolve(__dirname, 'src/lib'),34+ $lib: resolve(__dirname, "src/lib"),
35- '$components': resolve(__dirname, 'src/components'),35+ $components: resolve(__dirname, "src/components"),
36 },36 },
37 },37 },
38-});⏎̸38+});
M gui/vite.config.homepage.ts +22 -19
@@ -1,33 +1,36 @@
1-import { defineConfig } from 'vite';1+import { defineConfig } from "vite";
2-import { svelte } from '@sveltejs/vite-plugin-svelte';2+import { svelte } from "@sveltejs/vite-plugin-svelte";
3-import { resolve } from 'path';3+import { resolve } from "path";
4-import { pageGenerator } from './vite-plugin-pages';4+import { pageGenerator } from "./vite-plugin-pages";
5 5
6 export default defineConfig({6 export default defineConfig({
7 plugins: [7 plugins: [
8 svelte(),8 svelte(),
9 pageGenerator([9 pageGenerator([
10 {10 {
11- name: 'homepage',11+ name: "homepage",
12- title: 'Dumber Browser',12+ title: "Dumber Browser",
13- script: 'homepage.min.js',13+ script: "homepage.min.js",
14- css: 'homepage.css',14+ css: "homepage.css",
15- filename: 'index.html',15+ filename: "index.html",
16- }16+ },
17 ]),17 ]),
18 ],18 ],
19 build: {19 build: {
20 rollupOptions: {20 rollupOptions: {
21- input: resolve(__dirname, 'src/pages/homepage.ts'),21+ input: resolve(__dirname, "src/pages/homepage.ts"),
22 output: {22 output: {
23- dir: '../assets/gui',23+ dir: "../assets/gui",
24- entryFileNames: 'homepage.min.js',24+ entryFileNames: "homepage.min.js",
25- chunkFileNames: '[name].js',25+ chunkFileNames: "[name].js",
26- assetFileNames: '[name].[ext]',26+ assetFileNames: "[name].[ext]",
27+ manualChunks: undefined,
28+ format: 'iife',
29+ name: 'DumberHomepage'
27 },30 },
28 },31 },
29 emptyOutDir: false,32 emptyOutDir: false,
30- target: ['es2020', 'chrome91', 'firefox90'],33+ target: ["es2020", "chrome91", "firefox90"],
31 minify: true,34 minify: true,
32 sourcemap: false,35 sourcemap: false,
33 cssCodeSplit: true,36 cssCodeSplit: true,
@@ -35,8 +38,8 @@ export default defineConfig({
35 },38 },
36 resolve: {39 resolve: {
37 alias: {40 alias: {
38- '$lib': resolve(__dirname, 'src/lib'),41+ $lib: resolve(__dirname, "src/lib"),
39- '$components': resolve(__dirname, 'src/components'),42+ $components: resolve(__dirname, "src/components"),
40 },43 },
41 },44 },
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
30 30
31 func (s *ShortcutHandler) isActivePane() bool {31 func (s *ShortcutHandler) isActivePane() bool {
32 if s == nil || s.webView == nil {32 if s == nil || s.webView == nil {
33- log.Printf("[shortcut] isActivePane: handler or webView is nil")
34 return false33 return false
35 }34 }
36- if s.app == nil || s.app.activePane == nil {35+ if s.app == nil || s.app.workspace == nil {
37- log.Printf("[shortcut] isActivePane: app or activePane is nil, returning true")36+ return true // No workspace context; allow
38- return true
39 }37 }
40- isActive := s.app.activePane.webView == s.webView38+
41- log.Printf("[shortcut] isActivePane: handler webView=%p activePane webView=%p isActive=%v", s.webView, s.app.activePane.webView, isActive)39+ // Find node for this WebView
42- return isActive40+ 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
43 }60 }
44 61
45 // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards62 // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards
@@ -74,10 +91,8 @@ func (s *ShortcutHandler) RegisterShortcuts() {
74 _ = s.webView.Reload()91 _ = s.webView.Reload()
75 })92 })
76 93
77- // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are now handled94+ // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are handled by WorkspaceManager
78- // by the WorkspaceManager to ensure they work properly across all panes95+ // and zoom shortcuts (Ctrl +/-/0) are handled by WindowShortcutHandler to ensure they work properly across all panes
79-
80- // Zoom handled natively in webkit package (built-in shortcuts)
81 }96 }
82 97
83 func (s *ShortcutHandler) dispatchUIShortcut(action string, extra map[string]any) error {98 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 {
58 {"ctrl+f", h.handleFindToggle, "Find in page"},58 {"ctrl+f", h.handleFindToggle, "Find in page"},
59 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},59 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
60 {"F12", h.handleDevTools, "Developer tools"},60 {"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"},
61 }66 }
62 67
63 for _, shortcut := range shortcuts {68 for _, shortcut := range shortcuts {
@@ -144,6 +149,64 @@ func (h *WindowShortcutHandler) handleDevTools() {
144 }149 }
145 }150 }
146 151
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+
147 func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {210 func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
148 if h.app.activePane == nil {211 if h.app.activePane == nil {
149 return212 return
M internal/app/browser/workspace_manager.go +625 -104
@@ -22,8 +22,14 @@ type paneNode struct {
22 container uintptr // GtkPaned for branch nodes, stable WebView container for leaves22 container uintptr // GtkPaned for branch nodes, stable WebView container for leaves
23 orientation webkit.Orientation23 orientation webkit.Orientation
24 isLeaf bool24 isLeaf bool
25- isPopup bool // Track if this is a popup pane for OAuth auto-close25+ isPopup bool // Deprecated: use windowType instead
26- hoverToken uintptr26+ // 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
27 }33 }
28 34
29 // WorkspaceManager coordinates Zellij-style pane operations.35 // WorkspaceManager coordinates Zellij-style pane operations.
@@ -46,6 +52,13 @@ type WorkspaceManager struct {
46 paneModeSource *webkit.WebView // Which webview initiated pane mode52 paneModeSource *webkit.WebView // Which webview initiated pane mode
47 lastPaneModeEntry time.Time // When pane mode was last entered53 lastPaneModeEntry time.Time // When pane mode was last entered
48 paneMutex sync.Mutex // Protects pane mode state54 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
49 }62 }
50 63
51 const (64 const (
@@ -84,6 +97,7 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView)
84 })97 })
85 }98 }
86 99
100+ // Pane navigation shortcuts
87 registerFocusMove("alt+ArrowLeft", "left")101 registerFocusMove("alt+ArrowLeft", "left")
88 registerFocusMove("alt+ArrowRight", "right")102 registerFocusMove("alt+ArrowRight", "right")
89 registerFocusMove("alt+ArrowUp", "up")103 registerFocusMove("alt+ArrowUp", "up")
@@ -91,6 +105,9 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView)
91 registerFocusMove("cmdorctrl+ArrowUp", "up")105 registerFocusMove("cmdorctrl+ArrowUp", "up")
92 registerFocusMove("cmdorctrl+ArrowDown", "down")106 registerFocusMove("cmdorctrl+ArrowDown", "down")
93 107
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+
94 // Reduced logging: only log shortcuts registration during initialization, not on every hover111 // Reduced logging: only log shortcuts registration during initialization, not on every hover
95 if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {112 if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
96 log.Printf("[workspace] registered navigation shortcuts on webView=%p", webView)113 log.Printf("[workspace] registered navigation shortcuts on webView=%p", webView)
@@ -100,11 +117,12 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView)
100 // NewWorkspaceManager builds a workspace manager rooted at the provided pane.117 // NewWorkspaceManager builds a workspace manager rooted at the provided pane.
101 func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {118 func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
102 manager := &WorkspaceManager{119 manager := &WorkspaceManager{
103- app: app,120+ app: app,
104- window: rootPane.webView.Window(),121+ window: rootPane.webView.Window(),
105- viewToNode: make(map[*webkit.WebView]*paneNode),122+ viewToNode: make(map[*webkit.WebView]*paneNode),
106- lastSplitMsg: make(map[*webkit.WebView]time.Time),123+ lastSplitMsg: make(map[*webkit.WebView]time.Time),
107- lastExitMsg: make(map[*webkit.WebView]time.Time),124+ lastExitMsg: make(map[*webkit.WebView]time.Time),
125+ paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator
108 }126 }
109 manager.createWebViewFn = func() (*webkit.WebView, error) {127 manager.createWebViewFn = func() (*webkit.WebView, error) {
110 if manager.app == nil {128 if manager.app == nil {
@@ -241,6 +259,96 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
241 }259 }
242 wm.clonePaneState(node, newNode)260 wm.clonePaneState(node, newNode)
243 wm.splitting = false261 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+ }
244 default:352 default:
245 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)353 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
246 }354 }
@@ -276,9 +384,9 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) {
276 }384 }
277 }385 }
278 386
279- // Remove active class from previous pane (border stays, just color changes)387+ // Set previously active WebView as inactive
280- if wm.active != nil && wm.active.container != 0 {388+ if previousWebView != nil && previousWebView != node.pane.webView {
281- webkit.WidgetRemoveCSSClass(wm.active.container, activePaneClass)389+ previousWebView.SetActive(false)
282 }390 }
283 391
284 wm.active = node392 wm.active = node
@@ -290,6 +398,11 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) {
290 wm.app.messageHandler = node.pane.messageHandler398 wm.app.messageHandler = node.pane.messageHandler
291 wm.app.shortcutHandler = node.pane.shortcutHandler399 wm.app.shortcutHandler = node.pane.shortcutHandler
292 400
401+ // Set this WebView as active
402+ if node.pane != nil && node.pane.webView != nil {
403+ node.pane.webView.SetActive(true)
404+ }
405+
293 // Re-register workspace navigation shortcuts on the newly focused webView406 // Re-register workspace navigation shortcuts on the newly focused webView
294 wm.registerWorkspaceShortcuts(node.pane.webView)407 wm.registerWorkspaceShortcuts(node.pane.webView)
295 408
@@ -307,7 +420,12 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) {
307 container := node.container420 container := node.container
308 viewWidget := node.pane.webView.Widget()421 viewWidget := node.pane.webView.Widget()
309 if container != 0 && container != previousContainer {422 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)
311 webkit.WidgetAddCSSClass(container, activePaneClass)429 webkit.WidgetAddCSSClass(container, activePaneClass)
312 if !webkit.WidgetIsValid(container) {430 if !webkit.WidgetIsValid(container) {
313 log.Printf("[workspace] focus aborted: container invalid widget=%#x", container)431 log.Printf("[workspace] focus aborted: container invalid widget=%#x", container)
@@ -427,6 +545,17 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
427 if wm == nil || view == nil {545 if wm == nil || view == nil {
428 return546 return
429 }547 }
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+
430 if node, ok := wm.viewToNode[view]; ok {559 if node, ok := wm.viewToNode[view]; ok {
431 if wm.active != node {560 if wm.active != node {
432 wm.focusNode(node)561 wm.focusNode(node)
@@ -1094,34 +1223,25 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
1094 1223
1095 grand := parent.parent1224 grand := parent.parent
1096 if grand == nil {1225 if grand == nil {
1097- // Parent is the root node. We need to restructure.1226+ // Parent is the root node. Promote sibling to become the new root.
1098- // If going from 2 panes to 1, promote sibling to become the new root1227+ // The sibling can be either a leaf (when only 2 panes total) or a branch (when more panes exist)
1099- if remaining == 2 { // Will become 1 after this close1228+ log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
1100- log.Printf("[workspace] promoting sibling to root (last 2 panes): container=%#x", sibling.container)1229+ wm.root = sibling
1101- wm.root = sibling1230+ sibling.parent = nil
1102- sibling.parent = nil1231+
1103-1232+ // Unparent the sibling from the paned first, then set it as window child
1104- // Unparent the sibling from the paned first, then set it as window child1233+ if wm.window != nil {
1105- if wm.window != nil {1234+ // GTK requires explicit unparenting before reparenting
1106- // GTK requires explicit unparenting before reparenting1235+ if parent.left == sibling {
1107- if parent.left == sibling {1236+ webkit.PanedSetStartChild(parent.container, 0)
1108- webkit.PanedSetStartChild(parent.container, 0)1237+ } else {
1109- } else {1238+ webkit.PanedSetEndChild(parent.container, 0)
1110- webkit.PanedSetEndChild(parent.container, 0)1239+ }
1111- }1240+ // Now we can safely set it as window child
1112- // Now we can safely set it as window child1241+ wm.window.SetChild(sibling.container)
1113- wm.window.SetChild(sibling.container)1242+ if sibling.container != 0 {
1114- if sibling.container != 0 {1243+ webkit.WidgetQueueAllocate(sibling.container)
1115- webkit.WidgetQueueAllocate(sibling.container)
1116- }
1117 }1244 }
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")
1125 }1245 }
1126 } else {1246 } else {
1127 // Parent has a grandparent, so promote sibling to take parent's place1247 // 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
1282 return nil1402 return nil
1283 }1403 }
1284 1404
1405+ // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
1406+
1285 cfg := wm.app.config1407 cfg := wm.app.config
1286 if cfg == nil {1408 if cfg == nil {
1287 log.Printf("[workspace] HandlePopup: nil config - allowing native popup")1409 log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
@@ -1296,24 +1418,51 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
1296 return nil1418 return nil
1297 }1419 }
1298 1420
1299- // Get webkit config for new WebView creation1421+ // 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
1300 webkitCfg, err := wm.app.buildWebkitConfig()1459 webkitCfg, err := wm.app.buildWebkitConfig()
1301 if err != nil {1460 if err != nil {
1302 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)1461 log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1303 return nil1462 return nil
1304 }1463 }
1305- // Don't create window initially - we'll embed directly in workspace
1306 webkitCfg.CreateWindow = false1464 webkitCfg.CreateWindow = false
1307-1465+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
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)
1317 if err != nil {1466 if err != nil {
1318 log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)1467 log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
1319 return nil1468 return nil
@@ -1388,6 +1537,9 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
1388 // Ensure GUI components are available in the new pane1537 // Ensure GUI components are available in the new pane
1389 wm.ensureGUIInPane(newPane)1538 wm.ensureGUIInPane(newPane)
1390 1539
1540+ // Inject GUI components into the popup pane
1541+ wm.ensureGUIInPane(newPane)
1542+
1391 // Load the URL if provided1543 // Load the URL if provided
1392 if url != "" {1544 if url != "" {
1393 paneType := "popup"1545 paneType := "popup"
@@ -1398,6 +1550,10 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
1398 if err := newView.LoadURL(url); err != nil {1550 if err := newView.LoadURL(url); err != nil {
1399 log.Printf("[workspace] failed to load %s URL: %v", paneType, err)1551 log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
1400 }1552 }
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+ }
1401 }1557 }
1402 1558
1403 if isBlankTarget {1559 if isBlankTarget {
@@ -1408,6 +1564,410 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
1408 return newView1564 return newView
1409 }1565 }
1410 1566
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+
1411 // insertPopupPane inserts a pre-created popup pane into the workspace1971 // insertPopupPane inserts a pre-created popup pane into the workspace
1412 func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {1972 func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
1413 if target == nil || !target.isLeaf || target.pane == nil {1973 if target == nil || !target.isLeaf || target.pane == nil {
@@ -1431,6 +1991,12 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
1431 webkit.WidgetSetVExpand(newContainer, true)1991 webkit.WidgetSetVExpand(newContainer, true)
1432 webkit.WidgetRealizeInContainer(newContainer)1992 webkit.WidgetRealizeInContainer(newContainer)
1433 1993
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+
1434 existingContainer := target.container2000 existingContainer := target.container
1435 if existingContainer == 0 {2001 if existingContainer == 0 {
1436 return errors.New("existing pane missing container")2002 return errors.New("existing pane missing container")
@@ -1543,65 +2109,20 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
1543 }2109 }
1544 2110
1545 // ensureGUIInPane lazily loads GUI components into a pane when it gains focus2111 // 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
1546 func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {2114 func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
1547- if pane == nil || pane.HasGUI() {2115+ if pane == nil {
1548 return2116 return
1549 }2117 }
1550 2118
1551- log.Printf("[workspace] Injecting GUI components into pane %s", pane.ID())2119+ // GUI is already injected globally via WebKit's enableUserContentManager
1552-2120+ // Just mark the pane as having GUI to prevent unnecessary calls
1553- // Enhanced GUI injection script with verification and retry mechanism2121+ if !pane.HasGUI() {
1554- script := fmt.Sprintf(`2122+ pane.SetHasGUI(true)
1555- (async function() {2123+ pane.SetGUIComponent("manager", true)
1556- try {2124+ pane.SetGUIComponent("omnibox", true)
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
1597 }2125 }
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())
1605 }2126 }
1606 2127
1607 func mapDirection(direction string) (webkit.Orientation, bool) {2128 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) {
343 wm := newTestWorkspaceManagerWithMocksForTree(t)343 wm := newTestWorkspaceManagerWithMocksForTree(t)
344 344
345 // Create tree with 4 leaves345 // 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")
347 _, _ = wm.splitNode(node1, "down")349 _, _ = wm.splitNode(node1, "down")
348- _, _ = wm.splitNode(wm.root, "down")350+ _, _ = wm.splitNode(original, "down") // Split the original leaf, not the new root branch
349 351
350 leaves := wm.collectLeaves()352 leaves := wm.collectLeaves()
351 353
@@ -483,6 +485,142 @@ func TestFocusManagement(t *testing.T) {
483 })485 })
484 }486 }
485 487
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+
486 func TestEdgeCases(t *testing.T) {624 func TestEdgeCases(t *testing.T) {
487 t.Run("Closing last pane", func(t *testing.T) {625 t.Run("Closing last pane", func(t *testing.T) {
488 wm := newTestWorkspaceManagerWithMocksForTree(t)626 wm := newTestWorkspaceManagerWithMocksForTree(t)
M internal/app/control/zoom.go +15 -2
@@ -3,6 +3,7 @@ package control
3 import (3 import (
4 "context"4 "context"
5 "log"5 "log"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/services"8 "github.com/bnema/dumber/internal/services"
8 "github.com/bnema/dumber/pkg/webkit"9 "github.com/bnema/dumber/pkg/webkit"
@@ -13,6 +14,7 @@ type ZoomController struct {
13 currentURL string14 currentURL string
14 lastZoomDomain string15 lastZoomDomain string
15 programmaticChange bool16 programmaticChange bool
17+ programmaticTimer *time.Timer
16 browserService *services.BrowserService18 browserService *services.BrowserService
17 webView *webkit.WebView19 webView *webkit.WebView
18 }20 }
@@ -48,7 +50,7 @@ func (z *ZoomController) handleURIChange(url string) {
48 return50 return
49 }51 }
50 currentDomain := services.ZoomKeyForLog(url)52 currentDomain := services.ZoomKeyForLog(url)
51- z.loadZoomLevelAsync(url, currentDomain, true)53+ z.loadZoomLevelAsync(url, currentDomain, false)
52 }54 }
53 55
54 // handleZoomChange responds to zoom level changes and persists them56 // handleZoomChange responds to zoom level changes and persists them
@@ -57,6 +59,7 @@ func (z *ZoomController) handleZoomChange(level float64) {
57 if url == "" {59 if url == "" {
58 return60 return
59 }61 }
62+
60 go func(url string, level float64) {63 go func(url string, level float64) {
61 ctx := context.Background()64 ctx := context.Background()
62 if err := z.browserService.SetZoomLevel(ctx, url, level); err != nil {65 if err := z.browserService.SetZoomLevel(ctx, url, level); err != nil {
@@ -144,8 +147,18 @@ func (z *ZoomController) applyZoomLevel(url, domain string, zoomLevel float64, a
144 z.currentURL = url147 z.currentURL = url
145 }148 }
146 149
150+ // Clear any existing timer
151+ if z.programmaticTimer != nil {
152+ z.programmaticTimer.Stop()
153+ }
154+
147 z.programmaticChange = true155 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+ })
149 162
150 if err := z.webView.SetZoom(zoomLevel); err != nil {163 if err := z.webView.SetZoom(zoomLevel); err != nil {
151 log.Printf("Warning: failed to set zoom: %v", err)164 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 (
11 11
12 "github.com/bnema/dumber/internal/app/constants"12 "github.com/bnema/dumber/internal/app/constants"
13 "github.com/bnema/dumber/internal/app/control"13 "github.com/bnema/dumber/internal/app/control"
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/dumber/internal/services"16 "github.com/bnema/dumber/internal/services"
15 "github.com/bnema/dumber/pkg/webkit"17 "github.com/bnema/dumber/pkg/webkit"
16 )18 )
@@ -45,11 +47,34 @@ type Message struct {
45 HistoryID string `json:"historyId"`47 HistoryID string `json:"historyId"`
46 // Request tracking48 // Request tracking
47 RequestID string `json:"requestId"`49 RequestID string `json:"requestId"`
50+ // Popup close tracking
51+ WebViewID string `json:"webviewId"`
52+ Reason string `json:"reason"`
48 // Wails fetch bridge53 // Wails fetch bridge
49 ID string `json:"id"`54 ID string `json:"id"`
50 Payload json.RawMessage `json:"payload"`55 Payload json.RawMessage `json:"payload"`
51 }56 }
52 57
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+
53 // NewHandler creates a new message handler78 // NewHandler creates a new message handler
54 func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {79 func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
55 return &Handler{80 return &Handler{
@@ -98,6 +123,12 @@ func (h *Handler) Handle(payload string) {
98 h.handleHistoryDelete(msg)123 h.handleHistoryDelete(msg)
99 case "workspace":124 case "workspace":
100 h.handleWorkspace(msg)125 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)
101 }132 }
102 }133 }
103 134
@@ -161,6 +192,30 @@ func (h *Handler) handleWorkspace(msg Message) {
161 h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)192 h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
162 }193 }
163 194
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+
164 func (h *Handler) handleQuery(msg Message) {219 func (h *Handler) handleQuery(msg Message) {
165 log.Printf("[DEBUG] handleQuery called: q='%s', limit=%d", msg.Q, msg.Limit)220 log.Printf("[DEBUG] handleQuery called: q='%s', limit=%d", msg.Q, msg.Limit)
166 if h.webView == nil {221 if h.webView == nil {
@@ -422,7 +477,7 @@ func (h *Handler) handleHistoryRecent(msg Message) {
422 }477 }
423 478
424 // handleHistoryStats processes history stats requests479 // handleHistoryStats processes history stats requests
425-func (h *Handler) handleHistoryStats(msg Message) {480+func (h *Handler) handleHistoryStats(_ Message) {
426 if h.browserService == nil || h.webView == nil {481 if h.browserService == nil || h.webView == nil {
427 return482 return
428 }483 }
@@ -496,3 +551,54 @@ func (h *Handler) handleHistoryDelete(msg Message) {
496 b, _ := json.Marshal(successData)551 b, _ := json.Marshal(successData)
497 _ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")552 _ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
498 }553 }
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 {
135 log.Printf("[favicon] failed to close download file: %v", err)135 log.Printf("[favicon] failed to close download file: %v", err)
136 }136 }
137 }()137 }()
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+ }()
139 143
140 // Save raw favicon data144 // Save raw favicon data
141 if _, err := io.Copy(downloadFile, limitedBody); err != nil {145 if _, err := io.Copy(downloadFile, limitedBody); err != nil {
@@ -177,7 +181,11 @@ func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, er
177 if err != nil {181 if err != nil {
178 return nil, err182 return nil, err
179 }183 }
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+ }()
181 189
182 img, _, err := image.Decode(file)190 img, _, err := image.Decode(file)
183 return img, err191 return img, err
@@ -190,10 +198,16 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
190 if err != nil {198 if err != nil {
191 return fmt.Errorf("failed to create temp file: %w", err)199 return fmt.Errorf("failed to create temp file: %w", err)
192 }200 }
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+ }()
194 206
195 if err := png.Encode(file, img); err != nil {207 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+ }
197 return fmt.Errorf("failed to encode PNG: %w", err)211 return fmt.Errorf("failed to encode PNG: %w", err)
198 }212 }
199 213
M internal/cache/filter_cache.go +10 -3
@@ -4,6 +4,7 @@ import (
4 "encoding/binary"4 "encoding/binary"
5 "fmt"5 "fmt"
6 "io"6 "io"
7+ "log"
7 "os"8 "os"
8 "syscall"9 "syscall"
9 "time"10 "time"
@@ -110,18 +111,24 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) {
110 // Parse the header111 // Parse the header
111 header, err := fc.parseHeader()112 header, err := fc.parseHeader()
112 if err != nil {113 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+ }
114 return nil, fmt.Errorf("invalid cache header: %w", err)117 return nil, fmt.Errorf("invalid cache header: %w", err)
115 }118 }
116 119
117 // Validate magic and version120 // Validate magic and version
118 if header.Magic != cacheMagic {121 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+ }
120 return nil, fmt.Errorf("invalid cache magic bytes")125 return nil, fmt.Errorf("invalid cache magic bytes")
121 }126 }
122 127
123 if header.Version != cacheFormatVersion {128 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+ }
125 return nil, fmt.Errorf("unsupported cache version: %d", header.Version)132 return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
126 }133 }
127 134
M internal/cli/logs.go +87 -0
@@ -26,6 +26,7 @@ func NewLogsCmd() *cobra.Command {
26 cmd.AddCommand(newLogsListCmd())26 cmd.AddCommand(newLogsListCmd())
27 cmd.AddCommand(newLogsCleanCmd())27 cmd.AddCommand(newLogsCleanCmd())
28 cmd.AddCommand(newLogsPathCmd())28 cmd.AddCommand(newLogsPathCmd())
29+ cmd.AddCommand(newLogsConsoleCmd())
29 30
30 return cmd31 return cmd
31 }32 }
@@ -126,6 +127,32 @@ func newLogsPathCmd() *cobra.Command {
126 return cmd127 return cmd
127 }128 }
128 129
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+
129 // tailFile displays the last N lines of a file156 // tailFile displays the last N lines of a file
130 func tailFile(filePath string, lines int) error {157 func tailFile(filePath string, lines int) error {
131 file, err := os.Open(filePath)158 file, err := os.Open(filePath)
@@ -297,3 +324,63 @@ func formatSize(size int64) string {
297 324
298 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])325 return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
299 }326 }
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 {
101 EnableFileLog bool `mapstructure:"enable_file_log" yaml:"enable_file_log"`101 EnableFileLog bool `mapstructure:"enable_file_log" yaml:"enable_file_log"`
102 102
103 // Capture settings103 // Capture settings
104- CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout"`104+ CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout" json:"capture_stdout"`
105- CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr"`105+ CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr" json:"capture_stderr"`
106- CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output"`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"`
107 108
108 // Debug output109 // Debug output
109 DebugFile string `mapstructure:"debug_file" yaml:"debug_file"`110 DebugFile string `mapstructure:"debug_file" yaml:"debug_file"`
@@ -258,6 +259,15 @@ type PopupBehaviorConfig struct {
258 Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`259 Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`
259 OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`260 OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`
260 FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`261 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"`
261 }271 }
262 272
263 // WorkspaceStylingConfig defines visual styling for workspace panes.273 // WorkspaceStylingConfig defines visual styling for workspace panes.
@@ -678,6 +688,10 @@ func (m *Manager) setDefaults() {
678 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)688 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
679 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)689 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
680 m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)690 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)
681 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)695 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
682 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)696 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
683 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)697 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 {
101 CaptureStdout: false,101 CaptureStdout: false,
102 CaptureStderr: false,102 CaptureStderr: false,
103 CaptureCOutput: false, // Disabled by default to avoid performance impact103 CaptureCOutput: false, // Disabled by default to avoid performance impact
104+ CaptureConsole: false, // Disabled by default
104 DebugFile: "debug.log",105 DebugFile: "debug.log",
105 VerboseWebKit: false,106 VerboseWebKit: false,
106 },107 },
@@ -183,9 +184,12 @@ func DefaultConfig() *Config {
183 PreviousTab: "cmdorctrl+shift+tab",184 PreviousTab: "cmdorctrl+shift+tab",
184 },185 },
185 Popups: PopupBehaviorConfig{186 Popups: PopupBehaviorConfig{
186- Placement: defaultPopupPlacement,187+ Placement: defaultPopupPlacement,
187- OpenInNewPane: true,188+ OpenInNewPane: true,
188- FollowPaneContext: 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
189 },193 },
190 Styling: WorkspaceStylingConfig{194 Styling: WorkspaceStylingConfig{
191 BorderWidth: defaultBorderWidth,195 BorderWidth: defaultBorderWidth,
M internal/logging/logger.go +9 -2
@@ -163,7 +163,7 @@ func (l *Logger) writeLog(level LogLevel, message string, source string) {
163 }163 }
164 164
165 func (l *Logger) WriteTagged(source string, message string) {165 func (l *Logger) WriteTagged(source string, message string) {
166- l.writeLog(INFO, message, source)166+ l.WriteFileOnly(INFO, message, source)
167 }167 }
168 168
169 // WriteFileOnly writes a log entry only to the file (not stdout) to avoid interfering with CLI operations169 // 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) {
218 218
219 func CaptureWebKitLog(message string) {219 func CaptureWebKitLog(message string) {
220 if l := GetLogger(); l != nil {220 if l := GetLogger(); l != nil {
221- l.WriteTagged("WEBKIT", message)221+ l.WriteTagged("CONSOLE", message)
222 }222 }
223 }223 }
224 224
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+
225 // LogLevelInfo returns the INFO log level constant for external packages232 // LogLevelInfo returns the INFO log level constant for external packages
226 func LogLevelInfo() LogLevel {233 func LogLevelInfo() LogLevel {
227 return INFO234 return INFO
M pkg/webkit/keyboard_cgo.go +2 -73
@@ -3,9 +3,10 @@
3 package webkit3 package webkit
4 4
5 /*5 /*
6-#cgo pkg-config: gtk46+#cgo pkg-config: gtk4 webkitgtk-6.0
7 #include <gtk/gtk.h>7 #include <gtk/gtk.h>
8 #include <gdk/gdk.h>8 #include <gdk/gdk.h>
9+#include <webkit/webkit.h>
9 10
10 // Declarations for Go callbacks11 // Declarations for Go callbacks
11 extern gboolean goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);12 extern gboolean goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);
@@ -84,7 +85,6 @@ import (
84 "sync"85 "sync"
85 "sync/atomic"86 "sync/atomic"
86 "time"87 "time"
87- "unsafe"
88 )88 )
89 89
90 // Registry of accelerators per view id.90 // Registry of accelerators per view id.
@@ -533,77 +533,6 @@ func goHandleNewWindowPolicy(id C.ulong, curi *C.char, navType C.int, cframeName
533 }533 }
534 }534 }
535 535
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-
607 //export goHandleLoadChanged536 //export goHandleLoadChanged
608 func goHandleLoadChanged(id C.ulong, curi *C.char, loadEvent C.int) {537 func goHandleLoadChanged(id C.ulong, curi *C.char, loadEvent C.int) {
609 uid := uintptr(id)538 uid := uintptr(id)
M pkg/webkit/script_cgo.go +28 -0
@@ -32,3 +32,31 @@ func (w *WebView) InjectScript(js string) error {
32 )32 )
33 return nil33 return nil
34 }34 }
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
5 import (5 import (
6 "fmt"6 "fmt"
7 "log"7 "log"
8+ "strconv"
9+ "sync/atomic"
8 "unsafe"10 "unsafe"
9 )11 )
10 12
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+
11 // WebView represents a browser view powered by WebKit2GTK.21 // WebView represents a browser view powered by WebKit2GTK.
12 // Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering.22 // Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering.
13 type WebView struct {23 type WebView struct {
@@ -26,6 +36,12 @@ type WebView struct {
26 useDomZoom bool36 useDomZoom bool
27 domZoomSeed float6437 domZoomSeed float64
28 container uintptr38 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)
29 }45 }
30 46
31 // NewWebView constructs a new WebView instance.47 // NewWebView constructs a new WebView instance.
@@ -41,6 +57,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
41 wv.zoom = cfg.ZoomDefault57 wv.zoom = cfg.ZoomDefault
42 }58 }
43 wv.domZoomSeed = wv.zoom59 wv.domZoomSeed = wv.zoom
60+ wv.id = nextStubViewID() // Assign unique ID
44 // Construction succeeds; create a logical window placeholder.61 // Construction succeeds; create a logical window placeholder.
45 wv.window = &Window{Title: "Dumber Browser"}62 wv.window = &Window{Title: "Dumber Browser"}
46 wv.container = newWidgetHandle()63 wv.container = newWidgetHandle()
@@ -211,6 +228,19 @@ func (w *WebView) SeedDomZoom(level float64) {
211 w.domZoomSeed = level228 w.domZoomSeed = level
212 }229 }
213 230
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+
214 // InitializeContentBlocking initializes WebKit content blocking with filter manager (stub)244 // InitializeContentBlocking initializes WebKit content blocking with filter manager (stub)
215 func (w *WebView) InitializeContentBlocking(filterManager interface{}) error {245 func (w *WebView) InitializeContentBlocking(filterManager interface{}) error {
216 return ErrNotImplemented246 return ErrNotImplemented
@@ -225,3 +255,26 @@ func (w *WebView) OnNavigate(url string, filterManager interface{}) {
225 func (w *WebView) UpdateContentFilters(filterManager interface{}) error {255 func (w *WebView) UpdateContentFilters(filterManager interface{}) error {
226 return ErrNotImplemented256 return ErrNotImplemented
227 }257 }
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();
24 extern void goInvokeHandle(uintptr_t handle);24 extern void goInvokeHandle(uintptr_t handle);
25 extern gboolean goHandleNewWindowPolicy(unsigned long id, char* uri, int nav_type, char* frame_name, gboolean is_user_gesture, guint modifiers, guint mouse_button);25 extern gboolean goHandleNewWindowPolicy(unsigned long id, char* uri, int nav_type, char* frame_name, gboolean is_user_gesture, guint modifiers, guint mouse_button);
26 extern void goHandlePopupGeometry(unsigned long id, int x, int y, int width, int height);26 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);
29 extern void goHandleLoadChanged(unsigned long id, char* uri, int load_event);27 extern void goHandleLoadChanged(unsigned long id, char* uri, int load_event);
30 extern void goHandleWebViewClose(unsigned long id);28 extern void goHandleWebViewClose(unsigned long id);
31 extern void goCreateNewPanel(unsigned long id, char* uri);29 extern void goCreateNewPanel(unsigned long id, char* uri);
@@ -51,42 +49,16 @@ static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy);
51 49
52 // TLS error handling forward declarations50 // TLS error handling forward declarations
53 extern gboolean goHandleTLSError(char* failing_uri, char* host, int error_flags, char* cert_info);51 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+
54 static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,56 static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
55 const char *failing_uri,57 const char *failing_uri,
56 GTlsCertificate *certificate,58 GTlsCertificate *certificate,
57 GTlsCertificateFlags errors,59 GTlsCertificateFlags errors,
58 gpointer user_data);60 gpointer user_data);
59 61
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-}
90 62
91 // Handle load_changed signal for OAuth completion detection63 // Handle load_changed signal for OAuth completion detection
92 static void on_load_changed(WebKitWebView *web_view, WebKitLoadEvent load_event, gpointer user_data) {64 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) {
107 goHandleWebViewClose(view_id);79 goHandleWebViewClose(view_id);
108 }80 }
109 81
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-}
244 82
245 // Handle decide-policy signal for new window requests - more robust than create signal83 // Handle decide-policy signal for new window requests - more robust than create signal
246 static gboolean on_decide_policy(WebKitWebView *web_view,84 static gboolean on_decide_policy(WebKitWebView *web_view,
@@ -393,10 +231,9 @@ static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserCon
393 g_object_ref(u); // Add ref since we'll pass it to new WebView231 g_object_ref(u); // Add ref since we'll pass it to new WebView
394 }232 }
395 233
396- // Create WebView with same session and content manager for process sharing234+ // Create WebView with related view for process sharing (network session is inherited)
397 GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW,235 GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW,
398 "user-content-manager", u,236 "user-content-manager", u,
399- "network-session", related_session,
400 "related-view", related_view,237 "related-view", related_view,
401 NULL));238 NULL));
402 239
@@ -411,9 +248,9 @@ static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserCon
411 248
412 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {249 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {
413 if (!web_view) return;250 if (!web_view) return;
414- g_signal_connect_data(G_OBJECT(web_view), "decide-policy", G_CALLBACK(on_decide_policy), (gpointer)id, NULL, 0);251+ // Create signal is now handled in window_handler_cgo.go
415- // Keep create signal for JavaScript window.open() and other cases not caught by decide-policy252+ // Use ConnectCreateSignal from Go to connect the signal
416- g_signal_connect_data(G_OBJECT(web_view), "create", G_CALLBACK(on_create_web_view), (gpointer)id, NULL, 0);253+ printf("[webkit-debug] Policy handler connected (create signal handled separately)\n");
417 }254 }
418 255
419 // Connect close signal to a WebView for popup auto-close functionality256 // Connect close signal to a WebView for popup auto-close functionality
@@ -909,6 +746,7 @@ import (
909 "context"746 "context"
910 "crypto/sha256"747 "crypto/sha256"
911 "database/sql"748 "database/sql"
749+ "encoding/json"
912 "errors"750 "errors"
913 "fmt"751 "fmt"
914 "log"752 "log"
@@ -936,125 +774,7 @@ type FilterManager interface {
936 GetCosmeticScriptForDomain(domain string) string774 GetCosmeticScriptForDomain(domain string) string
937 }775 }
938 776
939-const domBridgeTemplate = `(() => { try {777+// domBridgeTemplate removed - now loading compiled main-world.min.js
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); } })();`
1058 778
1059 var registerSchemeOnce sync.Once779 var registerSchemeOnce sync.Once
1060 780
@@ -1210,6 +930,14 @@ type WebView struct {
1210 domZoomSeed float64930 domZoomSeed float64
1211 domBridgeScript *C.WebKitUserScript931 domBridgeScript *C.WebKitUserScript
1212 wasReparented bool // Track if this WebView has been reparented932 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
1213 }941 }
1214 942
1215 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.943 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -1366,6 +1094,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
1366 v.id = nextViewID()1094 v.id = nextViewID()
1367 registerView(v.id, v)1095 registerView(v.id, v)
1368 C.connect_policy_handler(v.native.wv, C.ulong(v.id))1096 C.connect_policy_handler(v.native.wv, C.ulong(v.id))
1097+ ConnectCreateSignal(v) // Connect create signal for window.open support
1369 1098
1370 // Register with global memory manager if monitoring is enabled1099 // Register with global memory manager if monitoring is enabled
1371 if cfg.Memory.EnableMemoryMonitoring {1100 if cfg.Memory.EnableMemoryMonitoring {
@@ -1388,55 +1117,9 @@ func NewWebView(cfg *Config) (*WebView, error) {
1388 C.connect_theme_changed_with_id(settings, C.ulong(v.id))1117 C.connect_theme_changed_with_id(settings, C.ulong(v.id))
1389 }1118 }
1390 1119
1391- // Native zoom shortcuts (independent of app services)1120+ // Zoom shortcuts are now handled by application-level workspace shortcuts
1392- // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset1121+ // in workspace_manager.go to ensure proper active pane targeting
1393- _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() {1122+ // Find in page (Ctrl/Cmd+F): handled by window-level shortcuts in window_shortcuts.go
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- })
1440 // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge1123 // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge
1441 _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() {1124 _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() {
1442 _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowleft'}}))")1125 _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowleft'}}))")
@@ -1578,9 +1261,124 @@ func NewWebView(cfg *Config) (*WebView, error) {
1578 }1261 }
1579 }1262 }
1580 1263
1264+ // Setup console message logging
1265+ v.setupConsoleLogging()
1266+
1581 return v, nil1267 return v, nil
1582 }1268 }
1583 1269
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+
1584 // NewWebViewWithRelated creates a new WebView related to an existing one for process sharing1382 // NewWebViewWithRelated creates a new WebView related to an existing one for process sharing
1585 func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) {1383 func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) {
1586 if relatedView == nil || relatedView.native == nil || relatedView.native.wv == nil {1384 if relatedView == nil || relatedView.native == nil || relatedView.native.wv == nil {
@@ -1613,6 +1411,17 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error)
1613 1411
1614 C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget)1412 C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget)
1615 1413
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+
1616 if cfg.CreateWindow {1425 if cfg.CreateWindow {
1617 win = C.new_window()1426 win = C.new_window()
1618 if win == nil {1427 if win == nil {
@@ -1653,6 +1462,7 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error)
1653 v.id = nextViewID()1462 v.id = nextViewID()
1654 registerView(v.id, v)1463 registerView(v.id, v)
1655 C.connect_policy_handler(v.native.wv, C.ulong(v.id))1464 C.connect_policy_handler(v.native.wv, C.ulong(v.id))
1465+ ConnectCreateSignal(v) // Connect create signal for window.open support
1656 1466
1657 // Register with memory manager if enabled1467 // Register with memory manager if enabled
1658 if cfg.Memory.EnableMemoryMonitoring {1468 if cfg.Memory.EnableMemoryMonitoring {
@@ -1870,6 +1680,46 @@ func (w *WebView) dispatchPopupRequest(uri string) *WebView {
1870 return nil1680 return nil
1871 }1681 }
1872 1682
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+
1873 // GetNativePointer returns the native WebView pointer for CGO callbacks1723 // GetNativePointer returns the native WebView pointer for CGO callbacks
1874 func (w *WebView) GetNativePointer() unsafe.Pointer {1724 func (w *WebView) GetNativePointer() unsafe.Pointer {
1875 if w != nil && w.native != nil && w.native.wv != nil {1725 if w != nil && w.native != nil && w.native.wv != nil {
@@ -2153,10 +2003,35 @@ func clampDomZoom(level float64) float64 {
2153 return level2003 return level
2154 }2004 }
2155 2005
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
2157 zoom := clampDomZoom(initialZoom)2020 zoom := clampDomZoom(initialZoom)
2158 zoomStr := strconv.FormatFloat(zoom, 'f', -1, 64)2021 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
2160 }2035 }
2161 2036
2162 func (w *WebView) UsesDomZoom() bool {2037 func (w *WebView) UsesDomZoom() bool {
@@ -2176,7 +2051,7 @@ func (w *WebView) installDomBridgeScript() {
2176 w.domBridgeScript = nil2051 w.domBridgeScript = nil
2177 }2052 }
2178 2053
2179- source := buildDomBridgeScript(w.domZoomSeed)2054+ source := buildDomBridgeScript(w.config, w.domZoomSeed, w.id, w)
2180 cBridge := C.CString(source)2055 cBridge := C.CString(source)
2181 defer C.free(unsafe.Pointer(cBridge))2056 defer C.free(unsafe.Pointer(cBridge))
2182 2057
@@ -2202,8 +2077,11 @@ func (w *WebView) SeedDomZoom(level float64) {
2202 return2077 return
2203 }2078 }
2204 w.domZoomSeed = seed2079 w.domZoomSeed = seed
2080+ // Instead of re-injecting the entire script, just update the zoom level
2205 w.RunOnMainThread(func() {2081 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)
2207 })2085 })
2208 }2086 }
2209 2087
@@ -2338,6 +2216,13 @@ func goHandleTLSError(failingURI *C.char, host *C.char, errorFlags C.int, certIn
2338 return C.FALSE // Don't proceed2216 return C.FALSE // Don't proceed
2339 }2217 }
2340 2218
2219+//export goHandleConsoleMessage
2220+//export goHandleConsoleMessage
2221+func goHandleConsoleMessage(message *C.char) {
2222+ messageText := C.GoString(message)
2223+ logging.CaptureWebKitLog(messageText)
2224+}
2225+
2341 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability2226 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability
2342 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {2227 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {
2343 if w == nil || w.native == nil || w.native.win == nil {2228 if w == nil || w.native == nil || w.native.win == nil {
@@ -2656,3 +2541,41 @@ func (w *WebView) detectAndLogRenderingBackend() {
2656 w.InjectScript(detectionScript)2541 w.InjectScript(detectionScript)
2657 }()2542 }()
2658 }2543 }
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) {
306 C.gtk_paned_set_resize_end_child((*C.GtkPaned)(unsafe.Pointer(paned)), goBool(resize))306 C.gtk_paned_set_resize_end_child((*C.GtkPaned)(unsafe.Pointer(paned)), goBool(resize))
307 }307 }
308 308
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+
309 // WidgetUnparent detaches a widget from its current parent.317 // WidgetUnparent detaches a widget from its current parent.
310 func WidgetUnparent(widget uintptr) {318 func WidgetUnparent(widget uintptr) {
311 if widget == 0 {319 if widget == 0 {
M pkg/webkit/workspace_widgets_stub.go +3 -0
@@ -93,6 +93,9 @@ func PanedSetResizeStart(paned uintptr, resize bool) {}
93 93
94 func PanedSetResizeEnd(paned uintptr, resize bool) {}94 func PanedSetResizeEnd(paned uintptr, resize bool) {}
95 95
96+// PanedSetPosition is a no-op in stub builds.
97+func PanedSetPosition(paned uintptr, pos int) {}
98+
96 func WidgetUnparent(widget uintptr) {}99 func WidgetUnparent(widget uintptr) {}
97 100
98 func WidgetSetHExpand(widget uintptr, expand bool) {}101 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