diff --git a/ui/desktop/src/components/TabSidecar.tsx b/ui/desktop/src/components/TabSidecar.tsx index c47ca2bf77..0486638ed5 100644 --- a/ui/desktop/src/components/TabSidecar.tsx +++ b/ui/desktop/src/components/TabSidecar.tsx @@ -4,6 +4,7 @@ import { Button } from './ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from './ui/Tooltip'; import { TabSidecarState } from './TabBar'; import DocumentEditor from './DocumentEditor'; +import WebBrowser from './WebBrowser'; interface TabSidecarProps { sidecarState: TabSidecarState; @@ -76,7 +77,7 @@ const renderContent = (contentType: string, contentProps: Record) = case 'localhost': return ; case 'web': - return ; + return ; case 'file': return ; case 'editor': diff --git a/ui/desktop/src/components/WebBrowser.tsx b/ui/desktop/src/components/WebBrowser.tsx new file mode 100644 index 0000000000..61057ced5e --- /dev/null +++ b/ui/desktop/src/components/WebBrowser.tsx @@ -0,0 +1,315 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { ArrowLeft, ArrowRight, RotateCcw, Home, Globe, X } from 'lucide-react'; +import { Button } from './ui/button'; + +interface WebBrowserProps { + initialUrl?: string; + title?: string; + onClose?: () => void; + className?: string; +} + +interface NavigationState { + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; + url: string; +} + +export const WebBrowser: React.FC = ({ + initialUrl = 'https://google.com', + title = 'Web Browser', + onClose, + className = '' +}) => { + const [currentUrl, setCurrentUrl] = useState(initialUrl); + const [inputUrl, setInputUrl] = useState(initialUrl); + const [viewId, setViewId] = useState(null); + const [navigationState, setNavigationState] = useState({ + canGoBack: false, + canGoForward: false, + isLoading: false, + url: initialUrl + }); + const [isInitialized, setIsInitialized] = useState(false); + const browserContainerRef = useRef(null); + const updateTimeoutRef = useRef(); + + // Create BrowserView when component mounts + useEffect(() => { + const initializeBrowser = async () => { + if (!browserContainerRef.current || isInitialized) return; + + const container = browserContainerRef.current; + const rect = container.getBoundingClientRect(); + + // Account for the address bar height (60px) + const bounds = { + x: rect.left, + y: rect.top + 60, // Offset for address bar + width: rect.width, + height: rect.height - 60 + }; + + try { + const result = await window.electron.createBrowserView(currentUrl, bounds); + + if (result.success && result.viewId) { + setViewId(result.viewId); + setIsInitialized(true); + console.log('WebBrowser: BrowserView created successfully:', result.viewId); + + // Start polling for navigation state + pollNavigationState(result.viewId); + } else { + console.error('WebBrowser: Failed to create BrowserView:', result.error); + } + } catch (error) { + console.error('WebBrowser: Error creating BrowserView:', error); + } + }; + + initializeBrowser(); + + // Cleanup on unmount + return () => { + if (viewId) { + window.electron.destroyBrowserView(viewId).catch(console.error); + } + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + }; + }, [currentUrl, isInitialized]); + + // Poll navigation state + const pollNavigationState = useCallback((browserViewId: string) => { + const updateState = async () => { + try { + const state = await window.electron.browserViewNavigationState(browserViewId); + if (state) { + setNavigationState(state); + setCurrentUrl(state.url); + setInputUrl(state.url); + } + } catch (error) { + console.error('WebBrowser: Error getting navigation state:', error); + } + + // Continue polling + updateTimeoutRef.current = setTimeout(() => updateState(), 1000); + }; + + updateState(); + }, []); + + // Update BrowserView bounds when container resizes + useEffect(() => { + const updateBounds = () => { + if (!viewId || !browserContainerRef.current) return; + + const container = browserContainerRef.current; + const rect = container.getBoundingClientRect(); + + const bounds = { + x: rect.left, + y: rect.top + 60, // Offset for address bar + width: rect.width, + height: rect.height - 60 + }; + + window.electron.updateBrowserViewBounds(viewId, bounds).catch(console.error); + }; + + const resizeObserver = new ResizeObserver(updateBounds); + if (browserContainerRef.current) { + resizeObserver.observe(browserContainerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [viewId]); + + // Navigation handlers + const handleNavigate = useCallback(async (url: string) => { + if (!viewId) return; + + try { + // Ensure URL has protocol + let fullUrl = url; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + // Check if it looks like a domain + if (url.includes('.') && !url.includes(' ')) { + fullUrl = `https://${url}`; + } else { + // Treat as search query + fullUrl = `https://www.google.com/search?q=${encodeURIComponent(url)}`; + } + } + + await window.electron.browserViewNavigate(viewId, fullUrl); + setCurrentUrl(fullUrl); + setInputUrl(fullUrl); + } catch (error) { + console.error('WebBrowser: Navigation error:', error); + } + }, [viewId]); + + const handleGoBack = useCallback(async () => { + if (!viewId || !navigationState.canGoBack) return; + + try { + await window.electron.browserViewGoBack(viewId); + } catch (error) { + console.error('WebBrowser: Go back error:', error); + } + }, [viewId, navigationState.canGoBack]); + + const handleGoForward = useCallback(async () => { + if (!viewId || !navigationState.canGoForward) return; + + try { + await window.electron.browserViewGoForward(viewId); + } catch (error) { + console.error('WebBrowser: Go forward error:', error); + } + }, [viewId, navigationState.canGoForward]); + + const handleRefresh = useCallback(async () => { + if (!viewId) return; + + try { + await window.electron.browserViewRefresh(viewId); + } catch (error) { + console.error('WebBrowser: Refresh error:', error); + } + }, [viewId]); + + const handleHome = useCallback(() => { + handleNavigate('https://google.com'); + }, [handleNavigate]); + + const handleUrlSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + handleNavigate(inputUrl); + }, [inputUrl, handleNavigate]); + + const handleUrlChange = useCallback((e: React.ChangeEvent) => { + setInputUrl(e.target.value); + }, []); + + return ( +
+ {/* Browser Toolbar */} +
+ {/* Navigation Buttons */} +
+ + + + + + + +
+ + {/* Address Bar */} +
+
+ + +
+
+ + {/* Close Button */} + {onClose && ( + + )} +
+ + {/* Browser Content Area */} +
+ {!isInitialized && ( +
+
+ + Loading browser... +
+
+ )} + + {/* The actual web content will be rendered by Electron's BrowserView */} + {/* This div serves as a placeholder and positioning reference */} +
+ + {/* Status Bar */} +
+
+ {navigationState.isLoading && ( + <> + + Loading... + + )} + {!navigationState.isLoading && ( + Ready + )} +
+ +
+ {new URL(currentUrl).hostname} +
+
+
+ ); +}; + +export default WebBrowser;