diff --git a/Dockerfile b/Dockerfile index 7924a0645..ccee00ece 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,6 +91,7 @@ ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=5003 ENV OPENCODE_SERVER_PORT=5551 +ENV DEV_PREVIEW_PORT=3056 ENV DATABASE_PATH=/app/data/opencode.db ENV WORKSPACE_PATH=/workspace ENV XDG_CACHE_HOME=/home/node/.cache @@ -112,7 +113,7 @@ RUN chmod +x /docker-entrypoint.sh RUN mkdir -p /workspace /app/data /home/node/.cache /home/node/.opencode && \ chown -R node:node /workspace /app/data /home/node -EXPOSE 5003 5100 5101 5102 5103 +EXPOSE 5003 3055 3056 HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD curl -f http://localhost:5003/api/health || exit 1 diff --git a/backend/eslint.config.js b/backend/eslint.config.js index f0084db62..a2972da61 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -26,6 +26,7 @@ export default defineConfig([ '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': 'error', 'no-useless-escape': 'warn', + 'no-empty': ['error', { allowEmptyCatch: true }], }, }, { diff --git a/backend/src/constants.ts b/backend/src/constants.ts index ca4d348d3..e2da00f44 100644 --- a/backend/src/constants.ts +++ b/backend/src/constants.ts @@ -1,56 +1,3 @@ -export const DEFAULT_AGENTS_MD = `# OpenCode Manager - Global Agent Instructions +import defaultAgentsMd from './default-agents.md' with { type: 'text' } -## Critical System Constraints - -- **DO NOT** use ports 5003 or 5551 - these are reserved for OpenCode Manager -- **DO NOT** kill or stop processes on ports 5003 or 5551 -- **DO NOT** modify files in the \`.config/opencode\` directory unless explicitly requested - -## Dev Server Ports - -When starting dev servers, use the pre-allocated ports 5100-5103: -- Port 5100: Primary dev server (frontend) -- Port 5101: Secondary dev server (API/backend) -- Port 5102: Additional service -- Port 5103: Additional service - -Always bind to \`0.0.0.0\` to allow external access from the Docker host. - -## Package Management - -### Node.js Packages -Prefer **pnpm** or **bun** over npm for installing dependencies to save disk space: -- Use \`pnpm install\` instead of \`npm install\` -- Use \`bun install\` as an alternative -- Both are pre-installed in the container - - ### Python Packages - Always create a virtual environment in the repository directory before installing packages: - - 1. Create virtual environment in repo: - \`cd \`\` - \`uv venv .venv\` - - 2. Activate the virtual environment: - \`source .venv/bin/activate\` # or \`uv pip sync\` for project-based workflows - - 3. Install packages into activated environment: - \`uv pip install \`\` - \`uv pip install -r requirements.txt\` - - 4. Run Python commands: - \`python script.py\` # Uses activated .venv - - Alternative: Use \`uv run python script.py\` to skip explicit activation - - **Important:** - - Always create .venv in the repository directory (not workspace root) - - Activate the environment before running pip operations - - uv is pre-installed in the container and provides faster package installation - - .venv directories created in repos will persist but can be removed safely - -## General Guidelines - -- This file is merged with any AGENTS.md files in individual repositories -- Repository-specific instructions take precedence for their respective codebases -` +export const DEFAULT_AGENTS_MD = defaultAgentsMd diff --git a/backend/src/default-agents.md b/backend/src/default-agents.md new file mode 100644 index 000000000..1b85361f0 --- /dev/null +++ b/backend/src/default-agents.md @@ -0,0 +1,56 @@ +# OpenCode Manager - Global Agent Instructions + +## Critical System Constraints + +- **DO NOT** use ports 5003 or 5551 - these are reserved for OpenCode Manager +- **DO NOT** kill or stop processes on ports 5003 or 5551 +- **DO NOT** modify files in the `.config/opencode` directory unless explicitly requested + +## Dev Server Port + +The preview feature checks the port provided in the `OCM_DEV_SERVER_PORT` environment variable. Always run the dev server on that port so it can be previewed. + +- Start the dev server on the port in `$OCM_DEV_SERVER_PORT` (e.g. `PORT=$OCM_DEV_SERVER_PORT ` or pass `--port $OCM_DEV_SERVER_PORT`). +- Bind to `0.0.0.0` to allow external access from the Docker host. + +## Package Management + +### Node.js Packages + +Prefer **pnpm** or **bun** over npm for installing dependencies to save disk space: + +- Use `pnpm install` instead of `npm install` +- Use `bun install` as an alternative +- Both are pre-installed in the container + +### Python Packages + +Always create a virtual environment in the repository directory before installing packages: + +1. Create virtual environment in repo: + `cd ` + `uv venv .venv` + +2. Activate the virtual environment: + `source .venv/bin/activate` # or `uv pip sync` for project-based workflows + +3. Install packages into activated environment: + `uv pip install ` + `uv pip install -r requirements.txt` + +4. Run Python commands: + `python script.py` # Uses activated .venv + +Alternative: Use `uv run python script.py` to skip explicit activation + +**Important:** + +- Always create .venv in the repository directory (not workspace root) +- Activate the environment before running pip operations +- uv is pre-installed in the container and provides faster package installation +- .venv directories created in repos will persist but can be removed safely + +## General Guidelines + +- This file is merged with any AGENTS.md files in individual repositories +- Repository-specific instructions take precedence for their respective codebases diff --git a/backend/src/index.ts b/backend/src/index.ts index 21dd615b2..3893ba5cb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -37,6 +37,8 @@ import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createInternalRoutes } from './routes/internal' import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' import { createOpenCodeProxyRoutes } from './routes/opencode-proxy' +import { createDevServerRoutes } from './routes/dev-server' +import { startPreviewServer } from './services/dev-server/preview-server' import { sseAggregator } from './services/sse-aggregator' import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations' import { SettingsService } from './services/settings' @@ -342,6 +344,7 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) +protectedApi.route('/dev-server', createDevServerRoutes(db)) app.route('/api', protectedApi) @@ -467,4 +470,7 @@ serve({ hostname: HOST, }) +startPreviewServer(auth, db) + logger.info(`🚀 OpenCode WebUI API running on http://${HOST}:${PORT}`) +logger.info(`🔍 Dev preview proxy running on http://${HOST}:${ENV.DEV_PREVIEW.PORT}`) diff --git a/backend/src/routes/dev-server.ts b/backend/src/routes/dev-server.ts new file mode 100644 index 000000000..6f7965cb6 --- /dev/null +++ b/backend/src/routes/dev-server.ts @@ -0,0 +1,28 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { getRepoById } from '../db/queries' +import { getDevServerState } from '../services/dev-server/manager' +import { resolveDevPreviewUrl } from '../services/dev-server/proxy-utils' + +export function createDevServerRoutes(db: Database): Hono { + const app = new Hono() + + app.get('/:repoId/status', async (c) => { + const repoId = parseInt(c.req.param('repoId'), 10) + if (isNaN(repoId)) { + return c.json({ error: 'Invalid repoId' }, 400) + } + + const repo = getRepoById(db, repoId) + if (!repo) { + return c.json({ error: 'Repository not found' }, 404) + } + + const previewUrl = resolveDevPreviewUrl(c.req.header('host'), c.req.header('x-forwarded-proto')) + const state = await getDevServerState(db, repo.id, previewUrl) + + return c.json(state) + }) + + return app +} diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 4b43f6e46..eb1623721 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -23,12 +23,139 @@ function getSpecialRoutePathFromRequest(c: Context, routeName: string): string | return match?.[1] ? decodeFilePath(match[1]) : undefined } +function getPreviewPathFromRequest(c: Context): string | undefined { + const path = c.req.path + const prefix = '/api/files/preview' + + const queryPath = c.req.query('path') + if (queryPath !== undefined) { + return queryPath + } + + if (path.startsWith(prefix + '/')) { + const previewPath = path.slice(prefix.length + 1) + return decodeURIComponent(previewPath) + } + + return undefined +} + +const PREVIEWABLE_MIME_TYPES = new Set([ + 'text/html', + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/json', + 'application/xml', + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', +]) + +function isPreviewableMimeType(mimeType?: string): boolean { + return mimeType !== undefined && PREVIEWABLE_MIME_TYPES.has(mimeType) +} + +function buildPreviewValidators(stat: { size: number; lastModified: Date }): { etag: string; lastModified: string } { + return { + etag: `"${stat.size.toString(16)}-${stat.lastModified.getTime().toString(16)}"`, + lastModified: stat.lastModified.toUTCString(), + } +} + +function isPreviewFresh(c: Context, validators: { etag: string; lastModified: string }): boolean { + const ifNoneMatch = c.req.header('if-none-match') + if (ifNoneMatch !== undefined) { + return ifNoneMatch.split(',').some((tag: string) => tag.trim() === validators.etag) + } + + const ifModifiedSince = c.req.header('if-modified-since') + if (ifModifiedSince !== undefined) { + const since = Date.parse(ifModifiedSince) + return !Number.isNaN(since) && Date.parse(validators.lastModified) <= since + } + + return false +} + +function getPreviewHeaders(result: { + name: string + size: number + mimeType?: string + lastModified: Date +}): Record { + const isHtmlSvg = result.mimeType === 'text/html' || result.mimeType === 'image/svg+xml' + const validators = buildPreviewValidators(result) + const headers: Record = { + 'Content-Type': result.mimeType || 'application/octet-stream', + 'Content-Length': result.size.toString(), + 'Content-Disposition': `inline; filename="${result.name}"`, + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'private, must-revalidate, max-age=0', + 'ETag': validators.etag, + 'Last-Modified': validators.lastModified, + } + + if (isHtmlSvg) { + headers['Content-Security-Policy'] = [ + 'sandbox allow-scripts allow-same-origin;', + "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval';", + "script-src 'self' http: https: 'unsafe-inline' 'unsafe-eval';", + "style-src 'self' http: https: 'unsafe-inline';", + "img-src 'self' http: https: data: blob:;", + "font-src 'self' http: https: data:;", + "connect-src 'self' http: https:;", + "media-src 'self' http: https: data: blob:;", + "object-src 'none';", + "base-uri 'none';", + "frame-ancestors 'self'", + ].join(' ') + } + + return headers +} + export function createFileRoutes() { const app = new Hono() app.get('*', async (c) => { const path = c.req.path + if (path === '/api/files/preview' || path.startsWith('/api/files/preview/')) { + const userPath = getPreviewPathFromRequest(c) + + if (!userPath) { + return c.json({ error: 'No path provided' }, 400) + } + + try { + const stat = await fileService.getFilePreviewStat(userPath) + + if (stat.isDirectory) { + return c.json({ error: 'Cannot preview directories' }, 400) + } + + if (!isPreviewableMimeType(stat.mimeType)) { + return c.json({ error: 'File type cannot be previewed' }, 415) + } + + const headers = getPreviewHeaders(stat) + + if (isPreviewFresh(c, buildPreviewValidators(stat))) { + return new Response(null, { status: 304, headers }) + } + + const content = await fileService.getRawFileContent(userPath) + + return new Response(content, { headers }) + } catch (error: unknown) { + logger.error('Failed to preview file:', error) + return c.json({ error: getErrorMessage(error) || 'Failed to preview file' }, getStatusCode(error) as ContentfulStatusCode) + } + } + if (path.endsWith('/download-zip')) { const userPath = getSpecialRoutePathFromRequest(c, 'download-zip') diff --git a/backend/src/routes/internal/git-credentials.ts b/backend/src/routes/internal/git-credentials.ts deleted file mode 100644 index 53a1c470e..000000000 --- a/backend/src/routes/internal/git-credentials.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Hono } from 'hono' -import type { Database } from 'bun:sqlite' -import { CredentialProvider } from '../../services/credential-provider' - -export function createInternalGitCredentialsRoutes(db: Database) { - const app = new Hono() - - app.get('/gh-env', (c) => { - const provider = new CredentialProvider(db) - return c.json(provider.getGhCliEnv({ cwd: c.req.query('cwd') })) - }) - - return app -} diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 590ef337e..a3200857f 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -13,7 +13,7 @@ import { createInternalRepoSyncRoutes } from './repo-sync' import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror' import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces' import { createInternalAssistantRoutes } from './assistant' -import { createInternalGitCredentialsRoutes } from './git-credentials' +import { createInternalShellEnvRoutes } from './shell-env' export function createInternalRoutes( db: Database, @@ -35,6 +35,6 @@ export function createInternalRoutes( app.route('/repos', repos) app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db)) app.route('/assistant', createInternalAssistantRoutes(openCodeClient)) - app.route('/git-credentials', createInternalGitCredentialsRoutes(db)) + app.route('/shell-env', createInternalShellEnvRoutes(db)) return app } diff --git a/backend/src/routes/internal/shell-env.ts b/backend/src/routes/internal/shell-env.ts new file mode 100644 index 000000000..19b4d8876 --- /dev/null +++ b/backend/src/routes/internal/shell-env.ts @@ -0,0 +1,18 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { CredentialProvider } from '../../services/credential-provider' +import { getDevServerPort } from '../../services/dev-server/manager' + +export function createInternalShellEnvRoutes(db: Database) { + const app = new Hono() + + app.get('/', (c) => { + const provider = new CredentialProvider(db) + return c.json({ + ...provider.getGhCliEnv({ cwd: c.req.query('cwd') }), + OCM_DEV_SERVER_PORT: String(getDevServerPort(db)), + }) + }) + + return app +} diff --git a/backend/src/services/dev-server/manager.ts b/backend/src/services/dev-server/manager.ts new file mode 100644 index 000000000..91445b581 --- /dev/null +++ b/backend/src/services/dev-server/manager.ts @@ -0,0 +1,22 @@ +import type { Database } from 'bun:sqlite' +import { DEFAULT_DEV_SERVER_PORT, type DevServerState } from '@opencode-manager/shared/types' +import { SettingsService } from '../settings' +import { isPortOpen } from './ports' + +export function getDevServerPort(db: Database): number { + const port = new SettingsService(db).getSettings('default').preferences.devServerPort + return port ?? DEFAULT_DEV_SERVER_PORT +} + +export async function getDevServerState(db: Database, repoId: number, previewUrl: string): Promise { + const port = getDevServerPort(db) + const isRunning = await isPortOpen(port) + + return { + repoId, + status: isRunning ? 'running' : 'stopped', + port, + error: isRunning ? null : `No dev server detected on localhost:${port}`, + previewUrl: isRunning ? previewUrl : null, + } +} diff --git a/backend/src/services/dev-server/ports.ts b/backend/src/services/dev-server/ports.ts new file mode 100644 index 000000000..4944ed7d5 --- /dev/null +++ b/backend/src/services/dev-server/ports.ts @@ -0,0 +1,19 @@ +import net from 'net' + +export async function isPortOpen(port: number, host = '127.0.0.1', timeoutMs = 1000): Promise { + return new Promise((resolve) => { + const socket = new net.Socket() + const onError = () => { + socket.destroy() + resolve(false) + } + socket.setTimeout(timeoutMs) + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', onError) + socket.once('timeout', onError) + socket.connect(port, host) + }) +} diff --git a/backend/src/services/dev-server/preview-server.ts b/backend/src/services/dev-server/preview-server.ts new file mode 100644 index 000000000..4ee0b443a --- /dev/null +++ b/backend/src/services/dev-server/preview-server.ts @@ -0,0 +1,182 @@ +import type { Database } from 'bun:sqlite' +import { ENV } from '@opencode-manager/shared/config/env' +import type { AuthInstance } from '../../auth' +import { getDevServerPort } from './manager' +import { buildUpstreamUrl, filterProxyHeaders, sanitizeUpstreamResponseHeaders } from './proxy-utils' +import { logger } from '../../utils/logger' + +const PreviewResponse = Response + +type WebSocketMessage = string | ArrayBuffer | Uint8Array + +interface PreviewWebSocketData { + upstreamUrl: string + protocol: string | null + upstream: WebSocket | null + pendingMessages: WebSocketMessage[] +} + +function getUnauthorizedHtml(): string { + return ` + +Sign in required + +

