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 (
+
+
+
+ );
+}
\ No newline at end of file