Merge branch 'featt/zellij-pane-enhancement'
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 stability | 12 | - **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 principles | 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 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 panes | 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 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 | ### Improved | 20 | ### 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-17 | 41 | ## [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 CSS | 46 | + | 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 build | 48 | @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 practices | 14 | // 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 patterns | 27 | // 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 implementation | 4 | * 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 highlights | 48 | // 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 search | 63 | // 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 nodes | 68 | // 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 node | 111 | // 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 node | 117 | // 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 after | 126 | // 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 elements | 134 | // 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 span | 139 | // 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 DOM | 143 | // Insert into DOM | ||
| @@ -145,7 +153,7 @@ export function findInPage(query: string): void { | |||||
| 145 | // Track highlight node for cleanup | 153 | // Track highlight node for cleanup | ||
| 146 | const highlightNode: HighlightNode = { | 154 | const highlightNode: HighlightNode = { | ||
| 147 | span: highlightSpan, | 155 | span: highlightSpan, | ||
| 148 | - | text: matchTextNode | 156 | + | 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 matches | 174 | // Add to matches | ||
| 167 | const findMatch: FindMatch = { | 175 | const findMatch: FindMatch = { | ||
| 168 | element: highlightSpan, | 176 | element: highlightSpan, | ||
| 169 | - | context | 177 | + | 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 match | 214 | // 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 view | 220 | // 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 functionality | 4 | * 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 communication | 4 | * 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 available | 11 | // Forward messages to the native WebKit message handler when available | ||
| 12 | // Fallback: for navigate, perform direct location change | 12 | // 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 available | 31 | // 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 backend | 42 | * 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 request | 50 | * 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 query | 58 | * 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 setSuggestions | 64 | // 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 responds | 68 | // 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 | - | OmniboxConfig | 12 | + | OmniboxConfig, |
| 13 | - | } from './types'; | 13 | + | } from "./types"; |
| 14 | 14 | ||||
| 15 | // Default configuration | 15 | // 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: 10 | 19 | + | defaultLimit: 10, |
| 20 | }; | 20 | }; | ||
| 21 | 21 | ||||
| 22 | // Global state using Svelte 5 runes | 22 | // 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 | // Configuration | 35 | // Configuration | ||
| @@ -37,29 +37,55 @@ let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG); | |||||
| 37 | 37 | ||||
| 38 | export const omniboxStore = { | 38 | export const omniboxStore = { | ||
| 39 | // State getters | 39 | // 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 getters | 74 | // 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 | // Actions | 91 | // Actions | ||
| @@ -67,14 +93,14 @@ export const omniboxStore = { | |||||
| 67 | if (value && !visible) { | 93 | if (value && !visible) { | ||
| 68 | // Store scroll state when opening | 94 | // 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 closing | 98 | // 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 closing | 102 | // 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 state | 111 | // Clear previous mode's state | ||
| 86 | - | if (newMode === 'omnibox') { | 112 | + | if (newMode === "omnibox") { |
| 87 | // Switching to omnibox mode - clear find state | 113 | // 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 suggestions | 116 | // 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 start | 123 | + | 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 mode | 174 | // 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 modules | 8 | // Import all GUI modules | ||
| 9 | - | import { initializeToast, type ToastConfig } from './modules/toast'; | 9 | + | import { initializeToast, type ToastConfig } from "./modules/toast"; |
| 10 | - | import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox'; | 10 | + | import { initializeOmnibox, type OmniboxInitConfig } from "./modules/omnibox"; |
| 11 | - | import { 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 WebKit | 19 | // Note: color-scheme module is loaded separately at document-start by WebKit | ||
| 14 | 20 | ||||
| 15 | // Global interface for the unified GUI system | 21 | // Global interface for the unified GUI system | ||
| @@ -24,9 +30,9 @@ interface DumberGUI { | |||||
| 24 | 30 | ||||
| 25 | // DOM readiness utility - handles all document ready states | 31 | // 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 more | 36 | // 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 bridge | 71 | // 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 frame | 81 | // 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 events | 87 | + | 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 events | 89 | + | 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 state | 97 | + | 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 tracking | 104 | + | 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 immediately | 109 | + | // 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 here | 112 | + | } |
| 111 | 113 | ||||
| 112 | - | // Initialize toast system first | 114 | + | // 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 available | 119 | + | 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 system | 137 | + | // 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 recovery | 148 | + | // 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 overlap | 150 | + | 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 in | 197 | + | // 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 remove | 203 | + | // 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 functions | 218 | + | 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 validation | 275 | + | // 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 others | 277 | + | // 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 needed | 360 | + | 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 query | 405 | + | `[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 preference | 49 | // 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 components | 61 | // 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.colorScheme | 73 | + | value: this.config.colorScheme, |
| 71 | - | }) | 74 | + | }), |
| 72 | ); | 75 | ); | ||
| 73 | } catch { | 76 | } catch { | ||
| 74 | // Ignore errors - native layer may not be available | 77 | // 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 preference | 104 | // 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: () => false | 117 | + | 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 compatibility | 136 | // 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 configurations | 160 | // Re-apply all configurations | ||
| 151 | this.initialize(); | 161 | this.initialize(); | ||
| @@ -158,4 +168,4 @@ const colorSchemeManager = new ColorSchemeManager(); | |||||
| 158 | // Export for potential runtime updates | 168 | // 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 controls | 27 | // 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 unloaded | 96 | // 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 system | 4 | * 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 system | 21 | * 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 ready | 38 | // 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 host | 44 | // 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 Element | 47 | + | target: mountEl as unknown as Element, |
| 46 | }); | 48 | }); | ||
| 47 | 49 | ||||
| 48 | // Wait for the component to mount and set up the global API | 50 | // 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 ready | 63 | // 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 | - | false | 104 | + | false, |
| 89 | ); | 105 | ); | ||
| 90 | // Unified event name | 106 | // 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 cleanup | 138 | // 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 the | 9 | // directly into the shadow root. The ?inline suffix makes Vite embed the | ||
| 10 | // processed CSS, ensuring all utility classes are available without relying | 10 | // 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 injection | 14 | // 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 container | 77 | // 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) works | 93 | // 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 later | 97 | // 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 once | 112 | // 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 v4 | 122 | // 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 styles | 146 | // root (e.g., toasts, omnibox) receive their styles | ||
| 137 | try { | 147 | try { | ||
| 138 | // Prefer constructable stylesheets when available | 148 | // 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 CSS | 162 | // 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 fails | 168 | // 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 globals | 16 | // Type definitions for window globals | ||
| @@ -26,34 +26,41 @@ let toastInitialized = false; | |||||
| 26 | // Apply theme based on current system/GTK preference | 26 | // 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, reinitialize | 37 | // 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, reinitialize | 45 | // 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 ready | 60 | // 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 again | 74 | 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 isolation | 90 | // Use the global Shadow DOM host for isolation | ||
| 82 | - | const rootElement = ensureShadowMount('dumber-toast'); | 91 | + | const rootElement = ensureShadowMount("dumber-toast"); |
| 83 | 92 | ||||
| 84 | // Initialize theme | 93 | // 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 Element | 101 | + | target: rootElement as unknown as Element, |
| 93 | }); | 102 | }); | ||
| 94 | 103 | ||||
| 95 | // Signal isolated GUI readiness for page-world bridge | 104 | // 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 signaling | 109 | // 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 functions | 112 | // 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 run | 131 | // Force immediate effect execution to ensure onMount callbacks run | ||
| 123 | flushSync(); | 132 | flushSync(); | ||
| 124 | 133 | ||||
| 125 | // Toast functions are now exposed by the component itself | 134 | // 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 fails | 142 | // 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 functions | 148 | // The Svelte ToastContainer component already exposes these functions | ||
| 141 | // We just need to make sure they exist, they're handled by the component | 149 | // 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 fallback | 156 | // 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: true | 97 | + | 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.previousTab | 126 | + | 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.followPaneContext | 131 | + | 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 webview | 147 | // 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 mode | 167 | // 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 fails | 185 | 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 | - | ...message | 218 | + | ...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 mode | 229 | // 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: false | 277 | + | 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: false | 289 | + | 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 | - | KeyboardEventType | 14 | + | KeyboardEventType, |
| 15 | - | } from './types'; | 15 | + | } from "./types"; |
| 16 | // COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers | 16 | // COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers | ||
| 17 | 17 | ||||
| 18 | export class KeyboardService { | 18 | export class KeyboardService { | ||
| 19 | private components = new Map<string, ComponentShortcuts>(); | 19 | private components = new Map<string, ComponentShortcuts>(); | ||
| 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 now | 27 | // 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 ?? false | 53 | + | whenFocused: shortcut.whenFocused ?? false, |
| 48 | })), | 54 | })), | ||
| 49 | - | focused: false | 55 | + | 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: componentId | 67 | + | 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: componentId | 97 | + | 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.activeComponent | 112 | + | 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: componentId | 125 | + | 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 layer | 136 | * 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 string | 141 | // Normalize shortcut string | ||
| 130 | const normalizedShortcut = this.normalizeShortcut(shortcut); | 142 | const normalizedShortcut = this.normalizeShortcut(shortcut); | ||
| 131 | 143 | ||||
| 132 | // Try to handle with focused component first | 144 | // 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 above | 164 | 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 events | 225 | * 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 listener | 238 | * 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.focused | 258 | + | focused: comp.focused, |
| 234 | })), | 259 | })), | ||
| 235 | activeComponent: this.activeComponent, | 260 | activeComponent: this.activeComponent, | ||
| 236 | - | globalShortcuts: this.globalShortcuts.length | 261 | + | globalShortcuts: this.globalShortcuts.length, |
| 237 | }; | 262 | }; | ||
| 238 | } | 263 | } | ||
| 239 | 264 | ||||
| 240 | // Private methods | 265 | // 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.description | 288 | + | 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: componentId | 294 | + | 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 mapping | 345 | // 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 cmdorctrl | 347 | // 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 key | 358 | // 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 handled | 425 | // 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 components | 431 | // 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 handlers | 437 | // 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 | - | KeyboardEventType | 15 | + | 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 service | 62 | * 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 mapping | 77 | * 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 application | 88 | * 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 integration | 3 | // Global window API types for browser integration | ||
| 4 | declare global { | 4 | declare global { | ||
| 5 | interface Window { | 5 | interface Window { | ||
| 6 | // Toast system API | 6 | // 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 integration | 17 | // 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 callbacks | 21 | // 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 communication | 56 | // 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 states | 18 | // 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 services | 22 | // Initialize services | ||
| 23 | await this.appService.initialize(); | 23 | await this.appService.initialize(); | ||
| 24 | - | 24 | + | ||
| 25 | // Initialize browser controls | 25 | // Initialize browser controls | ||
| 26 | await this.browserControls.initialize(); | 26 | await this.browserControls.initialize(); | ||
| 27 | 27 | ||||
| 28 | // Render the UI | 28 | // 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 ready | 60 | // 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 ready | 68 | // 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 been | 4 | // Proactively unregister any Service Workers and clear caches that may have been | ||
| 5 | // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to | 5 | // 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 DOM | 27 | // 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 backend | 32 | // 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: 0 | 38 | + | 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/fallback | 48 | // 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: null | 91 | + | 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/fallback | 97 | // 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 details | 10 | // 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 controls | 34 | // 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 navigation | 37 | // 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 intercept | 56 | // 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 process | 58 | return; // allow native handler to process | ||
| 45 | } | 59 | } | ||
| 46 | 60 | ||||
| 47 | // Copy URL | 61 | // 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 | // Navigation | 74 | // 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 needed | 143 | // 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 BrowserService | 156 | // 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 BrowserService | 186 | // 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 title | 218 | // 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 call | 227 | // 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 natively | 243 | // 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 panel | 24 | // 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 header | 42 | // 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 container | 62 | // 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 handlers | 74 | // 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 console | 85 | // 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.debug | 99 | + | 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 format | 136 | + | 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 | - | data | 141 | + | data, |
| 132 | }; | 142 | }; | ||
| 133 | 143 | ||||
| 134 | this.logs.push(entry); | 144 | this.logs.push(entry); | ||
| 135 | - | 145 | + | ||
| 136 | // Keep only the last maxLogs entries | 146 | // 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 bottom | 182 | // 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.name | 200 | + | 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.name | 217 | + | `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.message | 237 | + | "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 | - | result | 248 | + | "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 notification | 12 | // 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 notification | 33 | // 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 type | 44 | // 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 | - | ...baseStyles | 67 | + | ...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 timeout | 86 | // 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 duration | 92 | // 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 SCRIPT | 1 | // GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT | ||
| 2 | // Auto-generated TypeScript types from Go structs | 2 | // Auto-generated TypeScript types from Go structs | ||
| 3 | - | // Generated on: 2025-09-09T09:57:41.059Z | 3 | + | // Generated on: 2025-09-22T12:41:57.478Z |
| 4 | // Generator script: scripts/generate-types.js | 4 | // Generator script: scripts/generate-types.js | ||
| 5 | // Source files: internal/db/models.go, internal/parser/types.go | 5 | // Source files: internal/db/models.go, internal/parser/types.go | ||
| 6 | // Do not edit manually - run 'node scripts/generate-types.js' to regenerate | 6 | // 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 = 3 | 83 | + | InputTypeFallbackSearch = 3, |
| 75 | } | 84 | } | ||
| 76 | 85 | ||||
| 77 | // Type aliases for frontend usage | 86 | // 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 5 | 4 | // Enable rune mode for Svelte 5 | ||
| @@ -7,4 +7,4 @@ export default { | |||||
| 7 | }, | 7 | }, | ||
| 8 | // Use Vite's preprocessor for TypeScript and PostCSS | 8 | // 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 colors | 6 | // Custom design system colors | ||
| 9 | colors: { | 7 | colors: { | ||
| 10 | // Browser-specific colors that work well in both themes | 8 | // 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 UI | 18 | // Animation speeds optimized for browser UI | ||
| 21 | transitionDuration: { | 19 | transitionDuration: { | ||
| 22 | - | '150': '150ms', // Fast interactions | 20 | + | 150: "150ms", // Fast interactions |
| 23 | - | '250': '250ms', // UI feedback | 21 | + | 250: "250ms", // UI feedback |
| 24 | - | '400': '400ms', // Smooth transitions | 22 | + | 400: "400ms", // Smooth transitions |
| 25 | }, | 23 | }, | ||
| 26 | // Typography scale for browser UI | 24 | // 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 elements | 31 | // 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 class | 40 | // 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 assets | 18 | // 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 usage | 24 | // 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 page | 34 | // 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 page | 66 | // 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 lookups | 74 | // 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.html | 96 | + | 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 splitting | 13 | manualChunks: undefined, // Prevent chunk splitting | ||
| 14 | }, | 14 | }, | ||
| 15 | }, | 15 | }, | ||
| 16 | emptyOutDir: false, // Don't clear the directory since GUI bundle is also built here | 16 | 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 imports | 23 | // 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 splitting | 20 | manualChunks: undefined, // Prevent chunk splitting | ||
| 21 | - | assetFileNames: '[name].[ext]', // Don't hash asset names since they're inlined | 21 | + | assetFileNames: "[name].[ext]", // Don't hash asset names since they're inlined |
| 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 splitting | 28 | 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 imports | 33 | // 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 false | 33 | 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.webView | 38 | + | |
| 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 isActive | 40 | + | node := s.app.workspace.viewToNode[s.webView] |
| 41 | + | if node == nil { | |||
| 42 | + | return false | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // For related popups, they're active if parent chain contains the active node | |||
| 46 | + | if node.isRelated && node.parentPane != nil { | |||
| 47 | + | activeNode := s.app.workspace.active | |||
| 48 | + | cur := node | |||
| 49 | + | for cur != nil { | |||
| 50 | + | if cur == activeNode { | |||
| 51 | + | return true | |||
| 52 | + | } | |||
| 53 | + | cur = cur.parentPane | |||
| 54 | + | } | |||
| 55 | + | return false | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Independent panes: equal to active node | |||
| 59 | + | return node == s.app.workspace.active | |||
| 43 | } | 60 | } | ||
| 44 | 61 | ||||
| 45 | // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards | 62 | // 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 handled | 94 | + | // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are handled by WorkspaceManager |
| 78 | - | // by the WorkspaceManager to ensure they work properly across all panes | 95 | + | // 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 | return | 212 | 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 leaves | 22 | container uintptr // GtkPaned for branch nodes, stable WebView container for leaves | ||
| 23 | orientation webkit.Orientation | 23 | orientation webkit.Orientation | ||
| 24 | isLeaf bool | 24 | isLeaf bool | ||
| 25 | - | isPopup bool // Track if this is a popup pane for OAuth auto-close | 25 | + | isPopup bool // Deprecated: use windowType instead |
| 26 | - | hoverToken uintptr | 26 | + | // Window type tracking |
| 27 | + | windowType webkit.WindowType // Tab or Popup | |||
| 28 | + | windowFeatures *webkit.WindowFeatures // Features if popup | |||
| 29 | + | isRelated bool // Shares context | |||
| 30 | + | parentPane *paneNode // Parent for related views | |||
| 31 | + | autoClose bool // Auto-close on OAuth success | |||
| 32 | + | hoverToken uintptr | |||
| 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 mode | 52 | paneModeSource *webkit.WebView // Which webview initiated pane mode | ||
| 47 | lastPaneModeEntry time.Time // When pane mode was last entered | 53 | lastPaneModeEntry time.Time // When pane mode was last entered | ||
| 48 | paneMutex sync.Mutex // Protects pane mode state | 54 | 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 hover | 111 | // 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 = false | 261 | 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 = node | 392 | wm.active = node | ||
| @@ -290,6 +398,11 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) { | |||||
| 290 | wm.app.messageHandler = node.pane.messageHandler | 398 | wm.app.messageHandler = node.pane.messageHandler | ||
| 291 | wm.app.shortcutHandler = node.pane.shortcutHandler | 399 | 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 webView | 406 | // 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.container | 420 | 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 | return | 546 | 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.parent | 1224 | 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 root | 1227 | + | // 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 close | 1228 | + | 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 = sibling | 1230 | + | sibling.parent = nil |
| 1102 | - | sibling.parent = nil | 1231 | + | |
| 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 child | 1233 | + | if wm.window != nil { |
| 1105 | - | if wm.window != nil { | 1234 | + | // GTK requires explicit unparenting before reparenting |
| 1106 | - | // GTK requires explicit unparenting before reparenting | 1235 | + | 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 child | 1241 | + | 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 place | 1247 | // 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 nil | 1402 | 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.config | 1407 | 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 nil | 1418 | return nil | ||
| 1297 | } | 1419 | } | ||
| 1298 | 1420 | ||||
| 1299 | - | // Get webkit config for new WebView creation | 1421 | + | // Smart detection path: create temporary view and decide placement once type is known |
| 1422 | + | if popCfg.EnableSmartDetection { | |||
| 1423 | + | webkitCfg, err := wm.app.buildWebkitConfig() | |||
| 1424 | + | if err != nil { | |||
| 1425 | + | log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err) | |||
| 1426 | + | return nil | |||
| 1427 | + | } | |||
| 1428 | + | webkitCfg.CreateWindow = false | |||
| 1429 | + | // Create as related to avoid WindowFeatures crash; we'll decide final placement later | |||
| 1430 | + | newView, err := webkit.NewWebViewWithRelated(webkitCfg, source) | |||
| 1431 | + | if err != nil { | |||
| 1432 | + | log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err) | |||
| 1433 | + | return nil | |||
| 1434 | + | } | |||
| 1435 | + | ||||
| 1436 | + | // Register detection callback | |||
| 1437 | + | newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) { | |||
| 1438 | + | wm.RunOnUI(func() { | |||
| 1439 | + | wm.handleDetectedWindowType(node, newView, url, t, feat) | |||
| 1440 | + | }) | |||
| 1441 | + | }) | |||
| 1442 | + | ||||
| 1443 | + | // Fallback: if detection never fires, treat as popup as before | |||
| 1444 | + | go func() { | |||
| 1445 | + | time.Sleep(1500 * time.Millisecond) | |||
| 1446 | + | if newView != nil { | |||
| 1447 | + | wm.RunOnUI(func() { | |||
| 1448 | + | if wm.viewToNode[newView] == nil { // not yet placed | |||
| 1449 | + | wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil) | |||
| 1450 | + | } | |||
| 1451 | + | }) | |||
| 1452 | + | } | |||
| 1453 | + | }() | |||
| 1454 | + | ||||
| 1455 | + | return newView | |||
| 1456 | + | } | |||
| 1457 | + | ||||
| 1458 | + | // Legacy path preserved | |||
| 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 nil | 1462 | return nil | ||
| 1304 | } | 1463 | } | ||
| 1305 | - | // Don't create window initially - we'll embed directly in workspace | |||
| 1306 | webkitCfg.CreateWindow = false | 1464 | 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 nil | 1468 | 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 pane | 1537 | // 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 provided | 1543 | // 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 newView | 1564 | 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 workspace | 1971 | // 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.container | 2000 | 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 focus | 2111 | // 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 | return | 2116 | 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 mechanism | 2121 | + | 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 leaves | 345 | // 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 string | 14 | currentURL string | ||
| 14 | lastZoomDomain string | 15 | lastZoomDomain string | ||
| 15 | programmaticChange bool | 16 | programmaticChange bool | ||
| 17 | + | programmaticTimer *time.Timer | |||
| 16 | browserService *services.BrowserService | 18 | browserService *services.BrowserService | ||
| 17 | webView *webkit.WebView | 19 | webView *webkit.WebView | ||
| 18 | } | 20 | } | ||
| @@ -48,7 +50,7 @@ func (z *ZoomController) handleURIChange(url string) { | |||||
| 48 | return | 50 | 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 them | 56 | // 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 | return | 60 | 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 = url | 147 | 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 = true | 155 | 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 tracking | 48 | // 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 bridge | 53 | // 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 handler | 78 | // 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 requests | 479 | // 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 | return | 482 | 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 data | 144 | // 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, err | 182 | 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, err | 191 | 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 header | 111 | // 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 version | 120 | // 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 cmd | 31 | return cmd | ||
| 31 | } | 32 | } | ||
| @@ -126,6 +127,32 @@ func newLogsPathCmd() *cobra.Command { | |||||
| 126 | return cmd | 127 | 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 file | 156 | // 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 settings | 103 | // 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 output | 109 | // 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 impact | 103 | 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 operations | 169 | // 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 packages | 232 | // LogLevelInfo returns the INFO log level constant for external packages | ||
| 226 | func LogLevelInfo() LogLevel { | 233 | func LogLevelInfo() LogLevel { | ||
| 227 | return INFO | 234 | return INFO | ||
M pkg/webkit/keyboard_cgo.go +2 -73
| @@ -3,9 +3,10 @@ | |||||
| 3 | package webkit | 3 | package webkit | ||
| 4 | 4 | ||||
| 5 | /* | 5 | /* | ||
| 6 | - | #cgo pkg-config: gtk4 | 6 | + | #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 callbacks | 11 | // 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 goHandleLoadChanged | 536 | //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 nil | 33 | 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 bool | 36 | useDomZoom bool | ||
| 27 | domZoomSeed float64 | 37 | domZoomSeed float64 | ||
| 28 | container uintptr | 38 | 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.ZoomDefault | 57 | wv.zoom = cfg.ZoomDefault | ||
| 42 | } | 58 | } | ||
| 43 | wv.domZoomSeed = wv.zoom | 59 | 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 = level | 228 | 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 ErrNotImplemented | 246 | 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 ErrNotImplemented | 256 | 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 declarations | 50 | // 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 detection | 63 | // 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 signal | 83 | // 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 WebView | 231 | 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 sharing | 234 | + | // 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-policy | 252 | + | // 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 functionality | 256 | // 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) string | 774 | 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.Once | 779 | var registerSchemeOnce sync.Once | ||
| 1060 | 780 | ||||
| @@ -1210,6 +930,14 @@ type WebView struct { | |||||
| 1210 | domZoomSeed float64 | 930 | domZoomSeed float64 | ||
| 1211 | domBridgeScript *C.WebKitUserScript | 931 | domBridgeScript *C.WebKitUserScript | ||
| 1212 | wasReparented bool // Track if this WebView has been reparented | 932 | 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 enabled | 1099 | // 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' → Reset | 1121 | + | // 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 bridge | 1123 | // 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, nil | 1267 | 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 sharing | 1382 | // 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 enabled | 1467 | // 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 nil | 1680 | 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 callbacks | 1723 | // 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 level | 2003 | 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 = nil | 2051 | 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 | return | 2077 | return | ||
| 2203 | } | 2078 | } | ||
| 2204 | w.domZoomSeed = seed | 2079 | 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 proceed | 2216 | 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 capability | 2226 | // 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) {} | ||