Sign in required

+

Open the preview from an authenticated OpenCode Manager session.

+ +` +} + +function getNotRunningHtml(port: number): string { + return ` + +Dev Server Not Running + +

Dev Server Not Running

+

No development server responded on localhost:${port}.

+ +` +} + +function htmlResponse(html: string, status: number): Response { + return new PreviewResponse(html, { + status, + headers: { 'content-type': 'text/html; charset=UTF-8' }, + }) +} + +async function hasValidSession(auth: AuthInstance, headers: Headers): Promise { + try { + const session = await auth.api.getSession({ headers }) + return Boolean(session) + } catch { + return false + } +} + +async function handleAuthenticatedPreviewRequest(request: Request, db: Database, port: number): Promise { + const url = new URL(request.url) + const upstreamUrl = buildUpstreamUrl(port, url.pathname, url.search) + + const headers = filterProxyHeaders(request.headers) + let body: RequestInit['body'] = undefined + if (request.method !== 'GET' && request.method !== 'HEAD') { + body = request.body + } + + let upstreamResponse: Response + try { + upstreamResponse = await fetch(upstreamUrl, { + method: request.method, + headers, + body, + redirect: 'manual', + duplex: 'half', + } as RequestInit) + } catch { + return htmlResponse(getNotRunningHtml(port), 503) + } + + return new PreviewResponse(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: sanitizeUpstreamResponseHeaders(upstreamResponse.headers), + }) +} + +export function buildUpstreamWebSocketUrl(port: number, path: string, search: string): string { + return `ws://127.0.0.1:${port}${path}${search}` +} + +export function selectWebSocketProtocol(protocolHeader: string | null): string | null { + if (!protocolHeader) return null + return protocolHeader + .split(',') + .map(protocol => protocol.trim()) + .find(Boolean) ?? null +} + +function createPendingWebSocketData(upstreamUrl: string, protocol: string | null): PreviewWebSocketData { + return { + upstreamUrl, + protocol, + upstream: null, + pendingMessages: [], + } +} + +function sendToUpstream(data: PreviewWebSocketData, message: WebSocketMessage): void { + if (data.upstream?.readyState === WebSocket.OPEN) { + data.upstream.send(message) + return + } + data.pendingMessages.push(message) +} + +function flushPendingMessages(data: PreviewWebSocketData): void { + if (!data.upstream || data.upstream.readyState !== WebSocket.OPEN) return + for (const message of data.pendingMessages) { + data.upstream.send(message) + } + data.pendingMessages = [] +} + +function createUpstreamWebSocket(ws: Bun.ServerWebSocket): WebSocket { + const data = ws.data + const upstream = data.protocol + ? new WebSocket(data.upstreamUrl, data.protocol) + : new WebSocket(data.upstreamUrl) + + upstream.addEventListener('open', () => flushPendingMessages(data)) + upstream.addEventListener('message', event => ws.send(event.data)) + upstream.addEventListener('close', event => ws.close(event.code, event.reason)) + upstream.addEventListener('error', event => { + logger.warn('Dev preview upstream websocket error', event) + ws.close() + }) + return upstream +} + +function isPreviewWebSocketRequest(url: URL): boolean { + return url.searchParams.has('token') + || url.pathname.includes('webpack-hmr') + || url.pathname === '/ws' + || url.pathname.endsWith('/ws') + || url.pathname.includes('/sockjs-node') +} + +export function startPreviewServer(auth: AuthInstance, db: Database): Bun.Server { + return Bun.serve({ + port: ENV.DEV_PREVIEW.PORT, + hostname: ENV.SERVER.HOST, + async fetch(request, server) { + if (!(await hasValidSession(auth, request.headers))) { + return htmlResponse(getUnauthorizedHtml(), 401) + } + + const port = getDevServerPort(db) + const url = new URL(request.url) + if (isPreviewWebSocketRequest(url)) { + const protocol = selectWebSocketProtocol(request.headers.get('sec-websocket-protocol')) + const upgraded = server.upgrade(request, { + data: createPendingWebSocketData(buildUpstreamWebSocketUrl(port, url.pathname, url.search), protocol), + headers: protocol ? { 'Sec-WebSocket-Protocol': protocol } : undefined, + }) + if (upgraded) return undefined + } + + return handleAuthenticatedPreviewRequest(request, db, port) + }, + websocket: { + open(ws) { + ws.data.upstream = createUpstreamWebSocket(ws) + }, + message(ws, message) { + sendToUpstream(ws.data, message) + }, + close(ws) { + ws.data.upstream?.close() + }, + }, + }) +} diff --git a/backend/src/services/dev-server/proxy-utils.ts b/backend/src/services/dev-server/proxy-utils.ts new file mode 100644 index 000000000..efe76716e --- /dev/null +++ b/backend/src/services/dev-server/proxy-utils.ts @@ -0,0 +1,69 @@ +import { ENV } from '@opencode-manager/shared/config/env' + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'upgrade', + 'transfer-encoding', + 'content-length', + 'content-encoding', + 'host', +]) + +const FRAME_BLOCKING_HEADERS = new Set(['x-frame-options', 'content-security-policy']) + +export function buildUpstreamUrl(port: number, path: string, search: string): string { + return `http://127.0.0.1:${port}${path}${search}` +} + +export function filterProxyHeaders(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + result[key] = value + } + }) + return result +} + +export function sanitizeUpstreamResponseHeaders(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + const lower = key.toLowerCase() + if (!HOP_BY_HOP_HEADERS.has(lower) && !FRAME_BLOCKING_HEADERS.has(lower)) { + result[key] = value + } + }) + result['referrer-policy'] = 'no-referrer' + return result +} + +export function resolveDevPreviewUrl(requestHost: string | undefined, forwardedProto: string | undefined): string { + const publicUrl = ENV.DEV_PREVIEW.PUBLIC_URL.trim() + if (publicUrl) { + return publicUrl.endsWith('/') ? publicUrl : `${publicUrl}/` + } + + const hostname = stripPort(requestHost) || 'localhost' + const protocol = normalizeProtocol(forwardedProto) + return `${protocol}://${hostname}:${ENV.DEV_PREVIEW.PORT}/` +} + +function stripPort(host: string | undefined): string { + if (!host) return '' + const trimmed = host.trim() + if (trimmed.startsWith('[')) { + const closing = trimmed.indexOf(']') + return closing === -1 ? trimmed : trimmed.slice(0, closing + 1) + } + return trimmed.replace(/:\d+$/, '') +} + +function normalizeProtocol(forwardedProto: string | undefined): string { + const proto = forwardedProto?.split(',')[0]?.trim().toLowerCase() + return proto === 'https' ? 'https' : 'http' +} diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts index 4dfc77f12..5c4b7a4fe 100644 --- a/backend/src/services/files.ts +++ b/backend/src/services/files.ts @@ -61,6 +61,33 @@ export async function getRawFileContent(userPath: string): Promise { } } +export interface FilePreviewStat { + isDirectory: boolean + name: string + size: number + mimeType: string + lastModified: Date +} + +export async function getFilePreviewStat(userPath: string): Promise { + const validatedPath = validatePath(userPath) + + const exists = await fileExists(validatedPath) + if (!exists) { + throw { message: 'File not found or cannot be read', statusCode: 404 } + } + + const stats = await getFileStats(validatedPath) + + return { + isDirectory: stats.isDirectory, + name: path.basename(validatedPath), + size: stats.size, + mimeType: getMimeType(validatedPath), + lastModified: stats.lastModified, + } +} + export async function getFile(userPath: string): Promise { const validatedPath = validatePath(userPath) logger.info(`Getting file for path: ${userPath} -> ${validatedPath}`) @@ -253,6 +280,7 @@ function getMimeType(filePath: string): AllowedMimeType { const mimeTypes: Record = { '.txt': 'text/plain', '.html': 'text/html', + '.htm': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.ts': 'text/typescript', diff --git a/backend/src/services/opencode-gh-env-plugin.ts b/backend/src/services/opencode-gh-env-plugin.ts index 81e908d0f..7911156e6 100644 --- a/backend/src/services/opencode-gh-env-plugin.ts +++ b/backend/src/services/opencode-gh-env-plugin.ts @@ -16,7 +16,7 @@ async function fetchGhEnv(cwd) { const cached = cache.get(cacheKey) if (cached && now < cached.expiry) return cached.env try { - const url = new URL(baseUrl + '/git-credentials/gh-env') + const url = new URL(baseUrl + '/shell-env') if (cwd) url.searchParams.set('cwd', cwd) const res = await fetch(url, { headers: { Authorization: 'Bearer ' + token }, diff --git a/backend/src/text-modules.d.ts b/backend/src/text-modules.d.ts new file mode 100644 index 000000000..43d00fea7 --- /dev/null +++ b/backend/src/text-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const content: string + export default content +} diff --git a/backend/test/routes/dev-server.test.ts b/backend/test/routes/dev-server.test.ts new file mode 100644 index 000000000..64d506f23 --- /dev/null +++ b/backend/test/routes/dev-server.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../../src/services/dev-server/manager', () => ({ + getDevServerState: vi.fn(), + getDevServerPort: vi.fn(() => 5100), +})) + +vi.mock('../../src/db/queries', () => ({ + getRepoById: vi.fn(), +})) + +import { getDevServerState } from '../../src/services/dev-server/manager' +import { getRepoById } from '../../src/db/queries' +import { createDevServerRoutes } from '../../src/routes/dev-server' + +describe('DevServer Management Routes', () => { + let devServerApp: ReturnType + let mockDb: any + + beforeEach(() => { + vi.clearAllMocks() + mockDb = {} as any + devServerApp = createDevServerRoutes(mockDb) + }) + + describe('GET /:repoId/status', () => { + it('returns 404 when repo is not found', async () => { + vi.mocked(getRepoById).mockReturnValue(null) + + const res = await devServerApp.fetch(new Request('http://localhost/999/status')) + expect(res.status).toBe(404) + const body = await res.json() as Record + expect(body.error).toBe('Repository not found') + }) + + it('returns 400 for invalid repoId', async () => { + const res = await devServerApp.fetch(new Request('http://localhost/abc/status')) + expect(res.status).toBe(400) + }) + + it('returns status with an absolute preview url derived from the request host', async () => { + const mockRepo = { id: 1, fullPath: '/test/repo', repoUrl: null, localPath: '/test/repo' } + vi.mocked(getRepoById).mockReturnValue(mockRepo as any) + vi.mocked(getDevServerState).mockImplementation(async (_db, repoId, previewUrl) => ({ + repoId, + status: 'running' as const, + port: 5100, + error: null, + previewUrl, + })) + + const res = await devServerApp.fetch(new Request('http://manager.example:5003/1/status', { + headers: { host: 'manager.example:5003' }, + })) + expect(res.status).toBe(200) + const body = await res.json() as Record + expect(body.status).toBe('running') + expect(body.port).toBe(5100) + expect(body.previewUrl).toBe('http://manager.example:3056/') + expect(getDevServerState).toHaveBeenCalledWith(mockDb, 1, 'http://manager.example:3056/') + }) + }) +}) diff --git a/backend/test/routes/files.test.ts b/backend/test/routes/files.test.ts index 23aaa5adb..ceddc700d 100644 --- a/backend/test/routes/files.test.ts +++ b/backend/test/routes/files.test.ts @@ -30,6 +30,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ vi.mock('../../src/services/files', () => ({ getFile: vi.fn(), + getFilePreviewStat: vi.fn(), getRawFileContent: vi.fn(), getFileRange: vi.fn(), uploadFile: vi.fn(), @@ -47,6 +48,8 @@ vi.mock('../../src/services/archive', () => ({ })) const getFile = fileService.getFile as MockedFunction +const getFilePreviewStat = fileService.getFilePreviewStat as MockedFunction +const getRawFileContent = fileService.getRawFileContent as MockedFunction const getFileRange = fileService.getFileRange as MockedFunction const uploadFile = fileService.uploadFile as MockedFunction const createFileOrFolder = fileService.createFileOrFolder as MockedFunction @@ -69,6 +72,139 @@ describe('File Routes', () => { app.route('/api/files', filesApp) }) + describe('GET /preview/* - HTML Preview Assets', () => { + const lastModified = new Date('2024-01-01T00:00:00.000Z') + + const htmlStat = { + name: 'index.html', + isDirectory: false, + size: 31, + mimeType: 'text/html' as const, + lastModified, + } + + it('should serve HTML files with sandbox CSP and caching headers', async () => { + getFilePreviewStat.mockResolvedValue(htmlStat) + getRawFileContent.mockResolvedValue(Buffer.from('Hello')) + + const response = await app.request('/api/files/preview/test-repo/index.html') + const body = await response.text() + + expect(response.status).toBe(200) + expect(body).toBe('Hello') + expect(response.headers.get('Content-Type')).toContain('text/html') + expect(response.headers.get('Content-Disposition')).toContain('inline') + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff') + expect(response.headers.get('Referrer-Policy')).toBe('no-referrer') + expect(response.headers.get('Content-Security-Policy')).toContain('sandbox allow-scripts allow-same-origin') + expect(response.headers.get('Content-Security-Policy')).toContain('https:') + expect(response.headers.get('Cache-Control')).toBe('private, must-revalidate, max-age=0') + expect(response.headers.get('ETag')).toBeTruthy() + expect(response.headers.get('Last-Modified')).toBe(lastModified.toUTCString()) + expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/index.html') + expect(getRawFileContent).toHaveBeenCalledWith('test-repo/index.html') + }) + + it('should return 304 when ETag matches If-None-Match without reading the file', async () => { + getFilePreviewStat.mockResolvedValue(htmlStat) + getRawFileContent.mockResolvedValue(Buffer.from('Hello')) + + const first = await app.request('/api/files/preview/test-repo/index.html') + const etag = first.headers.get('ETag') as string + await first.text() + getRawFileContent.mockClear() + + const response = await app.request('/api/files/preview/test-repo/index.html', { + headers: { 'If-None-Match': etag }, + }) + + expect(response.status).toBe(304) + expect(getRawFileContent).not.toHaveBeenCalled() + }) + + it('should serve CSS files with text/css Content-Type', async () => { + getFilePreviewStat.mockResolvedValue({ + name: 'styles.css', + isDirectory: false, + size: 50, + mimeType: 'text/css', + lastModified, + }) + getRawFileContent.mockResolvedValue(Buffer.from('body { color: red }')) + + const response = await app.request('/api/files/preview/test-repo/styles/app.css') + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain('text/css') + expect(response.headers.get('Content-Security-Policy')).toBeNull() + expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/styles/app.css') + }) + + it('should support query-based path parameter', async () => { + getFilePreviewStat.mockResolvedValue(htmlStat) + getRawFileContent.mockResolvedValue(Buffer.from('Hello')) + + const response = await app.request('/api/files/preview?path=test-repo/index.html') + const body = await response.text() + + expect(response.status).toBe(200) + expect(body).toBe('Hello') + expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/index.html') + }) + + it('should return 400 for directory results', async () => { + getFilePreviewStat.mockResolvedValue({ + name: 'test-repo', + isDirectory: true, + size: 0, + mimeType: 'text/plain', + lastModified, + }) + + const response = await app.request('/api/files/preview/test-repo') + + expect(response.status).toBe(400) + const body = await response.json() as { error: string } + expect(body.error).toContain('Cannot preview directories') + }) + + it('should return 415 for non-previewable MIME types', async () => { + getFilePreviewStat.mockResolvedValue({ + name: 'doc.pdf', + isDirectory: false, + size: 100, + mimeType: 'application/pdf' as never, + lastModified, + }) + + const response = await app.request('/api/files/preview/test-repo/doc.pdf') + + expect(response.status).toBe(415) + const body = await response.json() as { error: string } + expect(body.error).toContain('File type cannot be previewed') + expect(getRawFileContent).not.toHaveBeenCalled() + }) + + it('should return 400 when no path is provided', async () => { + const response = await app.request('/api/files/preview') + + expect(response.status).toBe(400) + const body = await response.json() as { error: string } + expect(body.error).toContain('No path provided') + }) + + it('should return 403 for path traversal attempts', async () => { + const error = { message: 'Path traversal detected', statusCode: 403 } + getFilePreviewStat.mockRejectedValue(error) + + const response = await app.request('/api/files/preview/test-repo/../outside') + + expect(response.status).toBe(403) + const body = await response.json() as { error: string } + expect(body.error).toContain('Path traversal detected') + }) + }) + describe('GET /*/download-zip - Route Order Regression Test', () => { it('should route to archive service for directory download, not file service', async () => { const mockStream = { @@ -175,6 +311,17 @@ describe('File Routes', () => { expect(response.status).toBe(404) expect(body).toHaveProperty('error') }) + + it('should route paths starting with "preview" but not matching /preview/ to generic handler', async () => { + getFile.mockResolvedValue(mockFileInfo) + + const response = await app.request('/api/files/previewer') + const body = await response.json() as FileInfo + + expect(response.status).toBe(200) + expect(body).toHaveProperty('isDirectory', false) + expect(getFile).toHaveBeenCalledWith('previewer') + }) }) describe('GET /* with ?startLine&endLine - File Range', () => { diff --git a/backend/test/routes/internal/git-credentials.test.ts b/backend/test/routes/internal/git-credentials.test.ts deleted file mode 100644 index 4ae326be5..000000000 --- a/backend/test/routes/internal/git-credentials.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { Database } from 'bun:sqlite' -import { migrate } from '../../../src/db/migration-runner' -import { allMigrations } from '../../../src/db/migrations' -import { SettingsService } from '../../../src/services/settings' -import { createInternalGitCredentialsRoutes } from '../../../src/routes/internal/git-credentials' -import type { GitCredential } from '@opencode-manager/shared' - -describe('internal git-credentials routes', () => { - let db: Database - let settingsService: SettingsService - let app: ReturnType - - beforeEach(() => { - db = new Database(':memory:') - migrate(db, allMigrations) - settingsService = new SettingsService(db) - app = createInternalGitCredentialsRoutes(db) - }) - - it('GET /gh-env returns GH_TOKEN and GITHUB_TOKEN for a GitHub PAT', async () => { - settingsService.updateSettings({ - gitCredentials: [ - { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_test_token' } as GitCredential, - ], - }) - - const res = await app.request('/gh-env') - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ GH_TOKEN: 'ghp_test_token', GITHUB_TOKEN: 'ghp_test_token' }) - }) - - it('GET /gh-env returns an empty object when no GitHub credential exists', async () => { - const res = await app.request('/gh-env') - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({}) - }) -}) diff --git a/backend/test/routes/internal/shell-env.test.ts b/backend/test/routes/internal/shell-env.test.ts new file mode 100644 index 000000000..961e0d3d9 --- /dev/null +++ b/backend/test/routes/internal/shell-env.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const getGhCliEnvMock = vi.hoisted(() => vi.fn()) +const getDevServerPortMock = vi.hoisted(() => vi.fn()) + +vi.mock('../../../src/services/credential-provider', () => ({ + CredentialProvider: vi.fn().mockImplementation(() => ({ getGhCliEnv: getGhCliEnvMock })), +})) + +vi.mock('../../../src/services/dev-server/manager', () => ({ + getDevServerPort: getDevServerPortMock, +})) + +import { createInternalShellEnvRoutes } from '../../../src/routes/internal/shell-env' + +describe('internal shell-env routes', () => { + const mockDb = {} as never + + beforeEach(() => { + vi.clearAllMocks() + getGhCliEnvMock.mockReturnValue({ GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' }) + getDevServerPortMock.mockReturnValue(4321) + }) + + it('GET / merges gh env with the dev server port and forwards cwd', async () => { + const app = createInternalShellEnvRoutes(mockDb) + + const res = await app.request('/?cwd=%2Frepo') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + GH_TOKEN: 'ghp', + GITHUB_TOKEN: 'ghp', + OCM_DEV_SERVER_PORT: '4321', + }) + expect(getGhCliEnvMock).toHaveBeenCalledWith({ cwd: '/repo' }) + }) + + it('GET / returns only the dev server port when no GitHub credential exists', async () => { + getGhCliEnvMock.mockReturnValue({}) + const app = createInternalShellEnvRoutes(mockDb) + + const res = await app.request('/') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ OCM_DEV_SERVER_PORT: '4321' }) + }) +}) diff --git a/backend/test/services/dev-server/preview-server.test.ts b/backend/test/services/dev-server/preview-server.test.ts new file mode 100644 index 000000000..540f20b31 --- /dev/null +++ b/backend/test/services/dev-server/preview-server.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { + buildUpstreamWebSocketUrl, + selectWebSocketProtocol, +} from '../../../src/services/dev-server/preview-server' + +describe('buildUpstreamWebSocketUrl', () => { + it('targets the loopback dev server with path and search', () => { + expect(buildUpstreamWebSocketUrl(3055, '/_next/webpack-hmr', '?token=abc')).toBe( + 'ws://127.0.0.1:3055/_next/webpack-hmr?token=abc' + ) + }) +}) + +describe('selectWebSocketProtocol', () => { + it('selects the first offered protocol', () => { + expect(selectWebSocketProtocol('vite-hmr, other')).toBe('vite-hmr') + }) + + it('returns null when no protocol is offered', () => { + expect(selectWebSocketProtocol(null)).toBeNull() + }) +}) diff --git a/backend/test/services/dev-server/proxy-utils.test.ts b/backend/test/services/dev-server/proxy-utils.test.ts new file mode 100644 index 000000000..ebfcfccdd --- /dev/null +++ b/backend/test/services/dev-server/proxy-utils.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { + buildUpstreamUrl, + filterProxyHeaders, + sanitizeUpstreamResponseHeaders, + resolveDevPreviewUrl, +} from '../../../src/services/dev-server/proxy-utils' + +describe('buildUpstreamUrl', () => { + it('targets the loopback dev server with path and search', () => { + expect(buildUpstreamUrl(3055, '/_next/static/chunk.js', '?v=1')).toBe( + 'http://127.0.0.1:3055/_next/static/chunk.js?v=1' + ) + }) +}) + +describe('filterProxyHeaders', () => { + it('drops hop-by-hop and host headers', () => { + const headers = new Headers({ + host: 'manager.example', + connection: 'keep-alive', + 'content-type': 'text/html', + accept: '*/*', + }) + + const result = filterProxyHeaders(headers) + + expect(result.host).toBeUndefined() + expect(result.connection).toBeUndefined() + expect(result['content-type']).toBe('text/html') + expect(result.accept).toBe('*/*') + }) +}) + +describe('sanitizeUpstreamResponseHeaders', () => { + it('strips framing/security headers and forces no-referrer', () => { + const headers = new Headers({ + 'content-type': 'text/html', + 'x-frame-options': 'DENY', + 'content-security-policy': "frame-ancestors 'none'", + }) + + const result = sanitizeUpstreamResponseHeaders(headers) + + expect(result['content-type']).toBe('text/html') + expect(result['x-frame-options']).toBeUndefined() + expect(result['content-security-policy']).toBeUndefined() + expect(result['referrer-policy']).toBe('no-referrer') + }) +}) + +describe('resolveDevPreviewUrl', () => { + it('derives the preview origin from the request host and preview port', () => { + expect(resolveDevPreviewUrl('manager.example:5003', undefined)).toBe('http://manager.example:3056/') + }) + + it('honours the forwarded protocol', () => { + expect(resolveDevPreviewUrl('manager.example:5003', 'https')).toBe('https://manager.example:3056/') + }) + + it('handles bracketed IPv6 hosts', () => { + expect(resolveDevPreviewUrl('[::1]:5003', undefined)).toBe('http://[::1]:3056/') + }) + + it('falls back to localhost when no host is provided', () => { + expect(resolveDevPreviewUrl(undefined, undefined)).toBe('http://localhost:3056/') + }) +}) diff --git a/backend/test/services/opencode-gh-env-plugin.test.ts b/backend/test/services/opencode-gh-env-plugin.test.ts index 4ac101ee8..c3b40fb80 100644 --- a/backend/test/services/opencode-gh-env-plugin.test.ts +++ b/backend/test/services/opencode-gh-env-plugin.test.ts @@ -53,7 +53,7 @@ describe('ocm-gh-env plugin', () => { expect(output.env).toEqual({ GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' }) const [url, options] = fetchMock.mock.calls[0]! - expect(url.toString()).toBe('http://localhost:5003/api/internal/git-credentials/gh-env?cwd=%2Frepo') + expect(url.toString()).toBe('http://localhost:5003/api/internal/shell-env?cwd=%2Frepo') expect(options).toEqual({ headers: { Authorization: 'Bearer secret-token' } }) }) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index da53ef821..cfdd9d479 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -2,6 +2,18 @@ import { defineConfig } from 'vitest/config' import path from 'path' export default defineConfig({ + plugins: [ + { + name: 'markdown-as-text', + enforce: 'pre', + transform(code: string, id: string) { + if (id.endsWith('.md')) { + return { code: `export default ${JSON.stringify(code)};`, map: null } + } + return null + }, + }, + ], test: { globals: true, environment: 'node', diff --git a/docker-compose.yml b/docker-compose.yml index 00a023348..87614e015 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,16 +8,16 @@ services: container_name: opencode-manager ports: - "5003:5003" - - "5100:5100" - - "5101:5101" - - "5102:5102" - - "5103:5103" + - "3055:3055" + - "3056:3056" environment: - NODE_ENV=${NODE_ENV:-production} - HOST=0.0.0.0 - PORT=5003 - OPENCODE_SERVER_PORT=5551 - OPENCODE_HOST=127.0.0.1 + - DEV_PREVIEW_PORT=3056 + - DEV_PREVIEW_PUBLIC_URL=${DEV_PREVIEW_PUBLIC_URL:-} - DATABASE_PATH=/app/data/opencode.db - WORKSPACE_PATH=/workspace - PROCESS_START_WAIT_MS=2000 diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index 3c8bcf3fb..e8f68c341 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -38,16 +38,16 @@ services: container_name: opencode-manager ports: - "5003:5003" - - "5100:5100" - - "5101:5101" - - "5102:5102" - - "5103:5103" + - "3055:3055" + - "3056:3056" environment: - NODE_ENV=${NODE_ENV:-production} - HOST=0.0.0.0 - PORT=5003 - OPENCODE_SERVER_PORT=5551 - OPENCODE_HOST=127.0.0.1 + - DEV_PREVIEW_PORT=3056 + - DEV_PREVIEW_PUBLIC_URL=${DEV_PREVIEW_PUBLIC_URL:-} - DATABASE_PATH=/app/data/opencode.db - WORKSPACE_PATH=/workspace - PROCESS_START_WAIT_MS=2000 @@ -146,19 +146,22 @@ ports: - "8080:5003" # Access at localhost:8080 ``` -### Dev Server Ports +### Dev Server Port & Preview Proxy -Ports 5100-5103 are exposed for running dev servers inside repositories: +A repository's dev server listens on `$OCM_DEV_SERVER_PORT` inside the container. It defaults to `3055`, is configurable in **Settings** (`devServerPort`), and is passed to agents as the `$OCM_DEV_SERVER_PORT` environment variable. + +The in-app preview is served by a dedicated **authenticated preview listener** on a separate origin (`DEV_PREVIEW_PORT`, default `3056`). It validates your OpenCode Manager session, then proxies every request and HMR WebSocket to the dev server at the origin root with no path rewriting. Serving at root is what lets framework HMR work transparently across Vite, Next.js, Remix, SvelteKit, etc. + +Because the preview is a separate origin loaded directly by the browser, `3056` must be published to the host: ```yaml ports: - - "5100:5100" - - "5101:5101" - - "5102:5102" - - "5103:5103" + - "3056:3056" ``` -Configure your dev server to use one of these ports: +The session cookie is shared with the preview origin because it is the same host on a different port (same site). Behind a reverse proxy that cannot expose `3056` directly, set `DEV_PREVIEW_PUBLIC_URL` to the externally reachable preview origin (e.g. `https://preview.example.com`). + +Run your dev server on `$OCM_DEV_SERVER_PORT` and bind to `0.0.0.0`: === "Vite" @@ -166,7 +169,7 @@ Configure your dev server to use one of these ports: // vite.config.ts export default { server: { - port: 5100, + port: Number(process.env.OCM_DEV_SERVER_PORT) || 3055, host: '0.0.0.0' } } @@ -175,13 +178,13 @@ Configure your dev server to use one of these ports: === "Next.js" ```bash - next dev -p 5100 -H 0.0.0.0 + next dev -p $OCM_DEV_SERVER_PORT -H 0.0.0.0 ``` === "Express" ```javascript - app.listen(5100, '0.0.0.0') + app.listen(process.env.OCM_DEV_SERVER_PORT || 3055, '0.0.0.0') ``` ## Volume Mounts @@ -372,7 +375,7 @@ The container creates a default `AGENTS.md` file at `/workspace/.config/opencode Instructions for AI agents working in the container: - Reserved ports information -- Available dev server ports +- Dev server port (`$OCM_DEV_SERVER_PORT`, default 3055) - Docker-specific guidelines ### Editing diff --git a/frontend/src/api/devServer.ts b/frontend/src/api/devServer.ts new file mode 100644 index 000000000..60ddea1fc --- /dev/null +++ b/frontend/src/api/devServer.ts @@ -0,0 +1,7 @@ +import { fetchWrapper } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { DevServerState } from '@opencode-manager/shared/types' + +export async function getDevServerStatus(repoId: number): Promise { + return fetchWrapper(`${API_BASE_URL}/api/dev-server/${repoId}/status`) +} diff --git a/frontend/src/api/files.test.ts b/frontend/src/api/files.test.ts new file mode 100644 index 000000000..13373d12a --- /dev/null +++ b/frontend/src/api/files.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { getFileApiUrl } from './files' +import { encodePathForRoute, getFilePreviewUrl } from './files' + +describe('getFilePreviewUrl', () => { + it('constructs preview URL with encoded path segments', () => { + const url = getFilePreviewUrl('repo/dir/index.html') + expect(url).toBe('/api/files/preview/repo/dir/index.html') + }) + + it('encodes spaces per segment', () => { + const url = getFilePreviewUrl('my repo/file name.html') + expect(url).toBe('/api/files/preview/my%20repo/file%20name.html') + }) + + it('encodes hash characters per segment', () => { + const url = getFilePreviewUrl('repo/file#1.html') + expect(url).toBe('/api/files/preview/repo/file%231.html') + }) +}) + +describe('encodePathForRoute', () => { + it('encodes each path segment separately', () => { + const result = encodePathForRoute('repo/dir/index.html') + expect(result).toBe('repo/dir/index.html') + }) + + it('encodes special characters per segment', () => { + const result = encodePathForRoute('my dir/file name.html') + expect(result).toBe('my%20dir/file%20name.html') + }) +}) + +describe('getFileApiUrl - existing behavior unchanged', () => { + it('still returns a URL with path query param', () => { + const url = getFileApiUrl('repo/dir/index.html') + expect(url).toContain('/api/files') + expect(url).toContain('path=repo%2Fdir%2Findex.html') + }) +}) diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 7337d9f31..df4a12cd8 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -77,3 +77,12 @@ export async function applyFilePatches(path: string, patches: PatchOperation[]): body: JSON.stringify({ patches }), }) } + +export function encodePathForRoute(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +export function getFilePreviewUrl(path: string): string { + const encodedPath = encodePathForRoute(path) + return `${API_BASE_URL}/api/files/preview/${encodedPath}` +} diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index cabc73b2b..8439f09b9 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -71,6 +71,7 @@ export interface UserPreferences { repoSortMode?: 'recent' | 'manual' | 'name' serverEnvVars?: Array<{ key: string; value: string }> disabledDefaultServerEnvVars?: string[] + devServerPort?: number } export interface SettingsResponse { diff --git a/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx b/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx new file mode 100644 index 000000000..732bc98e5 --- /dev/null +++ b/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx @@ -0,0 +1,55 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { DevServerPreviewButton } from './DevServerPreviewButton' +import { useMutation } from '@tanstack/react-query' + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn(), +})) + +vi.mock('@/api/devServer', () => ({ + getDevServerStatus: vi.fn(), +})) + +describe('DevServerPreviewButton', () => { + it('calls onOpen with devserver input when configured port is running', () => { + const onOpen = vi.fn() + + type RunningState = { status: 'running'; port: number; previewUrl: string } + let capturedOnSuccess: ((state: RunningState) => void) | undefined + vi.mocked(useMutation).mockImplementation(((options: { onSuccess?: (state: RunningState) => void }) => { + capturedOnSuccess = options.onSuccess + return { + mutate: () => capturedOnSuccess?.({ status: 'running', port: 5100, previewUrl: 'http://manager.example:3056/' }), + isPending: false, + } as unknown as ReturnType + }) as typeof useMutation) + + render() + + const button = screen.getByLabelText('Open app preview') + expect(button).toBeInTheDocument() + + fireEvent.click(button) + + expect(onOpen).toHaveBeenCalledTimes(1) + expect(onOpen).toHaveBeenCalledWith({ + source: 'devserver', + previewUrl: 'http://manager.example:3056/', + title: 'App preview', + }) + }) + + it('disables button while pending', () => { + const onOpen = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: vi.fn(), + isPending: true, + } as unknown as ReturnType) + + render() + + const button = screen.getByLabelText('Open app preview') + expect(button).toBeDisabled() + }) +}) diff --git a/frontend/src/components/dev-server/DevServerPreviewButton.tsx b/frontend/src/components/dev-server/DevServerPreviewButton.tsx new file mode 100644 index 000000000..b13b2fd65 --- /dev/null +++ b/frontend/src/components/dev-server/DevServerPreviewButton.tsx @@ -0,0 +1,44 @@ +import { useMutation } from '@tanstack/react-query' +import { getDevServerStatus } from '@/api/devServer' +import { Button } from '@/components/ui/button' +import { showToast } from '@/lib/toast' +import { Loader2, Play } from 'lucide-react' +import type { OpenHtmlArtifactInput } from '@/lib/htmlArtifacts' + +interface DevServerPreviewButtonProps { + repoId: number + onOpen: (input: OpenHtmlArtifactInput) => void +} + +export function DevServerPreviewButton({ repoId, onOpen }: DevServerPreviewButtonProps) { + const { mutate, isPending } = useMutation({ + mutationFn: () => getDevServerStatus(repoId), + onSuccess: (state) => { + if (state.status !== 'running' || !state.previewUrl) { + showToast.error(`No app detected on localhost:${state.port}`) + return + } + + onOpen({ + source: 'devserver', + previewUrl: state.previewUrl, + title: 'App preview', + }) + }, + onError: () => { + showToast.error('Failed to check preview port') + }, + }) + + return ( + + ) +} diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index c6df1a35d..59d1db7cd 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -12,6 +12,9 @@ import { FolderOpen, Upload, RefreshCw, X } from 'lucide-react' import type { FileInfo } from '@/types/files' import { useMobile } from '@/hooks/useMobile' import { getFileApiUrl, useFile } from '@/api/files' +import { HtmlArtifactPanel } from '@/components/html-preview/HtmlArtifactPanel' +import type { HtmlArtifact } from '@/lib/htmlArtifacts' +import { createHtmlArtifact, isHtmlPath } from '@/lib/htmlArtifacts' export interface FileBrowserHandle { goBack: () => void @@ -51,6 +54,10 @@ const encodeBase64 = (content: string) => { return btoa(binary) } +const isHtmlFileInfo = (file: FileInfo): boolean => ( + isHtmlPath(file.path) || isHtmlPath(file.name) || file.mimeType === 'text/html' +) + async function readFileEntry(entry: FileSystemFileEntry): Promise { return new Promise((resolve, reject) => { entry.file(resolve, reject) @@ -135,6 +142,7 @@ export const FileBrowser = forwardRef(funct const [currentPath, setCurrentPath] = useState(basePath) const [files, setFiles] = useState(null) const [selectedFile, setSelectedFile] = useState(null) + const [htmlArtifact, setHtmlArtifact] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -146,17 +154,27 @@ export const FileBrowser = forwardRef(funct const uploadCancelledRef = useRef(false) const isMobile = useMobile() + const openMobilePreview = useCallback((file: FileInfo) => { + if (isHtmlFileInfo(file)) { + setHtmlArtifact(createHtmlArtifact({ source: 'file', path: file.path, title: file.name })) + setIsPreviewModalOpen(false) + } else { + setHtmlArtifact(null) + setIsPreviewModalOpen(true) + } + onPreviewStateChange?.(true) + }, [onPreviewStateChange]) + const { data: initialFileData, error: initialFileError } = useFile(initialSelectedFile) useEffect(() => { if (initialFileData) { setSelectedFile(initialFileData) if (isMobile) { - setIsPreviewModalOpen(true) - onPreviewStateChange?.(true) + openMobilePreview(initialFileData) } } -}, [initialFileData, isMobile, onPreviewStateChange]) +}, [initialFileData, isMobile, openMobilePreview]) useEffect(() => { if (initialFileError) { @@ -262,8 +280,7 @@ useEffect(() => { // On mobile, open preview in modal if (isMobile) { - setIsPreviewModalOpen(true) - onPreviewStateChange?.(true) + openMobilePreview(fullFileData) } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load file') @@ -271,10 +288,11 @@ useEffect(() => { } finally { setLoading(false) } - }, [onFileSelect, isMobile, onPreviewStateChange]) + }, [onFileSelect, isMobile, openMobilePreview]) - const handleCloseModal = useCallback(() => { + const handleClosePreview = useCallback(() => { setIsPreviewModalOpen(false) + setHtmlArtifact(null) setSelectedFile(null) onPreviewStateChange?.(false) }, [onPreviewStateChange]) @@ -476,13 +494,13 @@ useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { - handleCloseModal() + handleClosePreview() } } document.addEventListener('keydown', handleEscape) return () => document.removeEventListener('keydown', handleEscape) - }, [isPreviewModalOpen, handleCloseModal]) + }, [isPreviewModalOpen, handleClosePreview]) const showNavigateUp = allowNavigateAboveBase && normalizePath(currentPath) !== '..' ? true @@ -555,6 +573,15 @@ useEffect(() => { ) + const htmlArtifactPanel = isMobile && htmlArtifact && ( + + ) + if (embedded) { return (
{ {/* Mobile: File Preview Modal */} + + {htmlArtifactPanel}
) } @@ -743,9 +772,11 @@ useEffect(() => { {/* Mobile: File Preview Modal */} + + {htmlArtifactPanel} {uploadDialog} diff --git a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx index af322b0a8..421c97a62 100644 --- a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx +++ b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx @@ -7,6 +7,15 @@ import { FileBrowser } from './FileBrowser' import type { FileBrowserHandle } from './FileBrowser' import * as useMobile from '../../hooks/useMobile' +vi.mock('@/components/html-preview/HtmlArtifactPanel', () => ({ + HtmlArtifactPanel: ({ artifact, onClose }: { artifact: { path?: string } | null; onClose: () => void }) => ( +
+ {artifact?.path} + +
+ ), +})) + function createQueryClient() { return new QueryClient({ defaultOptions: { @@ -87,6 +96,60 @@ describe('FileBrowserSheet', () => { }) describe('FileBrowser navigation', () => { + it('opens mobile HTML files with the shared HTML artifact panel', async () => { + const mobileSpy = vi.spyOn(useMobile, 'useMobile').mockReturnValue(true) + const originalFetch = globalThis.fetch + const htmlFile = { + name: 'dashboard.html', + path: 'test-repo/dashboard.html', + isDirectory: false, + size: 24, + mimeType: 'text/html', + content: btoa('

Dashboard

'), + lastModified: new Date(), + } + + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost') + const path = url.searchParams.get('path') + + if (path === htmlFile.path) { + return { + ok: true, + json: async () => htmlFile, + } as Response + } + + return { + ok: true, + json: async () => ({ + name: 'test-repo', + path: 'test-repo', + isDirectory: true, + children: [htmlFile], + }), + } as Response + }) + + try { + render( + , + { wrapper: createWrapper() } + ) + + fireEvent.click(await screen.findByText('dashboard.html')) + + expect(await screen.findByTestId('html-artifact-panel')).toHaveTextContent('test-repo/dashboard.html') + expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument() + } finally { + globalThis.fetch = originalFetch + mobileSpy.mockRestore() + } + }) + it('exposes imperative handle with goBack, canGoBack, and getCurrentPath', () => { const ref = { current: null as unknown as FileBrowserHandle } @@ -1091,5 +1154,3 @@ describe('FileBrowserSheet swipe decision integration', () => { mockUseSwipeBack.mockRestore() }) }) - - diff --git a/frontend/src/components/file-browser/FilePreview.test.tsx b/frontend/src/components/file-browser/FilePreview.test.tsx new file mode 100644 index 000000000..99918745a --- /dev/null +++ b/frontend/src/components/file-browser/FilePreview.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import { FilePreview } from './FilePreview' +import type { FileInfo } from '@/types/files' + +vi.mock('@/api/files', () => ({ + getFileApiUrl: (path: string) => `/api/files?path=${encodeURIComponent(path)}`, + getFilePreviewUrl: (path: string) => { + const encodedPath = path.split('/').map(encodeURIComponent).join('/') + return `/api/files/preview/${encodedPath}` + }, +})) + +vi.mock('@/components/ui/virtualized-text-view', () => ({ + VirtualizedTextView: vi.fn(() =>
), +})) + +function htmlFile(content: string, overrides?: Partial): FileInfo { + return { + name: 'dashboard.html', + path: 'test-repo/dashboard.html', + isDirectory: false, + size: content.length, + mimeType: 'text/html', + content: btoa(content), + lastModified: new Date(), + ...overrides, + } +} + +function textFile(name: string, content: string, mimeType: string): FileInfo { + return { + name, + path: `test-repo/${name}`, + isDirectory: false, + size: content.length, + mimeType, + content: btoa(content), + lastModified: new Date(), + } +} + +describe('FilePreview - HTML preview', () => { + it('renders HTML preview iframe by default for .html file', () => { + render(Dashboard')} />) + + const iframe = screen.getByTitle('HTML preview: dashboard.html') + expect(iframe).toBeInTheDocument() + expect(iframe).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin') + expect(iframe.getAttribute('src')).toContain('/api/files/preview/test-repo/dashboard.html') + }) + + it('toggles to source view when code button is clicked', () => { + render(Dashboard')} />) + + const toggleButton = screen.getByTitle('Show HTML source') + expect(toggleButton).toBeInTheDocument() + + fireEvent.click(toggleButton) + + expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument() + expect(screen.getByText('

Dashboard

')).toBeInTheDocument() + }) + + it('toggles back to preview when eye button is clicked from source view', () => { + render(Dashboard')} />) + + const showSourceButton = screen.getByTitle('Show HTML source') + fireEvent.click(showSourceButton) + expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument() + + const showPreviewButton = screen.getByTitle('Preview rendered HTML') + expect(showPreviewButton).toBeInTheDocument() + + fireEvent.click(showPreviewButton) + + const iframe = screen.getByTitle('HTML preview: dashboard.html') + expect(iframe).toBeInTheDocument() + }) + + it('renders HTML preview for .htm file', () => { + render(HTM test

', { name: 'test.htm', path: 'test-repo/test.htm', mimeType: 'text/html' })} />) + + const iframe = screen.getByTitle('HTML preview: test.htm') + expect(iframe).toBeInTheDocument() + expect(iframe.getAttribute('src')).toContain('/api/files/preview/test-repo/test.htm') + }) + + it('does not show HTML preview toggle for non-HTML text file', () => { + render() + + expect(screen.queryByTitle('Show HTML source')).not.toBeInTheDocument() + expect(screen.queryByTitle('Preview rendered HTML')).not.toBeInTheDocument() + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('preserves markdown preview behavior for .md files', () => { + render() + + const toggleButton = screen.getByTitle('Show raw markdown') + expect(toggleButton).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index 9f8dfcb33..155da0d4c 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -2,9 +2,11 @@ import { useState, useCallback, useRef, useEffect, memo } from 'react' import { Button } from '@/components/ui/button' import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react' import type { FileInfo } from '@/types/files' -import { getFileApiUrl } from '@/api/files' +import { getFileApiUrl, getFilePreviewUrl } from '@/api/files' import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view' import { MarkdownRenderer } from './MarkdownRenderer' +import { HtmlPreviewFrame } from '@/components/html-preview/HtmlPreviewFrame' +import { isHtmlPath } from '@/lib/htmlArtifacts' const VIRTUALIZATION_THRESHOLD_BYTES = 50_000 @@ -21,6 +23,7 @@ interface FilePreviewProps { export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber }: FilePreviewProps) { const isMarkdownFile = file.name.toLowerCase().endsWith('.md') || file.name.toLowerCase().endsWith('.mdx') || file.mimeType === 'text/markdown' + const isHtmlFile = isHtmlPath(file.name) || file.mimeType === 'text/html' const [viewMode, setViewMode] = useState<'preview' | 'edit'>('preview') const [editContent, setEditContent] = useState('') @@ -29,6 +32,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, const [highlightedLine, setHighlightedLine] = useState(initialLineNumber) const [lineWrap, setLineWrap] = useState(true) const [markdownPreview, setMarkdownPreview] = useState(isMarkdownFile) + const [htmlPreview, setHtmlPreview] = useState(isHtmlFile) const [isLoadingAllContent, setIsLoadingAllContent] = useState(false) const [fullContentLoaded, setFullContentLoaded] = useState(false) const [fullContent, setFullContent] = useState(null) @@ -42,9 +46,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, useEffect(() => { setFullContentLoaded(false) setMarkdownPreview(isMarkdownFile) + setHtmlPreview(isHtmlFile) setFullContent(null) setLocalMdContent(null) - }, [file.path, isMarkdownFile]) + }, [file.path, isMarkdownFile, isHtmlFile]) useEffect(() => { if (shouldVirtualize && isMarkdownFile && markdownPreview && !isMarkdownTooLarge && !fullContentLoaded) { @@ -214,6 +219,12 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, ['application/json', 'application/xml', 'text/javascript', 'text/typescript'].includes(file.mimeType || '') const renderContent = () => { + const htmlPreviewFrame = ( +
+ +
+ ) + if (file.mimeType?.startsWith('image/')) { return (
@@ -227,8 +238,13 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, } if (shouldVirtualize && isTextFile) { + const showHtmlPreview = isHtmlFile && htmlPreview && viewMode !== 'edit' const showMarkdownPreview = isMarkdownFile && markdownPreview && viewMode !== 'edit' + if (showHtmlPreview) { + return htmlPreviewFrame + } + return ( <>
@@ -285,6 +301,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, /> ) } + + if (isHtmlFile && htmlPreview) { + return htmlPreviewFrame + } try { const textContent = decodeBase64(file.content) @@ -392,8 +412,20 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, {markdownPreview ? : } )} + + {isHtmlFile && viewMode !== 'edit' && ( + + )} - {isTextFile && !markdownPreview && ( + {isTextFile && !markdownPreview && !htmlPreview && ( + )}
diff --git a/frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx b/frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx new file mode 100644 index 000000000..5bbc15fa6 --- /dev/null +++ b/frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx @@ -0,0 +1,273 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { HtmlArtifactPanel } from './HtmlArtifactPanel' +import type { HtmlArtifact } from '@/lib/htmlArtifacts' + +vi.mock('@/api/files', () => ({ + getFilePreviewUrl: (path: string) => `/api/files/preview/${encodeURIComponent(path)}`, +})) + +function createInlineArtifact(overrides?: Partial): HtmlArtifact { + return { + id: 'test-1', + title: 'Test Preview', + source: 'inline', + html: '

Hello World

', + ...overrides, + } +} + +function createFileArtifact(overrides?: Partial): HtmlArtifact { + return { + id: 'test-2', + title: 'File Preview', + source: 'file', + path: 'my-repo/dist/index.html', + ...overrides, + } +} + +function createDevServerArtifact(overrides?: Partial): HtmlArtifact { + return { + id: 'test-3', + title: 'App Preview', + source: 'devserver', + previewUrl: 'http://manager.example:3056/', + ...overrides, + } +} + +describe('HtmlArtifactPanel', () => { + it('renders inline artifact with srcdoc and sandbox attribute', () => { + const artifact = createInlineArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const iframe = screen.getByTitle('Test Preview') + expect(iframe).toBeInTheDocument() + expect(iframe.getAttribute('srcdoc')).toContain('') + expect(iframe.getAttribute('srcdoc')).toContain('

Hello World

') + expect(iframe).toHaveAttribute('sandbox', 'allow-scripts') + }) + + it('renders file artifact iframe with src pointing to preview API', () => { + const artifact = createFileArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const iframe = screen.getByTitle('File Preview') + expect(iframe).toBeInTheDocument() + expect(iframe).toHaveAttribute('src') + expect(iframe.getAttribute('src')).toContain('/api/files/preview/') + expect(iframe).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin') + }) + + it('does not include allow-same-origin in sandbox', () => { + const artifact = createInlineArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const iframe = screen.getByTitle('Test Preview') + const sandbox = iframe.getAttribute('sandbox') + expect(sandbox).not.toContain('allow-same-origin') + }) + + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn() + const artifact = createInlineArtifact() + render( + {}} + />, + ) + + const closeButton = screen.getByLabelText('Close preview') + fireEvent.click(closeButton) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('renders fullscreen toggle button on desktop', () => { + const artifact = createInlineArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const toggleButton = screen.getByLabelText('Enter fullscreen') + expect(toggleButton).toBeInTheDocument() + }) + + it('renders FullscreenSheet on mobile', () => { + const artifact = createInlineArtifact() + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const sheetRoot = container.querySelector('.fixed.inset-0') + expect(sheetRoot).toBeInTheDocument() + }) + + it('renders FullscreenSheet when isFullscreen is true (desktop)', () => { + const artifact = createInlineArtifact() + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const sheetRoot = container.querySelector('.fixed.inset-0') + expect(sheetRoot).toBeInTheDocument() + }) + + it('returns null when artifact is null', () => { + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + expect(container.innerHTML).toBe('') + }) + + it('shows desktop side-panel classes on desktop', () => { + const artifact = createInlineArtifact() + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const panel = container.querySelector('.hidden.md\\:flex') + expect(panel).toBeInTheDocument() + }) + + it('does not show desktop side panel on mobile', () => { + const artifact = createInlineArtifact() + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const panel = container.querySelector('.hidden.md\\:flex') + expect(panel).not.toBeInTheDocument() + }) + + it('renders devserver artifact iframe with src and widened sandbox', () => { + const artifact = createDevServerArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const iframe = screen.getByTitle('App Preview') + expect(iframe).toBeInTheDocument() + expect(iframe).toHaveAttribute('src', 'http://manager.example:3056/') + expect(iframe).toHaveAttribute( + 'sandbox', + 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads', + ) + }) + + it('shows reload button for devserver artifact', () => { + const artifact = createDevServerArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + expect(screen.getByLabelText('Reload preview')).toBeInTheDocument() + }) + + it('shows reload button for file artifact', () => { + const artifact = createFileArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + expect(screen.getByLabelText('Reload preview')).toBeInTheDocument() + }) + + it('does not show reload button for inline artifact', () => { + const artifact = createInlineArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + expect(screen.queryByLabelText('Reload preview')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/html-preview/HtmlArtifactPanel.tsx b/frontend/src/components/html-preview/HtmlArtifactPanel.tsx new file mode 100644 index 000000000..ad5dd1057 --- /dev/null +++ b/frontend/src/components/html-preview/HtmlArtifactPanel.tsx @@ -0,0 +1,111 @@ +import { getFilePreviewUrl } from '@/api/files' +import { + FullscreenSheet, + FullscreenSheetContent, +} from '@/components/ui/fullscreen-sheet' +import { Button } from '@/components/ui/button' +import { X, Maximize2, Minimize2, RotateCw } from 'lucide-react' +import { useState } from 'react' +import { HtmlPreviewFrame } from './HtmlPreviewFrame' +import type { HtmlArtifact } from '@/lib/htmlArtifacts' + +interface HtmlArtifactPanelProps { + artifact: HtmlArtifact | null + isFullscreen: boolean + isMobile: boolean + onClose: () => void + onToggleFullscreen?: () => void +} + +export function HtmlArtifactPanel({ + artifact, + isFullscreen, + isMobile, + onClose, + onToggleFullscreen, +}: HtmlArtifactPanelProps) { + const [frameKey, setFrameKey] = useState(0) + + if (!artifact) return null + + const previewSrc = artifact.source === 'file' && artifact.path + ? getFilePreviewUrl(artifact.path) + : artifact.source === 'devserver' + ? artifact.previewUrl + : undefined + const previewSrcDoc = artifact.source === 'inline' ? artifact.html : undefined + const title = artifact.title + + const frameSandbox = artifact.source === 'devserver' + ? 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads' + : undefined + + const showReload = artifact.source === 'devserver' || artifact.source === 'file' + + const headerButtons = ( +
+ {showReload && ( + + )} + {!isMobile && ( + + )} + +
+ ) + + const frame = ( + + ) + + if (isMobile || isFullscreen) { + return ( + + +
+ {headerButtons} +
+ {frame} +
+
+ ) + } + + return ( +
+
+ {frame} +
+
+ {headerButtons} +
+
+ ) +} diff --git a/frontend/src/components/html-preview/HtmlPreviewFrame.tsx b/frontend/src/components/html-preview/HtmlPreviewFrame.tsx new file mode 100644 index 000000000..9b49279b0 --- /dev/null +++ b/frontend/src/components/html-preview/HtmlPreviewFrame.tsx @@ -0,0 +1,27 @@ +import { useMemo } from 'react' +import { cn } from '@/lib/utils' +import { normalizeHtmlPreviewDocument } from '@/lib/htmlArtifacts' + +interface HtmlPreviewFrameProps { + title: string + src?: string + srcDoc?: string + className?: string + sandbox?: string +} + +export function HtmlPreviewFrame({ title, src, srcDoc, className, sandbox }: HtmlPreviewFrameProps) { + const resolvedSandbox = sandbox ?? (src ? 'allow-scripts allow-same-origin' : 'allow-scripts') + const normalizedSrcDoc = useMemo(() => (srcDoc ? normalizeHtmlPreviewDocument(srcDoc) : undefined), [srcDoc]) + return ( +