feat(workspace): add advanced popup deduplication and OAuth auto-close system
- commit
- 522f04db5b8fce60ec714fb52d46cf7af9d40525
- author
- bamen · · 2025-09-22 18:33 UTC
- committer
- bamen · · 2025-09-22 18:37 UTC
- parent
- b0316ff
- signature
- unsigned
56 files · +3033 -1482
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 | ||||
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/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/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 +19 -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 | ||
| @@ -101,4 +104,4 @@ declare global { | |||||
| 101 | setActive: (active: boolean) => void; | 104 | setActive: (active: boolean) => void; | ||
| 102 | }; | 105 | }; | ||
| 103 | } | 106 | } | ||
| 104 | - | }⏎̸ | 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 +299 -250
| @@ -6,12 +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 { initializeWindowOpenInterceptor } from './modules/window-open'; | 12 | + | initializeWorkspace, |
| 13 | - | import { keyboardService, type KeyboardService } from '$lib/keyboard'; | 13 | + | type WorkspaceConfigPayload, |
| 14 | - | import type { Suggestion } from '../components/omnibox/types'; | 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"; | |||
| 15 | // 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 | ||
| 16 | 20 | ||||
| 17 | // Global interface for the unified GUI system | 21 | // Global interface for the unified GUI system | ||
| @@ -26,9 +30,9 @@ interface DumberGUI { | |||||
| 26 | 30 | ||||
| 27 | // DOM readiness utility - handles all document ready states | 31 | // DOM readiness utility - handles all document ready states | ||
| 28 | function whenDOMReady(callback: () => void) { | 32 | function whenDOMReady(callback: () => void) { | ||
| 29 | - | if (document.readyState === 'loading') { | 33 | + | if (document.readyState === "loading") { |
| 30 | - | document.addEventListener('DOMContentLoaded', callback, { once: true }); | 34 | + | document.addEventListener("DOMContentLoaded", callback, { once: true }); |
| 31 | - | } else if (document.readyState === 'interactive' && !document.body) { | 35 | + | } else if (document.readyState === "interactive" && !document.body) { |
| 32 | // Interactive but no body yet, wait a bit more | 36 | // Interactive but no body yet, wait a bit more | ||
| 33 | setTimeout(() => whenDOMReady(callback), 10); | 37 | setTimeout(() => whenDOMReady(callback), 10); | ||
| 34 | } else { | 38 | } else { | ||
| @@ -45,7 +49,11 @@ declare global { | |||||
| 45 | interface Window { | 49 | interface Window { | ||
| 46 | __dumber_gui?: DumberGUI; | 50 | __dumber_gui?: DumberGUI; | ||
| 47 | __dumber_gui_ready?: boolean; | 51 | __dumber_gui_ready?: boolean; | ||
| 48 | - | __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; | |||
| 49 | __dumber_showZoomToast?: (level: number) => void; | 57 | __dumber_showZoomToast?: (level: number) => void; | ||
| 50 | __dumber_keyboard?: KeyboardService; | 58 | __dumber_keyboard?: KeyboardService; | ||
| 51 | __dumber_workspace_config?: WorkspaceConfigPayload; | 59 | __dumber_workspace_config?: WorkspaceConfigPayload; | ||
| @@ -72,85 +80,100 @@ declare global { | |||||
| 72 | if (!window.__dumber_gui_ready) { | 80 | if (!window.__dumber_gui_ready) { | ||
| 73 | // Skip initialization in iframes - GUI only needed in main frame | 81 | // Skip initialization in iframes - GUI only needed in main frame | ||
| 74 | if (window.self !== window.top) { | 82 | if (window.self !== window.top) { | ||
| 75 | - | console.log('🚫 Dumber GUI skipped in iframe'); | 83 | + | console.log("🚫 Dumber GUI skipped in iframe"); |
| 76 | } else { | 84 | } else { | ||
| 77 | window.__dumber_gui_ready = true; | 85 | window.__dumber_gui_ready = true; | ||
| 78 | 86 | ||||
| 79 | - | 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 |
| 80 | - | let currentPaneId = window.__dumber_pane?.id || 'unknown'; | 88 | + | let currentPaneId = window.__dumber_pane?.id || "unknown"; |
| 81 | - | 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 |
| 82 | - | 90 | + | ||
| 83 | - | document.addEventListener('dumber:workspace-focus', (event: Event) => { | 91 | + | document.addEventListener("dumber:workspace-focus", (event: Event) => { |
| 84 | - | const detail = (event as CustomEvent).detail || {}; | 92 | + | const detail = (event as CustomEvent).detail || {}; |
| 85 | - | console.log('[workspace focus event]', detail, 'prev focus=', workspaceHasFocus); | 93 | + | console.log( |
| 86 | - | 94 | + | "[workspace focus event]", | |
| 87 | - | hasReceivedFocusEvent = true; | 95 | + | detail, |
| 88 | - | 96 | + | "prev focus=", | |
| 89 | - | // Update focus state | 97 | + | workspaceHasFocus, |
| 90 | - | if (typeof detail?.active === 'boolean') { | 98 | + | ); |
| 91 | - | workspaceHasFocus = detail.active; | 99 | + | |
| 92 | - | } else { | 100 | + | hasReceivedFocusEvent = true; |
| 93 | - | workspaceHasFocus = detail !== false; | 101 | + | |
| 94 | - | } | 102 | + | // Update focus state |
| 95 | - | 103 | + | if (typeof detail?.active === "boolean") { | |
| 96 | - | // Update pane tracking | 104 | + | workspaceHasFocus = detail.active; |
| 97 | - | if (detail?.paneId) { | |||
| 98 | - | currentPaneId = detail.paneId; | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | // Update global pane state | |||
| 102 | - | if (window.__dumber_pane) { | |||
| 103 | - | window.__dumber_pane.active = workspaceHasFocus; | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | console.log('[workspace focus updated] focus=', workspaceHasFocus, 'paneId=', currentPaneId); | |||
| 107 | - | ||||
| 108 | - | // Handle GUI visibility based on focus | |||
| 109 | - | if (window.__dumber_omnibox) { | |||
| 110 | - | if (workspaceHasFocus) { | |||
| 111 | - | window.__dumber_omnibox.setActive(true); | |||
| 112 | } else { | 105 | } else { | ||
| 113 | - | window.__dumber_omnibox.setActive(false); | 106 | + | workspaceHasFocus = detail !== false; |
| 114 | - | window.__dumber_omnibox.close(); | |||
| 115 | } | 107 | } | ||
| 116 | - | } | |||
| 117 | - | }); | |||
| 118 | 108 | ||||
| 119 | - | // Initialize Svelte GUI systems immediately | 109 | + | // Update pane tracking |
| 120 | - | whenDOMReady(async () => { | 110 | + | if (detail?.paneId) { |
| 121 | - | try { | 111 | + | currentPaneId = detail.paneId; |
| 122 | - | // Page-world bridge is injected by WebKit; avoid duplicate injection here | 112 | + | } |
| 123 | - | ||||
| 124 | - | // Initialize window.open interceptor first (must be early) | |||
| 125 | - | initializeWindowOpenInterceptor(); | |||
| 126 | 113 | ||||
| 127 | - | // Initialize toast system first | 114 | + | // Update global pane state |
| 128 | - | await initializeToast(); | 115 | + | if (window.__dumber_pane) { |
| 116 | + | window.__dumber_pane.active = workspaceHasFocus; | |||
| 117 | + | } | |||
| 129 | 118 | ||||
| 130 | - | // Verify toast functions are available | 119 | + | console.log( |
| 131 | - | if (typeof window.__dumber_showToast !== 'function') { | 120 | + | "[workspace focus updated] focus=", |
| 132 | - | 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 | + | } | |||
| 133 | } | 134 | } | ||
| 135 | + | }); | |||
| 134 | 136 | ||||
| 135 | - | // Initialize omnibox system | 137 | + | // Initialize Svelte GUI systems immediately |
| 136 | - | await initializeOmnibox(); | 138 | + | whenDOMReady(async () => { |
| 139 | + | try { | |||
| 140 | + | // Page-world bridge is injected by WebKit; avoid duplicate injection here | |||
| 137 | 141 | ||||
| 138 | - | // Initialize workspace controls (pane/tab scaffolding) | 142 | + | // Initialize window.open interceptor first (must be early) |
| 139 | - | initializeWorkspace(window.__dumber_workspace_config); | 143 | + | initializeWindowOpenInterceptor(); |
| 140 | 144 | ||||
| 141 | - | console.log('✅ All GUI systems initialized successfully'); | 145 | + | // Initialize toast system first |
| 142 | - | } catch (e) { | 146 | + | await initializeToast(); |
| 143 | - | console.error('❌ Failed to initialize Svelte toast system:', e); | |||
| 144 | 147 | ||||
| 145 | - | // Enhanced fallback with better error recovery | 148 | + | // Verify toast functions are available |
| 146 | - | window.__dumber_showToast = (message: string, duration: number = 2500) => { | 149 | + | if (typeof window.__dumber_showToast !== "function") { |
| 147 | - | // Remove existing toasts to prevent overlap | 150 | + | throw new Error("Toast functions not exposed after initialization"); |
| 148 | - | document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove()); | 151 | + | } |
| 149 | 152 | ||||
| 150 | - | const toast = document.createElement('div'); | 153 | + | // Initialize omnibox system |
| 151 | - | toast.className = 'dumber-fallback-toast'; | 154 | + | await initializeOmnibox(); |
| 152 | - | toast.textContent = message; | 155 | + | |
| 153 | - | 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 = ` | |||
| 154 | position: fixed; | 177 | position: fixed; | ||
| 155 | bottom: 20px; | 178 | bottom: 20px; | ||
| 156 | right: 20px; | 179 | right: 20px; | ||
| @@ -168,200 +191,226 @@ if (!window.__dumber_gui_ready) { | |||||
| 168 | word-wrap: break-word; | 191 | word-wrap: break-word; | ||
| 169 | `; | 192 | `; | ||
| 170 | 193 | ||||
| 171 | - | if (document.body) { | 194 | + | if (document.body) { |
| 172 | - | document.body.appendChild(toast); | 195 | + | document.body.appendChild(toast); |
| 173 | - | 196 | + | ||
| 174 | - | // Animate in | 197 | + | // Animate in |
| 175 | - | requestAnimationFrame(() => { | 198 | + | requestAnimationFrame(() => { |
| 176 | - | toast.style.opacity = '1'; | 199 | + | toast.style.opacity = "1"; |
| 177 | - | toast.style.transform = 'translateX(0)'; | 200 | + | toast.style.transform = "translateX(0)"; |
| 178 | - | }); | 201 | + | }); |
| 179 | - | 202 | + | ||
| 180 | - | // Animate out and remove | 203 | + | // Animate out and remove |
| 181 | - | setTimeout(() => { | 204 | + | setTimeout(() => { |
| 182 | - | toast.style.opacity = '0'; | 205 | + | toast.style.opacity = "0"; |
| 183 | - | toast.style.transform = 'translateX(100%)'; | 206 | + | toast.style.transform = "translateX(100%)"; |
| 184 | - | setTimeout(() => toast.remove(), 300); | 207 | + | setTimeout(() => toast.remove(), 300); |
| 185 | - | }, duration); | 208 | + | }, duration); |
| 186 | - | 209 | + | } | |
| 187 | - | } | 210 | + | }; |
| 188 | - | }; | 211 | + | |
| 189 | - | 212 | + | window.__dumber_showZoomToast = (level: number) => { | |
| 190 | - | window.__dumber_showZoomToast = (level: number) => { | 213 | + | const percentage = Math.round(level * 100); |
| 191 | - | const percentage = Math.round(level * 100); | 214 | + | window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500); |
| 192 | - | window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500); | 215 | + | }; |
| 193 | - | }; | 216 | + | |
| 194 | - | 217 | + | // Expose other required functions | |
| 195 | - | // Expose other required functions | 218 | + | window.__dumber_dismissToast = () => { |
| 196 | - | window.__dumber_dismissToast = () => { | 219 | + | document |
| 197 | - | document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove()); | 220 | + | .querySelectorAll(".dumber-fallback-toast") |
| 198 | - | }; | 221 | + | .forEach((el) => el.remove()); |
| 199 | - | 222 | + | }; | |
| 200 | - | window.__dumber_clearToasts = () => { | 223 | + | |
| 201 | - | document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove()); | 224 | + | window.__dumber_clearToasts = () => { |
| 202 | - | }; | 225 | + | document |
| 203 | - | } | 226 | + | .querySelectorAll(".dumber-fallback-toast") |
| 204 | - | }); | 227 | + | .forEach((el) => el.remove()); |
| 205 | - | 228 | + | }; | |
| 206 | - | // Expose keyboard service globally for Go bridge | |||
| 207 | - | window.__dumber_keyboard = keyboardService; | |||
| 208 | - | ||||
| 209 | - | // Initialize global keyboard and mouse event listeners for the keyboard service | |||
| 210 | - | whenDOMReady(() => { | |||
| 211 | - | // Global keyboard event listener (capture phase for high priority) | |||
| 212 | - | document.addEventListener('keydown', (event) => { | |||
| 213 | - | keyboardService.handleKeyboardEvent(event); | |||
| 214 | - | }, true); | |||
| 215 | - | ||||
| 216 | - | // Global mouse event listener for navigation buttons | |||
| 217 | - | document.addEventListener('mousedown', (event) => { | |||
| 218 | - | keyboardService.handleMouseEvent(event); | |||
| 219 | - | }, true); | |||
| 220 | - | ||||
| 221 | - | console.log('✅ KeyboardService initialized with global listeners'); | |||
| 222 | - | ||||
| 223 | - | // Listen to bridge keyboard events from page-world and forward to KeyboardService | |||
| 224 | - | document.addEventListener('dumber:key', (e: Event) => { | |||
| 225 | - | const detail = (e as CustomEvent).detail || {}; | |||
| 226 | - | if (detail && typeof detail.shortcut === 'string') { | |||
| 227 | - | keyboardService.handleNativeShortcut(detail.shortcut); | |||
| 228 | } | 229 | } | ||
| 229 | }); | 230 | }); | ||
| 230 | 231 | ||||
| 231 | - | document.addEventListener('dumber:ui:shortcut', (e: Event) => { | 232 | + | // Expose keyboard service globally for Go bridge |
| 232 | - | const detail = (e as CustomEvent).detail || {}; | 233 | + | window.__dumber_keyboard = keyboardService; |
| 233 | - | const action = detail?.action; | 234 | + | |
| 234 | - | const eventPaneId = detail?.paneId; | 235 | + | // Initialize global keyboard and mouse event listeners for the keyboard service |
| 235 | - | 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 | + | }); | |||
| 264 | + | ||||
| 265 | + | document.addEventListener("dumber:ui:shortcut", (e: Event) => { | |||
| 266 | + | const detail = (e as CustomEvent).detail || {}; | |||
| 267 | + | const action = detail?.action; | |||
| 268 | + | const eventPaneId = detail?.paneId; | |||
| 269 | + | const source = detail?.source || "unknown"; | |||
| 236 | 270 | ||||
| 237 | - | if (typeof action !== 'string') { | 271 | + | if (typeof action !== "string") { |
| 238 | - | return; | 272 | + | return; |
| 239 | - | } | 273 | + | } |
| 240 | 274 | ||||
| 241 | - | // Enhanced focus checking with pane ID validation | 275 | + | // Enhanced focus checking with pane ID validation |
| 242 | - | const isForThisPane = !eventPaneId || eventPaneId === currentPaneId; | 276 | + | const isForThisPane = !eventPaneId || eventPaneId === currentPaneId; |
| 243 | - | // 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 |
| 244 | - | const isOmniboxAction = action.startsWith('omnibox-'); | 278 | + | const isOmniboxAction = action.startsWith("omnibox-"); |
| 245 | - | 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 | + | } | |||
| 246 | 300 | ||||
| 247 | - | if (!shouldHandle) { | 301 | + | console.log("[dumber shortcuts] handling action", { |
| 248 | - | console.log('[dumber shortcuts] ignored action', { | |||
| 249 | action, | 302 | action, | ||
| 250 | - | workspaceHasFocus, | |||
| 251 | - | isForThisPane, | |||
| 252 | - | eventPaneId, | |||
| 253 | - | currentPaneId, | |||
| 254 | source, | 303 | source, | ||
| 255 | - | detail, | 304 | + | paneId: currentPaneId, |
| 256 | - | hasReceivedFocusEvent | |||
| 257 | }); | 305 | }); | ||
| 258 | - | 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); | |||
| 259 | } | 344 | } | ||
| 345 | + | }; | |||
| 260 | 346 | ||||
| 261 | - | console.log('[dumber shortcuts] handling action', { action, source, paneId: currentPaneId }); | 347 | + | window.__dumber_find_open = (query?: string) => { |
| 262 | - | 348 | + | try { | |
| 263 | - | const omnibox = window.__dumber_omnibox; | 349 | + | if (window.__dumber_omnibox?.open) { |
| 264 | - | 350 | + | window.__dumber_omnibox.open("find", query); | |
| 265 | - | switch (action) { | 351 | + | return; |
| 266 | - | case 'omnibox-nav-toggle': | 352 | + | } |
| 267 | - | omnibox?.open?.('omnibox', detail?.query); | 353 | + | |
| 268 | - | break; | 354 | + | throw new Error("Omnibox find requested but API is unavailable"); |
| 269 | - | case 'omnibox-open': | 355 | + | } catch (error) { |
| 270 | - | omnibox?.open?.('omnibox', detail?.query); | 356 | + | console.error("❌ Error in __dumber_find_open:", error); |
| 271 | - | break; | |||
| 272 | - | case 'omnibox-find-toggle': | |||
| 273 | - | omnibox?.open?.('find', detail?.query); | |||
| 274 | - | break; | |||
| 275 | - | case 'omnibox-close': | |||
| 276 | - | omnibox?.close?.(); | |||
| 277 | - | break; | |||
| 278 | - | default: | |||
| 279 | - | console.warn('[dumber] Unknown UI shortcut action:', action); | |||
| 280 | } | 357 | } | ||
| 281 | - | }); | 358 | + | }; |
| 282 | 359 | ||||
| 283 | - | // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed | 360 | + | window.__dumber_find_close = () => { |
| 284 | - | }); | 361 | + | if (window.__dumber_omnibox?.close) { |
| 285 | - | 362 | + | window.__dumber_omnibox.close(); | |
| 286 | - | // Legacy compatibility functions for existing Go code | |||
| 287 | - | window.__dumber_toggle = () => { | |||
| 288 | - | try { | |||
| 289 | - | console.log('🎯 __dumber_toggle called'); | |||
| 290 | - | console.log('🔧 Omnibox API available:', !!window.__dumber_omnibox); | |||
| 291 | - | if (window.__dumber_omnibox?.toggle) { | |||
| 292 | - | console.log('✅ Using Svelte omnibox toggle'); | |||
| 293 | - | window.__dumber_omnibox.toggle(); | |||
| 294 | return; | 363 | return; | ||
| 295 | } | 364 | } | ||
| 296 | 365 | ||||
| 297 | - | throw new Error('Omnibox toggle requested but API is unavailable'); | 366 | + | const error = new Error("Omnibox close requested but API is unavailable"); |
| 298 | - | } catch (error) { | 367 | + | console.error("❌ Error in __dumber_find_close:", error); |
| 299 | - | console.error('❌ Error in __dumber_toggle:', error); | 368 | + | }; |
| 300 | - | } | |||
| 301 | - | }; | |||
| 302 | 369 | ||||
| 303 | - | window.__dumber_find_open = (query?: string) => { | 370 | + | // Find query function that Go uses to set find query |
| 304 | - | try { | 371 | + | window.__dumber_find_query = (query: string) => { |
| 305 | - | if (window.__dumber_omnibox?.open) { | 372 | + | if (window.__dumber_omnibox?.findQuery) { |
| 306 | - | window.__dumber_omnibox.open('find', query); | 373 | + | window.__dumber_omnibox.findQuery(query); |
| 307 | - | return; | |||
| 308 | } | 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, | |||
| 388 | + | ||||
| 389 | + | isReady: true, | |||
| 390 | + | }; | |||
| 391 | + | ||||
| 392 | + | // Bootstrap function for lazy GUI initialization by workspace manager | |||
| 393 | + | window.__dumber_gui_bootstrap = function () { | |||
| 394 | + | console.log("[gui-bootstrap] Initializing GUI for workspace"); | |||
| 395 | + | ||||
| 396 | + | // Pane ID will be updated via focus events | |||
| 309 | 397 | ||||
| 310 | - | throw new Error('Omnibox find requested but API is unavailable'); | 398 | + | // Ensure omnibox is available for this pane |
| 311 | - | } catch (error) { | 399 | + | if (!window.__dumber_omnibox) { |
| 312 | - | console.error('❌ Error in __dumber_find_open:', error); | 400 | + | console.log( |
| 313 | - | } | 401 | + | `[gui-bootstrap] Omnibox not yet available for pane ${currentPaneId}, will be loaded when needed`, |
| 314 | - | }; | 402 | + | ); |
| 315 | - | 403 | + | } else { | |
| 316 | - | window.__dumber_find_close = () => { | 404 | + | console.log( |
| 317 | - | if (window.__dumber_omnibox?.close) { | 405 | + | `[gui-bootstrap] Omnibox already available for pane ${currentPaneId}`, |
| 318 | - | window.__dumber_omnibox.close(); | 406 | + | ); |
| 319 | - | return; | 407 | + | // Ensure omnibox is aware of the current pane |
| 320 | - | } | 408 | + | window.__dumber_omnibox.setActive(workspaceHasFocus); |
| 321 | - | 409 | + | } | |
| 322 | - | const error = new Error('Omnibox close requested but API is unavailable'); | |||
| 323 | - | console.error('❌ Error in __dumber_find_close:', error); | |||
| 324 | - | }; | |||
| 325 | - | ||||
| 326 | - | // Find query function that Go uses to set find query | |||
| 327 | - | window.__dumber_find_query = (query: string) => { | |||
| 328 | - | if (window.__dumber_omnibox?.findQuery) { | |||
| 329 | - | window.__dumber_omnibox.findQuery(query); | |||
| 330 | - | } | |||
| 331 | - | }; | |||
| 332 | - | ||||
| 333 | - | // Create the global GUI object | |||
| 334 | - | window.__dumber_gui = { | |||
| 335 | - | initializeToast: async (config?: ToastConfig) => { | |||
| 336 | - | return await initializeToast(config); | |||
| 337 | - | }, | |||
| 338 | - | ||||
| 339 | - | initializeOmnibox: async (config?: OmniboxInitConfig) => { | |||
| 340 | - | return await initializeOmnibox(config); | |||
| 341 | - | }, | |||
| 342 | - | ||||
| 343 | - | keyboard: keyboardService, | |||
| 344 | - | ||||
| 345 | - | isReady: true | |||
| 346 | - | }; | |||
| 347 | - | ||||
| 348 | - | // Bootstrap function for lazy GUI initialization by workspace manager | |||
| 349 | - | window.__dumber_gui_bootstrap = function() { | |||
| 350 | - | console.log('[gui-bootstrap] Initializing GUI for workspace'); | |||
| 351 | - | ||||
| 352 | - | // Pane ID will be updated via focus events | |||
| 353 | - | ||||
| 354 | - | // Ensure omnibox is available for this pane | |||
| 355 | - | if (!window.__dumber_omnibox) { | |||
| 356 | - | console.log(`[gui-bootstrap] Omnibox not yet available for pane ${currentPaneId}, will be loaded when needed`); | |||
| 357 | - | } else { | |||
| 358 | - | console.log(`[gui-bootstrap] Omnibox already available for pane ${currentPaneId}`); | |||
| 359 | - | // Ensure omnibox is aware of the current pane | |||
| 360 | - | window.__dumber_omnibox.setActive(workspaceHasFocus); | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | - | console.log(`[gui-bootstrap] GUI bootstrap complete for pane ${currentPaneId}`); | |||
| 364 | - | }; | |||
| 365 | 410 | ||||
| 411 | + | console.log( | |||
| 412 | + | `[gui-bootstrap] GUI bootstrap complete for pane ${currentPaneId}`, | |||
| 413 | + | ); | |||
| 414 | + | }; | |||
| 366 | } | 415 | } | ||
| 367 | } | 416 | } | ||
M gui/src/injected/main-world.ts +673 -144
| @@ -18,11 +18,17 @@ declare global { | |||||
| 18 | __dumber_dom_zoom_seed?: number; | 18 | __dumber_dom_zoom_seed?: number; | ||
| 19 | __dumber_dom_zoom_level?: number; | 19 | __dumber_dom_zoom_level?: number; | ||
| 20 | __dumber_initial_theme?: string; | 20 | __dumber_initial_theme?: string; | ||
| 21 | - | __dumber_setTheme?: (theme: 'light' | 'dark') => void; | 21 | + | __dumber_setTheme?: (theme: "light" | "dark") => void; |
| 22 | __dumber_applyDomZoom?: (level: number) => void; | 22 | __dumber_applyDomZoom?: (level: number) => void; | ||
| 23 | - | __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void; | 23 | + | __dumber_showToast?: ( |
| 24 | + | message: string, | |||
| 25 | + | duration?: number, | |||
| 26 | + | type?: "info" | "success" | "error", | |||
| 27 | + | ) => number | void; | |||
| 24 | __dumber_showZoomToast?: (level: number) => void; | 28 | __dumber_showZoomToast?: (level: number) => void; | ||
| 25 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | 29 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | ||
| 30 | + | __dumber_webview_id?: string; | |||
| 31 | + | __dumber_is_active?: boolean; | |||
| 26 | webkit?: { | 32 | webkit?: { | ||
| 27 | messageHandlers?: { | 33 | messageHandlers?: { | ||
| 28 | dumber?: { | 34 | dumber?: { | ||
| @@ -39,6 +45,8 @@ interface WindowIntent { | |||||
| 39 | features: string; | 45 | features: string; | ||
| 40 | timestamp: number; | 46 | timestamp: number; | ||
| 41 | userTriggered: boolean; | 47 | userTriggered: boolean; | ||
| 48 | + | requestId: string; | |||
| 49 | + | windowType: string; // "tab", "popup", "unknown" | |||
| 42 | } | 50 | } | ||
| 43 | 51 | ||||
| 44 | interface Suggestion { | 52 | interface Suggestion { | ||
| @@ -48,7 +56,11 @@ interface Suggestion { | |||||
| 48 | 56 | ||||
| 49 | interface DumberAPI { | 57 | interface DumberAPI { | ||
| 50 | toast: { | 58 | toast: { | ||
| 51 | - | show: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => void; | 59 | + | show: ( |
| 60 | + | message: string, | |||
| 61 | + | duration?: number, | |||
| 62 | + | type?: "info" | "success" | "error", | |||
| 63 | + | ) => void; | |||
| 52 | zoom: (level: number) => void; | 64 | zoom: (level: number) => void; | ||
| 53 | }; | 65 | }; | ||
| 54 | omnibox: { | 66 | omnibox: { | ||
| @@ -56,6 +68,31 @@ interface DumberAPI { | |||||
| 56 | }; | 68 | }; | ||
| 57 | } | 69 | } | ||
| 58 | 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 | + | ||||
| 59 | (() => { | 96 | (() => { | ||
| 60 | try { | 97 | try { | ||
| 61 | // Prevent multiple installations | 98 | // Prevent multiple installations | ||
| @@ -68,52 +105,68 @@ interface DumberAPI { | |||||
| 68 | const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go | 105 | const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go | ||
| 69 | window.__dumber_dom_zoom_seed = initialZoom; | 106 | window.__dumber_dom_zoom_seed = initialZoom; | ||
| 70 | 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 | + | ||||
| 71 | // Theme setter function for GTK theme integration | 112 | // Theme setter function for GTK theme integration | ||
| 72 | - | window.__dumber_setTheme = (theme: 'light' | 'dark') => { | 113 | + | window.__dumber_setTheme = (theme: "light" | "dark") => { |
| 73 | window.__dumber_initial_theme = theme; | 114 | window.__dumber_initial_theme = theme; | ||
| 74 | - | console.log('[dumber] Setting theme to:', theme); | 115 | + | console.log("[dumber] Setting theme to:", theme); |
| 75 | - | if (theme === 'dark') { | 116 | + | if (theme === "dark") { |
| 76 | - | document.documentElement.classList.add('dark'); | 117 | + | document.documentElement.classList.add("dark"); |
| 77 | } else { | 118 | } else { | ||
| 78 | - | document.documentElement.classList.remove('dark'); | 119 | + | document.documentElement.classList.remove("dark"); |
| 79 | } | 120 | } | ||
| 80 | }; | 121 | }; | ||
| 81 | 122 | ||||
| 82 | // Initialize unified API object | 123 | // Initialize unified API object | ||
| 83 | - | window.__dumber = window.__dumber || {} as DumberAPI; | 124 | + | window.__dumber = window.__dumber || ({} as DumberAPI); |
| 84 | 125 | ||||
| 85 | // Toast bridge | 126 | // Toast bridge | ||
| 86 | window.__dumber.toast = window.__dumber.toast || { | 127 | window.__dumber.toast = window.__dumber.toast || { | ||
| 87 | show: (message: string, duration?: number, type?: string) => { | 128 | show: (message: string, duration?: number, type?: string) => { | ||
| 88 | try { | 129 | try { | ||
| 89 | - | document.dispatchEvent(new CustomEvent('dumber:toast:show', { | 130 | + | document.dispatchEvent( |
| 90 | - | detail: { message, duration, type } | 131 | + | new CustomEvent("dumber:toast:show", { |
| 91 | - | })); | 132 | + | detail: { message, duration, type }, |
| 133 | + | }), | |||
| 134 | + | ); | |||
| 92 | // Legacy compatibility | 135 | // Legacy compatibility | ||
| 93 | - | document.dispatchEvent(new CustomEvent('dumber:showToast', { | 136 | + | document.dispatchEvent( |
| 94 | - | detail: { message, duration, type } | 137 | + | new CustomEvent("dumber:showToast", { |
| 95 | - | })); | 138 | + | detail: { message, duration, type }, |
| 139 | + | }), | |||
| 140 | + | ); | |||
| 96 | } catch { | 141 | } catch { | ||
| 97 | // ignore | 142 | // ignore | ||
| 98 | } | 143 | } | ||
| 99 | }, | 144 | }, | ||
| 100 | zoom: (level: number) => { | 145 | zoom: (level: number) => { | ||
| 101 | try { | 146 | try { | ||
| 102 | - | document.dispatchEvent(new CustomEvent('dumber:toast:zoom', { | 147 | + | document.dispatchEvent( |
| 103 | - | detail: { level } | 148 | + | new CustomEvent("dumber:toast:zoom", { |
| 104 | - | })); | 149 | + | detail: { level }, |
| 150 | + | }), | |||
| 151 | + | ); | |||
| 105 | // Legacy compatibility | 152 | // Legacy compatibility | ||
| 106 | - | document.dispatchEvent(new CustomEvent('dumber:showZoomToast', { | 153 | + | document.dispatchEvent( |
| 107 | - | detail: { level } | 154 | + | new CustomEvent("dumber:showZoomToast", { |
| 108 | - | })); | 155 | + | detail: { level }, |
| 156 | + | }), | |||
| 157 | + | ); | |||
| 109 | } catch { | 158 | } catch { | ||
| 110 | // ignore | 159 | // ignore | ||
| 111 | } | 160 | } | ||
| 112 | - | } | 161 | + | }, |
| 113 | }; | 162 | }; | ||
| 114 | 163 | ||||
| 115 | // Legacy toast helpers | 164 | // Legacy toast helpers | ||
| 116 | - | window.__dumber_showToast = (message: string, duration?: number, type?: 'info' | 'success' | 'error') => { | 165 | + | window.__dumber_showToast = ( |
| 166 | + | message: string, | |||
| 167 | + | duration?: number, | |||
| 168 | + | type?: "info" | "success" | "error", | |||
| 169 | + | ) => { | |||
| 117 | window.__dumber.toast.show(message, duration, type); | 170 | window.__dumber.toast.show(message, duration, type); | ||
| 118 | }; | 171 | }; | ||
| 119 | window.__dumber_showZoomToast = (level: number) => { | 172 | window.__dumber_showZoomToast = (level: number) => { | ||
| @@ -121,7 +174,7 @@ interface DumberAPI { | |||||
| 121 | }; | 174 | }; | ||
| 122 | 175 | ||||
| 123 | // DOM zoom functionality | 176 | // DOM zoom functionality | ||
| 124 | - | if (typeof window.__dumber_dom_zoom_level !== 'number') { | 177 | + | if (typeof window.__dumber_dom_zoom_level !== "number") { |
| 125 | window.__dumber_dom_zoom_level = initialZoom; | 178 | window.__dumber_dom_zoom_level = initialZoom; | ||
| 126 | } | 179 | } | ||
| 127 | 180 | ||||
| @@ -130,13 +183,13 @@ interface DumberAPI { | |||||
| 130 | 183 | ||||
| 131 | if (Math.abs(level - 1.0) < 1e-6) { | 184 | if (Math.abs(level - 1.0) < 1e-6) { | ||
| 132 | // Reset zoom | 185 | // Reset zoom | ||
| 133 | - | node.style.removeProperty('zoom'); | 186 | + | node.style.removeProperty("zoom"); |
| 134 | - | node.style.removeProperty('transform'); | 187 | + | node.style.removeProperty("transform"); |
| 135 | - | node.style.removeProperty('transform-origin'); | 188 | + | node.style.removeProperty("transform-origin"); |
| 136 | - | node.style.removeProperty('width'); | 189 | + | node.style.removeProperty("width"); |
| 137 | - | node.style.removeProperty('min-width'); | 190 | + | node.style.removeProperty("min-width"); |
| 138 | - | node.style.removeProperty('height'); | 191 | + | node.style.removeProperty("height"); |
| 139 | - | node.style.removeProperty('min-height'); | 192 | + | node.style.removeProperty("min-height"); |
| 140 | return; | 193 | return; | ||
| 141 | } | 194 | } | ||
| 142 | 195 | ||||
| @@ -144,12 +197,12 @@ interface DumberAPI { | |||||
| 144 | const inversePercent = 100 / scale; | 197 | const inversePercent = 100 / scale; | ||
| 145 | const widthValue = `${inversePercent}%`; | 198 | const widthValue = `${inversePercent}%`; | ||
| 146 | 199 | ||||
| 147 | - | node.style.removeProperty('zoom'); | 200 | + | node.style.removeProperty("zoom"); |
| 148 | node.style.transform = `scale(${scale})`; | 201 | node.style.transform = `scale(${scale})`; | ||
| 149 | - | node.style.transformOrigin = '0 0'; | 202 | + | node.style.transformOrigin = "0 0"; |
| 150 | node.style.width = widthValue; | 203 | node.style.width = widthValue; | ||
| 151 | node.style.minWidth = widthValue; | 204 | node.style.minWidth = widthValue; | ||
| 152 | - | node.style.minHeight = '100%'; | 205 | + | node.style.minHeight = "100%"; |
| 153 | }; | 206 | }; | ||
| 154 | 207 | ||||
| 155 | window.__dumber_applyDomZoom = (level: number) => { | 208 | window.__dumber_applyDomZoom = (level: number) => { | ||
| @@ -160,8 +213,8 @@ interface DumberAPI { | |||||
| 160 | if (document.body) { | 213 | if (document.body) { | ||
| 161 | applyZoomStyles(document.body, level); | 214 | applyZoomStyles(document.body, level); | ||
| 162 | } | 215 | } | ||
| 163 | - | } catch (e) { | 216 | + | } catch (err) { |
| 164 | - | console.error('[dumber] DOM zoom error', e); | 217 | + | console.error("[dumber] DOM zoom error", err); |
| 165 | } | 218 | } | ||
| 166 | }; | 219 | }; | ||
| 167 | 220 | ||||
| @@ -169,11 +222,15 @@ interface DumberAPI { | |||||
| 169 | window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level); | 222 | window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level); | ||
| 170 | 223 | ||||
| 171 | if (!document.body) { | 224 | if (!document.body) { | ||
| 172 | - | document.addEventListener('DOMContentLoaded', () => { | 225 | + | document.addEventListener( |
| 173 | - | if (typeof window.__dumber_dom_zoom_level === 'number') { | 226 | + | "DOMContentLoaded", |
| 174 | - | window.__dumber_applyDomZoom!(window.__dumber_dom_zoom_level); | 227 | + | () => { |
| 175 | - | } | 228 | + | if (typeof window.__dumber_dom_zoom_level === "number") { |
| 176 | - | }, { once: true }); | 229 | + | window.__dumber_applyDomZoom!(window.__dumber_dom_zoom_level); |
| 230 | + | } | |||
| 231 | + | }, | |||
| 232 | + | { once: true }, | |||
| 233 | + | ); | |||
| 177 | } | 234 | } | ||
| 178 | 235 | ||||
| 179 | // Omnibox suggestions bridge | 236 | // Omnibox suggestions bridge | ||
| @@ -182,15 +239,19 @@ interface DumberAPI { | |||||
| 182 | 239 | ||||
| 183 | const omniboxDispatch = (suggestions: Suggestion[]) => { | 240 | const omniboxDispatch = (suggestions: Suggestion[]) => { | ||
| 184 | try { | 241 | try { | ||
| 185 | - | document.dispatchEvent(new CustomEvent('dumber:omnibox:suggestions', { | 242 | + | document.dispatchEvent( |
| 186 | - | detail: { suggestions } | 243 | + | new CustomEvent("dumber:omnibox:suggestions", { |
| 187 | - | })); | 244 | + | detail: { suggestions }, |
| 245 | + | }), | |||
| 246 | + | ); | |||
| 188 | // Legacy compatibility | 247 | // Legacy compatibility | ||
| 189 | - | document.dispatchEvent(new CustomEvent('dumber:omnibox-suggestions', { | 248 | + | document.dispatchEvent( |
| 190 | - | detail: { suggestions } | 249 | + | new CustomEvent("dumber:omnibox-suggestions", { |
| 191 | - | })); | 250 | + | detail: { suggestions }, |
| 192 | - | } catch (e) { | 251 | + | }), |
| 193 | - | console.error('[dumber] Omnibox dispatch error', e); | 252 | + | ); |
| 253 | + | } catch (err) { | |||
| 254 | + | console.error("[dumber] Omnibox dispatch error", err); | |||
| 194 | } | 255 | } | ||
| 195 | }; | 256 | }; | ||
| 196 | 257 | ||||
| @@ -200,18 +261,18 @@ interface DumberAPI { | |||||
| 200 | } else { | 261 | } else { | ||
| 201 | try { | 262 | try { | ||
| 202 | omniboxQueue.push(...suggestions); | 263 | omniboxQueue.push(...suggestions); | ||
| 203 | - | } catch (e){ | 264 | + | } catch (err) { |
| 204 | - | console.error('[dumber] Omnibox queue error', e); | 265 | + | console.error("[dumber] Omnibox queue error", err); |
| 205 | } | 266 | } | ||
| 206 | } | 267 | } | ||
| 207 | }; | 268 | }; | ||
| 208 | 269 | ||||
| 209 | - | document.addEventListener('dumber:omnibox-ready', () => { | 270 | + | document.addEventListener("dumber:omnibox-ready", () => { |
| 210 | omniboxReady = true; | 271 | omniboxReady = true; | ||
| 211 | if (omniboxQueue && omniboxQueue.length) { | 272 | if (omniboxQueue && omniboxQueue.length) { | ||
| 212 | const items = omniboxQueue.slice(); | 273 | const items = omniboxQueue.slice(); | ||
| 213 | omniboxQueue.length = 0; | 274 | omniboxQueue.length = 0; | ||
| 214 | - | items.forEach(s => omniboxDispatch([s]) ); | 275 | + | items.forEach((s) => omniboxDispatch([s])); |
| 215 | } | 276 | } | ||
| 216 | }); | 277 | }); | ||
| 217 | 278 | ||||
| @@ -219,14 +280,411 @@ interface DumberAPI { | |||||
| 219 | window.__dumber.omnibox = window.__dumber.omnibox || { | 280 | window.__dumber.omnibox = window.__dumber.omnibox || { | ||
| 220 | suggestions: (suggestions: Suggestion[]) => { | 281 | suggestions: (suggestions: Suggestion[]) => { | ||
| 221 | window.__dumber_omnibox_suggestions!(suggestions); | 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); | |||
| 222 | } | 400 | } | ||
| 223 | }; | 401 | }; | ||
| 224 | 402 | ||||
| 225 | - | // User interaction tracking and window.open interceptor | 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 | |||
| 226 | let lastUserInteraction = 0; | 686 | let lastUserInteraction = 0; | ||
| 227 | const INTERACTION_TIMEOUT = 5000; // 5 seconds | 687 | const INTERACTION_TIMEOUT = 5000; // 5 seconds | ||
| 228 | - | const WINDOW_OPEN_DEBOUNCE = 1000; // 1 second between calls | |||
| 229 | - | const recentWindowOpenCalls = new Map<string, number>(); | |||
| 230 | 688 | ||||
| 231 | const trackUserInteraction = () => { | 689 | const trackUserInteraction = () => { | ||
| 232 | lastUserInteraction = Date.now(); | 690 | lastUserInteraction = Date.now(); | ||
| @@ -238,113 +696,173 @@ interface DumberAPI { | |||||
| 238 | return timeSinceInteraction <= INTERACTION_TIMEOUT; | 696 | return timeSinceInteraction <= INTERACTION_TIMEOUT; | ||
| 239 | }; | 697 | }; | ||
| 240 | 698 | ||||
| 241 | - | const isDuplicateWindowOpen = (url: string): boolean => { | |||
| 242 | - | const now = Date.now(); | |||
| 243 | - | const lastCall = recentWindowOpenCalls.get(url); | |||
| 244 | 699 | ||||
| 245 | - | if (lastCall && (now - lastCall) < WINDOW_OPEN_DEBOUNCE) { | 700 | + | const createFakeWindow = (url: string, popupId?: string): WindowProxy => { |
| 246 | - | return true; | 701 | + | // Generate unique popup ID if not provided |
| 247 | - | } | 702 | + | const actualPopupId = popupId || `${window.__dumber_webview_id}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; |
| 248 | 703 | ||||
| 249 | - | recentWindowOpenCalls.set(url, now); | 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 | + | })); | |||
| 250 | 712 | ||||
| 251 | - | // Clean up old entries | 713 | + | console.log(`[window-open] [WebView ${window.__dumber_webview_id}] Set up shared storage communication for popup ID: ${actualPopupId}`); |
| 252 | - | for (const [key, timestamp] of recentWindowOpenCalls.entries()) { | 714 | + | } catch (err) { |
| 253 | - | if (now - timestamp > WINDOW_OPEN_DEBOUNCE * 2) { | 715 | + | console.warn(`[window-open] [WebView ${window.__dumber_webview_id}] Failed to set up shared storage:`, err); |
| 254 | - | recentWindowOpenCalls.delete(key); | |||
| 255 | - | } | |||
| 256 | } | 716 | } | ||
| 257 | 717 | ||||
| 258 | - | return false; | 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; | |||
| 259 | }; | 758 | }; | ||
| 260 | 759 | ||||
| 760 | + | ||||
| 261 | // Track user interactions for popup validation | 761 | // Track user interactions for popup validation | ||
| 262 | - | ['click', 'mousedown', 'keydown', 'touchstart'].forEach(eventType => { | 762 | + | ["click", "mousedown", "keydown", "touchstart"].forEach((eventType) => { |
| 263 | document.addEventListener(eventType, trackUserInteraction, true); | 763 | document.addEventListener(eventType, trackUserInteraction, true); | ||
| 264 | }); | 764 | }); | ||
| 265 | 765 | ||||
| 266 | - | console.log('[window-open] User interaction tracking enabled'); | 766 | + | console.log( |
| 767 | + | `[window-open] [WebView ${window.__dumber_webview_id}] User interaction tracking enabled`, | |||
| 768 | + | ); | |||
| 769 | + | ||||
| 267 | 770 | ||||
| 268 | // Window.open interceptor - only install once | 771 | // Window.open interceptor - only install once | ||
| 269 | if (!window.__dumber_window_open_intercepted) { | 772 | if (!window.__dumber_window_open_intercepted) { | ||
| 270 | try { | 773 | try { | ||
| 271 | - | 774 | + | window.open = function ( | |
| 272 | - | window.open = function( | |||
| 273 | url?: string | URL | null, | 775 | url?: string | URL | null, | ||
| 274 | target?: string | null, | 776 | target?: string | null, | ||
| 275 | - | features?: string | null | 777 | + | features?: string | null, |
| 276 | ): WindowProxy | null { | 778 | ): WindowProxy | null { | ||
| 277 | - | console.log('[window-open] Bridge called with:', url, target, features); | 779 | + | const urlString = url |
| 278 | - | console.log('[window-open] Call stack:', new Error().stack); | 780 | + | ? typeof url === "string" |
| 279 | - | 781 | + | ? url | |
| 280 | - | const urlString = url ? (typeof url === 'string' ? url : url.toString()) : ''; | 782 | + | : url.toString() |
| 281 | - | 783 | + | : ""; | |
| 282 | - | // Check for duplicate calls to prevent multiple panes | 784 | + | const webviewId = window.__dumber_webview_id || "unknown"; |
| 283 | - | if (isDuplicateWindowOpen(urlString)) { | 785 | + | |
| 284 | - | console.log('[window-open] Blocked: duplicate call within debounce window'); | 786 | + | console.log( |
| 285 | - | return { | 787 | + | `[window-open] [WebView ${webviewId}] Bridge called with:`, |
| 286 | - | closed: true, | 788 | + | urlString, |
| 287 | - | location: { href: urlString }, | 789 | + | target, |
| 288 | - | close: () => {}, | 790 | + | features, |
| 289 | - | focus: () => {}, | 791 | + | ); |
| 290 | - | blur: () => {}, | 792 | + | |
| 291 | - | postMessage: () => {} | 793 | + | |
| 292 | - | } as unknown as WindowProxy; | 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); | |||
| 293 | } | 808 | } | ||
| 294 | 809 | ||||
| 295 | - | // Check for user interaction to prevent popup spam | 810 | + | // User interaction check |
| 296 | if (!isUserInteraction()) { | 811 | if (!isUserInteraction()) { | ||
| 297 | - | console.log('[window-open] Blocked: no recent user interaction detected'); | 812 | + | console.log( |
| 298 | - | return { | 813 | + | `[window-open] [WebView ${webviewId}] BLOCKED: No recent user interaction`, |
| 299 | - | closed: true, | 814 | + | ); |
| 300 | - | location: { href: '' }, | 815 | + | return createFakeWindow(""); |
| 301 | - | close: () => {}, | |||
| 302 | - | focus: () => {}, | |||
| 303 | - | blur: () => {}, | |||
| 304 | - | postMessage: () => {} | |||
| 305 | - | } as unknown as WindowProxy; | |||
| 306 | } | 816 | } | ||
| 307 | 817 | ||||
| 308 | - | const originalFeatures = features || ''; | 818 | + | // Register request and get unique ID |
| 309 | - | console.log('[window-open] Using original features:', originalFeatures); | 819 | + | const requestId = windowOpenDebouncer.registerRequest( |
| 820 | + | urlString, | |||
| 821 | + | webviewId, | |||
| 822 | + | ); | |||
| 310 | 823 | ||||
| 311 | const intent: WindowIntent = { | 824 | const intent: WindowIntent = { | ||
| 312 | url: urlString, | 825 | url: urlString, | ||
| 313 | - | target: target || '_blank', | 826 | + | target: target || "_blank", |
| 314 | - | features: originalFeatures, | 827 | + | features: features || "", |
| 315 | timestamp: Date.now(), | 828 | timestamp: Date.now(), | ||
| 316 | - | userTriggered: true | 829 | + | userTriggered: true, |
| 830 | + | requestId, // NEW: Add unique request ID | |||
| 831 | + | windowType: detectWindowType(features), // Detect popup vs tab | |||
| 317 | }; | 832 | }; | ||
| 318 | 833 | ||||
| 319 | - | // Send message to Go to handle popup creation | 834 | + | // Send to Go backend |
| 320 | try { | 835 | try { | ||
| 321 | if (window.webkit?.messageHandlers?.dumber) { | 836 | if (window.webkit?.messageHandlers?.dumber) { | ||
| 322 | - | window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({ | 837 | + | window.webkit.messageHandlers.dumber.postMessage( |
| 323 | - | type: 'handle-window-open', | 838 | + | JSON.stringify({ |
| 324 | - | payload: intent | 839 | + | type: "handle-window-open", |
| 325 | - | })); | 840 | + | payload: intent, |
| 326 | - | console.log('[window-open] Sent window.open request to Go - bypassing WebKit completely'); | 841 | + | }), |
| 842 | + | ); | |||
| 843 | + | console.log( | |||
| 844 | + | `[window-open] [WebView ${webviewId}] Sent request ${requestId} to Go backend`, | |||
| 845 | + | ); | |||
| 327 | } | 846 | } | ||
| 328 | - | } catch (e) { | 847 | + | } catch (err) { |
| 329 | - | console.error('[window-open] Failed to send to Go:', e); | 848 | + | console.error( |
| 849 | + | `[window-open] [WebView ${webviewId}] Failed to send request ${requestId}:`, | |||
| 850 | + | err, | |||
| 851 | + | ); | |||
| 330 | } | 852 | } | ||
| 331 | 853 | ||||
| 332 | - | // Return fake window object - don't call original window.open | 854 | + | return createFakeWindow(urlString, requestId); |
| 333 | - | console.log('[window-open] Returning fake window object - bypassing WebKit create signal'); | |||
| 334 | - | return { | |||
| 335 | - | closed: false, | |||
| 336 | - | location: { href: urlString }, | |||
| 337 | - | close() { (this as { closed: boolean }).closed = true; }, | |||
| 338 | - | focus: () => {}, | |||
| 339 | - | blur: () => {}, | |||
| 340 | - | postMessage: () => {} | |||
| 341 | - | } as unknown as WindowProxy; | |||
| 342 | }; | 855 | }; | ||
| 343 | 856 | ||||
| 344 | window.__dumber_window_open_intercepted = true; | 857 | window.__dumber_window_open_intercepted = true; | ||
| 345 | - | console.log('[window-open] ✅ Page-world bridge installed'); | 858 | + | console.log( |
| 859 | + | `[window-open] [WebView ${window.__dumber_webview_id}] ✅ Page-world bridge installed`, | |||
| 860 | + | ); | |||
| 346 | } catch (error) { | 861 | } catch (error) { | ||
| 347 | - | console.error('[window-open] ❌ Failed to install page-world bridge:', error); | 862 | + | console.error( |
| 863 | + | `[window-open] [WebView ${window.__dumber_webview_id}] ❌ Failed to install page-world bridge:`, | |||
| 864 | + | error, | |||
| 865 | + | ); | |||
| 348 | } | 866 | } | ||
| 349 | } | 867 | } | ||
| 350 | 868 | ||||
| @@ -355,48 +873,59 @@ interface DumberAPI { | |||||
| 355 | warn: console.warn, | 873 | warn: console.warn, | ||
| 356 | error: console.error, | 874 | error: console.error, | ||
| 357 | info: console.info, | 875 | info: console.info, | ||
| 358 | - | debug: console.debug | 876 | + | debug: console.debug, |
| 359 | }; | 877 | }; | ||
| 360 | 878 | ||||
| 361 | const sendConsoleMessage = (level: string, args: unknown[]) => { | 879 | const sendConsoleMessage = (level: string, args: unknown[]) => { | ||
| 362 | try { | 880 | try { | ||
| 363 | - | const message = args.map(arg => | 881 | + | const message = args |
| 364 | - | typeof arg === 'object' ? JSON.stringify(arg) : String(arg) | 882 | + | .map((arg) => |
| 365 | - | ).join(' '); | 883 | + | typeof arg === "object" ? JSON.stringify(arg) : String(arg), |
| 884 | + | ) | |||
| 885 | + | .join(" "); | |||
| 366 | 886 | ||||
| 367 | const formattedMessage = `${window.location.href} ${message}`; | 887 | const formattedMessage = `${window.location.href} ${message}`; | ||
| 368 | 888 | ||||
| 369 | // Send to Go via existing message handler | 889 | // Send to Go via existing message handler | ||
| 370 | if (window.webkit?.messageHandlers?.dumber) { | 890 | if (window.webkit?.messageHandlers?.dumber) { | ||
| 371 | - | window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({ | 891 | + | window.webkit.messageHandlers.dumber.postMessage( |
| 372 | - | type: 'console-message', | 892 | + | JSON.stringify({ |
| 373 | - | payload: { level, message: formattedMessage, url: window.location.href } | 893 | + | type: "console-message", |
| 374 | - | })); | 894 | + | payload: { |
| 895 | + | level, | |||
| 896 | + | message: formattedMessage, | |||
| 897 | + | url: window.location.href, | |||
| 898 | + | webviewId: window.__dumber_webview_id, | |||
| 899 | + | }, | |||
| 900 | + | }), | |||
| 901 | + | ); | |||
| 375 | } | 902 | } | ||
| 376 | } catch { | 903 | } catch { | ||
| 377 | // Silently ignore errors in console capture to avoid infinite loops | 904 | // Silently ignore errors in console capture to avoid infinite loops | ||
| 378 | } | 905 | } | ||
| 379 | }; | 906 | }; | ||
| 380 | 907 | ||||
| 381 | - | const createConsoleWrapper = (originalMethod: (...args: unknown[]) => void, level: string) => { | 908 | + | const createConsoleWrapper = ( |
| 382 | - | return function(...args: unknown[]) { | 909 | + | originalMethod: (...args: unknown[]) => void, |
| 910 | + | level: string, | |||
| 911 | + | ) => { | |||
| 912 | + | return function (...args: unknown[]) { | |||
| 383 | originalMethod.apply(console, args); | 913 | originalMethod.apply(console, args); | ||
| 384 | sendConsoleMessage(level, args); | 914 | sendConsoleMessage(level, args); | ||
| 385 | }; | 915 | }; | ||
| 386 | }; | 916 | }; | ||
| 387 | 917 | ||||
| 388 | // Override console methods to capture messages | 918 | // Override console methods to capture messages | ||
| 389 | - | console.log = createConsoleWrapper(originalConsole.log, 'LOG'); | 919 | + | console.log = createConsoleWrapper(originalConsole.log, "LOG"); |
| 390 | - | console.warn = createConsoleWrapper(originalConsole.warn, 'WARN'); | 920 | + | console.warn = createConsoleWrapper(originalConsole.warn, "WARN"); |
| 391 | - | console.error = createConsoleWrapper(originalConsole.error, 'ERROR'); | 921 | + | console.error = createConsoleWrapper(originalConsole.error, "ERROR"); |
| 392 | - | console.info = createConsoleWrapper(originalConsole.info, 'INFO'); | 922 | + | console.info = createConsoleWrapper(originalConsole.info, "INFO"); |
| 393 | - | console.debug = createConsoleWrapper(originalConsole.debug, 'DEBUG'); | 923 | + | console.debug = createConsoleWrapper(originalConsole.debug, "DEBUG"); |
| 394 | }; | 924 | }; | ||
| 395 | 925 | ||||
| 396 | // Initialize console capture | 926 | // Initialize console capture | ||
| 397 | setupConsoleCapture(); | 927 | setupConsoleCapture(); | ||
| 398 | - | 928 | + | } catch (err) { | |
| 399 | - | } catch (e) { | 929 | + | console.warn("[dumber] unified bridge init failed", err); |
| 400 | - | console.warn('[dumber] unified bridge init failed', e); | |||
| 401 | } | 930 | } | ||
| 402 | - | })();⏎̸ | 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 | + | } |
M gui/src/injected/modules/window-open.ts +2 -2
| @@ -18,7 +18,7 @@ export function initializeWindowOpenInterceptor(): void { | |||||
| 18 | 18 | ||||
| 19 | // No-op - window.open handled directly via page-world bridge | 19 | // No-op - window.open handled directly via page-world bridge | ||
| 20 | isInitialized = true; | 20 | isInitialized = true; | ||
| 21 | - | console.log('[window-open] ✅ Interceptor initialized (bypass mode)'); | 21 | + | console.log("[window-open] ✅ Interceptor initialized (bypass mode)"); |
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | /** | 24 | /** | ||
| @@ -26,4 +26,4 @@ export function initializeWindowOpenInterceptor(): void { | |||||
| 26 | */ | 26 | */ | ||
| 27 | export function isWindowOpenInterceptorInitialized(): boolean { | 27 | export function isWindowOpenInterceptorInitialized(): boolean { | ||
| 28 | return isInitialized; | 28 | return isInitialized; | ||
| 29 | - | }⏎̸ | 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 | + | }); |
M gui/vite.config.main-world.ts +12 -12
| @@ -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/main-world.ts'), | 7 | + | input: resolve(__dirname, "src/injected/main-world.ts"), |
| 8 | output: { | 8 | output: { | ||
| 9 | - | dir: '../assets/gui', | 9 | + | dir: "../assets/gui", |
| 10 | - | format: 'iife', | 10 | + | format: "iife", |
| 11 | - | entryFileNames: 'main-world.min.js', | 11 | + | entryFileNames: "main-world.min.js", |
| 12 | - | name: '__dumberMainWorld', | 12 | + | name: "__dumberMainWorld", |
| 13 | inlineDynamicImports: true, | 13 | inlineDynamicImports: true, | ||
| 14 | manualChunks: undefined, | 14 | manualChunks: undefined, | ||
| 15 | - | assetFileNames: '[name].[ext]', | 15 | + | assetFileNames: "[name].[ext]", |
| 16 | }, | 16 | }, | ||
| 17 | }, | 17 | }, | ||
| 18 | emptyOutDir: false, | 18 | emptyOutDir: false, | ||
| 19 | - | target: ['es2020', 'chrome91', 'firefox90'], | 19 | + | target: ["es2020", "chrome91", "firefox90"], |
| 20 | minify: true, | 20 | minify: true, | ||
| 21 | sourcemap: false, | 21 | sourcemap: false, | ||
| 22 | cssCodeSplit: false, | 22 | cssCodeSplit: false, | ||
| 23 | assetsInlineLimit: 100000000, | 23 | assetsInlineLimit: 100000000, | ||
| 24 | }, | 24 | }, | ||
| 25 | define: { | 25 | define: { | ||
| 26 | - | '__DOM_ZOOM_DEFAULT__': '1.0' // Default placeholder, will be replaced by Go | 26 | + | __DOM_ZOOM_DEFAULT__: "1.0", // Default placeholder, will be replaced by Go |
| 27 | - | } | 27 | + | }, |
| 28 | - | });⏎̸ | 28 | + | }); |
M internal/app/browser/shortcuts.go +2 -4
| @@ -91,10 +91,8 @@ func (s *ShortcutHandler) RegisterShortcuts() { | |||||
| 91 | _ = s.webView.Reload() | 91 | _ = s.webView.Reload() | ||
| 92 | }) | 92 | }) | ||
| 93 | 93 | ||||
| 94 | - | // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are now handled | 94 | + | // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are handled by WorkspaceManager |
| 95 | - | // 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 |
| 96 | - | ||||
| 97 | - | // Zoom handled natively in webkit package (built-in shortcuts) | |||
| 98 | } | 96 | } | ||
| 99 | 97 | ||||
| 100 | 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 +199 -120
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "log" | 6 | "log" | ||
| 7 | "math" | 7 | "math" | ||
| 8 | - | neturl "net/url" | |||
| 9 | "strings" | 8 | "strings" | ||
| 10 | "sync" | 9 | "sync" | ||
| 11 | "time" | 10 | "time" | ||
| @@ -53,6 +52,13 @@ type WorkspaceManager struct { | |||||
| 53 | paneModeSource *webkit.WebView // Which webview initiated pane mode | 52 | paneModeSource *webkit.WebView // Which webview initiated pane mode | ||
| 54 | lastPaneModeEntry time.Time // When pane mode was last entered | 53 | lastPaneModeEntry time.Time // When pane mode was last entered | ||
| 55 | 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 | |||
| 56 | } | 62 | } | ||
| 57 | 63 | ||||
| 58 | const ( | 64 | const ( | ||
| @@ -91,6 +97,7 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView) | |||||
| 91 | }) | 97 | }) | ||
| 92 | } | 98 | } | ||
| 93 | 99 | ||||
| 100 | + | // Pane navigation shortcuts | |||
| 94 | registerFocusMove("alt+ArrowLeft", "left") | 101 | registerFocusMove("alt+ArrowLeft", "left") | ||
| 95 | registerFocusMove("alt+ArrowRight", "right") | 102 | registerFocusMove("alt+ArrowRight", "right") | ||
| 96 | registerFocusMove("alt+ArrowUp", "up") | 103 | registerFocusMove("alt+ArrowUp", "up") | ||
| @@ -98,6 +105,9 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView) | |||||
| 98 | registerFocusMove("cmdorctrl+ArrowUp", "up") | 105 | registerFocusMove("cmdorctrl+ArrowUp", "up") | ||
| 99 | registerFocusMove("cmdorctrl+ArrowDown", "down") | 106 | registerFocusMove("cmdorctrl+ArrowDown", "down") | ||
| 100 | 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 | + | ||||
| 101 | // 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 | ||
| 102 | if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug { | 112 | if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug { | ||
| 103 | log.Printf("[workspace] registered navigation shortcuts on webView=%p", webView) | 113 | log.Printf("[workspace] registered navigation shortcuts on webView=%p", webView) | ||
| @@ -107,11 +117,12 @@ func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView) | |||||
| 107 | // NewWorkspaceManager builds a workspace manager rooted at the provided pane. | 117 | // NewWorkspaceManager builds a workspace manager rooted at the provided pane. | ||
| 108 | func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager { | 118 | func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager { | ||
| 109 | manager := &WorkspaceManager{ | 119 | manager := &WorkspaceManager{ | ||
| 110 | - | app: app, | 120 | + | app: app, |
| 111 | - | window: rootPane.webView.Window(), | 121 | + | window: rootPane.webView.Window(), |
| 112 | - | viewToNode: make(map[*webkit.WebView]*paneNode), | 122 | + | viewToNode: make(map[*webkit.WebView]*paneNode), |
| 113 | - | lastSplitMsg: make(map[*webkit.WebView]time.Time), | 123 | + | lastSplitMsg: make(map[*webkit.WebView]time.Time), |
| 114 | - | lastExitMsg: make(map[*webkit.WebView]time.Time), | 124 | + | lastExitMsg: make(map[*webkit.WebView]time.Time), |
| 125 | + | paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator | |||
| 115 | } | 126 | } | ||
| 116 | manager.createWebViewFn = func() (*webkit.WebView, error) { | 127 | manager.createWebViewFn = func() (*webkit.WebView, error) { | ||
| 117 | if manager.app == nil { | 128 | if manager.app == nil { | ||
| @@ -249,17 +260,33 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||||
| 249 | wm.clonePaneState(node, newNode) | 260 | wm.clonePaneState(node, newNode) | ||
| 250 | wm.splitting = false | 261 | wm.splitting = false | ||
| 251 | case "create-pane": | 262 | case "create-pane": | ||
| 252 | - | log.Printf("[workspace] create-pane requested: url=%s action=%s", msg.URL, msg.Action) | 263 | + | log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID) |
| 253 | 264 | ||||
| 254 | if msg.URL == "" { | 265 | if msg.URL == "" { | ||
| 255 | log.Printf("[workspace] create-pane: empty URL, ignoring") | 266 | log.Printf("[workspace] create-pane: empty URL, ignoring") | ||
| 256 | break | 267 | break | ||
| 257 | } | 268 | } | ||
| 258 | 269 | ||||
| 259 | - | // Create a basic window intent from the message | 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 | |||
| 260 | intent := &messaging.WindowIntent{ | 278 | intent := &messaging.WindowIntent{ | ||
| 261 | - | URL: msg.URL, | 279 | + | URL: msg.URL, |
| 262 | - | WindowType: msg.Action, // "tab" or "popup" | 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 | |||
| 263 | } | 290 | } | ||
| 264 | 291 | ||||
| 265 | // Use the existing methods to handle tab vs popup creation | 292 | // Use the existing methods to handle tab vs popup creation | ||
| @@ -287,6 +314,41 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||||
| 287 | log.Printf("[workspace] create-pane: failed to create default tab") | 314 | log.Printf("[workspace] create-pane: failed to create default tab") | ||
| 288 | } | 315 | } | ||
| 289 | } | 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 | + | } | |||
| 290 | default: | 352 | default: | ||
| 291 | log.Printf("[workspace] unhandled workspace event: %s", msg.Event) | 353 | log.Printf("[workspace] unhandled workspace event: %s", msg.Event) | ||
| 292 | } | 354 | } | ||
| @@ -322,9 +384,9 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) { | |||||
| 322 | } | 384 | } | ||
| 323 | } | 385 | } | ||
| 324 | 386 | ||||
| 325 | - | // Remove active class from previous pane (border stays, just color changes) | 387 | + | // Set previously active WebView as inactive |
| 326 | - | if wm.active != nil && wm.active.container != 0 { | 388 | + | if previousWebView != nil && previousWebView != node.pane.webView { |
| 327 | - | webkit.WidgetRemoveCSSClass(wm.active.container, activePaneClass) | 389 | + | previousWebView.SetActive(false) |
| 328 | } | 390 | } | ||
| 329 | 391 | ||||
| 330 | wm.active = node | 392 | wm.active = node | ||
| @@ -336,6 +398,11 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) { | |||||
| 336 | wm.app.messageHandler = node.pane.messageHandler | 398 | wm.app.messageHandler = node.pane.messageHandler | ||
| 337 | wm.app.shortcutHandler = node.pane.shortcutHandler | 399 | wm.app.shortcutHandler = node.pane.shortcutHandler | ||
| 338 | 400 | ||||
| 401 | + | // Set this WebView as active | |||
| 402 | + | if node.pane != nil && node.pane.webView != nil { | |||
| 403 | + | node.pane.webView.SetActive(true) | |||
| 404 | + | } | |||
| 405 | + | ||||
| 339 | // Re-register workspace navigation shortcuts on the newly focused webView | 406 | // Re-register workspace navigation shortcuts on the newly focused webView | ||
| 340 | wm.registerWorkspaceShortcuts(node.pane.webView) | 407 | wm.registerWorkspaceShortcuts(node.pane.webView) | ||
| 341 | 408 | ||||
| @@ -353,7 +420,12 @@ func (wm *WorkspaceManager) focusNode(node *paneNode) { | |||||
| 353 | container := node.container | 420 | container := node.container | ||
| 354 | viewWidget := node.pane.webView.Widget() | 421 | viewWidget := node.pane.webView.Widget() | ||
| 355 | if container != 0 && container != previousContainer { | 422 | if container != 0 && container != previousContainer { | ||
| 356 | - | // 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) | |||
| 357 | webkit.WidgetAddCSSClass(container, activePaneClass) | 429 | webkit.WidgetAddCSSClass(container, activePaneClass) | ||
| 358 | if !webkit.WidgetIsValid(container) { | 430 | if !webkit.WidgetIsValid(container) { | ||
| 359 | log.Printf("[workspace] focus aborted: container invalid widget=%#x", container) | 431 | log.Printf("[workspace] focus aborted: container invalid widget=%#x", container) | ||
| @@ -473,6 +545,17 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) { | |||||
| 473 | if wm == nil || view == nil { | 545 | if wm == nil || view == nil { | ||
| 474 | return | 546 | return | ||
| 475 | } | 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 | + | ||||
| 476 | if node, ok := wm.viewToNode[view]; ok { | 559 | if node, ok := wm.viewToNode[view]; ok { | ||
| 477 | if wm.active != node { | 560 | if wm.active != node { | ||
| 478 | wm.focusNode(node) | 561 | wm.focusNode(node) | ||
| @@ -1140,34 +1223,25 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 1140 | 1223 | ||||
| 1141 | grand := parent.parent | 1224 | grand := parent.parent | ||
| 1142 | if grand == nil { | 1225 | if grand == nil { | ||
| 1143 | - | // Parent is the root node. We need to restructure. | 1226 | + | // Parent is the root node. Promote sibling to become the new root. |
| 1144 | - | // 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) |
| 1145 | - | 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) |
| 1146 | - | log.Printf("[workspace] promoting sibling to root (last 2 panes): container=%#x", sibling.container) | 1229 | + | wm.root = sibling |
| 1147 | - | wm.root = sibling | 1230 | + | sibling.parent = nil |
| 1148 | - | sibling.parent = nil | 1231 | + | |
| 1149 | - | 1232 | + | // Unparent the sibling from the paned first, then set it as window child | |
| 1150 | - | // Unparent the sibling from the paned first, then set it as window child | 1233 | + | if wm.window != nil { |
| 1151 | - | if wm.window != nil { | 1234 | + | // GTK requires explicit unparenting before reparenting |
| 1152 | - | // GTK requires explicit unparenting before reparenting | 1235 | + | if parent.left == sibling { |
| 1153 | - | if parent.left == sibling { | 1236 | + | webkit.PanedSetStartChild(parent.container, 0) |
| 1154 | - | webkit.PanedSetStartChild(parent.container, 0) | 1237 | + | } else { |
| 1155 | - | } else { | 1238 | + | webkit.PanedSetEndChild(parent.container, 0) |
| 1156 | - | webkit.PanedSetEndChild(parent.container, 0) | 1239 | + | } |
| 1157 | - | } | 1240 | + | // Now we can safely set it as window child |
| 1158 | - | // Now we can safely set it as window child | 1241 | + | wm.window.SetChild(sibling.container) |
| 1159 | - | wm.window.SetChild(sibling.container) | 1242 | + | if sibling.container != 0 { |
| 1160 | - | if sibling.container != 0 { | 1243 | + | webkit.WidgetQueueAllocate(sibling.container) |
| 1161 | - | webkit.WidgetQueueAllocate(sibling.container) | |||
| 1162 | - | } | |||
| 1163 | } | 1244 | } | ||
| 1164 | - | } else { | |||
| 1165 | - | // More than 2 panes remain, so keep the tree structure | |||
| 1166 | - | // The sibling should remain as a child of root, but we need to remove the parent paned | |||
| 1167 | - | log.Printf("[workspace] removing parent paned, keeping sibling under root: container=%#x", sibling.container) | |||
| 1168 | - | // This shouldn't happen in a proper binary tree structure | |||
| 1169 | - | log.Printf("[workspace] ERROR: unexpected tree state - parent is root but more than 2 panes remain") | |||
| 1170 | - | return errors.New("unexpected tree state during pane closure") | |||
| 1171 | } | 1245 | } | ||
| 1172 | } else { | 1246 | } else { | ||
| 1173 | // 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 | ||
| @@ -1454,56 +1528,11 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web | |||||
| 1454 | return newView | 1528 | return newView | ||
| 1455 | } | 1529 | } | ||
| 1456 | 1530 | ||||
| 1457 | - | // handleIntentAsTab creates an independent tab pane based on window.open intent | 1531 | + | // registerOAuthAutoClose sets up OAuth auto-close functionality for popups |
| 1532 | + | // Note: OAuth detection is now handled by the main-world.js injection script | |||
| 1458 | func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) { | 1533 | func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) { | ||
| 1459 | - | initial := strings.TrimSpace(strings.ToLower(url)) | 1534 | + | log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url) |
| 1460 | - | closed := false | 1535 | + | log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script") |
| 1461 | - | view.RegisterURIChangedHandler(func(uri string) { | |||
| 1462 | - | if closed { | |||
| 1463 | - | return | |||
| 1464 | - | } | |||
| 1465 | - | if uri == "" { | |||
| 1466 | - | return | |||
| 1467 | - | } | |||
| 1468 | - | u := strings.ToLower(uri) | |||
| 1469 | - | if u == "about:blank" || u == initial { | |||
| 1470 | - | return | |||
| 1471 | - | } | |||
| 1472 | - | ||||
| 1473 | - | should := false | |||
| 1474 | - | if strings.Contains(u, "auth/callback") || strings.Contains(u, "callback") || strings.Contains(u, "redirect") { | |||
| 1475 | - | should = true | |||
| 1476 | - | } | |||
| 1477 | - | if !should { | |||
| 1478 | - | if parsed, err := neturl.Parse(uri); err == nil { | |||
| 1479 | - | q := parsed.Query() | |||
| 1480 | - | if q.Has("code") || q.Has("access_token") || q.Has("id_token") { | |||
| 1481 | - | should = true | |||
| 1482 | - | } | |||
| 1483 | - | if !should && parsed.Fragment != "" { | |||
| 1484 | - | frag := strings.ToLower(parsed.Fragment) | |||
| 1485 | - | if strings.Contains(frag, "access_token=") || strings.Contains(frag, "id_token=") || strings.Contains(frag, "code=") { | |||
| 1486 | - | should = true | |||
| 1487 | - | } | |||
| 1488 | - | } | |||
| 1489 | - | } | |||
| 1490 | - | } | |||
| 1491 | - | ||||
| 1492 | - | if should { | |||
| 1493 | - | log.Printf("[workspace] OAuth URL-based auto-close triggered for: %s", uri) | |||
| 1494 | - | closed = true | |||
| 1495 | - | time.AfterFunc(200*time.Millisecond, func() { | |||
| 1496 | - | webkit.IdleAdd(func() bool { | |||
| 1497 | - | if n := wm.viewToNode[view]; n != nil && n.isPopup { | |||
| 1498 | - | if err := wm.closePane(n); err != nil { | |||
| 1499 | - | log.Printf("[workspace] Failed to close OAuth popup pane: %v", err) | |||
| 1500 | - | } | |||
| 1501 | - | } | |||
| 1502 | - | return false | |||
| 1503 | - | }) | |||
| 1504 | - | }) | |||
| 1505 | - | } | |||
| 1506 | - | }) | |||
| 1507 | } | 1536 | } | ||
| 1508 | 1537 | ||||
| 1509 | func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) { | 1538 | func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) { | ||
| @@ -1632,6 +1661,34 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string | |||||
| 1632 | return nil | 1661 | return nil | ||
| 1633 | } | 1662 | } | ||
| 1634 | 1663 | ||||
| 1664 | + | // Log the parent-popup WebView ID relationship for OAuth auto-close | |||
| 1665 | + | parentWebViewID := sourceNode.pane.webView.ID() | |||
| 1666 | + | popupWebViewID := newView.ID() | |||
| 1667 | + | log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url) | |||
| 1668 | + | ||||
| 1669 | + | // Store popup WebView ID in parent's localStorage for OAuth callback lookup | |||
| 1670 | + | storeScript := fmt.Sprintf(` | |||
| 1671 | + | try { | |||
| 1672 | + | const parentWebViewId = '%s'; | |||
| 1673 | + | const popupWebViewId = '%s'; | |||
| 1674 | + | const popupMapping = { | |||
| 1675 | + | parentId: parentWebViewId, | |||
| 1676 | + | popupId: popupWebViewId, | |||
| 1677 | + | timestamp: Date.now(), | |||
| 1678 | + | url: '%s' | |||
| 1679 | + | }; | |||
| 1680 | + | localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping)); | |||
| 1681 | + | console.log('[workspace] Stored popup mapping:', popupMapping); | |||
| 1682 | + | } catch(e) { | |||
| 1683 | + | console.warn('[workspace] Failed to store popup mapping:', e); | |||
| 1684 | + | } | |||
| 1685 | + | `, parentWebViewID, popupWebViewID, url) | |||
| 1686 | + | ||||
| 1687 | + | // Inject into parent WebView so it can find its popup later | |||
| 1688 | + | if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil { | |||
| 1689 | + | log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err) | |||
| 1690 | + | } | |||
| 1691 | + | ||||
| 1635 | newPane, err := wm.createPane(newView) | 1692 | newPane, err := wm.createPane(newView) | ||
| 1636 | if err != nil { | 1693 | if err != nil { | ||
| 1637 | log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err) | 1694 | log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err) | ||
| @@ -1649,6 +1706,7 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string | |||||
| 1649 | } | 1706 | } | ||
| 1650 | 1707 | ||||
| 1651 | node := wm.viewToNode[newView] | 1708 | node := wm.viewToNode[newView] | ||
| 1709 | + | var requestID string | |||
| 1652 | if node != nil { | 1710 | if node != nil { | ||
| 1653 | node.windowType = webkit.WindowTypePopup | 1711 | node.windowType = webkit.WindowTypePopup | ||
| 1654 | node.isRelated = true | 1712 | node.isRelated = true | ||
| @@ -1656,6 +1714,11 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string | |||||
| 1656 | node.isPopup = true | 1714 | node.isPopup = true | ||
| 1657 | node.autoClose = wm.shouldAutoClose(url) | 1715 | node.autoClose = wm.shouldAutoClose(url) | ||
| 1658 | 1716 | ||||
| 1717 | + | // Store requestID for deduplication cleanup | |||
| 1718 | + | if intent != nil { | |||
| 1719 | + | requestID = intent.RequestID | |||
| 1720 | + | } | |||
| 1721 | + | ||||
| 1659 | // Apply window features from JavaScript intent | 1722 | // Apply window features from JavaScript intent | ||
| 1660 | wm.applyWindowFeatures(newView, intent, true) | 1723 | wm.applyWindowFeatures(newView, intent, true) | ||
| 1661 | } | 1724 | } | ||
| @@ -1663,6 +1726,12 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string | |||||
| 1663 | // Register close handler for popup auto-close | 1726 | // Register close handler for popup auto-close | ||
| 1664 | newView.RegisterCloseHandler(func() { | 1727 | newView.RegisterCloseHandler(func() { | ||
| 1665 | log.Printf("[workspace] Popup requesting close via window.close()") | 1728 | log.Printf("[workspace] Popup requesting close via window.close()") | ||
| 1729 | + | ||||
| 1730 | + | // Clear the RequestID from deduplicator to allow new popups with same ID | |||
| 1731 | + | if requestID != "" && wm.paneDeduplicator != nil { | |||
| 1732 | + | wm.paneDeduplicator.ClearRequestID(requestID) | |||
| 1733 | + | } | |||
| 1734 | + | ||||
| 1666 | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | 1735 | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | ||
| 1667 | time.AfterFunc(200*time.Millisecond, func() { | 1736 | time.AfterFunc(200*time.Millisecond, func() { | ||
| 1668 | webkit.IdleAdd(func() bool { | 1737 | webkit.IdleAdd(func() bool { | ||
| @@ -1772,22 +1841,50 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView | |||||
| 1772 | 1841 | ||||
| 1773 | // shouldAutoClose checks simple OAuth-like URL patterns and config flag | 1842 | // shouldAutoClose checks simple OAuth-like URL patterns and config flag | ||
| 1774 | func (wm *WorkspaceManager) shouldAutoClose(url string) bool { | 1843 | func (wm *WorkspaceManager) shouldAutoClose(url string) bool { | ||
| 1844 | + | log.Printf("[workspace] shouldAutoClose called for URL: %s", url) | |||
| 1845 | + | ||||
| 1775 | if wm == nil || wm.app == nil || wm.app.config == nil { | 1846 | if wm == nil || wm.app == nil || wm.app.config == nil { | ||
| 1847 | + | log.Printf("[workspace] shouldAutoClose: missing config, returning true") | |||
| 1776 | return true | 1848 | return true | ||
| 1777 | } | 1849 | } | ||
| 1778 | if !wm.app.config.Workspace.Popups.OAuthAutoClose { | 1850 | if !wm.app.config.Workspace.Popups.OAuthAutoClose { | ||
| 1851 | + | log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false") | |||
| 1779 | return false | 1852 | return false | ||
| 1780 | } | 1853 | } | ||
| 1854 | + | ||||
| 1781 | u := strings.ToLower(url) | 1855 | u := strings.ToLower(url) | ||
| 1782 | - | patterns := []string{"oauth", "authorize", "login/oauth", "auth/callback", "connect/authorize"} | 1856 | + | log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u) |
| 1783 | - | for _, p := range patterns { | 1857 | + | |
| 1784 | - | if strings.Contains(u, p) { | 1858 | + | // RFC 6749 compliant OAuth 2.0 URL patterns |
| 1859 | + | oauthPatterns := []string{ | |||
| 1860 | + | // Standard OAuth endpoints | |||
| 1861 | + | "oauth", "authorize", "authorization", | |||
| 1862 | + | // Standard callback/redirect patterns | |||
| 1863 | + | "callback", "redirect", "auth/callback", | |||
| 1864 | + | // OpenID Connect patterns | |||
| 1865 | + | "oidc", "openid", | |||
| 1866 | + | // Common OAuth parameter indicators | |||
| 1867 | + | "response_type=", "client_id=", "redirect_uri=", "scope=", "state=", | |||
| 1868 | + | // Standard OAuth response parameters | |||
| 1869 | + | "code=", "access_token=", "id_token=", "token_type=", | |||
| 1870 | + | // Error response parameters | |||
| 1871 | + | "error=", "error_description=", | |||
| 1872 | + | } | |||
| 1873 | + | ||||
| 1874 | + | // Check for OAuth patterns in URL | |||
| 1875 | + | for _, pattern := range oauthPatterns { | |||
| 1876 | + | if strings.Contains(u, pattern) { | |||
| 1877 | + | log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern) | |||
| 1785 | return true | 1878 | return true | ||
| 1786 | } | 1879 | } | ||
| 1787 | } | 1880 | } | ||
| 1881 | + | ||||
| 1882 | + | log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false") | |||
| 1788 | return false | 1883 | return false | ||
| 1789 | } | 1884 | } | ||
| 1790 | 1885 | ||||
| 1886 | + | // isPopupVerificationPage determines if a URL is a popup verification page that should redirect instead of close | |||
| 1887 | + | ||||
| 1791 | // RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded | 1888 | // RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded | ||
| 1792 | func (wm *WorkspaceManager) RunOnUI(fn func()) { | 1889 | func (wm *WorkspaceManager) RunOnUI(fn func()) { | ||
| 1793 | if fn != nil { | 1890 | if fn != nil { | ||
| @@ -1976,38 +2073,20 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||||
| 1976 | } | 2073 | } | ||
| 1977 | 2074 | ||||
| 1978 | // ensureGUIInPane lazily loads GUI components into a pane when it gains focus | 2075 | // ensureGUIInPane lazily loads GUI components into a pane when it gains focus | ||
| 2076 | + | // ensureGUIInPane is now a no-op since GUI is injected globally by WebKit | |||
| 2077 | + | // This prevents duplicate GUI injection that was causing duplicate log messages | |||
| 1979 | func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | 2078 | func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | ||
| 1980 | - | if pane == nil || pane.HasGUI() { | 2079 | + | if pane == nil { |
| 1981 | return | 2080 | return | ||
| 1982 | } | 2081 | } | ||
| 1983 | 2082 | ||||
| 1984 | - | log.Printf("[workspace] Injecting GUI components into pane %s", pane.ID()) | 2083 | + | // GUI is already injected globally via WebKit's enableUserContentManager |
| 1985 | - | 2084 | + | // Just mark the pane as having GUI to prevent unnecessary calls | |
| 1986 | - | // Inject GUI manager and omnibox | 2085 | + | if !pane.HasGUI() { |
| 1987 | - | script := fmt.Sprintf(` | 2086 | + | pane.SetHasGUI(true) |
| 1988 | - | (async function() { | 2087 | + | pane.SetGUIComponent("manager", true) |
| 1989 | - | try { | 2088 | + | pane.SetGUIComponent("omnibox", true) |
| 1990 | - | // Set up GUI manager for this pane | |||
| 1991 | - | window.__dumber_pane.active = true; | |||
| 1992 | - | window.__dumber_gui_bootstrap && window.__dumber_gui_bootstrap('%s'); | |||
| 1993 | - | ||||
| 1994 | - | // For now, mark as ready - full GUIManager will be implemented later | |||
| 1995 | - | console.log('[workspace] GUI components ready for pane %s'); | |||
| 1996 | - | } catch (error) { | |||
| 1997 | - | console.error('[workspace] Failed to initialize GUI:', error); | |||
| 1998 | - | } | |||
| 1999 | - | })(); | |||
| 2000 | - | `, pane.ID(), pane.ID()) | |||
| 2001 | - | ||||
| 2002 | - | if err := pane.WebView().InjectScript(script); err != nil { | |||
| 2003 | - | log.Printf("[workspace] Failed to inject GUI into pane %s: %v", pane.ID(), err) | |||
| 2004 | - | return | |||
| 2005 | } | 2089 | } | ||
| 2006 | - | ||||
| 2007 | - | // Mark GUI as injected | |||
| 2008 | - | pane.SetHasGUI(true) | |||
| 2009 | - | pane.SetGUIComponent("manager", true) | |||
| 2010 | - | pane.SetGUIComponent("omnibox", true) | |||
| 2011 | } | 2090 | } | ||
| 2012 | 2091 | ||||
| 2013 | func mapDirection(direction string) (webkit.Orientation, bool) { | 2092 | 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 +44 -12
| @@ -47,6 +47,9 @@ type Message struct { | |||||
| 47 | HistoryID string `json:"historyId"` | 47 | HistoryID string `json:"historyId"` | ||
| 48 | // Request tracking | 48 | // Request tracking | ||
| 49 | RequestID string `json:"requestId"` | 49 | RequestID string `json:"requestId"` | ||
| 50 | + | // Popup close tracking | |||
| 51 | + | WebViewID string `json:"webviewId"` | |||
| 52 | + | Reason string `json:"reason"` | |||
| 50 | // Wails fetch bridge | 53 | // Wails fetch bridge | ||
| 51 | ID string `json:"id"` | 54 | ID string `json:"id"` | ||
| 52 | Payload json.RawMessage `json:"payload"` | 55 | Payload json.RawMessage `json:"payload"` | ||
| @@ -54,13 +57,15 @@ type Message struct { | |||||
| 54 | 57 | ||||
| 55 | // WindowIntent represents an intercepted window.open call from JavaScript | 58 | // WindowIntent represents an intercepted window.open call from JavaScript | ||
| 56 | type WindowIntent struct { | 59 | type WindowIntent struct { | ||
| 57 | - | URL string `json:"url"` | 60 | + | URL string `json:"url"` |
| 58 | - | Target string `json:"target"` | 61 | + | Target string `json:"target"` |
| 59 | - | Features string `json:"features"` | 62 | + | Features string `json:"features"` |
| 60 | - | Timestamp int64 `json:"timestamp"` | 63 | + | Timestamp int64 `json:"timestamp"` |
| 61 | - | WindowType string `json:"windowType"` // "tab", "popup", "unknown" | 64 | + | WindowType string `json:"windowType"` // "tab", "popup", "unknown" |
| 62 | - | IsPopup bool `json:"isPopup"` | 65 | + | IsPopup bool `json:"isPopup"` |
| 63 | - | IsTab bool `json:"isTab"` | 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 | |||
| 64 | // Parsed features | 69 | // Parsed features | ||
| 65 | Width *int `json:"width,omitempty"` | 70 | Width *int `json:"width,omitempty"` | ||
| 66 | Height *int `json:"height,omitempty"` | 71 | Height *int `json:"height,omitempty"` | ||
| @@ -120,6 +125,8 @@ func (h *Handler) Handle(payload string) { | |||||
| 120 | h.handleWorkspace(msg) | 125 | h.handleWorkspace(msg) | ||
| 121 | case "handle-window-open": | 126 | case "handle-window-open": | ||
| 122 | h.handleWindowOpen(msg) | 127 | h.handleWindowOpen(msg) | ||
| 128 | + | case "close-popup": | |||
| 129 | + | h.handleClosePopup(msg) | |||
| 123 | case "console-message": | 130 | case "console-message": | ||
| 124 | h.handleConsoleMessage(msg) | 131 | h.handleConsoleMessage(msg) | ||
| 125 | } | 132 | } | ||
| @@ -185,6 +192,30 @@ func (h *Handler) handleWorkspace(msg Message) { | |||||
| 185 | h.workspaceObserver.OnWorkspaceMessage(h.webView, msg) | 192 | h.workspaceObserver.OnWorkspaceMessage(h.webView, msg) | ||
| 186 | } | 193 | } | ||
| 187 | 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 | + | ||||
| 188 | func (h *Handler) handleQuery(msg Message) { | 219 | func (h *Handler) handleQuery(msg Message) { | ||
| 189 | 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) | ||
| 190 | if h.webView == nil { | 221 | if h.webView == nil { | ||
| @@ -529,16 +560,17 @@ func (h *Handler) handleWindowOpen(msg Message) { | |||||
| 529 | return | 560 | return | ||
| 530 | } | 561 | } | ||
| 531 | 562 | ||||
| 532 | - | log.Printf("[messaging] Handling direct window.open request: url=%s type=%s", intent.URL, intent.WindowType) | 563 | + | log.Printf("[messaging] Handling direct window.open request: url=%s type=%s requestId=%s", intent.URL, intent.WindowType, intent.RequestID) |
| 533 | 564 | ||||
| 534 | // Forward to workspace observer if available | 565 | // Forward to workspace observer if available | ||
| 535 | if h.workspaceObserver != nil && h.webView != nil { | 566 | if h.workspaceObserver != nil && h.webView != nil { | ||
| 536 | // Create a workspace message to trigger pane creation | 567 | // Create a workspace message to trigger pane creation | ||
| 537 | workspaceMsg := Message{ | 568 | workspaceMsg := Message{ | ||
| 538 | - | Type: "workspace", | 569 | + | Type: "workspace", |
| 539 | - | Event: "create-pane", | 570 | + | Event: "create-pane", |
| 540 | - | URL: intent.URL, | 571 | + | URL: intent.URL, |
| 541 | - | Action: intent.WindowType, // "tab" or "popup" | 572 | + | Action: intent.WindowType, // "tab" or "popup" |
| 573 | + | RequestID: intent.RequestID, // NEW: Pass through request ID | |||
| 542 | } | 574 | } | ||
| 543 | log.Printf("[messaging] Forwarding to workspace: %+v", workspaceMsg) | 575 | log.Printf("[messaging] Forwarding to workspace: %+v", workspaceMsg) | ||
| 544 | h.workspaceObserver.OnWorkspaceMessage(h.webView, workspaceMsg) | 576 | h.workspaceObserver.OnWorkspaceMessage(h.webView, workspaceMsg) | ||
M internal/cli/logs.go +3 -3
| @@ -342,12 +342,12 @@ func tailConsoleMessages(filePath string, lines int, level string) error { | |||||
| 342 | scanner := bufio.NewScanner(file) | 342 | scanner := bufio.NewScanner(file) | ||
| 343 | for scanner.Scan() { | 343 | for scanner.Scan() { | ||
| 344 | line := scanner.Text() | 344 | line := scanner.Text() | ||
| 345 | - | 345 | + | ||
| 346 | // Filter for [CONSOLE] tagged messages | 346 | // Filter for [CONSOLE] tagged messages | ||
| 347 | if !strings.Contains(line, "[CONSOLE]") { | 347 | if !strings.Contains(line, "[CONSOLE]") { | ||
| 348 | continue | 348 | continue | ||
| 349 | } | 349 | } | ||
| 350 | - | 350 | + | ||
| 351 | // Filter by level if specified | 351 | // Filter by level if specified | ||
| 352 | if level != "" { | 352 | if level != "" { | ||
| 353 | levelFilter := fmt.Sprintf("[%s]", strings.ToUpper(level)) | 353 | levelFilter := fmt.Sprintf("[%s]", strings.ToUpper(level)) | ||
| @@ -355,7 +355,7 @@ func tailConsoleMessages(filePath string, lines int, level string) error { | |||||
| 355 | continue | 355 | continue | ||
| 356 | } | 356 | } | ||
| 357 | } | 357 | } | ||
| 358 | - | 358 | + | ||
| 359 | consoleLines = append(consoleLines, line) | 359 | consoleLines = append(consoleLines, line) | ||
| 360 | } | 360 | } | ||
| 361 | 361 | ||||
M internal/logging/logger.go +1 -1
| @@ -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 | ||
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 +30 -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,7 @@ 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 | |||
| 29 | 40 | ||||
| 30 | // Window type fields (no-op in stub) | 41 | // Window type fields (no-op in stub) | ||
| 31 | windowType WindowType | 42 | windowType WindowType | ||
| @@ -46,6 +57,7 @@ func NewWebView(cfg *Config) (*WebView, error) { | |||||
| 46 | wv.zoom = cfg.ZoomDefault | 57 | wv.zoom = cfg.ZoomDefault | ||
| 47 | } | 58 | } | ||
| 48 | wv.domZoomSeed = wv.zoom | 59 | wv.domZoomSeed = wv.zoom | ||
| 60 | + | wv.id = nextStubViewID() // Assign unique ID | |||
| 49 | // Construction succeeds; create a logical window placeholder. | 61 | // Construction succeeds; create a logical window placeholder. | ||
| 50 | wv.window = &Window{Title: "Dumber Browser"} | 62 | wv.window = &Window{Title: "Dumber Browser"} | ||
| 51 | wv.container = newWidgetHandle() | 63 | wv.container = newWidgetHandle() | ||
| @@ -248,3 +260,21 @@ func (w *WebView) UpdateContentFilters(filterManager interface{}) error { | |||||
| 248 | func (w *WebView) SetWindowFeatures(features *WindowFeatures) { | 260 | func (w *WebView) SetWindowFeatures(features *WindowFeatures) { | ||
| 249 | w.windowFeatures = features | 261 | w.windowFeatures = features | ||
| 250 | } | 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 +52 -52
| @@ -928,6 +928,7 @@ type WebView struct { | |||||
| 928 | windowTypeCallback func(WindowType, *WindowFeatures) | 928 | windowTypeCallback func(WindowType, *WindowFeatures) | ||
| 929 | parentWebView *WebView | 929 | parentWebView *WebView | ||
| 930 | isRelated bool | 930 | isRelated bool | ||
| 931 | + | isActive bool // Track if this WebView is currently active/focused | |||
| 931 | } | 932 | } | ||
| 932 | 933 | ||||
| 933 | // NewWebView constructs a new WebView instance with native WebKit2GTK widgets. | 934 | // NewWebView constructs a new WebView instance with native WebKit2GTK widgets. | ||
| @@ -1107,51 +1108,8 @@ func NewWebView(cfg *Config) (*WebView, error) { | |||||
| 1107 | C.connect_theme_changed_with_id(settings, C.ulong(v.id)) | 1108 | C.connect_theme_changed_with_id(settings, C.ulong(v.id)) | ||
| 1108 | } | 1109 | } | ||
| 1109 | 1110 | ||||
| 1110 | - | // Native zoom shortcuts (independent of app services) | 1111 | + | // Zoom shortcuts are now handled by application-level workspace shortcuts |
| 1111 | - | // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset | 1112 | + | // in workspace_manager.go to ensure proper active pane targeting |
| 1112 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() { | |||
| 1113 | - | nz := v.zoom | |||
| 1114 | - | if nz <= 0 { | |||
| 1115 | - | nz = 1.0 | |||
| 1116 | - | } | |||
| 1117 | - | nz *= 1.1 | |||
| 1118 | - | if nz < 0.25 { | |||
| 1119 | - | nz = 0.25 | |||
| 1120 | - | } | |||
| 1121 | - | if nz > 5.0 { | |||
| 1122 | - | nz = 5.0 | |||
| 1123 | - | } | |||
| 1124 | - | _ = v.SetZoom(nz) | |||
| 1125 | - | }) | |||
| 1126 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() { | |||
| 1127 | - | nz := v.zoom | |||
| 1128 | - | if nz <= 0 { | |||
| 1129 | - | nz = 1.0 | |||
| 1130 | - | } | |||
| 1131 | - | nz *= 1.1 | |||
| 1132 | - | if nz < 0.25 { | |||
| 1133 | - | nz = 0.25 | |||
| 1134 | - | } | |||
| 1135 | - | if nz > 5.0 { | |||
| 1136 | - | nz = 5.0 | |||
| 1137 | - | } | |||
| 1138 | - | _ = v.SetZoom(nz) | |||
| 1139 | - | }) | |||
| 1140 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() { | |||
| 1141 | - | nz := v.zoom | |||
| 1142 | - | if nz <= 0 { | |||
| 1143 | - | nz = 1.0 | |||
| 1144 | - | } | |||
| 1145 | - | nz /= 1.1 | |||
| 1146 | - | if nz < 0.25 { | |||
| 1147 | - | nz = 0.25 | |||
| 1148 | - | } | |||
| 1149 | - | if nz > 5.0 { | |||
| 1150 | - | nz = 5.0 | |||
| 1151 | - | } | |||
| 1152 | - | _ = v.SetZoom(nz) | |||
| 1153 | - | }) | |||
| 1154 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) }) | |||
| 1155 | // Find in page (Ctrl/Cmd+F): handled by window-level shortcuts in window_shortcuts.go | 1113 | // Find in page (Ctrl/Cmd+F): handled by window-level shortcuts in window_shortcuts.go | ||
| 1156 | // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge | 1114 | // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge | ||
| 1157 | _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() { | 1115 | _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() { | ||
| @@ -1305,7 +1263,7 @@ func (v *WebView) setupConsoleLogging() { | |||||
| 1305 | if v.native == nil || v.native.wv == nil { | 1263 | if v.native == nil || v.native.wv == nil { | ||
| 1306 | return | 1264 | return | ||
| 1307 | } | 1265 | } | ||
| 1308 | - | 1266 | + | ||
| 1309 | // Check if console capture is enabled in config | 1267 | // Check if console capture is enabled in config | ||
| 1310 | cfg := config.Get() | 1268 | cfg := config.Get() | ||
| 1311 | if !cfg.Logging.CaptureConsole { | 1269 | if !cfg.Logging.CaptureConsole { | ||
| @@ -1315,13 +1273,13 @@ func (v *WebView) setupConsoleLogging() { | |||||
| 1315 | } | 1273 | } | ||
| 1316 | return | 1274 | return | ||
| 1317 | } | 1275 | } | ||
| 1318 | - | 1276 | + | ||
| 1319 | // If console capture is enabled, disable stdout and enable our capture | 1277 | // If console capture is enabled, disable stdout and enable our capture | ||
| 1320 | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | 1278 | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | ||
| 1321 | C.webkit_settings_set_enable_write_console_messages_to_stdout(settings, C.gboolean(0)) | 1279 | C.webkit_settings_set_enable_write_console_messages_to_stdout(settings, C.gboolean(0)) | ||
| 1322 | log.Printf("[webkit] Console message capture enabled - redirecting to console.log file") | 1280 | log.Printf("[webkit] Console message capture enabled - redirecting to console.log file") | ||
| 1323 | } | 1281 | } | ||
| 1324 | - | 1282 | + | ||
| 1325 | // TODO: Implement JavaScript injection for console capture | 1283 | // TODO: Implement JavaScript injection for console capture | ||
| 1326 | // This will inject code to intercept console.log/error/warn and send to Go | 1284 | // This will inject code to intercept console.log/error/warn and send to Go | ||
| 1327 | } | 1285 | } | ||
| @@ -1394,11 +1352,11 @@ func (v *WebView) handleConsoleMessage(payload string) { | |||||
| 1394 | Type string `json:"type"` | 1352 | Type string `json:"type"` | ||
| 1395 | Data string `json:"data"` | 1353 | Data string `json:"data"` | ||
| 1396 | } | 1354 | } | ||
| 1397 | - | 1355 | + | ||
| 1398 | if err := json.Unmarshal([]byte(payload), &msg); err != nil { | 1356 | if err := json.Unmarshal([]byte(payload), &msg); err != nil { | ||
| 1399 | return | 1357 | return | ||
| 1400 | } | 1358 | } | ||
| 1401 | - | 1359 | + | ||
| 1402 | if msg.Type == "console" { | 1360 | if msg.Type == "console" { | ||
| 1403 | // Send to logging system with [CONSOLE] tag | 1361 | // Send to logging system with [CONSOLE] tag | ||
| 1404 | logging.CaptureWebKitLog(msg.Data) | 1362 | logging.CaptureWebKitLog(msg.Data) | ||
| @@ -2031,7 +1989,7 @@ func clampDomZoom(level float64) float64 { | |||||
| 2031 | return level | 1989 | return level | ||
| 2032 | } | 1990 | } | ||
| 2033 | 1991 | ||||
| 2034 | - | func buildDomBridgeScript(cfg *Config, initialZoom float64) string { | 1992 | + | func buildDomBridgeScript(cfg *Config, initialZoom float64, webViewId uintptr, w *WebView) string { |
| 2035 | if cfg == nil || cfg.Assets == nil { | 1993 | if cfg == nil || cfg.Assets == nil { | ||
| 2036 | log.Printf("[webkit] Config or assets not available for main-world script") | 1994 | log.Printf("[webkit] Config or assets not available for main-world script") | ||
| 2037 | return "" | 1995 | return "" | ||
| @@ -2049,6 +2007,15 @@ func buildDomBridgeScript(cfg *Config, initialZoom float64) string { | |||||
| 2049 | zoomStr := strconv.FormatFloat(zoom, 'f', -1, 64) | 2007 | zoomStr := strconv.FormatFloat(zoom, 'f', -1, 64) | ||
| 2050 | script := strings.ReplaceAll(string(scriptBytes), "__DOM_ZOOM_DEFAULT__", zoomStr) | 2008 | script := strings.ReplaceAll(string(scriptBytes), "__DOM_ZOOM_DEFAULT__", zoomStr) | ||
| 2051 | 2009 | ||||
| 2010 | + | // Inject WebView ID and active state | |||
| 2011 | + | webViewIdStr := strconv.FormatUint(uint64(webViewId), 10) | |||
| 2012 | + | script = strings.ReplaceAll(script, "__WEBVIEW_ID__", webViewIdStr) | |||
| 2013 | + | // Default to active for the initial WebView, since it will be focused immediately | |||
| 2014 | + | // For single WebView scenarios, assume it should be active | |||
| 2015 | + | activeState := "true" | |||
| 2016 | + | log.Printf("[webkit] Building script with WebView %s, active state: %s", webViewIdStr, activeState) | |||
| 2017 | + | script = strings.ReplaceAll(script, "\"__WEBVIEW_ACTIVE__\"", activeState) | |||
| 2018 | + | ||||
| 2052 | log.Printf("[webkit] Loaded main-world script successfully, size: %d bytes", len(scriptBytes)) | 2019 | log.Printf("[webkit] Loaded main-world script successfully, size: %d bytes", len(scriptBytes)) | ||
| 2053 | return script | 2020 | return script | ||
| 2054 | } | 2021 | } | ||
| @@ -2070,7 +2037,7 @@ func (w *WebView) installDomBridgeScript() { | |||||
| 2070 | w.domBridgeScript = nil | 2037 | w.domBridgeScript = nil | ||
| 2071 | } | 2038 | } | ||
| 2072 | 2039 | ||||
| 2073 | - | source := buildDomBridgeScript(w.config, w.domZoomSeed) | 2040 | + | source := buildDomBridgeScript(w.config, w.domZoomSeed, w.id, w) |
| 2074 | cBridge := C.CString(source) | 2041 | cBridge := C.CString(source) | ||
| 2075 | defer C.free(unsafe.Pointer(cBridge)) | 2042 | defer C.free(unsafe.Pointer(cBridge)) | ||
| 2076 | 2043 | ||||
| @@ -2565,3 +2532,36 @@ func (w *WebView) detectAndLogRenderingBackend() { | |||||
| 2565 | func (w *WebView) SetWindowFeatures(features *WindowFeatures) { | 2532 | func (w *WebView) SetWindowFeatures(features *WindowFeatures) { | ||
| 2566 | w.windowFeatures = features | 2533 | w.windowFeatures = features | ||
| 2567 | } | 2534 | } | ||
| 2535 | + | ||||
| 2536 | + | // SetActive sets whether this WebView is currently active/focused | |||
| 2537 | + | func (w *WebView) SetActive(active bool) { | |||
| 2538 | + | if w == nil || w.destroyed { | |||
| 2539 | + | return | |||
| 2540 | + | } | |||
| 2541 | + | ||||
| 2542 | + | if w.isActive != active { | |||
| 2543 | + | w.isActive = active | |||
| 2544 | + | log.Printf("[webkit] WebView %d active state changed to: %v", w.id, active) | |||
| 2545 | + | ||||
| 2546 | + | // Update the active state in the JavaScript context | |||
| 2547 | + | activeStr := "false" | |||
| 2548 | + | if active { | |||
| 2549 | + | activeStr = "true" | |||
| 2550 | + | } | |||
| 2551 | + | script := fmt.Sprintf("window.__dumber_is_active = %s;", activeStr) | |||
| 2552 | + | w.InjectScript(script) | |||
| 2553 | + | } | |||
| 2554 | + | } | |||
| 2555 | + | ||||
| 2556 | + | // IsActive returns whether this WebView is currently active/focused | |||
| 2557 | + | func (w *WebView) IsActive() bool { | |||
| 2558 | + | return w != nil && w.isActive | |||
| 2559 | + | } | |||
| 2560 | + | ||||
| 2561 | + | // ID returns the unique identifier for this WebView as a string | |||
| 2562 | + | func (w *WebView) ID() string { | |||
| 2563 | + | if w == nil { | |||
| 2564 | + | return "" | |||
| 2565 | + | } | |||
| 2566 | + | return strconv.FormatUint(uint64(w.id), 10) | |||
| 2567 | + | } | |||