diff --git a/docs/PREVIEW_REMOTE_RELAY.md b/docs/PREVIEW_REMOTE_RELAY.md new file mode 100644 index 00000000..21671210 --- /dev/null +++ b/docs/PREVIEW_REMOTE_RELAY.md @@ -0,0 +1,326 @@ +# Preview — Remote-host relay (design) + +Status: design only, no implementation. +Owner: TBD. +Audience: contributors planning the next phase of the embedded preview feature. + +## Problem + +The current preview implementation (`packages/web/server/lib/preview/proxy-runtime.js`, +`packages/ui/src/components/layout/ContextPanel.tsx`) terminates inside the +OpenChamber server process and forwards requests to a **loopback** target +(`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`). It works for these topologies: + +| Topology | Works today? | +| ------------------------------------------------------------------------ | ------------ | +| Web UI in browser, OpenChamber server on same host as dev server | yes | +| Electron desktop, dev server on same host | yes | +| VS Code extension, dev server on same host | yes | +| Mobile/tablet hitting OpenChamber over LAN, dev server on host | yes | +| **Remote OpenChamber** (cloud / shared / tunneled), dev server on user's local machine | **no** | + +The blocked case is real: a user runs `openchamber serve` on a remote box (or a +hosted OpenChamber instance) but their dev server (`vite`, `next dev`, etc.) +runs on their laptop. The proxy correctly refuses to talk to non-loopback +targets — that is a deliberate SSRF gate, not a bug. We need a separate path +that tunnels traffic from the remote OpenChamber back to the user's laptop +without weakening that gate. + +## Non-goals + +- Replacing the existing loopback proxy. The local-loopback path is the common + case and stays unchanged. +- Acting as a generic public ingress for arbitrary local services. We only + expose dev servers selected through the preview UI, scoped to the active + user's session. +- Providing a hosted relay service. The relay is something the user runs; + OpenChamber provides the agent + the server endpoints. + +## Constraints (carried forward from the loopback proxy) + +- Same-origin in the browser. The iframe must load from the OpenChamber + origin so HTTPS, cookies, and CSP behave predictably. +- Per-target cookie auth. A target id must not be guessable, and the cookie + must be HttpOnly + scoped to that target's path. +- WebSocket upgrade support (HMR is a hard requirement; without it the + feature is uninteresting). +- Strip frame-busting headers on the response. +- Strip OpenChamber credentials before forwarding to the dev server. +- Survive partial failure cleanly: if the agent disconnects, the iframe + should land on the existing "dev server is not responding" overlay, not a + zombie hang. + +## Architecture + +Three components, in order of where they run. + +### 1. Local agent (runs on the user's laptop) + +A small process the user starts on the same machine as the dev server. Two +shipping options: + +- A subcommand of the existing CLI: `openchamber preview-agent`. +- A standalone single-binary build for users who do not have the full UI + installed locally. + +Responsibilities: + +- Open exactly one outbound, authenticated WebSocket to the remote + OpenChamber server (`wss:///api/preview/agent`). Outbound-only — no + inbound port on the user's machine, so it works behind NAT, VPN, + corporate firewall, etc. +- Authenticate with a short-lived enrollment token issued by the remote + OpenChamber server (see "Pairing flow"). +- Advertise the set of dev servers the user has authorised. Scope is + loopback-only on the agent side (same allowlist as the existing proxy: + `localhost`, `127.0.0.1`, `::1`, `0.0.0.0`). The agent never proxies to + arbitrary hosts on the user's network. +- Multiplex per-request streams over the single control WebSocket + (frame protocol below). Each browser request becomes one logical stream. +- Forward HTTP and upgraded WebSocket connections to the local dev server. +- Send authoritative `agent-disconnected` notifications so the server can + evict targets immediately rather than waiting for TTL. + +Deliberately out of scope for the agent: + +- TLS termination. The agent only talks to loopback over plain HTTP; the + outbound link to OpenChamber is TLS via the server's existing cert. +- Anything that mutates the user's filesystem. +- Acting as a general SOCKS/HTTP proxy. It is dev-server-scoped. + +### 2. Remote OpenChamber server (extends `proxy-runtime.js`) + +Adds two new surfaces alongside the existing loopback proxy: + +- `GET /api/preview/agent` (WebSocket): the single control channel an agent + connects to after enrollment. Authenticated by the enrollment token + the + user's UI session. +- `POST /api/preview/targets/remote`: same shape as the existing + `POST /api/preview/targets`, but the URL is interpreted **relative to a + connected agent**. The body becomes + `{ agentId, url, ttlMs? }` (or the existing endpoint accepts an optional + `agentId` and dispatches to the right path). The response keeps the same + contract: `{ id, proxyBasePath, expiresAt }`. The browser does not learn + it is talking to a remote agent — that is a server-side detail. + +The existing `/api/preview/proxy/:id/*` route is reused unchanged from the +browser's perspective. Internally it now dispatches based on the registered +target type: + +- `kind: 'loopback'` (existing) → `http-proxy-middleware` to a local origin. +- `kind: 'agent'` (new) → encode the request into a frame, push it onto the + matching agent's WebSocket, await the response frames, stream them back + to the browser. + +This dispatch boundary is the only invasive change to the existing runtime. +The factory stays `createPreviewProxyRuntime`; the agent registry, frame +codec, and response streaming live in a sibling module +(`packages/web/server/lib/preview/agent-runtime.js`) so the loopback path +remains readable and individually testable. + +### 3. Browser (UI layer) + +Almost no change. `PreviewPane` already POSTs to `/api/preview/targets` and +loads the iframe at the returned `proxyBasePath`. The remote case adds: + +- A small "no agent connected" empty state when the user's profile has no + active agent but tries to preview a non-public URL. Gives them the exact + command to run and a one-click copy of the enrollment token. +- The existing 502 / dev-server-down overlay handles agent disconnects too + — the proxy returns 502 if the agent vanishes mid-request. + +## Pairing / enrollment flow + +The agent must prove it is acting on behalf of a specific UI user, and the +server must be able to revoke that proof. + +1. User opens Settings → Preview → "Connect a local dev-server agent". +2. Server mints a short-lived (5 min) enrollment token bound to the user's + UI session id, with a single allowed scope: `preview-agent.connect`. UI + shows the command: + ``` + openchamber preview-agent --server https:// --token + ``` +3. Agent posts the enrollment token to `POST /api/preview/agent/enroll` and + receives a long-lived `agentId` + `agentSecret`. Stored in the agent's + config dir (`$XDG_CONFIG_HOME/openchamber/agent.json` or platform + equivalent). +4. Agent opens the control WebSocket, authenticating with `agentId` + + `agentSecret`. The server verifies and registers the agent against the + owning user. +5. Agent sends an initial `hello` frame with: agent version, OS, hostname + hint (display only — never used for routing), and a list of dev-server + URLs the user has explicitly approved on the agent side. + +Revocation: + +- User can revoke an agent from Settings; the server invalidates the + `agentSecret` and closes any open WebSocket. +- The agent honours `disconnect` frames from the server with a clean + shutdown. +- Enrollment tokens are single-use and expire after 5 min. + +## Wire protocol (control WebSocket) + +Binary frames, little-endian, one frame = one logical operation. JSON metadata +header followed by an opaque body. Designed to be implementable in Node and +Bun without exotic deps. + +``` ++--------+--------+--------+----------------------+----------------------+ +| u8 ver | u8 op | u32 len| metadata (JSON, len) | body (remaining) | ++--------+--------+--------+----------------------+----------------------+ +``` + +Operations: + +| op | name | direction | metadata | body | +| ---- | ----------------- | -------------- | ------------------------------------------------------------------- | ----------------------------------- | +| 0x01 | hello | agent → server | `{ agentVersion, hostnameHint, allowedTargets: [{origin}] }` | empty | +| 0x02 | hello-ack | server → agent | `{ ok, serverVersion }` or `{ ok: false, reason }` | empty | +| 0x10 | http-request | server → agent | `{ streamId, method, path, headers, originHint }` | request body bytes | +| 0x11 | http-response-head| agent → server | `{ streamId, status, headers }` | empty | +| 0x12 | http-response-data| agent → server | `{ streamId, fin: bool }` | response body chunk | +| 0x13 | http-error | agent → server | `{ streamId, code, message }` | empty | +| 0x20 | ws-open | server → agent | `{ streamId, path, headers, subprotocols }` | empty | +| 0x21 | ws-open-ack | agent → server | `{ streamId, ok, status?, subprotocol? }` | empty | +| 0x22 | ws-frame | both | `{ streamId, opcode: 'text'|'binary', fin: bool }` | frame payload | +| 0x23 | ws-close | both | `{ streamId, code?, reason? }` | empty | +| 0x30 | cancel | server → agent | `{ streamId }` | empty | +| 0xFE | ping | both | `{ ts }` | empty | +| 0xFF | disconnect | server → agent | `{ reason }` | empty | + +Notes: + +- `streamId` is server-assigned for `http-request` and `ws-open`. It scopes + ordering and back-pressure per logical request. +- Body chunks for HTTP responses are streamed (`fin: false` until the last + chunk). The server proxies them to the browser without buffering, so + large downloads do not balloon memory on either side. +- The `originHint` lets the agent log which approved target a request was + routed to; routing itself is determined by the registered target's + `agentId` + origin, not by anything the browser sends. +- Back-pressure: if the server's downstream socket is paused, it stops + reading from the agent's WebSocket. WebSocket flow control then applies + end-to-end. We do not implement an additional credit scheme until + measurement shows we need one. + +## Security model + +Every guarantee the loopback proxy gives must hold here too. Checked +against the same threat model: + +- **Server-side SSRF**: target URLs are still validated against the loopback + allowlist — but on the agent, not the server. The server never makes a + network call on behalf of a target. +- **Cross-user target access**: a target id is owned by the user that + registered it. Cookie + path scope unchanged. +- **Cross-agent leakage**: a target id is also bound to the specific + `agentId` it was registered against. Even if two users somehow share a + target id (they cannot — ids are 128-bit random), dispatch only reaches + the agent the target was bound to. +- **Agent impersonation**: `agentSecret` is per-agent, stored only on the + user's machine, transported only over TLS during enrollment + connect. + Revocable from Settings. +- **Frame-busting headers**: stripped server-side after the agent returns + the response, identical to the loopback path. Same code path + (`stripFrameBustingHeaders`) — keep it as a single point of truth. +- **Dev-server credentials**: the agent strips `cookie`, `authorization`, + and `x-openchamber-ui-session` before forwarding to the local dev + server, mirroring the existing `proxyReq` handler. +- **Public-internet exposure**: no inbound port opens on the user's + machine; no egress to non-loopback addresses; the agent process refuses + to start with `0.0.0.0` upstream targets that resolve off-loopback. +- **Connection pinning**: when the agent's WebSocket disconnects, all of + its targets are evicted immediately and any in-flight streams are + aborted with 502. The cached entry on the browser side (see + `previewProxyTargetCache` in `ContextPanel.tsx`) will then re-register + on the next attempt and surface the "no agent connected" empty state. + +Out-of-scope hardening to revisit later: + +- mTLS for the agent ↔ server link (current proposal: TLS + agentSecret; + mTLS is a future option for self-hosters who want it). +- Audit logging of every proxied request (today the loopback path doesn't + do this; the remote path should not become an exception without a UX + for inspecting the log). + +## Failure modes + +| Failure | Behaviour | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Agent never connected | `POST /api/preview/targets/remote` returns 409 with `{ error: 'No agent connected' }`. UI shows empty state. | +| Agent disconnected mid-request | Server cancels the stream, returns 502 to the browser, evicts the target. Existing overlay handles it. | +| Dev server down on user's laptop | Agent forwards the connection refusal as `http-error`; server emits 502. Existing overlay handles it. | +| Slow agent / dev server | Streamed response keeps flowing; no buffering on the server. WebSocket flow control gates the data rate. | +| Server restarted | Agent reconnects with stored `agentSecret`. Browser-side cache 404s on next request and re-registers. | +| Enrollment token expired | `POST /api/preview/agent/enroll` returns 401 with a clear error; UI prompts to mint a new one. | +| Two agents registered for same user | Allowed. The browser-side flow always picks the most recently active agent for a given upstream URL. | + +## Open questions + +These need a decision before implementation, not before the doc lands. + +1. **CLI surface.** Is `openchamber preview-agent` the right verb, or should + it live under `openchamber agent preview`? Bias: the former; only one + agent today, and we can rename without breaking anything if we ever ship + a second. +2. **Multi-agent UX.** When a user has two agents online (laptop + desktop) + and registers a `localhost:3000` preview, which one wins? Most-recent + activity is a sensible default but we should also let the user pin a + target to an agent. +3. **Browser-side detection of remote vs loopback.** Today the UI has no + reason to know. If the empty state needs the user's enrolled agents, + that becomes a new `GET /api/preview/agents` endpoint. Acceptable. +4. **Storage of `agentSecret`.** Plain file under the agent config dir is + simplest. OS keychain integration is nicer but a much larger surface. + Bias: file first, keychain later. +5. **Frame protocol vs. full HTTP/2 / gRPC.** The custom frame protocol is + maybe 200 lines in each runtime. gRPC would handle streaming and back + pressure for us but adds a heavy dep. Bias: custom frames; revisit only + if we hit a back-pressure or multiplexing bug we cannot solve cleanly. +6. **Compression.** The current loopback path forces `accept-encoding: + identity` to keep the proxy simple. The remote path probably wants + gzip/br between the agent and the server to save bandwidth on slow + links — but the dev server may not be configured for it. Decide once we + measure. + +## Implementation milestones + +Each milestone is independently shippable and reviewable. Numbers are +sequence, not effort. + +1. Agent registry + enrollment endpoints on the server. No proxying yet. + Settings UI to mint and revoke enrollment tokens. +2. Standalone agent that connects, says hello, and stays connected with + ping/pong. No proxying yet. Validates the auth + reconnect story. +3. HTTP-only proxying through the agent (`http-request` / + `http-response-*`). Browser can register a remote target and load + static pages. No HMR yet. +4. WebSocket proxying through the agent (`ws-open` / `ws-frame` / + `ws-close`). HMR works. +5. Failure-mode polish: 502 on disconnect, target eviction, browser-side + empty state, "agent connected" indicator in Settings. +6. Documentation + tutorial for the remote-host scenario; update + `docs/REVERSE_PROXY.md` cross-link. + +## Why not …? + +- **A reverse SSH tunnel from the agent.** Works but requires SSH server + on the OpenChamber host, exposes a port, and breaks the same-origin + guarantee unless we also reverse-proxy that port through the + OpenChamber HTTP server. The control-WebSocket design avoids all of + that and keeps a single TLS endpoint. +- **Cloudflare/ngrok-style hosted relay.** Would work but turns + OpenChamber into a service that depends on a third party (or on us + hosting a relay). The agent design lets users run entirely + self-hosted. +- **WebRTC data channels.** Lower latency in theory, much harder to debug + and to reason about behind corporate NATs. Not worth the complexity + for HTTP + WS forwarding. + +## Cross-references + +- Loopback runtime: `packages/web/server/lib/preview/proxy-runtime.js` +- Browser PreviewPane + cache: `packages/ui/src/components/layout/ContextPanel.tsx` +- Reverse-proxy deployment notes: `docs/REVERSE_PROXY.md` diff --git a/package.json b/package.json index a410ac8f..5dd2be99 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "Bohdan Triapitsyn", "license": "MIT", "scripts": { - "dev": "concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"", + "dev": "OPENCHAMBER_DEV_SHUTDOWN=true concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"", "build": "bun run --filter '*' build", "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index a308b5ce..03d5c950 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -104,6 +104,7 @@ const MIN_WINDOW_WIDTH = 800; const MIN_WINDOW_HEIGHT = 520; const MIN_RESTORE_WINDOW_WIDTH = 900; const MIN_RESTORE_WINDOW_HEIGHT = 560; +const MAX_CAPTURE_PAGE_RECT_AREA = 4_000_000; const LOCAL_HOST_ID = 'local'; const ENV_OVERRIDE_HOST_ID = '__env'; const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'; @@ -1648,6 +1649,38 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_get_app_version': return APP_VERSION; + case 'desktop_capture_page_rect': { + if (!browserWindow || browserWindow.isDestroyed()) { + throw new Error('Window is not available'); + } + + const bounds = browserWindow.getContentBounds(); + const x = Number.isFinite(args.x) ? Math.max(0, Math.floor(args.x)) : 0; + const y = Number.isFinite(args.y) ? Math.max(0, Math.floor(args.y)) : 0; + const width = Number.isFinite(args.width) ? Math.max(1, Math.floor(args.width)) : 1; + const height = Number.isFinite(args.height) ? Math.max(1, Math.floor(args.height)) : 1; + const clampedX = Math.min(x, Math.max(0, bounds.width - 1)); + const clampedY = Math.min(y, Math.max(0, bounds.height - 1)); + const rect = { + x: clampedX, + y: clampedY, + width: Math.min(width, Math.max(1, bounds.width - clampedX)), + height: Math.min(height, Math.max(1, bounds.height - clampedY)), + }; + if (rect.width * rect.height > MAX_CAPTURE_PAGE_RECT_AREA) { + throw new Error('Capture area is too large'); + } + + const image = await browserWindow.webContents.capturePage(rect); + const buffer = image.toJPEG(82); + return { + mime: 'image/jpeg', + base64: buffer.toString('base64'), + width: image.getSize().width, + height: image.getSize().height, + }; + } + case 'desktop_save_markdown_file': { const defaultPath = typeof args.defaultFileName === 'string' ? args.defaultFileName.trim() : ''; if (!defaultPath) { @@ -2247,6 +2280,7 @@ const COMMANDS_SAFE_FOR_REMOTE = new Set([ 'desktop_start_window_drag', 'desktop_get_app_version', 'desktop_get_lan_address', + 'desktop_capture_page_rect', ]); ipcMain.handle('openchamber:invoke', async (event, command, args) => { diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 24b724d5..5e516297 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1021,8 +1021,38 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [currentSessionId, newSessionDraftOpen] ) ); + const draftSourceKey = useInlineCommentDraftStore( + React.useCallback( + (state) => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : []; + let previewConsole = 0; + let previewAnnotation = 0; + let review = 0; + for (const draft of drafts) { + if (draft.source === 'preview-console') previewConsole += 1; + else if (draft.source === 'preview-annotation') previewAnnotation += 1; + else review += 1; + } + return `${previewConsole}:${previewAnnotation}:${review}`; + }, + [currentSessionId, newSessionDraftOpen] + ) + ); const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); + const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; + const [previewConsoleCount, previewAnnotationCount, reviewCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); + const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + if (!sessionKey) return; + const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? []; + for (const draft of drafts) { + if (draft.source === source) { + removeInlineCommentDraft(sessionKey, draft.id); + } + } + }, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]); // User message history for up/down arrow navigation. // Keep this on a narrow hook instead of full session message records. @@ -3270,19 +3300,61 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo onEditMessage={handleQueuedMessageEdit} /> {hasDrafts && ( -
-
- {t('chat.chatInput.reviewComments')} - - {draftCount} - -
+
+ {reviewCount > 0 ? ( +
+ {t('chat.chatInput.reviewComments')} + {reviewCount} +
+ ) : null} + {previewConsoleCount > 0 ? ( +
+ {t('chat.chatInput.devServerLogs')} + {previewConsoleCount} + +
+ ) : null} + {previewAnnotationCount > 0 ? ( +
+ {t('chat.chatInput.previewAnnotations')} + {previewAnnotationCount} + +
+ ) : null}
)} diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index cc7472d7..8ec7d896 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -11,13 +11,13 @@ import remend from 'remend'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; -import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react'; +import { RiFileCopyLine, RiCheckLine, RiDownloadLine, RiEyeLine, RiCodeLine } from '@remixicon/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n } from '@/lib/i18n'; -import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; +import { isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getDefaultTheme } from '@/lib/theme/themes'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; @@ -694,6 +694,26 @@ const CODE_SHARED_STYLE: React.CSSProperties = { lineHeight: 'var(--markdown-code-block-line-height)', }; +const downloadTextFile = (content: string, filename: string, mimeType: string) => { + if (typeof window === 'undefined') { + return; + } + + try { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + // Best-effort; callers can optionally toast. + } +}; + const MarkdownCodeBlock: React.FC<{ code: string; language: string; @@ -701,9 +721,18 @@ const MarkdownCodeBlock: React.FC<{ }> = ({ code, language, syntaxTheme }) => { const [copied, setCopied] = React.useState(false); const [highlight, setHighlight] = React.useState(true); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); const prevCodeRef = React.useRef(code); const timerRef = React.useRef | null>(null); + const canPreview = language === 'html' || language === 'htm'; + + React.useEffect(() => { + if (!canPreview && viewMode !== 'code') { + setViewMode('code'); + } + }, [canPreview, viewMode]); + // Defer Prism highlighting while code is actively streaming. // Initial mount renders highlighted immediately (plays nice with finalized blocks). React.useEffect(() => { @@ -732,46 +761,96 @@ const MarkdownCodeBlock: React.FC<{ window.setTimeout(() => setCopied(false), 2000); }, [code]); + const handleDownload = React.useCallback(() => { + if (!canPreview) { + return; + } + + const safeSuffix = Date.now().toString(36); + downloadTextFile(code, `preview-${safeSuffix}.html`, 'text/html;charset=utf-8'); + }, [canPreview, code]); + return (
{language} -
+
+ {canPreview ? ( + + ) : null} + {canPreview ? ( + + ) : null}
-
- {highlight ? ( - - {code} - - ) : ( -
-            {code}
-          
- )} -
+ {canPreview && viewMode === 'preview' ? ( +
+