From c83f9e1ba1971906cebc1a4a6c31856f226b5b4a Mon Sep 17 00:00:00 2001 From: gettyeuro <202671653+yankawai@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:08:13 +0300 Subject: [PATCH 1/2] Use Ubuntu package sources for Linux CI dependencies Avoid unrelated third-party apt repositories on hosted runners and let all platform checks finish independently. Keep normal developer setup unchanged. --- .github/workflows/ci.yml | 3 ++- scripts/install-linux-deps-debian.sh | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) 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/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 \ From 8e11c7ee3b212dea4d830b6580aae9b8329a6821 Mon Sep 17 00:00:00 2001 From: gettyeuro <202671653+yankawai@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:46:22 +0300 Subject: [PATCH 2/2] Add an in-app web preview pane Reuse the existing file pane and tab chrome for manual HTTP(S) navigation and automatic previews from new loopback server output. Deduplicate server origins and preserve conversation focus and workspace restoration. Keep Wry children outside the Tauri registry and command bridge, with pane-bound visibility, geometry, focus notification and cleanup. Cover URL handling, output detection and native bridge lifecycle in tests. --- Cargo.lock | 2 + src-tauri/Cargo.toml | 3 + src-tauri/src/browser_preview.rs | 331 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 7 + src/App.tsx | 45 ++++ src/chrome/SurfaceTabs.tsx | 13 +- src/chrome/TitleBar.tsx | 8 + src/hooks/useBrowserPreview.ts | 219 ++++++++++++++++++ src/hooks/useLocalPreviews.test.ts | 118 ++++++++++ src/hooks/useLocalPreviews.ts | 86 ++++++++ src/lib/browserPreview.test.ts | 123 +++++++++++ src/lib/browserPreview.ts | 70 ++++++ src/lib/layout.ts | 38 +++- src/lib/workspaceSnapshot.ts | 21 ++ src/surfaces/BrowserPreview.test.ts | 188 ++++++++++++++++ src/surfaces/BrowserPreview.tsx | 173 +++++++++++++++ src/surfaces/FilePane.tsx | 8 +- src/surfaces/TerminalView.tsx | 27 ++- 18 files changed, 1466 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/browser_preview.rs create mode 100644 src/hooks/useBrowserPreview.ts create mode 100644 src/hooks/useLocalPreviews.test.ts create mode 100644 src/hooks/useLocalPreviews.ts create mode 100644 src/lib/browserPreview.test.ts create mode 100644 src/lib/browserPreview.ts create mode 100644 src/surfaces/BrowserPreview.test.ts create mode 100644 src/surfaces/BrowserPreview.tsx 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/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}]`); },