From 536105710105893f6372cb1eeb739523da2d5591 Mon Sep 17 00:00:00 2001 From: Dustin Loring Date: Thu, 16 Jan 2025 11:36:39 -0500 Subject: [PATCH] feat: open preview in new tab allows you to open a preview in a new tab --- app/components/workbench/Preview.tsx | 38 ++++ app/lib/stores/previews.ts | 255 +++++++++++++++++++++++- app/routes/webcontainer.preview.$id.tsx | 92 +++++++++ 3 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 app/routes/webcontainer.preview.$id.tsx diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx index 30a3bf8..92f45d6 100644 --- a/app/components/workbench/Preview.tsx +++ b/app/components/workbench/Preview.tsx @@ -78,6 +78,44 @@ export const Preview = memo(() => { )}
+ { + console.log('Button clicked'); + if (activePreview?.baseUrl) { + console.log('Active preview baseUrl:', activePreview.baseUrl); + // Extract the preview ID from the WebContainer URL + const match = activePreview.baseUrl.match( + /^https?:\/\/([^.]+)\.local-(?:corp|credentialless)\.webcontainer-api\.io/, + ); + + console.log('URL match:', match); + if (match) { + const previewId = match[1]; + console.log('Preview ID:', previewId); + + // Open in new tab using our route with absolute path + // Use the current port for development + const port = window.location.port ? `:${window.location.port}` : ''; + const previewUrl = `${window.location.protocol}//${window.location.hostname}${port}/webcontainer/preview/${previewId}`; + console.log('Opening URL:', previewUrl); + const newWindow = window.open(previewUrl, '_blank', 'noopener,noreferrer'); + + // Force focus on the new window + if (newWindow) { + newWindow.focus(); + } else { + console.warn('Failed to open new window'); + } + } else { + console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl); + } + } else { + console.warn('No active preview available'); + } + }} + title="Open Preview in New Tab" + />
(); #webcontainer: Promise; + #broadcastChannel: BroadcastChannel; + #lastUpdate = new Map(); + #watchedFiles = new Set(); + #refreshTimeouts = new Map(); + #REFRESH_DELAY = 300; + #storageChannel: BroadcastChannel; previews = atom([]); constructor(webcontainerPromise: Promise) { this.#webcontainer = webcontainerPromise; + this.#broadcastChannel = new BroadcastChannel(PREVIEW_CHANNEL); + this.#storageChannel = new BroadcastChannel('storage-sync-channel'); + + // Listen for preview updates from other tabs + this.#broadcastChannel.onmessage = (event) => { + const { type, previewId } = event.data; + + if (type === 'file-change') { + const timestamp = event.data.timestamp; + const lastUpdate = this.#lastUpdate.get(previewId) || 0; + + if (timestamp > lastUpdate) { + this.#lastUpdate.set(previewId, timestamp); + this.refreshPreview(previewId); + } + } + }; + + // Listen for storage sync messages + this.#storageChannel.onmessage = (event) => { + const { storage, source } = event.data; + + if (storage && source !== this._getTabId()) { + this._syncStorage(storage); + } + }; + + // Override localStorage setItem to catch all changes + if (typeof window !== 'undefined') { + const originalSetItem = localStorage.setItem; + + localStorage.setItem = (...args) => { + originalSetItem.apply(localStorage, args); + this._broadcastStorageSync(); + }; + } this.#init(); } + // Generate a unique ID for this tab + private _getTabId(): string { + if (typeof window !== 'undefined') { + if (!window._tabId) { + window._tabId = Math.random().toString(36).substring(2, 15); + } + + return window._tabId; + } + + return ''; + } + + // Sync storage data between tabs + private _syncStorage(storage: Record) { + if (typeof window !== 'undefined') { + Object.entries(storage).forEach(([key, value]) => { + try { + const originalSetItem = Object.getPrototypeOf(localStorage).setItem; + originalSetItem.call(localStorage, key, value); + } catch (error) { + console.error('[Preview] Error syncing storage:', error); + } + }); + + // Force a refresh after syncing storage + const previews = this.previews.get(); + previews.forEach((preview) => { + const previewId = this.getPreviewId(preview.baseUrl); + + if (previewId) { + this.refreshPreview(previewId); + } + }); + } + } + + // Broadcast storage state to other tabs + private _broadcastStorageSync() { + if (typeof window !== 'undefined') { + const storage: Record = {}; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + + if (key) { + storage[key] = localStorage.getItem(key) || ''; + } + } + + this.#storageChannel.postMessage({ + type: 'storage-sync', + storage, + source: this._getTabId(), + timestamp: Date.now(), + }); + } + } + async #init() { const webcontainer = await this.#webcontainer; + // Listen for server ready events + webcontainer.on('server-ready', (port, url) => { + console.log('[Preview] Server ready on port:', port, url); + this.broadcastUpdate(url); + + // Initial storage sync when preview is ready + this._broadcastStorageSync(); + }); + + try { + // Watch for file changes + webcontainer.internal.watchPaths( + { include: ['**/*'], exclude: ['**/node_modules', '.git'], includeContent: true }, + bufferWatchEvents<[PathWatcherEvent[]]>(100, (events) => { + const watchEvents = events.flat(2); + const previews = this.previews.get(); + + for (const preview of previews) { + const previewId = this.getPreviewId(preview.baseUrl); + + if (previewId) { + this.broadcastFileChange(previewId); + } + } + }), + ); + + // Watch for DOM changes that might affect storage + if (typeof window !== 'undefined') { + const observer = new MutationObserver(() => { + // Broadcast storage changes when DOM changes + this._broadcastStorageSync(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + }); + } + } catch (error) { + console.error('[Preview] Error setting up watchers:', error); + } + + // Listen for port events webcontainer.on('port', (port, type, url) => { let previewInfo = this.#availablePreviews.get(port); - - if (type === 'close' && previewInfo) { - this.#availablePreviews.delete(port); - this.previews.set(this.previews.get().filter((preview) => preview.port !== port)); - - return; - } - const previews = this.previews.get(); if (!previewInfo) { - previewInfo = { port, ready: type === 'open', baseUrl: url }; + previewInfo = { + port, + ready: false, + baseUrl: url, + }; + this.#availablePreviews.set(port, previewInfo); previews.push(previewInfo); } @@ -44,6 +199,86 @@ export class PreviewsStore { previewInfo.baseUrl = url; this.previews.set([...previews]); + + if (type === 'open') { + this.broadcastUpdate(url); + } }); } + + // Helper to extract preview ID from URL + getPreviewId(url: string): string | null { + const match = url.match(/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/); + return match ? match[1] : null; + } + + // Broadcast state change to all tabs + broadcastStateChange(previewId: string) { + const timestamp = Date.now(); + this.#lastUpdate.set(previewId, timestamp); + + this.#broadcastChannel.postMessage({ + type: 'state-change', + previewId, + timestamp, + }); + } + + // Broadcast file change to all tabs + broadcastFileChange(previewId: string) { + const timestamp = Date.now(); + this.#lastUpdate.set(previewId, timestamp); + + this.#broadcastChannel.postMessage({ + type: 'file-change', + previewId, + timestamp, + }); + } + + // Broadcast update to all tabs + broadcastUpdate(url: string) { + const previewId = this.getPreviewId(url); + + if (previewId) { + const timestamp = Date.now(); + this.#lastUpdate.set(previewId, timestamp); + + this.#broadcastChannel.postMessage({ + type: 'file-change', + previewId, + timestamp, + }); + } + } + + // Method to refresh a specific preview + refreshPreview(previewId: string) { + // Clear any pending refresh for this preview + const existingTimeout = this.#refreshTimeouts.get(previewId); + + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + // Set a new timeout for this refresh + const timeout = setTimeout(() => { + const previews = this.previews.get(); + const preview = previews.find((p) => this.getPreviewId(p.baseUrl) === previewId); + + if (preview) { + preview.ready = false; + this.previews.set([...previews]); + + requestAnimationFrame(() => { + preview.ready = true; + this.previews.set([...previews]); + }); + } + + this.#refreshTimeouts.delete(previewId); + }, this.#REFRESH_DELAY); + + this.#refreshTimeouts.set(previewId, timeout); + } } diff --git a/app/routes/webcontainer.preview.$id.tsx b/app/routes/webcontainer.preview.$id.tsx new file mode 100644 index 0000000..e30bb9b --- /dev/null +++ b/app/routes/webcontainer.preview.$id.tsx @@ -0,0 +1,92 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare'; +import { useLoaderData } from '@remix-run/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const PREVIEW_CHANNEL = 'preview-updates'; + +export async function loader({ params }: LoaderFunctionArgs) { + const previewId = params.id; + + if (!previewId) { + throw new Response('Preview ID is required', { status: 400 }); + } + + return json({ previewId }); +} + +export default function WebContainerPreview() { + const { previewId } = useLoaderData(); + const iframeRef = useRef(null); + const broadcastChannelRef = useRef(); + const [previewUrl, setPreviewUrl] = useState(''); + + // Handle preview refresh + const handleRefresh = useCallback(() => { + if (iframeRef.current && previewUrl) { + // Force a clean reload + iframeRef.current.src = ''; + requestAnimationFrame(() => { + if (iframeRef.current) { + iframeRef.current.src = previewUrl; + } + }); + } + }, [previewUrl]); + + // Notify other tabs that this preview is ready + const notifyPreviewReady = useCallback(() => { + if (broadcastChannelRef.current && previewUrl) { + broadcastChannelRef.current.postMessage({ + type: 'preview-ready', + previewId, + url: previewUrl, + timestamp: Date.now(), + }); + } + }, [previewId, previewUrl]); + + useEffect(() => { + // Initialize broadcast channel + broadcastChannelRef.current = new BroadcastChannel(PREVIEW_CHANNEL); + + // Listen for preview updates + broadcastChannelRef.current.onmessage = (event) => { + if (event.data.previewId === previewId) { + if (event.data.type === 'refresh-preview' || event.data.type === 'file-change') { + handleRefresh(); + } + } + }; + + // Construct the WebContainer preview URL + const url = `https://${previewId}.local-corp.webcontainer-api.io`; + setPreviewUrl(url); + + // Set the iframe src + if (iframeRef.current) { + iframeRef.current.src = url; + } + + // Notify other tabs that this preview is ready + notifyPreviewReady(); + + // Cleanup + return () => { + broadcastChannelRef.current?.close(); + }; + }, [previewId, handleRefresh, notifyPreviewReady]); + + return ( +
+