diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6313d3c0..6f443edc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ concurrency: jobs: check: strategy: + fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} @@ -20,7 +21,7 @@ jobs: - name: Install Linux system dependencies if: runner.os == 'Linux' - run: bash scripts/install-linux-deps-debian.sh + run: bash scripts/install-linux-deps-debian.sh --sources /etc/apt/sources.list.d/ubuntu.sources - uses: actions/setup-node@v4 with: diff --git a/Cargo.lock b/Cargo.lock index 0d4684b2..3196cdd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2218,6 +2218,7 @@ version = "0.1.41" dependencies = [ "base64 0.22.1", "block2", + "gtk", "libc", "notify-rust", "objc2", @@ -2238,6 +2239,7 @@ dependencies = [ "tauri-plugin-window-state", "ureq", "windows-sys 0.61.2", + "wry", ] [[package]] diff --git a/scripts/install-linux-deps-debian.sh b/scripts/install-linux-deps-debian.sh index 38893406..2b9668c1 100755 --- a/scripts/install-linux-deps-debian.sh +++ b/scripts/install-linux-deps-debian.sh @@ -1,6 +1,17 @@ #!/usr/bin/env bash set -euo pipefail +# Hosted runners include unrelated third-party repositories. CI can select the +# distribution's own source file without editing the machine's apt config. +APT_SOURCES=() +if [ "$#" -gt 0 ]; then + if [ "$#" -ne 2 ] || [ "$1" != "--sources" ] || [ ! -f "$2" ]; then + echo "Usage: $0 [--sources EXISTING_SOURCE_FILE]" >&2 + exit 1 + fi + APT_SOURCES=(-o "Dir::Etc::sourcelist=$2" -o "Dir::Etc::sourceparts=-") +fi + if ! command -v apt-get >/dev/null 2>&1; then echo "This helper currently supports Ubuntu/Debian systems with apt-get." >&2 exit 1 @@ -15,8 +26,9 @@ else exit 1 fi -"${SUDO[@]}" apt-get update -"${SUDO[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y \ +# The conditional expansions also support empty arrays with nounset on Bash 3. +${SUDO[@]+"${SUDO[@]}"} apt-get ${APT_SOURCES[@]+"${APT_SOURCES[@]}"} update +${SUDO[@]+"${SUDO[@]}"} env DEBIAN_FRONTEND=noninteractive apt-get ${APT_SOURCES[@]+"${APT_SOURCES[@]}"} install -y \ build-essential \ curl \ file \ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d023c040..3b4f863b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["protocol-asset", "macos-private-api"] } +# Use the same engine as Tauri for previews, without its application IPC bridge. +wry = { version = "0.55", default-features = false, features = ["os-webview"] } tauri-plugin-opener = "2" serde.workspace = true serde_json.workspace = true @@ -43,6 +45,7 @@ raw-window-handle = "0.6" [target.'cfg(target_os = "linux")'.dependencies] notify-rust = "4.18" +gtk = "0.18" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/browser_preview.rs b/src-tauri/src/browser_preview.rs new file mode 100644 index 00000000..7a6fd811 --- /dev/null +++ b/src-tauri/src/browser_preview.rs @@ -0,0 +1,331 @@ +//! A plain Wry child has no Tauri initialization scripts, custom protocols or +//! command dispatcher. Keeping it outside Tauri's webview registry also keeps +//! existing WebviewWindow arguments and window/quit handling valid. +use std::{cell::RefCell, collections::HashMap}; + +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager, Url, WebviewWindow}; +use wry::{dpi::PhysicalPosition, dpi::PhysicalSize, Rect, WebView, WebViewBuilder}; + +const EVENT: &str = "browser-preview"; + +thread_local! { + // Wry views must be created, used and dropped on the UI thread. + static VIEWS: RefCell> = RefCell::default(); + #[cfg(target_os = "linux")] + static CONTAINERS: RefCell> = RefCell::default(); +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewEvent { + id: String, + kind: &'static str, + url: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewBounds { + x: f64, + y: f64, + width: f64, + height: f64, + viewport_width: f64, +} + +impl PreviewBounds { + fn physical(&self, window: &WebviewWindow) -> Result { + if ![self.x, self.y, self.width, self.height, self.viewport_width] + .iter() + .all(|n| n.is_finite()) + || self.x < 0.0 + || self.y < 0.0 + || self.width <= 0.0 + || self.height <= 0.0 + || self.viewport_width <= 0.0 + { + return Err("Invalid preview bounds".into()); + } + let size = window.inner_size().map_err(|e| e.to_string())?; + // CSS pixels also include the app's webview zoom, unlike OS scale alone. + let scale = f64::from(size.width) / self.viewport_width; + let x = (self.x * scale).min(f64::from(size.width)); + let y = (self.y * scale).min(f64::from(size.height)); + Ok(Rect { + position: PhysicalPosition::new(x, y).into(), + size: PhysicalSize::new( + (self.width * scale).min(f64::from(size.width) - x), + (self.height * scale).min(f64::from(size.height) - y), + ) + .into(), + }) + } +} + +fn allowed_url(value: &str, app_origin: Option<&Url>) -> Result { + let url = Url::parse(value).map_err(|_| "Enter an HTTP or HTTPS address")?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url + .host_str() + .is_some_and(|host| host.ends_with(".localhost")) + || app_origin.is_some_and(|origin| origin.origin() == url.origin()) + { + return Err("Only web addresses outside the application are allowed".into()); + } + Ok(url) +} + +async fn on_ui( + window: WebviewWindow, + task: impl FnOnce(WebviewWindow) -> Result + Send + 'static, +) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + let owner = window.clone(); + window + .run_on_main_thread(move || { + let _ = tx.send(task(owner)); + }) + .map_err(|e| e.to_string())?; + tauri::async_runtime::spawn_blocking(move || rx.recv()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn browser_preview_open( + window: WebviewWindow, + id: String, + url: String, + bounds: PreviewBounds, +) -> Result<(), String> { + on_ui(window, move |window| { + let origin = window.config().build.dev_url.clone(); + let url = allowed_url(&url, origin.as_ref())?; + let rect = bounds.physical(&window)?; + let key = (window.label().to_string(), id.clone()); + VIEWS.with(|views| { + if views.borrow().contains_key(&key) { + return Err("Preview already exists".into()); + } + let navigation_window = window.clone(); + let navigation_id = id.clone(); + let load_window = window.clone(); + let load_id = id.clone(); + let popup_window = window.clone(); + let popup_id = id.clone(); + let download_window = window.clone(); + let download_id = id.clone(); + let focus_window = window.clone(); + let builder = WebViewBuilder::new() + .with_url(url.as_str()) + .with_bounds(rect) + .with_incognito(true) + .with_focused(false) + .with_navigation_handler(move |value| { + let allowed = allowed_url(&value, origin.as_ref()).is_ok(); + if !allowed { + let _ = navigation_window.emit(EVENT, PreviewEvent { + id: navigation_id.clone(), kind: "blocked", url: String::new(), + }); + } + allowed + }) + .with_on_page_load_handler(move |event, url| { + let kind = match event { + wry::PageLoadEvent::Started => "loading", + wry::PageLoadEvent::Finished => "loaded", + }; + let _ = load_window.emit(EVENT, PreviewEvent { id: load_id.clone(), kind, url }); + }) + .with_new_window_req_handler(move |_, _| { + let _ = popup_window.emit(EVENT, PreviewEvent { + id: popup_id.clone(), kind: "popup", url: String::new(), + }); + wry::NewWindowResponse::Deny + }) + .with_download_started_handler(move |_, _| { + let _ = download_window.emit(EVENT, PreviewEvent { + id: download_id.clone(), kind: "download", url: String::new(), + }); + false + }) + // One notification-only message preserves pane focus when a + // native child consumes a click. No command names/args, eval or + // filesystem operations can be dispatched through this channel. + .with_initialization_script( + "document.addEventListener('pointerdown',()=>window.ipc.postMessage('focus'),true);", + ) + .with_ipc_handler(move |request| { + if request.body() == "focus" { + let _ = focus_window.emit(EVENT, PreviewEvent { + id: id.clone(), kind: "focus", url: String::new(), + }); + } + }); + #[cfg(not(target_os = "linux"))] + let view = builder.build_as_child(&window).map_err(|e| e.to_string())?; + #[cfg(target_os = "linux")] + let view = { + use wry::WebViewBuilderExtUnix; + let fixed = preview_container(&window)?; + builder.build_gtk(&fixed).map_err(|e| e.to_string())? + }; + views.borrow_mut().insert(key, view); + Ok(()) + }) + }).await +} + +#[tauri::command] +pub async fn browser_preview_sync( + window: WebviewWindow, + id: String, + bounds: Option, +) -> Result<(), String> { + on_ui(window, move |window| { + VIEWS.with(|views| { + let views = views.borrow(); + let view = views + .get(&(window.label().to_string(), id)) + .ok_or("Preview is closed")?; + if let Some(bounds) = bounds { + view.set_bounds(bounds.physical(&window)?) + .map_err(|e| e.to_string())?; + view.set_visible(true).map_err(|e| e.to_string()) + } else { + view.set_visible(false).map_err(|e| e.to_string()) + } + }) + }) + .await +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewAction { + Navigate(String), + Back, + Forward, + Reload, +} + +#[tauri::command] +pub async fn browser_preview_action( + window: WebviewWindow, + id: String, + action: PreviewAction, +) -> Result<(), String> { + on_ui(window, move |window| { + VIEWS.with(|views| { + let views = views.borrow(); + let view = views + .get(&(window.label().to_string(), id)) + .ok_or("Preview is closed")?; + let result = match action { + PreviewAction::Navigate(value) => { + let url = allowed_url(&value, window.config().build.dev_url.as_ref())?; + view.load_url(url.as_str()) + } + PreviewAction::Back => view.evaluate_script("history.back()"), + PreviewAction::Forward => view.evaluate_script("history.forward()"), + PreviewAction::Reload => view.reload(), + }; + result.map_err(|e| e.to_string()) + }) + }) + .await +} + +#[tauri::command] +pub async fn browser_preview_url(window: WebviewWindow, id: String) -> Result { + on_ui(window, move |window| { + VIEWS.with(|views| { + views + .borrow() + .get(&(window.label().to_string(), id)) + .ok_or("Preview is closed")? + .url() + .map_err(|e| e.to_string()) + }) + }) + .await +} + +#[tauri::command] +pub async fn browser_preview_close(window: WebviewWindow, id: String) -> Result<(), String> { + on_ui(window, move |window| { + VIEWS.with(|views| { + views.borrow_mut().remove(&(window.label().to_string(), id)); + }); + Ok(()) + }) + .await +} + +pub fn close_window(label: &str) { + VIEWS.with(|views| views.borrow_mut().retain(|(owner, _), _| owner != label)); + #[cfg(target_os = "linux")] + CONTAINERS.with(|containers| { + containers.borrow_mut().remove(label); + }); +} + +#[cfg(target_os = "linux")] +fn preview_container(window: &WebviewWindow) -> Result { + use gtk::prelude::*; + CONTAINERS.with(|containers| { + let mut containers = containers.borrow_mut(); + if let Some(fixed) = containers.get(window.label()) { + return Ok(fixed.clone()); + } + let vbox = window.default_vbox().map_err(|e| e.to_string())?; + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + for child in vbox.children() { + vbox.remove(&child); + content.pack_start(&child, true, true, 0); + } + let overlay = gtk::Overlay::new(); + overlay.add(&content); + let fixed = gtk::Fixed::new(); + overlay.add_overlay(&fixed); + vbox.pack_start(&overlay, true, true, 0); + overlay.show_all(); + containers.insert(window.label().to_string(), fixed.clone()); + Ok(fixed) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_web_pages_without_credentials_or_app_origins_are_allowed() { + let app = Url::parse("http://localhost:1420").unwrap(); + for value in [ + "https://example.com", + "http://localhost:3000", + "http://[::1]:5173/a", + ] { + assert!(allowed_url(value, Some(&app)).is_ok(), "{value}"); + } + for value in [ + "file:///etc/passwd", + "javascript:alert(1)", + "tauri://localhost", + "https://tauri.localhost", + "http://asset.localhost/a", + "http://localhost:1420/app", + "https://user:pass@example.com", + "data:text/html,hi", + "about:blank", + ] { + assert!(allowed_url(value, Some(&app)).is_err(), "{value}"); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bfeca204..93036cdf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use tauri::Manager; +mod browser_preview; mod chat_background; mod checkpoint; mod cursor_store; @@ -196,6 +197,11 @@ pub fn run() { menu::dispatch(app, event.id().as_ref()); }) .invoke_handler(tauri::generate_handler![ + browser_preview::browser_preview_open, + browser_preview::browser_preview_sync, + browser_preview::browser_preview_action, + browser_preview::browser_preview_url, + browser_preview::browser_preview_close, default_cwd, home_dir, notifications::notification_permission, @@ -365,6 +371,7 @@ pub fn run() { event: tauri::WindowEvent::Destroyed, .. } => { + browser_preview::close_window(&label); let other_window = handle.webview_windows().keys().any(|name| name != &label); if !other_window { reap_harness_children(handle); diff --git a/src/App.tsx b/src/App.tsx index 359697f8..23739433 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,8 @@ import { MenuBar } from "./chrome/MenuBar"; import { FilePicker } from "./chrome/FilePicker"; import { UsageFooter } from "./chrome/UsageFooter"; import { useProjectBranches } from "./hooks/useProjectBranches"; +import { useLocalPreviews } from "./hooks/useLocalPreviews"; +import { claimLocalPreview, type LocalPreview } from "./lib/browserPreview"; import { loadProjectRailOpen, loadSidebarTabOrder, @@ -73,6 +75,7 @@ import { openChangesTab, openCommitTab, openEditorTab, + openBrowserTab, openSessionChangesTab, openTerminalTab, removePane, @@ -3405,6 +3408,47 @@ export default function App({ }); }, []); + const seenPreviews = useRef(new Map>()); + const onLocalPreview = useCallback( + ({ cwd, url }: LocalPreview, sessionId?: string) => { + if (!sessionId && !isEqualOrInside(cwd, projectCwdRef.current)) return; + const tab = tabsRef.current.find((entry) => + sessionId + ? leafIds(entry.layout).includes(sessionId) + : entry.id === activeTabIdRef.current, + ); + if (!tab) return; + const seen = seenPreviews.current.get(tab.id) ?? new Set(); + const target = claimLocalPreview(url, seen); + if (!target) return; + seenPreviews.current.set(tab.id, seen); + setTabs((previous) => + previous.map((entry) => { + if (entry.id !== tab.id) return entry; + const opened = openBrowserTab(entry, cwd, target); + // Showing a server must not take typing focus from the conversation. + return { ...opened, focusedId: entry.focusedId }; + }), + ); + }, + [], + ); + useLocalPreviews(sessions, onLocalPreview); + useEffect(() => { + for (const id of seenPreviews.current.keys()) + if (!tabs.some((tab) => tab.id === id)) seenPreviews.current.delete(id); + }, [tabs]); + const onOpenBrowser = useCallback(() => { + setTabs((previous) => + previous.map((entry) => + entry.id === activeTabId + ? openBrowserTab(entry, sidebarCwdRef.current) + : entry, + ), + ); + setComposerFocused(false); + }, [activeTabId]); + const onOpenFile = useCallback( (path, navigation) => { void (async () => { @@ -5446,6 +5490,7 @@ export default function App({ onNew={onNew} onNewTerminal={onNewTerminal} onShowTerminal={onShowProjectTerminal} + onOpenBrowser={onOpenBrowser} projectTerminalActive={ !!currentProjectDock && currentProjectDock.pane.files.length > 0 } diff --git a/src/chrome/SurfaceTabs.tsx b/src/chrome/SurfaceTabs.tsx index f832823a..2b2a1789 100644 --- a/src/chrome/SurfaceTabs.tsx +++ b/src/chrome/SurfaceTabs.tsx @@ -1,4 +1,4 @@ -import { GitCompare, GripVertical, Terminal, X } from "./icons"; +import { AppWindow, GitCompare, GripVertical, Terminal, X } from "./icons"; import type { PointerEvent as ReactPointerEvent, ReactNode } from "react"; import { useLayoutEffect, useRef } from "react"; import { basename } from "../lib/fs"; @@ -41,6 +41,13 @@ export type SurfaceTabPresentation = { export function surfaceTabPresentation( file: FilePaneTab, ): SurfaceTabPresentation { + if (file.browser) + return { + name: "Browser", + label: "Browser", + iconName: "", + tooltip: file.browser.url || "Web preview", + }; if (isReleaseNotesTab(file)) { const title = releaseNotesTitle(file.releaseNotes.version); return { @@ -221,7 +228,9 @@ export function SurfaceTabs({ active ? "text-content" : "text-content/55 hover:text-content" }`} > - {terminal ? ( + {file.browser ? ( + + ) : terminal ? ( ) : changes || commit ? ( diff --git a/src/chrome/TitleBar.tsx b/src/chrome/TitleBar.tsx index 12d3b0e6..5e5f4943 100644 --- a/src/chrome/TitleBar.tsx +++ b/src/chrome/TitleBar.tsx @@ -1,5 +1,6 @@ import { ChevronLeft, + AppWindow, ChevronRight, Inbox, PanelLeft, @@ -72,6 +73,7 @@ type Props = { onNew: () => void; onNewTerminal?: () => void; onShowTerminal?: () => void; + onOpenBrowser?: () => void; projectTerminalActive?: boolean; onOpenSettings?: () => void; onOpenInbox?: () => void; @@ -538,6 +540,7 @@ function TitleBarComponent({ onNew, onNewTerminal, onShowTerminal, + onOpenBrowser, projectTerminalActive = false, onOpenSettings, onOpenInbox, @@ -721,6 +724,11 @@ function TitleBarComponent({ ) : null} + {!projectless && onOpenBrowser ? ( + + + + ) : null} {!projectless && (onShowTerminal || onNewTerminal) ? ( void; + action: (action: Action) => void; +}; + +export function useBrowserPreview( + host: RefObject, + url: string, + onEvent: (event: PreviewEvent) => void, + onError: (error: string) => void, +) { + const controller = useRef(null); + const callbacks = useRef({ onEvent, onError }); + useEffect(() => { + callbacks.current = { onEvent, onError }; + }, [onEvent, onError]); + + useEffect(() => { + const element = host.current; + if (!element) return; + const id = crypto.randomUUID(); + let closed = false; + let created = false; + let creationFailed = false; + let desiredUrl = ""; + let openedUrl = ""; + let geometry = ""; + let dragging = false; + let frame = 0; + let polling = false; + const listening = listen("browser-preview", ({ payload }) => { + if (!closed && payload.id === id) callbacks.current.onEvent(payload); + }); + let queue: Promise = listening; + const enqueue = (task: () => Promise) => { + queue = queue.then(task).catch((error: unknown) => { + if (!closed) callbacks.current.onError(String(error)); + }); + }; + + const bounds = () => { + if ( + document.hidden || + dragging || + element.closest('[inert], [aria-hidden="true"]') + ) + return null; + // A native view sits above HTML. Suspend it while application overlays + // are present so dialogs, menus and approval buttons remain usable. + const overlay = [ + ...document.querySelectorAll( + '[role="dialog"], [data-popover-side], .approval-toast', + ), + ].some((node) => node.getClientRects().length > 0); + if ( + overlay || + document.documentElement.classList.contains("is-reordering") + ) + return null; + const rect = element.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return null; + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + viewportWidth: window.innerWidth, + }; + }; + const sync = () => + enqueue(async () => { + await listening; + if (closed) return; + const next = bounds(); + if (!created) { + if (!next || !desiredUrl || creationFailed) return; + const initialUrl = desiredUrl; + try { + await invoke("browser_preview_open", { + id, + url: initialUrl, + bounds: next, + }); + } catch (error) { + creationFailed = true; + throw error; + } + created = true; + openedUrl = initialUrl; + geometry = ""; + } + if (desiredUrl && desiredUrl !== openedUrl) { + await invoke("browser_preview_action", { + id, + action: { navigate: desiredUrl }, + }); + openedUrl = desiredUrl; + } + const serialized = JSON.stringify(next); + if (serialized !== geometry) { + await invoke("browser_preview_sync", { id, bounds: next }); + geometry = serialized; + } + }); + const schedule = () => { + if (frame || closed) return; + frame = requestAnimationFrame(() => { + frame = 0; + sync(); + }); + }; + const current: Controller = { + navigate(value) { + const target = previewUrl(value); + if (!target) { + callbacks.current.onError("Enter an HTTP or HTTPS address."); + return; + } + desiredUrl = target; + creationFailed = false; + sync(); + }, + action(action) { + if (!created) { + creationFailed = false; + sync(); + return; + } + enqueue(async () => { + if (!closed && created) + await invoke("browser_preview_action", { id, action }); + }); + }, + }; + controller.current = current; + const resize = new ResizeObserver(schedule); + resize.observe(element); + const mutations = new MutationObserver(schedule); + mutations.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["class", "style", "inert", "aria-hidden"], + }); + const down = () => { + dragging = true; + sync(); + }; + const up = () => { + dragging = false; + schedule(); + }; + window.addEventListener("pointerdown", down, true); + window.addEventListener("pointerup", up, true); + window.addEventListener("pointercancel", up, true); + window.addEventListener("resize", schedule); + document.addEventListener("visibilitychange", schedule); + const interval = setInterval(() => { + if (closed || !created || polling || !bounds()) return; + polling = true; + enqueue(async () => { + try { + if (closed) return; + const currentUrl = await invoke("browser_preview_url", { + id, + }); + if (!closed && currentUrl) + callbacks.current.onEvent({ id, kind: "url", url: currentUrl }); + } finally { + polling = false; + } + }); + }, 1000); + return () => { + closed = true; + controller.current = null; + cancelAnimationFrame(frame); + clearInterval(interval); + resize.disconnect(); + mutations.disconnect(); + window.removeEventListener("pointerdown", down, true); + window.removeEventListener("pointerup", up, true); + window.removeEventListener("pointercancel", up, true); + window.removeEventListener("resize", schedule); + document.removeEventListener("visibilitychange", schedule); + // Close only after an in-flight create has finished; StrictMode/remounts + // use different ids and cannot close the replacement's view. + void queue + .then(async () => { + if (created) await invoke("browser_preview_close", { id }); + }) + .catch((error: unknown) => + console.error("Could not close preview", error), + ); + void listening + .then((unlisten) => unlisten()) + .catch((error: unknown) => + console.error("Could not release preview listener", error), + ); + }; + }, [host]); + + useEffect(() => { + if (url) controller.current?.navigate(url); + }, [url]); + return (action: Action) => controller.current?.action(action); +} diff --git a/src/hooks/useLocalPreviews.test.ts b/src/hooks/useLocalPreviews.test.ts new file mode 100644 index 00000000..ba28b951 --- /dev/null +++ b/src/hooks/useLocalPreviews.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { announceLocalPreview } from "../lib/browserPreview"; +import { newSession, type Session } from "../lib/session"; +import { useLocalPreviews } from "./useLocalPreviews"; + +describe("new local server output", () => { + let root: Root; + let container: HTMLDivElement; + const onPreview = vi.fn(); + function Harness({ sessions }: { sessions: Session[] }) { + useLocalPreviews(sessions, onPreview); + return null; + } + async function render(sessions: Session[]) { + await act(async () => root.render(createElement(Harness, { sessions }))); + } + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("location", new URL("http://localhost:1420")); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + onPreview.mockClear(); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("does not replay historical output, but recognizes a new streamed shell result", async () => { + const session = { ...newSession("codex", "/repo"), busy: true }; + await render([session]); + const block = { + id: "tool", + role: "tool" as const, + text: "", + tool: { + status: "in_progress", + preview: { + kind: "shell" as const, + output: "Local: http://localhost:3", + }, + }, + }; + await render([{ ...session, blocks: [block] }]); + expect(onPreview).not.toHaveBeenCalled(); + block.tool.preview.output += "000/\n"; + await render([{ ...session, blocks: [{ ...block }] }]); + expect(onPreview).toHaveBeenCalledExactlyOnceWith( + { cwd: "/repo", url: "http://localhost:3000/" }, + session.id, + ); + await render([{ ...session, blocks: [{ ...block }] }]); + expect(onPreview).toHaveBeenCalledTimes(1); + const old = { + ...newSession("codex", "/other"), + busy: true, + blocks: [{ ...block, id: "old" }], + }; + await render([session, old]); + expect(onPreview).toHaveBeenCalledTimes(1); + }); + + it("recognizes output delivered with turn completion without replaying later history", async () => { + const session = { ...newSession("codex", "/repo"), busy: true }; + await render([session]); + const result = { + id: "done", + role: "tool" as const, + text: "", + tool: { + status: "completed", + preview: { kind: "shell" as const, output: "http://localhost:5173/" }, + }, + }; + await render([{ ...session, busy: false, blocks: [result] }]); + expect(onPreview).toHaveBeenCalledExactlyOnceWith( + { cwd: "/repo", url: "http://localhost:5173/" }, + session.id, + ); + await render([ + { ...session, busy: false, blocks: [result, { ...result, id: "old" }] }, + ]); + expect(onPreview).toHaveBeenCalledTimes(1); + }); + + it("ignores assistant prose and file-read output, and releases the terminal subscription", async () => { + const session = { ...newSession("codex", "/repo"), busy: true }; + await render([session]); + await render([ + { + ...session, + blocks: [ + { id: "a", role: "assistant", text: "http://localhost:3000/" }, + { + id: "r", + role: "tool", + text: "", + tool: { + preview: { kind: "read", output: "http://localhost:3000/" }, + }, + }, + ], + }, + ]); + expect(onPreview).not.toHaveBeenCalled(); + announceLocalPreview("/repo", "http://localhost:5000/"); + expect(onPreview).toHaveBeenCalledTimes(1); + await act(async () => root.unmount()); + announceLocalPreview("/repo", "http://localhost:6000/"); + expect(onPreview).toHaveBeenCalledTimes(1); + root = createRoot(container); + }); +}); diff --git a/src/hooks/useLocalPreviews.ts b/src/hooks/useLocalPreviews.ts new file mode 100644 index 00000000..a8511bd1 --- /dev/null +++ b/src/hooks/useLocalPreviews.ts @@ -0,0 +1,86 @@ +import { useEffect, useRef } from "react"; +import { + LOCAL_PREVIEW_EVENT, + localPreviewScanner, + type LocalPreview, +} from "../lib/browserPreview"; +import type { Session } from "../lib/session"; + +/** Only new command output can trigger previews; loaded transcripts cannot. */ +export function useLocalPreviews( + sessions: Session[], + onPreview: (preview: LocalPreview, sessionId?: string) => void, +) { + const outputs = useRef( + new Map< + string, + { text: string; scan: ReturnType } + >(), + ); + const initialized = useRef(false); + const knownSessions = useRef(new Set()); + const runningSessions = useRef(new Set()); + const callback = useRef(onPreview); + useEffect(() => { + callback.current = onPreview; + }, [onPreview]); + + useEffect(() => { + const handler = (event: Event) => + onPreview((event as CustomEvent).detail); + window.addEventListener(LOCAL_PREVIEW_EVENT, handler); + return () => window.removeEventListener(LOCAL_PREVIEW_EVENT, handler); + }, [onPreview]); + + useEffect(() => { + const present = new Set(); + for (const session of sessions) { + const firstSeen = !knownSessions.current.has(session.id); + knownSessions.current.add(session.id); + for (const block of session.blocks) { + const preview = block.tool?.preview; + if (preview?.kind !== "shell" || !preview.output) continue; + const key = `${session.id}:${block.id}`; + present.add(key); + let previous = outputs.current.get(key); + if (!previous) { + previous = { + text: "", + scan: localPreviewScanner((url) => + callback.current({ cwd: session.cwd, url }, session.id), + ), + }; + outputs.current.set(key, previous); + if ( + !initialized.current || + firstSeen || + (!session.busy && !runningSessions.current.has(session.id)) + ) { + previous.text = preview.output; + continue; + } + } + const delta = preview.output.startsWith(previous.text) + ? preview.output.slice(previous.text.length) + : preview.output; + previous.text = preview.output; + previous.scan( + delta, + !session.busy || + ["completed", "failed", "cancelled", "interrupted"].includes( + block.tool?.status ?? "", + ), + ); + } + } + for (const key of outputs.current.keys()) + if (!present.has(key)) outputs.current.delete(key); + for (const id of knownSessions.current) + if (!sessions.some((session) => session.id === id)) + knownSessions.current.delete(id); + initialized.current = true; + runningSessions.current = new Set( + sessions.filter((session) => session.busy).map((session) => session.id), + ); + }, [sessions, onPreview]); +} diff --git a/src/lib/browserPreview.test.ts b/src/lib/browserPreview.test.ts new file mode 100644 index 00000000..fc7896fe --- /dev/null +++ b/src/lib/browserPreview.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { + localPreviewScanner, + localPreviewUrls, + claimLocalPreview, + previewUrl, +} from "./browserPreview"; +import { + isFilesystemTab, + newFileTab, + newTab, + openBrowserTab, + openEditorTab, +} from "./layout"; +import { + collectWorkspaceSnapshot, + hydrateWorkspaceSnapshot, +} from "./workspaceSnapshot"; +import { newSession } from "./session"; + +describe("web preview addresses", () => { + it("accepts HTTP(S) and rejects native schemes, credentials and app hosts", () => { + expect(previewUrl(" https://example.com/docs ")).toBe( + "https://example.com/docs", + ); + for (const url of [ + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,hi", + "tauri://localhost", + "http://asset.localhost/a", + "https://user:secret@example.com", + "//example.com", + "not a URL", + ]) + expect(previewUrl(url), url).toBeUndefined(); + }); + + it("only auto-opens loopback servers and normalizes wildcard bind addresses", () => { + expect( + localPreviewUrls( + "\x1b[32mLocal: http://localhost:5173/\x1b[0m\nNetwork: http://192.168.1.2:5173\nhttps://example.com:444\nhttp://localhost.evil.test:3000\nhttp://0.0.0.0:8080\nhttp://[::1]:3000/app", + ), + ).toEqual([ + "http://localhost:5173/", + "http://127.0.0.1:8080/", + "http://[::1]:3000/app", + ]); + expect(localPreviewUrls("http://localhost http://127.0.0.1")).toEqual([]); + }); + + it("waits for a split terminal URL to finish instead of opening a partial port", () => { + const found = vi.fn(); + const scan = localPreviewScanner(found); + scan("Local: http://local"); + scan("host:3"); + expect(found).not.toHaveBeenCalled(); + scan("000/\n"); + expect(found).toHaveBeenCalledExactlyOnceWith("http://localhost:3000/"); + scan("http://127.0.0.1:8080"); + scan("", true); + expect(found).toHaveBeenLastCalledWith("http://127.0.0.1:8080/"); + }); +}); + +describe("preview tabs", () => { + it("deduplicates a server after closing the preview without suppressing another workspace", () => { + const seen = new Set(); + expect(claimLocalPreview("http://localhost:5173/", seen)).toBe( + "http://localhost:5173/", + ); + expect( + claimLocalPreview("http://localhost:5173/again", seen), + ).toBeUndefined(); + expect(claimLocalPreview("https://example.com:443", seen)).toBeUndefined(); + expect(claimLocalPreview("http://localhost:5173/", new Set())).toBe( + "http://localhost:5173/", + ); + }); + it("opens in the existing right pane, stays out of filesystem actions, and reuses its tab", () => { + let tab = openEditorTab(newTab("s"), newFileTab("/repo/app.ts", "/repo")); + const paneId = tab.editorPanes[0].id; + tab = openBrowserTab(tab, "/repo", "http://localhost:3000/"); + expect(tab.editorPanes).toHaveLength(1); + const browser = tab.editorPanes[0].files[1]; + expect(isFilesystemTab(browser)).toBe(false); + expect(tab.focusedId).toBe(paneId); + const next = openBrowserTab(tab, "/repo", "http://localhost:4000/"); + expect(next.editorPanes[0].files).toHaveLength(2); + expect(next.editorPanes[0].files[1]).toMatchObject({ + id: browser.id, + browser: { url: "http://localhost:4000/" }, + }); + expect( + openBrowserTab(next, "/repo").editorPanes[0].files[1].browser?.url, + ).toBe("http://localhost:4000/"); + }); + + it("does not restore a website or mistake its tab for a local file", () => { + const session = newSession("codex", "/repo"); + session.blocks = [{ id: "u", role: "user", text: "Build a page" }]; + const tab = openBrowserTab( + newTab(session.id), + "/repo", + "https://example.com/private", + ); + const snapshot = collectWorkspaceSnapshot( + [tab], + [session], + tab.id, + "/repo", + ); + const restored = hydrateWorkspaceSnapshot( + snapshot, + new Map([[session.id, session]]), + ); + expect(restored?.tabs[0].editorPanes[0].files[0]).toMatchObject({ + path: "Browser", + browser: { url: "" }, + }); + expect(JSON.stringify(snapshot)).not.toContain("example.com"); + }); +}); diff --git a/src/lib/browserPreview.ts b/src/lib/browserPreview.ts new file mode 100644 index 00000000..fe40309c --- /dev/null +++ b/src/lib/browserPreview.ts @@ -0,0 +1,70 @@ +export const LOCAL_PREVIEW_EVENT = "monocode:local-preview"; + +export type LocalPreview = { cwd: string; url: string }; + +/** Pages are web content; never treat app, file or script URLs as previews. */ +export function previewUrl(value: string): string | undefined { + try { + const url = new URL(value.trim()); + if (!/^https?:$/.test(url.protocol) || url.username || url.password) + return undefined; + if ( + url.hostname.endsWith(".localhost") || + (typeof location !== "undefined" && url.origin === location.origin) + ) + return undefined; + return url.href; + } catch { + return undefined; + } +} + +export function localPreviewUrls(text: string): string[] { + const clean = text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); + const urls = new Set(); + for (const match of clean.matchAll(/https?:\/\/[^\s<>"'`]+/g)) { + const value = previewUrl(match[0].replace(/[),.;]+$/, "")); + if (!value) continue; + const url = new URL(value); + if ( + !url.port || + !["localhost", "127.0.0.1", "[::1]", "0.0.0.0"].includes(url.hostname) + ) + continue; + if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"; + urls.add(url.href); + } + return [...urls]; +} + +/** Keep split terminal chunks until a complete line arrives. */ +export function localPreviewScanner(onUrl: (url: string) => void) { + let rest = ""; + return (chunk: string, flush = false) => { + const lines = (rest + chunk).split(/\r?\n/); + rest = flush ? "" : (lines.pop() ?? "").slice(-8192); + for (const line of lines) + for (const url of localPreviewUrls(line)) onUrl(url); + }; +} + +export function announceLocalPreview(cwd: string, url: string) { + window.dispatchEvent( + new CustomEvent(LOCAL_PREVIEW_EVENT, { + detail: { cwd, url }, + }), + ); +} + +/** Remember an automatic open even after its tab closes, until the workspace closes. */ +export function claimLocalPreview( + url: string, + seenOrigins: Set, +): string | undefined { + const target = localPreviewUrls(url)[0]; + if (!target) return undefined; + const origin = new URL(target).origin; + if (seenOrigins.has(origin)) return undefined; + seenOrigins.add(origin); + return target; +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index 2608971d..10a67489 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -62,6 +62,8 @@ export type FilePaneTab = { /** Historical commit review (unified diff, read-only). */ commit?: CommitTabSource; terminal?: boolean; + /** Ephemeral web preview. Never pass its address to filesystem helpers. */ + browser?: { url: string }; /** Foreground command when it isn't the shell. Live only — not persisted. */ foreground?: string; }; @@ -304,7 +306,12 @@ export function isTerminalTab(file: FilePaneTab): boolean { } export function isVirtualDocumentTab(file: FilePaneTab): boolean { - return isPlanTab(file) || isReleaseNotesTab(file) || isCommitTab(file); + return ( + !!file.browser || + isPlanTab(file) || + isReleaseNotesTab(file) || + isCommitTab(file) + ); } export function isFilesystemTab(file: FilePaneTab): boolean { @@ -382,6 +389,7 @@ export function isSessionChangesTab( } export function editorTabKey(file: FilePaneTab): string { + if (file.browser) return `browser:${file.cwd}`; if (file.terminal) return `terminal:${file.id}`; if (file.plan) return `plan:${file.plan.blockId}`; if (file.releaseNotes) return `release-notes:${file.releaseNotes.version}`; @@ -400,6 +408,34 @@ export function newEditorPane(file: FilePaneTab): EditorPane { }; } +/** Reuse the preview for this directory in its workspace tab. */ +export function openBrowserTab( + tab: WorkspaceTab, + cwd: string, + url?: string, +): WorkspaceTab { + const existing = tab.editorPanes + .flatMap((pane) => pane.files) + .find((file) => file.browser && file.cwd === cwd); + const file = existing ?? { + id: crypto.randomUUID(), + path: "Browser", + cwd, + browser: { url: url ?? "" }, + }; + const opened = openEditorTab(tab, file); + if (!existing || url === undefined) return opened; + return { + ...opened, + editorPanes: opened.editorPanes.map((pane) => ({ + ...pane, + files: pane.files.map((entry) => + entry.id === file.id ? { ...entry, browser: { url } } : entry, + ), + })), + }; +} + /** Focus an existing editor tab, or open it in the focused editor pane / a new split. */ export function openEditorTab( tab: WorkspaceTab, diff --git a/src/lib/workspaceSnapshot.ts b/src/lib/workspaceSnapshot.ts index 419f24b7..ad0b9502 100644 --- a/src/lib/workspaceSnapshot.ts +++ b/src/lib/workspaceSnapshot.ts @@ -400,6 +400,27 @@ function sanitizeFile(raw: unknown): FilePaneTab | null { if (typeof value.id !== "string" || !value.id) return null; if (typeof value.path !== "string" || !value.path) return null; if (typeof value.cwd !== "string" || !value.cwd) return null; + // Keep the pane, but never persist/revisit a page when restoring a workspace. + if ("browser" in value) { + if ( + !value.browser || + typeof value.browser !== "object" || + value.plan || + value.review || + value.terminal || + value.commit || + value.sessionChanges || + value.releaseNotes || + value.changes + ) + return null; + return { + id: value.id, + path: "Browser", + cwd: value.cwd, + browser: { url: "" }, + }; + } const plan = sanitizePlan(value.plan); const hasReleaseNotes = "releaseNotes" in value; const releaseNotes = sanitizeReleaseNotes(value.releaseNotes); diff --git a/src/surfaces/BrowserPreview.test.ts b/src/surfaces/BrowserPreview.test.ts new file mode 100644 index 00000000..d59e2252 --- /dev/null +++ b/src/surfaces/BrowserPreview.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserPreview } from "./BrowserPreview"; +import type { PreviewEvent } from "../hooks/useBrowserPreview"; + +const bridge = vi.hoisted(() => ({ + invoke: vi.fn< + (command: string, args?: Record) => Promise + >(async (command) => + command === "browser_preview_url" ? "http://localhost:5173/" : undefined, + ), + events: new Set<(event: { payload: PreviewEvent }) => void>(), + external: vi.fn(async () => {}), +})); +vi.mock("@tauri-apps/api/core", () => ({ invoke: bridge.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn( + async (_: string, handler: (event: { payload: PreviewEvent }) => void) => { + bridge.events.add(handler); + return () => { + bridge.events.delete(handler); + }; + }, + ), +})); +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: bridge.external })); + +describe("native preview lifecycle", () => { + let root: Root; + let container: HTMLDivElement; + const focus = vi.fn(); + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("location", new URL("http://localhost:1420")); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( + new DOMRect(400, 100, 500, 600), + ); + vi.spyOn(HTMLElement.prototype, "getClientRects").mockReturnValue([ + new DOMRect(400, 100, 500, 600), + ] as unknown as DOMRectList); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + bridge.invoke.mockClear(); + bridge.external.mockClear(); + focus.mockClear(); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + bridge.events.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + async function render(url = "http://localhost:5173/") { + await act(async () => + root.render( + createElement(BrowserPreview, { initialUrl: url, onFocus: focus }), + ), + ); + } + async function waitFor(assertion: () => void) { + await act(async () => vi.waitFor(assertion)); + } + function calls(command: string) { + return bridge.invoke.mock.calls.filter(([name]) => name === command); + } + function emit(kind: PreviewEvent["kind"], url = "") { + const id = calls("browser_preview_open")[0][1]!.id as string; + for (const handler of bridge.events) + handler({ payload: { id, kind, url } }); + } + + it("opens one native view and routes toolbar controls to that view", async () => { + await render(); + expect(calls("browser_preview_open")).toHaveLength(1); + expect(calls("browser_preview_open")[0][1]).toMatchObject({ + url: "http://localhost:5173/", + bounds: { x: 400, y: 100, width: 500, height: 600 }, + }); + for (const [label, action] of [ + ["Back", "back"], + ["Forward", "forward"], + ["Reload", "reload"], + ]) { + await act(async () => + container + .querySelector(`button[aria-label="${label}"]`)! + .click(), + ); + expect(calls("browser_preview_action").at(-1)?.[1]).toMatchObject({ + action, + }); + } + await act(async () => emit("focus")); + expect(focus).toHaveBeenCalledTimes(1); + await act(async () => + container + .querySelector( + 'button[aria-label="Open in external browser"]', + )! + .click(), + ); + expect(bridge.external).toHaveBeenCalledWith("http://localhost:5173/"); + }); + + it("hides for menus/dialogs and inactive tabs, then restores without creating another view", async () => { + await render(); + const dialog = document.createElement("div"); + dialog.setAttribute("role", "dialog"); + await act(async () => { + document.body.append(dialog); + }); + await waitFor(() => + expect(calls("browser_preview_sync").at(-1)?.[1]?.bounds).toBeNull(), + ); + await act(async () => dialog.remove()); + await waitFor(() => + expect(calls("browser_preview_sync").at(-1)?.[1]?.bounds).not.toBeNull(), + ); + await act(async () => container.setAttribute("aria-hidden", "true")); + await waitFor(() => + expect(calls("browser_preview_sync").at(-1)?.[1]?.bounds).toBeNull(), + ); + expect(calls("browser_preview_open")).toHaveLength(1); + }); + + it("closes a view even when opening finishes after the component unmounts", async () => { + let finish!: () => void; + bridge.invoke.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await render(); + await act(async () => root.unmount()); + expect(calls("browser_preview_close")).toHaveLength(0); + await act(async () => finish()); + await waitFor(() => expect(calls("browser_preview_close")).toHaveLength(1)); + expect(calls("browser_preview_close")[0][1]?.id).toBe( + calls("browser_preview_open")[0][1]?.id, + ); + expect(bridge.events.size).toBe(0); + root = createRoot(container); + }); + + it("shows native errors and retries only after an explicit reload", async () => { + bridge.invoke.mockRejectedValueOnce(new Error("Could not create webview")); + await render(); + expect(container.textContent).toContain("Could not create webview"); + await act(async () => container.setAttribute("class", "changed")); + await act(async () => new Promise((resolve) => setTimeout(resolve, 30))); + expect(calls("browser_preview_open")).toHaveLength(1); + await act(async () => + container + .querySelector('button[aria-label="Reload"]')! + .click(), + ); + expect(calls("browser_preview_open")).toHaveLength(2); + }); + + it("does not create a view for an app or file URL", async () => { + await render("file:///etc/passwd"); + expect(calls("browser_preview_open")).toHaveLength(0); + expect(container.textContent).toContain("Enter an HTTP or HTTPS address"); + }); + + it("explains blocked page actions and clears the notice after navigation succeeds", async () => { + await render(); + for (const [kind, message] of [ + ["blocked", "This address cannot be opened"], + ["popup", "This page requested another window"], + ["download", "Use the external browser to download"], + ] as const) { + await act(async () => emit(kind)); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + message, + ); + await act(async () => emit("url", "http://localhost:5173/")); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + await act(async () => emit("loaded", "http://localhost:5173/next")); + expect(container.querySelector('[role="status"]')).toBeNull(); + } + }); +}); diff --git a/src/surfaces/BrowserPreview.tsx b/src/surfaces/BrowserPreview.tsx new file mode 100644 index 00000000..3408b6c8 --- /dev/null +++ b/src/surfaces/BrowserPreview.tsx @@ -0,0 +1,173 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ChevronLeft, + ChevronRight, + ExternalLink, + RefreshCw, +} from "../chrome/icons"; +import { previewUrl } from "../lib/browserPreview"; +import { + useBrowserPreview, + type PreviewEvent, +} from "../hooks/useBrowserPreview"; + +const BUTTON = + "grid size-6 shrink-0 place-items-center rounded text-content/55 hover:bg-content/10 hover:text-content disabled:opacity-30 disabled:pointer-events-none"; + +export function BrowserPreview({ + initialUrl, + onFocus, +}: { + initialUrl: string; + onFocus: () => void; +}) { + const host = useRef(null); + const [url, setUrl] = useState(initialUrl); + const [address, setAddress] = useState(initialUrl); + const [displayUrl, setDisplayUrl] = useState(initialUrl); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const editing = useRef(false); + useEffect(() => { + setUrl(initialUrl); + setAddress(initialUrl); + }, [initialUrl]); + const onEvent = useCallback( + (event: PreviewEvent) => { + if (event.kind === "focus") { + onFocus(); + return; + } + if ( + event.kind === "blocked" || + event.kind === "popup" || + event.kind === "download" + ) { + setError( + event.kind === "download" + ? "Use the external browser to download this file." + : event.kind === "popup" + ? "This page requested another window. Use the external browser to continue." + : "This address cannot be opened in the preview.", + ); + return; + } + if (event.kind !== "url") { + setLoading(event.kind === "loading"); + setError(""); + } + if (event.url) { + setDisplayUrl(event.url); + if (!editing.current) setAddress(event.url); + } + }, + [onFocus], + ); + const action = useBrowserPreview(host, url, onEvent, setError); + useEffect(() => { + if (!loading) return; + const timeout = setTimeout(() => { + setError( + "The page is taking longer than expected. Reload or open it in your browser.", + ); + setLoading(false); + }, 15000); + return () => clearTimeout(timeout); + }, [loading]); + + return ( +
+
{ + event.preventDefault(); + const target = previewUrl(address); + if (!target) { + setError("Enter an HTTP or HTTPS address."); + return; + } + setError(""); + if (target === url) action({ navigate: target }); + else setUrl(target); + }} + > + + + + setAddress(event.target.value)} + onFocus={() => { + editing.current = true; + }} + onBlur={() => { + editing.current = false; + }} + className="h-6 min-w-0 flex-1 rounded-md border border-content/10 bg-content/5 px-2 text-[12px] text-content outline-none placeholder:text-content/35 focus:border-accent/60" + /> + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ {!url ? ( +
+ Enter a web address or start a local development server. +
+ ) : null} +
+
+ ); +} diff --git a/src/surfaces/FilePane.tsx b/src/surfaces/FilePane.tsx index 152c4887..fc9d194e 100644 --- a/src/surfaces/FilePane.tsx +++ b/src/surfaces/FilePane.tsx @@ -26,6 +26,7 @@ import { BuildTargetButton } from "../chrome/SecondOpinionButton"; import { loadDiffViewer, subscribeDiffViewer } from "../lib/settings"; import { MarkdownPreview } from "./AgentMarkdown"; import { BinaryFileView } from "./BinaryFileView"; +import { BrowserPreview } from "./BrowserPreview"; import { CommitDiff } from "./CommitDiff"; import { FileEditor } from "./FileEditor"; import { ReleaseNotesSurface } from "./ReleaseNotesSurface"; @@ -146,7 +147,12 @@ function FilePaneComponent({ : "hidden" } > - {isPlanTab(file) ? ( + {file.browser ? ( + onFocus(pane.id)} + /> + ) : isPlanTab(file) ? ( + announceLocalPreview(previewCwd, url), + ); const unsubscribe = subscribePty( id, (data) => { + const text = decoder.decode(data, { stream: true }); + const scanned = scanOscCwd(text, oscBuffer); + oscBuffer = scanned.rest; + if (scanned.cwd) previewCwd = scanned.cwd; const onMeta = onMetaChangeRef.current; - if (onMeta) { - const text = new TextDecoder().decode(data); - const scanned = scanOscCwd(text, oscBuffer); - oscBuffer = scanned.rest; - if (scanned.cwd) { - const patch: TerminalMetaPatch = { cwd: scanned.cwd }; - if (!runningProcessRef.current) { - patch.title = defaultTerminalTitle(scanned.cwd); - } - onMeta(patch); + if (onMeta && scanned.cwd) { + const patch: TerminalMetaPatch = { cwd: scanned.cwd }; + if (!runningProcessRef.current) { + patch.title = defaultTerminalTitle(scanned.cwd); } + onMeta(patch); } + scanPreview(text); term.write(data); }, (code) => { if (closed) return; + scanPreview(decoder.decode(), true); const status = code == null ? "" : ` (${code})`; term.writeln(`\r\n[process exited${status}]`); },