Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added the "Open Preview in a New Tab" #1101

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion app/components/workbench/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,35 @@ export const Preview = memo(() => {
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
/>

{/* Open in new tab button */}
<IconButton
icon="i-ph:arrow-square-out"
onClick={() => {
if (activePreview?.baseUrl) {
// Extract the preview ID from the WebContainer URL
const match = activePreview.baseUrl.match(
/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/,
);

if (match) {
const previewId = match[1];

// Open in new tab using our route
const previewUrl = `/webcontainer/preview/${previewId}`;
const newWindow = window.open(previewUrl, '_blank', 'noopener,noreferrer');

// Force focus on the new window
if (newWindow) {
newWindow.focus();
}
} else {
console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl);
}
}
}}
title="Open Preview in New Tab"
/>
</div>

<div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
Expand All @@ -294,7 +323,8 @@ export const Preview = memo(() => {
title="preview"
className="border-none w-full h-full bg-white"
src={iframeUrl}
allowFullScreen
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
allow="cross-origin-isolated"
/>
<ScreenshotSelector
isSelectionMode={isSelectionMode}
Expand Down
245 changes: 245 additions & 0 deletions app/lib/stores/previews.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,192 @@
import type { WebContainer } from '@webcontainer/api';
import { atom } from 'nanostores';

// Extend Window interface to include our custom property
declare global {
interface Window {
_tabId?: string;
}
}

export interface PreviewInfo {
port: number;
ready: boolean;
baseUrl: string;
}

// Create a broadcast channel for preview updates
const PREVIEW_CHANNEL = 'preview-updates';

export class PreviewsStore {
#availablePreviews = new Map<number, PreviewInfo>();
#webcontainer: Promise<WebContainer>;
#broadcastChannel: BroadcastChannel;
#lastUpdate = new Map<string, number>();
#watchedFiles = new Set<string>();
#refreshTimeouts = new Map<string, NodeJS.Timeout>();
#REFRESH_DELAY = 300;
#storageChannel: BroadcastChannel;

previews = atom<PreviewInfo[]>([]);

constructor(webcontainerPromise: Promise<WebContainer>) {
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<string, string>) {
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);
}
});

// Reload the page content
if (typeof window !== 'undefined' && window.location) {
const iframe = document.querySelector('iframe');

if (iframe) {
iframe.src = iframe.src;
}
}
}
}

// Broadcast storage state to other tabs
private _broadcastStorageSync() {
if (typeof window !== 'undefined') {
const storage: Record<string, string> = {};

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
const watcher = await webcontainer.fs.watch('**/*', { persistent: true });

// Use the native watch events
(watcher as any).addEventListener('change', async () => {
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((_mutations) => {
// 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);

Expand All @@ -44,6 +209,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);
}
}
Loading
Loading