diff --git a/Cargo.lock b/Cargo.lock index 8e3be05ea5..9221c1fe32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -952,7 +952,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -1628,7 +1628,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "gpui_util", "indexmap", @@ -2426,7 +2426,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "proc-macro2", "quote", @@ -3647,7 +3647,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "accesskit", "anyhow", @@ -3733,7 +3733,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/zeronsh/gpui-component?rev=4764fd002ab5894ec9413511d01d37481b2129ab#4764fd002ab5894ec9413511d01d37481b2129ab" +source = "git+https://github.com/zeronsh/gpui-component?rev=47ccd07295858d66f8fe826df9651a56218eb887#47ccd07295858d66f8fe826df9651a56218eb887" dependencies = [ "aho-corasick", "anyhow", @@ -3763,7 +3763,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "accesskit", "accesskit_unix", @@ -3815,7 +3815,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "accesskit", "accesskit_macos", @@ -3864,7 +3864,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3875,7 +3875,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "console_error_panic_hook", "gpui", @@ -3888,7 +3888,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "schemars", "serde", @@ -3898,7 +3898,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "gpui", @@ -3909,7 +3909,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "log", @@ -3919,7 +3919,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3943,7 +3943,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "bytemuck", @@ -3972,7 +3972,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "accesskit", "accesskit_windows", @@ -4316,7 +4316,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "async-compression", @@ -5563,7 +5563,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -6937,7 +6937,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "collections", "serde", @@ -8051,7 +8051,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "derive_refineable", ] @@ -8772,7 +8772,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "async-task", "backtrace", @@ -9619,7 +9619,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "heapless 0.9.3", "log", @@ -11131,7 +11131,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=0966d065b23e0b7c9b53c2e186705b8bce16fe2d#0966d065b23e0b7c9b53c2e186705b8bce16fe2d" +source = "git+https://github.com/zeronsh/zui?rev=dce5c1f737a834b32c2b531d552ea78035e54093#dce5c1f737a834b32c2b531d552ea78035e54093" dependencies = [ "perf", "quote", @@ -13334,6 +13334,9 @@ dependencies = [ "url", "usvg", "uuid", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", "windows-sys 0.61.2", "wry", "x11rb 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index b5edee7fd6..9eb9e5b5be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,15 +87,15 @@ loro-protocol = "0.3" # Linux — the Metal path's snapshot/blur/composite, ported). # Paint-time rounded image masks (#9), bounded Windows backdrop blur and # translucent compositing, plus blur-aware scene batching (#10). -gpui = { git = "https://github.com/zeronsh/zui", rev = "0966d065b23e0b7c9b53c2e186705b8bce16fe2d" } -gpui_platform = { git = "https://github.com/zeronsh/zui", rev = "0966d065b23e0b7c9b53c2e186705b8bce16fe2d", features = [ +gpui = { git = "https://github.com/zeronsh/zui", rev = "dce5c1f737a834b32c2b531d552ea78035e54093" } +gpui_platform = { git = "https://github.com/zeronsh/zui", rev = "dce5c1f737a834b32c2b531d552ea78035e54093", features = [ "wayland", "x11", "font-kit", "runtime_shaders", ] } -gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "0966d065b23e0b7c9b53c2e186705b8bce16fe2d" } -gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "4764fd002ab5894ec9413511d01d37481b2129ab" } +gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "dce5c1f737a834b32c2b531d552ea78035e54093" } +gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "47ccd07295858d66f8fe826df9651a56218eb887" } # diffs similar = "2" diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 0ee6078d19..d26f7046d3 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -3471,10 +3471,13 @@ impl RpcService for EngineRpc { .filter_map(|chat| chat.cwd) .map(std::path::PathBuf::from) .collect(); - let chunk = self - .uploads - .read_chunk(&p.path, p.offset, &roots) - .map_err(|e| RpcError::Failed(e.to_string()))?; + let uploads = self.uploads.clone(); + let chunk = tokio::task::spawn_blocking(move || { + uploads.read_chunk(&p.path, p.offset, &roots) + }) + .await + .map_err(|e| RpcError::Failed(e.to_string()))? + .map_err(|e| RpcError::Failed(e.to_string()))?; RpcReply::value(&chunk) } methods::FETCH_TOOL_BLOB => { diff --git a/crates/engine/src/uploads.rs b/crates/engine/src/uploads.rs index 2557681375..a3aa7a3690 100644 --- a/crates/engine/src/uploads.rs +++ b/crates/engine/src/uploads.rs @@ -332,17 +332,37 @@ impl Uploads { } else { path }; - // Canonicalize BOTH sides so `..` segments and symlinks can't escape. - let resolved = std::fs::canonicalize(path).map_err(|_| outside())?; let read_roots = self .inner .read_only_roots .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); - let allowed = std::iter::once(&self.inner.dir) + let roots: Vec<&PathBuf> = std::iter::once(&self.inner.dir) .chain(read_roots.iter()) .chain(extra_roots.iter()) + .collect(); + // Jail lexically BEFORE any filesystem call: tool chips send paths an + // agent printed, and merely opening `\\host\share\a.png` (or an autofs + // `/net/host/…`) reaches out to that host — on Windows with the + // user's NTLM credentials. Roots match in raw and canonical form + // (`/var` vs `/private/var`, symlinked cwds). + let requested = lexical_components(Path::new(path)).ok_or_else(outside)?; + let lexically_allowed = roots.iter().any(|root| { + let canonical = std::fs::canonicalize(root).ok(); + [Some(root.as_path()), canonical.as_deref()] + .into_iter() + .flatten() + .filter_map(lexical_components) + .any(|root| requested.len() > root.len() && requested.starts_with(&root)) + }); + if !lexically_allowed { + return Err(outside()); + } + // Canonicalize BOTH sides so symlinks can't escape. + let resolved = std::fs::canonicalize(path).map_err(|_| outside())?; + let allowed = roots + .iter() .filter_map(|root| std::fs::canonicalize(root).ok()) .any(|root| resolved.starts_with(&root) && resolved != root); if !allowed { @@ -389,6 +409,48 @@ struct InspectedFile { size: u64, } +/// An absolute local path's components with `.`/`..` resolved WITHOUT +/// touching the filesystem. `None` for relative or drive-relative paths and +/// for any Windows prefix but a drive letter (UNC, `\\.\` devices, verbatim +/// non-disk). `C:` and `\\?\C:` agree; Windows compares case-insensitively. +fn lexical_components(path: &Path) -> Option> { + use std::path::{Component, Prefix}; + if !path.is_absolute() { + return None; + } + let mut out: Vec = Vec::new(); + // Prefix + root: `/..` is `/`, so `..` never pops below them. + let mut anchor = 0; + for component in path.components() { + match component { + Component::Prefix(prefix) => match prefix.kind() { + Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => { + out.push(format!("{}:", drive.to_ascii_uppercase() as char).into()); + anchor = out.len(); + } + _ => return None, + }, + Component::RootDir => { + out.push(std::path::MAIN_SEPARATOR_STR.into()); + anchor = out.len(); + } + Component::CurDir => {} + Component::ParentDir => { + if out.len() > anchor { + out.pop(); + } + } + Component::Normal(name) => { + #[cfg(windows)] + out.push(name.to_string_lossy().to_lowercase().into()); + #[cfg(not(windows))] + out.push(name.to_os_string()); + } + } + } + Some(out) +} + fn chunk_files(dir: &Path) -> Result, EngineError> { let Ok(entries) = std::fs::read_dir(dir) else { return Ok(Vec::new()); @@ -596,6 +658,81 @@ mod tests { assert!(target.starts_with(dir.path())); assert!(!target.to_string_lossy().contains("..")); } + + #[test] + fn network_and_escaping_paths_are_refused_before_touching_the_filesystem() { + let dir = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let uploads = Uploads::from_root(dir.path()); + let roots = [workspace.path().to_path_buf()]; + std::fs::write(workspace.path().join("shot.png"), b"png").unwrap(); + std::fs::write(elsewhere.path().join("shot.png"), b"png").unwrap(); + let inside = workspace.path().join("shot.png"); + assert!( + uploads + .read_chunk(inside.to_str().unwrap(), 0, &roots) + .is_ok() + ); + let escape = workspace + .path() + .join("..") + .join(elsewhere.path().file_name().unwrap()) + .join("shot.png"); + let mut paths = vec![ + r"\\host\share\x.png".to_string(), + "//host/share/x.png".into(), + r"\\?\UNC\host\share\x.png".into(), + r"\\.\pipe\x.png".into(), + "file:////host/share/x.png".into(), + "/net/somehost/x.png".into(), + "C:x.png".into(), + "shot.png".into(), + escape.to_string_lossy().into_owned(), + ]; + // A link from outside every root into one canonicalizes inside the + // jail; refusing it proves the lexical check runs before any + // filesystem call on the requested path. + #[cfg(unix)] + { + let link = elsewhere.path().join("link"); + std::os::unix::fs::symlink(workspace.path(), &link).unwrap(); + paths.push(link.join("shot.png").to_string_lossy().into_owned()); + } + for path in paths { + let error = uploads + .read_chunk(&path, 0, &roots) + .expect_err(&format!("accepted {path:?}")); + assert!( + error.to_string().contains("outside the upload cache"), + "{path:?}: {error}" + ); + } + } + + #[test] + fn lexical_components_resolve_dots_and_refuse_non_disk_prefixes() { + let lexical = |path: &str| lexical_components(Path::new(path)); + assert_eq!(lexical("/a/./b/../c/../../.."), lexical("/")); + assert_eq!(lexical("/a/b/../c.png"), lexical("/a/c.png")); + assert_eq!(lexical("relative/c.png"), None); + #[cfg(windows)] + { + assert_eq!(lexical(r"\\?\c:\A\b.png"), lexical(r"C:\a\B.PNG")); + assert_eq!(lexical(r"C:\..\..\a.png"), lexical(r"C:\a.png")); + for path in [ + r"\\host\share\x.png", + "//host/share/x.png", + r"\\?\UNC\host\share\x.png", + r"\\.\pipe\x.png", + r"\\?\pipe\x.png", + "C:x.png", + r"\x.png", + ] { + assert_eq!(lexical(path), None, "{path}"); + } + } + } } /// Imported generated media: only these sanitized fields may leave the engine. diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 2f6d6abc86..79a6e7c6b1 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -35,6 +35,10 @@ required-features = ["windows-render-fixture"] name = "appshots-fixture" required-features = ["appshots-fixture"] +[[example]] +name = "tool-images-fixture" +required-features = ["appshots-fixture"] + [[example]] name = "new-project-fixture" required-features = ["project-palette-fixture"] @@ -124,4 +128,11 @@ mimalloc.workspace = true tracing-subscriber.workspace = true [target.'cfg(windows)'.dependencies] +# Embedded browser: WebView2 in visual-hosting mode, composited by the zui +# overlay plane. Same windows 0.61 as gpui_windows, so COM types interoperate. +webview2-com = "0.38" +windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Direct2D_Common", "Win32_Graphics_DirectComposition", "Win32_System_Com", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } +windows-core = "0.61" +raw-window-handle = "0.6" +reqwest.workspace = true windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/ui/examples/tool-images-fixture.rs b/crates/ui/examples/tool-images-fixture.rs new file mode 100644 index 0000000000..c84b6ef464 --- /dev/null +++ b/crates/ui/examples/tool-images-fixture.rs @@ -0,0 +1,428 @@ +//! Interactive tool-image preview session with isolated data and mock images. +//! Images load through the real engine RPC (ReadAttachmentChunk) from a temp +//! workspace. Close the window to exit. No agent runs. +use gpui::{AppContext, Bounds, WindowBounds, WindowOptions, px, size}; +use image::{ImageEncoder, Rgba, RgbaImage}; +use serde_json::json; +use std::{path::Path, sync::Arc}; +use zeron_proto::ToolCall; +use zeron_ui::*; + +fn port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn gradient(width: u32, height: u32, seed: u8) -> RgbaImage { + RgbaImage::from_fn(width, height, |x, y| { + let (fx, fy) = (x as f32 / width as f32, y as f32 / height as f32); + let ring = (((fx - 0.5).powi(2) + (fy - 0.5).powi(2)).sqrt() * 18.0).sin() > 0.6; + Rgba([ + (fx * 255.0) as u8 ^ seed, + (fy * 220.0) as u8, + if ring { 255 } else { 90 + seed / 2 }, + 255, + ]) + }) +} + +fn write_png(path: &Path, image: &RgbaImage) -> anyhow::Result<()> { + std::fs::create_dir_all(path.parent().unwrap())?; + let file = std::io::BufWriter::new(std::fs::File::create(path)?); + image::codecs::png::PngEncoder::new_with_quality( + file, + image::codecs::png::CompressionType::Fast, + image::codecs::png::FilterType::NoFilter, + ) + .write_image( + image, + image.width(), + image.height(), + image::ExtendedColorType::Rgba8, + )?; + Ok(()) +} + +fn seed_workspace(root: &Path) -> anyhow::Result<()> { + write_png(&root.join("screenshots/home.png"), &gradient(1600, 1000, 0))?; + let mut chart = RgbaImage::from_pixel(800, 500, Rgba([24, 24, 28, 255])); + for (i, h) in [120u32, 260, 180, 420, 330, 210].iter().enumerate() { + for x in (60 + i as u32 * 120)..(140 + i as u32 * 120) { + for y in (480 - h)..480 { + chart.put_pixel(x, y, Rgba([90, 160 + i as u8 * 15, 255, 255])); + } + } + } + write_png(&root.join("charts/chart.png"), &chart)?; + write_png(&root.join("user/attached.png"), &gradient(900, 600, 77))?; + write_png(&root.join("generated/gen.png"), &gradient(1024, 1024, 150))?; + std::fs::create_dir_all(root.join("photos"))?; + image::DynamicImage::ImageRgba8(gradient(1920, 1080, 33)) + .to_rgb8() + .save(root.join("photos/sunset.jpg"))?; + std::fs::create_dir_all(root.join("assets"))?; + image::DynamicImage::ImageRgba8(gradient(512, 512, 200)).save(root.join("assets/icon.webp"))?; + { + let file = std::fs::File::create(root.join("assets/spinner.gif"))?; + let mut encoder = image::codecs::gif::GifEncoder::new(file); + for seed in [10, 120] { + encoder.encode_frame(image::Frame::new(gradient(300, 300, seed)))?; + } + } + std::fs::write( + root.join("assets/logo.svg"), + r##"zeron"##, + )?; + std::fs::write(root.join("assets/notes.txt"), "not an image")?; + // Decode-bound check: 15 MP source, previewed as a ≤1280×640 thumbnail. + write_png(&root.join("big/huge.png"), &gradient(5000, 3000, 5))?; + for i in 0..12 { + write_png( + &root.join(format!("stress/frame-{i:02}.png")), + &gradient(1400, 900, i * 20), + )?; + } + Ok(()) +} + +fn tool(id: &str, call: ToolCall, output: &str, is_error: bool) -> serde_json::Value { + json!({"id": id, "kind": "tool", "call": call, "isError": is_error, "resolved": true, "output": output}) +} + +async fn pause(cx: &mut gpui::AsyncApp, ms: u64) { + cx.background_executor() + .timer(std::time::Duration::from_millis(ms)) + .await; +} + +fn capture( + window: gpui::AnyWindowHandle, + cx: &mut gpui::AsyncApp, + dir: &Path, + name: &str, +) -> anyhow::Result<()> { + // Best effort: some platforms (Windows) have no render_to_image. + window.update(cx, |_, w, cx| { + w.draw(cx).clear(); + if let Ok(image) = w.render_to_image() { + image.save(dir.join(format!("{name}.png"))).ok(); + } + })?; + Ok(()) +} + +/// Expand, spam-toggle, collapse and copy through the production views, +/// asserting residency at each step. Restores the user's clipboard. +async fn self_check( + window: gpui::WindowHandle, + dir: &Path, + chart: &str, + cx: &mut gpui::AsyncApp, +) -> anyhow::Result<()> { + std::fs::create_dir_all(dir)?; + let transcript = window.update(cx, |shell, _, _| shell.fixture_transcript())?; + let stats = + |cx: &mut gpui::AsyncApp| transcript.read_with(cx, |t, _| t.fixture_tool_image_stats()); + let toggle = |open: bool, cx: &mut gpui::AsyncApp| { + transcript.update(cx, |t, cx| t.fixture_tool_images(open, cx)) + }; + let mut log = String::new(); + pause(cx, 1500).await; + capture(window.into(), cx, dir, "1-collapsed")?; + let collapsed = stats(cx); + log += &format!( + "collapsed: {collapsed:?} +" + ); + anyhow::ensure!(collapsed == (0, 0), "collapsed chips hold previews"); + + toggle(true, cx); + transcript.update(cx, |t, cx| t.fixture_appshots_start(cx)); + pause(cx, 8000).await; + capture(window.into(), cx, dir, "2-expanded")?; + let expanded = stats(cx); + log += &format!( + "expanded: {expanded:?} +" + ); + anyhow::ensure!( + expanded.0 > 0 && expanded.1 > 0, + "expanded chips decoded nothing" + ); + + let started = std::time::Instant::now(); + for i in 0..60 { + toggle(i % 2 == 1, cx); + pause(cx, 16).await; + } + pause(cx, 300).await; + let spammed = stats(cx); + log += &format!( + "after 60 toggles in {:?}: {spammed:?} +", + started.elapsed() + ); + anyhow::ensure!(spammed == expanded, "spam re-decoded or dropped previews"); + + toggle(false, cx); + pause(cx, 250).await; + let grace = stats(cx); + log += &format!( + "250ms after collapse: {grace:?} +" + ); + pause(cx, 1200).await; + capture(window.into(), cx, dir, "3-collapsed-again")?; + let freed = stats(cx); + log += &format!( + "1450ms after collapse: {freed:?} +" + ); + anyhow::ensure!(freed == (0, 0), "collapsed previews were not freed"); + + let previous = cx.update(|cx| cx.read_from_clipboard()); + gpui::AnyWindowHandle::from(window).update(cx, |_, w, cx| { + transcript.update(cx, |t, cx| t.fixture_image_menu(chart.into(), None, w, cx)) + })?; + pause(cx, 2500).await; + capture(window.into(), cx, dir, "4-menu")?; + gpui::AnyWindowHandle::from(window).update(cx, |_, w, cx| { + transcript.update(cx, |t, cx| { + t.fixture_image_menu(chart.into(), Some(0), w, cx) + }) + })?; + let text = cx.update(|cx| cx.read_from_clipboard().and_then(|c| c.text())); + log += &format!( + "copy path: {text:?} +" + ); + anyhow::ensure!(text.as_deref() == Some(chart), "copy path mismatch"); + gpui::AnyWindowHandle::from(window).update(cx, |_, w, cx| { + transcript.update(cx, |t, cx| { + t.fixture_image_menu(chart.into(), Some(1), w, cx) + }) + })?; + pause(cx, 2000).await; + let image = cx.update(|cx| { + cx.read_from_clipboard().and_then(|c| { + c.entries().iter().find_map(|e| match e { + gpui::ClipboardEntry::Image(image) => Some(image.bytes().len()), + _ => None, + }) + }) + }); + log += &format!( + "copy image bytes: {image:?} +" + ); + if let Some(previous) = previous { + cx.update(|cx| cx.write_to_clipboard(previous)); + } + anyhow::ensure!( + image.is_some_and(|n| n > 0), + "copy image put no image on the clipboard" + ); + log += "PASS +"; + print!("{log}"); + std::fs::write(dir.join("result.txt"), log)?; + Ok(()) +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter("warn,zeron_ui::tool_images=debug") + .init(); + let temp = tempfile::tempdir()?; + let workspace = temp.path().join("workspace"); + seed_workspace(&workspace)?; + let cwd = workspace.to_string_lossy().into_owned(); + let abs = |rel: &str| { + workspace + .join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)) + .to_string_lossy() + .into_owned() + }; + let runtime = tokio::runtime::Runtime::new()?; + let core = runtime.block_on(async { + zeron_engine::EngineCore::assemble( + &temp.path().join("engine"), + Arc::new(zeron_engine::default_registry()), + zeron_proto::HarnessId::ClaudeCode, + None, + ) + })?; + core.workspace.create_chat( + "tool-images", + None, + Some(&core.device_id), + None, + Some(cwd.clone()), + )?; + core.workspace + .rename_chat("tool-images", "Tool image previews")?; + let ipc_port = port(); + let _ipc = runtime.block_on(zeron_engine::serve_ipc(ipc_port, core.rpc_service()))?; + let data = temp.path().join("ui"); + std::fs::create_dir(&data)?; + let boot = EngineBootConfig { + data_dir: data.clone(), + ipc_port, + edge_url: String::new(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::ClaudeCode, + }; + let handle = runtime.block_on(state::EngineHandle::bootstrap(boot.clone()))?; + let chats = core.workspace.read_chats()?; + let device = core.device_id.clone(); + + let read = |path: &str| ToolCall::ReadFile { path: path.into() }; + let exec = |command: &str| ToolCall::Exec { + command: command.into(), + }; + let outside = if cfg!(windows) { + r"C:\Windows\Web\Wallpaper\Windows\img0.jpg" + } else { + "/usr/share/backgrounds/outside.png" + }; + let capture = std::env::args() + .skip_while(|arg| arg != "--capture") + .nth(1) + .map(std::path::PathBuf::from); + let chart = abs("charts/chart.png"); + let entries = json!([ + {"id": "u1", "role": "user", "createdAt": 1788900000000_i64, "deviceId": device, + "parts": [{"id": "t", "kind": "text", "text": attachments::with_attachments( + "Here is the mockup I attached. Render every image the tools touch.", + &[abs("user/attached.png")])}]}, + {"id": "a1", "role": "assistant", "createdAt": 1788900001000_i64, "deviceId": device, "status": "complete", + "parts": [ + {"id": "p0", "kind": "text", "text": "Open a tool group, then expand a chip. Its image decodes only while the chip is open. Right-click any image for **Copy path** / **Copy image**."}, + tool("t1", read("screenshots/home.png"), "Read image (1600×1000)", false), + tool("t2", exec("python scripts/plot.py --out charts/chart.png"), "Saved chart to charts/chart.png", false), + tool("t3", ToolCall::Mcp { server: "browser".into(), tool: "screenshot".into(), + input: Some(json!({"path": abs("photos/sunset.jpg"), "fullPage": true})) }, "Captured viewport", false), + tool("t4", exec("ls assets"), "icon.webp\nlogo.svg\nnotes.txt\nspinner.gif", false), + tool("t5", read("big/huge.png"), "Read image (5000×3000)", false), + tool("t6", read("missing/nope.png"), "File not found", true), + tool("t7", read(outside), "Read image", false), + tool("t8", read("src/main.rs"), "fn main() {}", false), + {"id": "p1", "kind": "text", "text": "Above: PNG, chart, JPEG via MCP, WebP/SVG/GIF from output, a 15 MP PNG, a missing file and a path outside the workspace jail (both show *Image unavailable*)."}, + {"id": "g1", "kind": "image", "path": abs("generated/gen.png"), "name": "gen.png", "mimeType": "image/png"}, + ]}, + {"id": "a2", "role": "assistant", "createdAt": 1788900002000_i64, "deviceId": device, "status": "complete", + "parts": (0..12).map(|i| tool(&format!("s{i}"), read(&format!("stress/frame-{i:02}.png")), "Read image (1400×900)", false)) + .chain(std::iter::once(json!({"id": "p2", "kind": "text", "text": "Stress group: spam-toggle these chips. Previews reuse their texture inside the 600 ms grace and free after it (watch the `tool_images` debug log)."}))) + .collect::>()}, + ]); + + gpui_platform::application() + .with_assets(icons::Assets) + .run(move |cx| { + gpui_tokio::init(cx); + gpui_base::init(cx); + let settings = settings::UiSettings::default(); + settings::init(settings.clone(), data.clone(), cx); + let fonts = typography::register_fonts(cx); + typography::init( + settings.ui_font_family.clone(), + settings.ui_font_size, + settings.terminal_font_family.clone(), + settings.terminal_font_size, + settings.code_font_family.clone(), + settings.code_font_size, + fonts, + cx, + ); + theme_library::init(data.clone(), cx); + appearance::init( + appearance::AppearanceMode::Dark, + settings.theme_selection, + settings.accent, + settings.surface, + cx, + ); + history::init( + settings.git_history_columns, + settings.git_history_column_widths, + settings.git_history_column_order, + settings.git_history_author_display, + cx, + ); + composer::init(cx, settings.composer_send_behavior); + terminal::panel::init(cx); + app_menus::init(cx); + let state = cx.new(|_| { + let mut s = state::AppState::new(); + s.fixture_attachment_engine(handle); + s.connection = zeron_proto::view::ConnectionStatus::Ready; + s.workspace_scope = Some(zeron_proto::WorkspaceScope::Development); + s.local_device_id = Some(device.clone()); + s.devices = vec![ + serde_json::from_value(json!({"id": device, "name": "This device", + "platform": std::env::consts::OS, "lastSeenAt": null})) + .unwrap(), + ]; + s.chats = chats; + s.selected_chat = Some("tool-images".into()); + s.auto_selected = true; + s.chats_synced = true; + s.spaces_synced = true; + s.no_project = true; + s + }); + let window = cx + .open_window( + WindowOptions { + window_background: theme::Theme::of(cx).window_background_appearance(), + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + gpui::point(px(40.), px(40.)), + size(px(1200.), px(900.)), + ))), + ..Default::default() + }, + |_, cx| cx.new(|cx| shell::Shell::new(state.clone(), boot, cx)), + ) + .unwrap(); + cx.on_window_closed(|cx, _| cx.quit()).detach(); + state.update(cx, |_, cx| cx.notify()); + cx.activate(true); + cx.spawn(async move |cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(800)) + .await; + state.update(cx, |s, cx| { + s.receive_transcript_frame( + zeron_doc::TranscriptFrame::Reset { + reset: serde_json::from_value(entries).unwrap(), + }, + cx, + ) + .unwrap(); + cx.notify(); + }); + if let Some(dir) = capture { + if let Err(error) = self_check(window, &dir, &chart, cx).await { + eprintln!("self-check failed: {error:#}"); + std::fs::write( + dir.join("result.txt"), + format!( + "FAILED: {error:#} +" + ), + ) + .ok(); + } + cx.update(|cx| cx.quit()); + } + }) + .detach(); + }); + runtime.block_on(core.shutdown()); + Ok(()) +} diff --git a/crates/ui/src/attachments.rs b/crates/ui/src/attachments.rs index 38fa5195bf..06b0d65f55 100644 --- a/crates/ui/src/attachments.rs +++ b/crates/ui/src/attachments.rs @@ -631,7 +631,7 @@ pub struct LoadedAttachmentImage { /// `ReadAttachmentChunk` loop: 45KB base64 chunks until `done` (bounded, with /// the same stuck-offset guard as zeron's `readAttachmentImage`). Returns the /// file's name, MIME type and bytes. -async fn read_attachment_bytes( +pub(crate) async fn read_attachment_bytes( engine: &EngineHandle, executor: &BackgroundExecutor, target_device_id: Option<&str>, @@ -680,7 +680,11 @@ async fn read_attachment_bytes( if !done { return None; } - let bytes = BASE64.decode(b64.as_bytes()).ok()?; + // Callers await this on the UI thread; decode the (up to 24 MiB) payload + // off it. + let bytes = executor + .spawn(async move { BASE64.decode(b64.as_bytes()).ok() }) + .await?; Some((name, mime, bytes)) } diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index 6b4cb526a8..83f03f529b 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -7,6 +7,10 @@ mod macos; use linux as native; #[cfg(target_os = "macos")] use macos as native; +#[cfg(windows)] +mod windows_webview; +#[cfg(windows)] +use windows_webview as native; pub mod model; mod view; @@ -70,12 +74,12 @@ pub enum BrowserEvent { /// A window/profile's ephemeral website data, allocated on first navigation. #[derive(Clone, Default)] pub struct BrowserContext { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] data: native::BrowserData, } pub struct BrowserSurface { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] context: BrowserContext, address: Entity, focus: FocusHandle, @@ -92,19 +96,22 @@ pub struct BrowserSurface { presentation: Presentation, #[cfg(target_os = "macos")] resize_inset: gpui::Pixels, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] right_occlusion: gpui::Pixels, _input_sub: Subscription, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] native: Option, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] native_tx: tokio::sync::mpsc::Sender, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] _native_task: gpui::Task<()>, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] favicon_task: Option>, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] favicon_generation: u64, + /// The page's pointer cursor (Windows: GPUI owns the cursor). + #[cfg(windows)] + page_cursor: gpui::CursorStyle, } impl EventEmitter for BrowserSurface {} @@ -135,9 +142,9 @@ impl BrowserSurface { cx.notify(); } }); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] let (native_tx, mut events) = tokio::sync::mpsc::channel(64); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] let native_task = cx.spawn_in(window, async move |this, cx| { while let Some(event) = events.recv().await { if this @@ -150,10 +157,10 @@ impl BrowserSurface { } } }); - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] let _ = (window, context); Self { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] context, address, focus: cx.focus_handle(), @@ -170,19 +177,21 @@ impl BrowserSurface { presentation: Presentation::Hidden, #[cfg(target_os = "macos")] resize_inset: gpui::px(0.0), - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] right_occlusion: gpui::px(0.0), _input_sub: input_sub, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] native: None, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] native_tx, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] _native_task: native_task, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] favicon_task: None, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] favicon_generation: 0, + #[cfg(windows)] + page_cursor: gpui::CursorStyle::Arrow, } } @@ -193,7 +202,7 @@ impl BrowserSurface { self.remote = remote; } pub fn set_shortcuts(&mut self, keymap: &crate::settings::KeymapConfig) { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] if let Some(native) = &self.native { native.set_shortcuts( crate::settings::ShortcutId::ALL @@ -203,12 +212,12 @@ impl BrowserSurface { .collect(), ); } - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", windows)))] let _ = keymap; } pub fn focus_address(&mut self, window: &mut Window, cx: &mut Context) { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] if let Some(native) = &self.native { native.focus_chrome(); } @@ -226,7 +235,7 @@ impl BrowserSurface { } /// Crop a GPUI overlay out of both native painting and native hit testing. - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] pub fn set_right_occlusion(&mut self, width: gpui::Pixels, cx: &mut Context) { if self.right_occlusion != width { self.right_occlusion = width; @@ -239,7 +248,7 @@ impl BrowserSurface { return; } self.presentation = presentation; - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] if let Some(native) = &mut self.native { native.present(presentation); } @@ -331,9 +340,9 @@ impl BrowserSurface { self.page.title.clear(); self.page.error = None; self.clear_favicon(cx); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] { self.favicon_generation += 1; self.favicon_task = None; @@ -354,7 +363,7 @@ impl BrowserSurface { } window.focus(&self.focus, cx); } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { let _ = window; cx.open_url(&url); @@ -369,7 +378,7 @@ impl BrowserSurface { } fn reload(&mut self, cx: &mut Context) { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] if let Some(native) = &self.native { if self.page.error.is_some() { if let Some(url) = &self.page.url { @@ -380,7 +389,7 @@ impl BrowserSurface { } self.page.error = None; } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] self.open_external(cx); cx.notify(); } @@ -397,11 +406,11 @@ impl BrowserSurface { } fn history(&mut self, forward: bool) { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] if let Some(native) = &self.native { native.history(forward); } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] let _ = forward; } @@ -415,7 +424,7 @@ impl BrowserSurface { pub fn close(&mut self, cx: &mut Context) { self.set_presentation(Presentation::Hidden, cx); self.clear_favicon(cx); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "linux", windows))] { if let Some(native) = &mut self.native { native.present(Presentation::Hidden); @@ -425,7 +434,7 @@ impl BrowserSurface { cx.defer(move |cx| gpui::ImageSource::Render(image).evict(None, cx)); } self.native = None; - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] { self.favicon_task = None; self.favicon_generation += 1; @@ -434,7 +443,7 @@ impl BrowserSurface { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] impl BrowserSurface { fn on_native_event( &mut self, @@ -495,6 +504,13 @@ impl BrowserSurface { }); } } + #[cfg(windows)] + native::NativeEvent::Cursor(style) => { + if self.page_cursor != style { + self.page_cursor = style; + cx.notify(); + } + } native::NativeEvent::Favicon { page, url } => { if self.page.url.as_deref() != Some(&page) || !model::allowed_navigation(&url) { return; @@ -607,7 +623,11 @@ impl BrowserSurface { self.presentation != Presentation::Hidden && self.native.as_ref().is_some_and(|n| n.image.is_some()) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + { + self.presentation != Presentation::Hidden && self.native.is_some() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { false } diff --git a/crates/ui/src/browser/model.rs b/crates/ui/src/browser/model.rs index 92d5a94b35..306bc13a70 100644 --- a/crates/ui/src/browser/model.rs +++ b/crates/ui/src/browser/model.rs @@ -137,6 +137,14 @@ pub fn allowed_navigation(address: &str) -> bool { }) } +/// Subframe navigations: web content, including a frame's own documents +/// (`about:blank`, `about:srcdoc`, `data:`, `blob:`) — never a scheme that +/// leaves the page (`file:`, `javascript:`, app links). +pub fn allowed_frame_navigation(address: &str) -> bool { + allowed_navigation(address) + || url::Url::parse(address).is_ok_and(|u| matches!(u.scheme(), "about" | "data" | "blob")) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Presentation { #[default] @@ -193,6 +201,28 @@ mod tests { assert!(!allowed_navigation("https://user@example.com/")); } #[test] + fn frames_load_web_content_but_never_leave_the_page() { + for ok in [ + "https://example.com/embed", + "about:blank", + "about:srcdoc", + "data:text/html,hi", + "blob:https://example.com/0f3e", + ] { + assert!(allowed_frame_navigation(ok), "{ok}"); + } + for blocked in [ + "zeron://open/chat/a", + "ms-settings:privacy", + "file:///C:/Windows/win.ini", + "javascript:alert(1)", + "mailto:a@example.com", + "", + ] { + assert!(!allowed_frame_navigation(blocked), "{blocked}"); + } + } + #[test] fn native_visibility_never_outlives_its_surface() { assert_eq!(presentation(false, false), Presentation::Hidden); assert_eq!(presentation(false, true), Presentation::Hidden); diff --git a/crates/ui/src/browser/view.rs b/crates/ui/src/browser/view.rs index 47d268754b..f968cfddf2 100644 --- a/crates/ui/src/browser/view.rs +++ b/crates/ui/src/browser/view.rs @@ -284,7 +284,7 @@ impl BrowserSurface { if self.page.url.is_none() && self.previews_task.is_some() { return self.preview_body(theme, cx); } - let external = !cfg!(any(target_os = "macos", target_os = "linux")); + let external = !cfg!(any(target_os = "macos", target_os = "linux", windows)); let has_error = self.page.error.is_some(); let title = if has_error { "Couldn’t load this page" @@ -399,11 +399,140 @@ impl BrowserSurface { } } +#[cfg(windows)] +impl BrowserSurface { + /// The page composites natively beneath GPUI's overlay plane; this + /// element only positions it and forwards pointer input that GPUI's hit + /// testing delivers here, so popovers above the page occlude naturally. + fn windows_page( + &self, + body: gpui::Stateful, + host: std::rc::Rc, + cx: &mut Context, + ) -> gpui::Stateful { + use super::native::{PointerKind, wheel_units}; + let entity = cx.entity().downgrade(); + let forward = |kind: PointerKind| { + move |this: &mut Self, + position: gpui::Point, + button: Option, + modifiers: gpui::Modifiers, + clicks: usize| { + if let Some(native) = &this.native { + native.pointer(kind, position, button, modifiers, clicks); + } + } + }; + let right_occlusion = self.right_occlusion; + let mut body = body.cursor(self.page_cursor).child( + gpui::canvas( + |_, _, _| (), + move |bounds, _, window, _| { + // The Files panel slides over the page from the right. + let mut mask = window.content_mask().bounds; + let right = (window.viewport_size().width - right_occlusion).max(mask.left()); + mask.size.width = mask.size.width.min(right - mask.left()); + let scale = window.scale_factor(); + let composition = window.native_composition(); + let page = std::rc::Rc::downgrade(&host); + window.on_present(move || { + if let Some(page) = page.upgrade() { + page.sync(bounds, mask, scale, composition); + } + }); + // A drag that began in the page keeps extending its + // selection outside the page, like a native child. + let capture = entity.clone(); + window.on_mouse_event(move |event: &gpui::MouseMoveEvent, phase, _, cx| { + if phase == gpui::DispatchPhase::Bubble + && !bounds.contains(&event.position) + && !cx.has_active_drag() + { + let _ = capture.update(cx, |this, _| { + if let Some(native) = + this.native.as_ref().filter(|native| native.pressed()) + { + native.pointer( + PointerKind::Move, + event.position, + None, + event.modifiers, + 0, + ); + } + }); + } + }); + }, + ) + .absolute() + .inset_0(), + ); + for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] { + let down = forward(PointerKind::Down); + let up = forward(PointerKind::Up); + let up_out = forward(PointerKind::Up); + body = body + .on_mouse_down( + button, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + if cx.has_active_drag() { + return; + } + window.focus(&this.focus, cx); + down( + this, + event.position, + Some(event.button), + event.modifiers, + event.click_count, + ); + cx.stop_propagation(); + }), + ) + .on_mouse_up( + button, + cx.listener(move |this, event: &gpui::MouseUpEvent, _, cx| { + up(this, event.position, Some(event.button), event.modifiers, 0); + cx.stop_propagation(); + }), + ) + .on_mouse_up_out( + button, + cx.listener(move |this, event: &gpui::MouseUpEvent, _, _| { + if this.native.as_ref().is_some_and(|native| native.pressed()) { + up_out(this, event.position, Some(event.button), event.modifiers, 0); + } + }), + ); + } + let moved = forward(PointerKind::Move); + body.on_mouse_move( + cx.listener(move |this, event: &gpui::MouseMoveEvent, _, cx| { + if !cx.has_active_drag() { + moved(this, event.position, None, event.modifiers, 0); + } + }), + ) + .on_scroll_wheel(cx.listener(|this, event: &gpui::ScrollWheelEvent, _, cx| { + if let Some(native) = &this.native { + native.wheel(event.position, wheel_units(event.delta), event.modifiers); + cx.stop_propagation(); + } + })) + .on_hover(cx.listener(|this, hovered: &bool, _, _| { + if !*hovered && let Some(native) = &this.native { + native.leave(); + } + })) + } +} + impl Render for BrowserSurface { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::of(cx).clone(); let focused = self.address.focus_handle(cx).is_focused(window); - let external = !cfg!(any(target_os = "macos", target_os = "linux")); + let external = !cfg!(any(target_os = "macos", target_os = "linux", windows)); let has_page = self.page.url.is_some(); let back = button( "browser-back", @@ -446,11 +575,11 @@ impl Render for BrowserSurface { .on_mouse_down( MouseButton::Left, cx.listener(|this, _, _, _| { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] if let Some(native) = &this.native { native.focus_chrome(); } - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", windows)))] let _ = this; }), ) @@ -613,7 +742,17 @@ impl Render for BrowserSurface { } else { body.child(self.empty_body(&theme, cx)) }; - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + let body = if let Some(native) = &self.native { + if self.page.error.is_some() { + body.child(self.empty_body(&theme, cx)) + } else { + self.windows_page(body, native.handle(), cx) + } + } else { + body.child(self.empty_body(&theme, cx)) + }; + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] let body = body.child(self.empty_body(&theme, cx)); #[cfg(target_os = "linux")] diff --git a/crates/ui/src/browser/windows_webview.rs b/crates/ui/src/browser/windows_webview.rs new file mode 100644 index 0000000000..d96594be75 --- /dev/null +++ b/crates/ui/src/browser/windows_webview.rs @@ -0,0 +1,1360 @@ +//! Windows boundary for the browser: WebView2 in visual-hosting mode. Each +//! page is a DirectComposition visual mounted in the zui overlay plane's +//! native layer, so DWM composites it between GPUI's base and overlay planes +//! without copies or child windows. GPUI keeps every pointer event and +//! forwards only those that reach the page; the page owns the keyboard while +//! focused. Callbacks enqueue events and never re-enter GPUI. +use super::model::{PageState, Presentation, allowed_frame_navigation, allowed_navigation}; +use gpui::{Bounds, CursorStyle, Keystroke, Modifiers, MouseButton, Pixels, Point, Window}; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; +use webview2_com::{ + AcceleratorKeyPressedEventHandler, CreateCoreWebView2CompositionControllerCompletedHandler, + CreateCoreWebView2EnvironmentCompletedHandler, CursorChangedEventHandler, + DocumentTitleChangedEventHandler, DownloadStartingEventHandler, FaviconChangedEventHandler, + HistoryChangedEventHandler, LaunchingExternalUriSchemeEventHandler, + Microsoft::Web::WebView2::Win32::*, NavigationCompletedEventHandler, + NavigationStartingEventHandler, NewWindowRequestedEventHandler, + PermissionRequestedEventHandler, ProcessFailedEventHandler, SourceChangedEventHandler, + take_pwstr, +}; +use windows::{ + Win32::{ + Foundation::{HWND, POINT, RECT}, + Graphics::{Direct2D::Common::D2D_RECT_F, DirectComposition::*}, + UI::Input::KeyboardAndMouse::{ + GetKeyState, VIRTUAL_KEY, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN, VK_SHIFT, + }, + }, + core::{HSTRING, Interface, PWSTR}, +}; + +type Sender = tokio::sync::mpsc::Sender; + +pub(super) enum NativeEvent { + Changed, + Finished, + NewTab(String), + Key(Keystroke), + Favicon { page: String, url: String }, + Cursor(CursorStyle), +} + +// --------------------------------------------------------------------------- +// Shared environment: one browser process for a window's tabs +// --------------------------------------------------------------------------- + +type EnvironmentWaiter = Box)>; + +#[derive(Default)] +enum Environment { + #[default] + Idle, + Pending(Vec), + Ready(ICoreWebView2Environment), + Failed(String), +} + +struct EnvironmentState { + environment: Environment, + /// Set once the shared folder answered ERROR_BUSY (see [`is_busy`]). + own_folder: bool, + /// InPrivate profile of this window/profile's pages. Every InPrivate + /// controller with the same profile name shares one cookie jar — across + /// windows, profile switches and processes sharing the folder — so each + /// `BrowserData` gets its own, as macOS gets a fresh non-persistent store. + profile: String, +} + +impl Default for EnvironmentState { + fn default() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let started = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + Self { + environment: Environment::default(), + own_folder: false, + profile: format!( + "zeron-{}-{started}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ), + } + } +} + +/// A window/profile's WebView2 environment, created on first use: one +/// browser process for all of its tabs. Pages are InPrivate, so nothing +/// outlives the session (as macOS's non-persistent store); the user-data +/// folder only holds the runtime's own caches. +#[derive(Clone, Default)] +pub(super) struct BrowserData(Rc>); + +impl BrowserData { + fn with_environment( + &self, + done: impl FnOnce(Result) + 'static, + ) { + let mut state = self.0.borrow_mut(); + match &mut state.environment { + Environment::Ready(environment) => { + let environment = environment.clone(); + drop(state); + done(Ok(environment)); + } + Environment::Failed(error) => { + let error = error.clone(); + drop(state); + done(Err(error)); + } + Environment::Pending(waiters) => waiters.push(Box::new(done)), + Environment::Idle => { + state.environment = Environment::Pending(vec![Box::new(done)]); + drop(state); + create_environment(Rc::downgrade(&self.0)); + } + } + } + + /// Drop `environment` after its browser process exited, so the next page + /// starts a fresh one. A newer environment is left alone. + fn forget(&self, environment: &ICoreWebView2Environment) { + let mut state = self.0.borrow_mut(); + if matches!(&state.environment, Environment::Ready(ready) if ready == environment) { + state.environment = Environment::Idle; + } + } + + fn profile(&self) -> String { + self.0.borrow().profile.clone() + } + + /// Move this process to its own folder after the shared one was busy. + /// Returns false when that already happened (no further retries). + fn fall_back(&self) -> bool { + let mut state = self.0.borrow_mut(); + if state.own_folder { + return false; + } + state.own_folder = true; + if !matches!(state.environment, Environment::Pending(_)) { + state.environment = Environment::Idle; + } + true + } +} + +/// A folder still held by another process (a second Zeron, or one shutting +/// down after a quick restart) answers ERROR_BUSY at any creation step. +fn is_busy(error: &windows::core::Error) -> bool { + error.code() == windows::core::HRESULT(0x8007_00AA_u32 as i32) +} + +/// The runtime's caches are shared across launches for a warm start; a +/// busy shared folder sends this process to its own. +fn user_data_folder(own: bool) -> std::path::PathBuf { + let temp = std::env::temp_dir(); + if !own { + return temp.join("zeron-webview2"); + } + // Sweep folders of exited processes. Only those: removal deletes what + // it can before failing on a locked file, so a live instance's folder + // would lose its unlocked files. + let pid = std::process::id(); + let sweep = temp.clone(); + std::thread::spawn(move || { + for entry in std::fs::read_dir(&sweep).into_iter().flatten().flatten() { + let name = entry.file_name(); + let owner = name + .to_str() + .and_then(|name| name.strip_prefix("zeron-webview2-")) + .and_then(|owner| owner.parse::().ok()); + if let Some(owner) = owner + && owner != pid + && !process_alive(owner) + { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + }); + temp.join(format!("zeron-webview2-{pid}")) +} + +/// Whether `pid` names a running process (pids are reused, so a false +/// "alive" only leaves a stale folder for a later sweep). +fn process_alive(pid: u32) -> bool { + use windows_sys::Win32::{ + Foundation::{CloseHandle, STILL_ACTIVE}, + System::Threading::{GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}, + }; + unsafe { + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if process.is_null() { + return false; + } + let mut code = 0u32; + let alive = GetExitCodeProcess(process, &mut code) != 0 && code == STILL_ACTIVE as u32; + CloseHandle(process); + alive + } +} + +fn create_environment(shared: std::rc::Weak>) { + let Some(state) = shared.upgrade() else { + return; + }; + let own = state.borrow().own_folder; + drop(state); + let finish = |shared: &std::rc::Weak>, + outcome: Result| { + let Some(shared) = shared.upgrade() else { + return; + }; + let next = match &outcome { + Ok(environment) => Environment::Ready(environment.clone()), + Err(error) => Environment::Failed(error.clone()), + }; + let waiters = match std::mem::replace(&mut shared.borrow_mut().environment, next) { + Environment::Pending(waiters) => waiters, + _ => Vec::new(), + }; + for waiter in waiters { + waiter(outcome.clone()); + } + }; + let retry = |shared: std::rc::Weak>| { + if let Some(state) = shared.upgrade() { + state.borrow_mut().own_folder = true; + } + create_environment(shared); + }; + let callback = shared.clone(); + let handler = CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new( + move |result, environment| { + match result.and_then(|()| environment.ok_or_else(windows::core::Error::empty)) { + Ok(environment) => finish(&callback, Ok(environment)), + Err(error) if !own && is_busy(&error) => retry(callback), + Err(error) => finish(&callback, Err(runtime_error(&error))), + } + Ok(()) + }, + )); + let created = unsafe { + CreateCoreWebView2EnvironmentWithOptions( + None, + &HSTRING::from(user_data_folder(own).to_string_lossy().as_ref()), + None::<&ICoreWebView2EnvironmentOptions>, + &handler, + ) + }; + match created { + Ok(()) => {} + Err(error) if !own && is_busy(&error) => retry(shared), + Err(error) => finish(&shared, Err(runtime_error(&error))), + } +} + +fn runtime_error(error: &windows::core::Error) -> String { + tracing::warn!(%error, "WebView2 unavailable"); + "The Microsoft Edge WebView2 Runtime is required to show pages here.".into() +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +/// COM objects of a created page. +struct Page { + /// The environment the page lives in (forgotten if its browser exits). + environment: ICoreWebView2Environment, + composition: ICoreWebView2CompositionController, + controller: ICoreWebView2Controller, + webview: ICoreWebView2, + /// The page's root visual and the layer it mounts in, from `generation`. + visual: Option<(IDCompositionVisual, IDCompositionVisual, u64)>, + mounted: bool, + /// What was last handed to WebView2 and the visual, so an unchanged + /// frame makes no calls at all. + applied: Option, + shown: Option, +} + +#[derive(Clone, Copy, PartialEq)] +struct Applied { + origin: (f32, f32), + size: (i32, i32), + clip: [f32; 4], + scale: f32, +} + +#[derive(Default)] +struct Geometry { + /// Page bounds and their visible part, in physical pixels. + origin: (f32, f32), + size: (i32, i32), + clip: D2D_RECT_F, + scale: f32, +} + +pub(super) struct Host { + hwnd: HWND, + tx: Sender, + /// Where pages are created (again, after the browser process exited). + data: BrowserData, + page: RefCell>, + pending_url: RefCell>, + requested_url: RefCell>, + error: RefCell>, + loading: Cell, + changed_pending: Cell, + presentation: Cell, + geometry: RefCell, + has_area: Cell, + /// Pointer buttons pressed inside the page (forwarded until released). + pressed: Cell, + hovered: Cell, + shortcuts: RefCell>, +} + +pub(super) struct NativePage(Rc); + +impl NativePage { + pub fn new(window: &Window, data: &BrowserData, tx: Sender) -> Result { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let hwnd = match HasWindowHandle::window_handle(window) + .map_err(|error| error.to_string())? + .as_raw() + { + RawWindowHandle::Win32(handle) => HWND(handle.hwnd.get() as *mut _), + _ => return Err("Browser requires a Win32 window".into()), + }; + window + .enable_scene_overlay() + .map_err(|error| error.to_string())?; + let host = Rc::new(Host { + hwnd, + tx, + data: data.clone(), + page: RefCell::new(None), + pending_url: RefCell::new(None), + requested_url: RefCell::new(None), + error: RefCell::new(None), + loading: Cell::new(true), + changed_pending: Cell::new(false), + presentation: Cell::new(Presentation::Hidden), + geometry: RefCell::new(Geometry::default()), + has_area: Cell::new(false), + pressed: Cell::new(0), + hovered: Cell::new(false), + shortcuts: RefCell::new(Vec::new()), + }); + Host::start(&host, data.clone()); + Ok(Self(host)) + } + + pub fn handle(&self) -> Rc { + self.0.clone() + } + + pub fn set_shortcuts(&self, shortcuts: Vec) { + *self.0.shortcuts.borrow_mut() = shortcuts + .iter() + .filter_map(|s| Keystroke::parse(s).ok()) + .collect(); + } + + pub fn present(&mut self, presentation: Presentation) { + self.0.presentation.set(presentation); + if presentation != Presentation::Live { + self.0.leave(); + } + self.0.update_visibility(); + } + + pub fn load(&self, url: &str) -> Result<(), String> { + self.0.error.borrow_mut().take(); + *self.0.requested_url.borrow_mut() = Some(url.into()); + self.0.loading.set(true); + match &*self.0.page.borrow() { + Some(page) => unsafe { page.webview.Navigate(&HSTRING::from(url)) } + .map_err(|error| error.message()), + None => { + *self.0.pending_url.borrow_mut() = Some(url.into()); + Ok(()) + } + } + } + + pub fn reload(&self) { + let failed = self.0.error.borrow_mut().take().is_some(); + if failed && self.0.page.borrow().is_none() { + // Nothing is being created (that ends in a page or an error): + // start over, in a fresh environment if the old one died. + self.0.loading.set(true); + Host::start(&self.0, self.0.data.clone()); + } + if let Some(page) = &*self.0.page.borrow() { + // Retrying the requested URL also works after a failed load. + let failed_url = self.0.requested_url.borrow().clone(); + unsafe { + let _ = match failed_url { + Some(url) if self.0.page_url().is_none() => { + page.webview.Navigate(&HSTRING::from(url)) + } + _ => page.webview.Reload(), + }; + } + } + self.0.update_visibility(); + self.0.changed(); + } + + pub fn history(&self, forward: bool) { + if let Some(page) = &*self.0.page.borrow() { + unsafe { + let _ = if forward { + page.webview.GoForward() + } else { + page.webview.GoBack() + }; + } + } + } + + pub fn state(&self) -> PageState { + let host = &self.0; + host.changed_pending.set(false); + let (title, can_back, can_forward) = match &*host.page.borrow() { + Some(page) => unsafe { + let mut title = PWSTR::null(); + let mut back = windows::core::BOOL::default(); + let mut forward = windows::core::BOOL::default(); + let _ = page.webview.DocumentTitle(&mut title); + let _ = page.webview.CanGoBack(&mut back); + let _ = page.webview.CanGoForward(&mut forward); + (take_pwstr(title), back.as_bool(), forward.as_bool()) + }, + None => (String::new(), false, false), + }; + PageState { + url: host + .requested_url + .borrow() + .clone() + .or_else(|| host.page_url()), + title, + loading: host.loading.get(), + can_back, + can_forward, + error: host.error.borrow().clone(), + } + } + + pub fn discover_favicon(&self, page_url: String) { + if let Some(page) = &*self.0.page.borrow() + && let Ok(webview) = page.webview.cast::() + { + let mut uri = PWSTR::null(); + if unsafe { webview.FaviconUri(&mut uri) }.is_ok() { + let url = take_pwstr(uri); + if !url.is_empty() { + let _ = self.0.tx.try_send(NativeEvent::Favicon { + page: page_url, + url, + }); + } + } + } + } + + /// Return the keyboard to GPUI chrome (the address bar). + pub fn focus_chrome(&self) { + use windows::Win32::UI::{ + Input::KeyboardAndMouse::{GetFocus, SetFocus}, + WindowsAndMessaging::IsChild, + }; + unsafe { + let focus = GetFocus(); + if !focus.is_invalid() && focus != self.0.hwnd && IsChild(self.0.hwnd, focus).as_bool() + { + let _ = SetFocus(Some(self.0.hwnd)); + } + } + } + + /// A press, release or move at `position` (window coordinates). + pub fn pointer( + &self, + kind: PointerKind, + position: Point, + button: Option, + modifiers: Modifiers, + click_count: usize, + ) { + self.0 + .pointer(kind, position, button, modifiers, click_count); + } + + pub fn wheel(&self, position: Point, delta: Point, modifiers: Modifiers) { + self.0.wheel(position, delta, modifiers); + } + + pub fn leave(&self) { + self.0.leave(); + } + + pub fn pressed(&self) -> bool { + self.0.pressed.get() != 0 + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum PointerKind { + Down, + Up, + Move, +} + +impl Host { + /// Create the page in `data`'s environment. A busy folder at any step + /// moves the window to its own folder and retries once. + fn start(host: &Rc, data: BrowserData) { + let weak = Rc::downgrade(host); + data.clone().with_environment(move |environment| { + let Some(host) = weak.upgrade() else { return }; + match environment { + Ok(environment) => { + if let Err(error) = Host::create_controller(&host, &environment, data) { + host.fail(&runtime_error(&error)); + } + } + Err(error) => host.fail(&error), + } + }); + } + + fn create_controller( + host: &Rc, + environment: &ICoreWebView2Environment, + data: BrowserData, + ) -> windows::core::Result<()> { + let profile = HSTRING::from(data.profile()); + let attach_environment = environment.clone(); + let weak = Rc::downgrade(host); + let handler = CreateCoreWebView2CompositionControllerCompletedHandler::create(Box::new( + move |result, composition| { + let Some(host) = weak.upgrade() else { + return Ok(()); + }; + match result.and_then(|()| composition.ok_or_else(windows::core::Error::empty)) { + Ok(composition) => { + if let Err(error) = Host::attach(&host, composition, &attach_environment) { + host.fail(&runtime_error(&error)); + } + } + Err(error) if is_busy(&error) && data.fall_back() => Host::start(&host, data), + Err(error) => host.fail(&runtime_error(&error)), + } + Ok(()) + }, + )); + // InPrivate in this window's own profile, or not at all: a runtime + // without controller options would persist cookies in the folder. + unsafe { + let environment = environment.cast::()?; + let options = environment.CreateCoreWebView2ControllerOptions()?; + options.SetIsInPrivateModeEnabled(true)?; + options.SetProfileName(&profile)?; + environment + .CreateCoreWebView2CompositionControllerWithOptions(host.hwnd, &options, &handler) + } + } + + fn attach( + host: &Rc, + composition: ICoreWebView2CompositionController, + environment: &ICoreWebView2Environment, + ) -> windows::core::Result<()> { + let controller: ICoreWebView2Controller = composition.cast()?; + let webview = unsafe { controller.CoreWebView2()? }; + unsafe { + let settings = webview.Settings()?; + settings.SetAreDevToolsEnabled(false)?; + // No page↔host channel exists; keep it that way. + settings.SetIsWebMessageEnabled(false)?; + settings.SetAreHostObjectsAllowed(false)?; + settings.SetIsStatusBarEnabled(false)?; + settings.SetIsZoomControlEnabled(true)?; + if let Ok(controller) = controller.cast::() { + // GPUI owns DPI: it re-syncs the scale with the bounds. + controller.SetShouldDetectMonitorScaleChanges(false)?; + } + controller.SetIsVisible(false)?; + let mut token = 0i64; + let weak = Rc::downgrade(host); + webview.add_NavigationStarting( + &NavigationStartingEventHandler::create(Box::new(move |_, args| { + if let (Some(host), Some(args)) = (weak.upgrade(), args) { + // Fail closed: an unreadable target is cancelled. + let mut uri = PWSTR::null(); + let uri = args.Uri(&mut uri).map(|()| take_pwstr(uri)); + match uri { + Ok(uri) if allowed_navigation(&uri) => { + *host.requested_url.borrow_mut() = Some(uri); + host.error.borrow_mut().take(); + host.loading.set(true); + host.changed(); + } + _ => args.SetCancel(true)?, + } + } + Ok(()) + })), + &mut token, + )?; + // Subframes too (the main-frame event does not see them): web + // content only — their own documents (about:blank, srcdoc, data:, + // blob:) included — never a scheme that leaves the page. + webview.add_FrameNavigationStarting( + &NavigationStartingEventHandler::create(Box::new(move |_, args| { + if let Some(args) = args { + let mut uri = PWSTR::null(); + let allowed = args + .Uri(&mut uri) + .is_ok_and(|()| allowed_frame_navigation(&take_pwstr(uri))); + if !allowed { + args.SetCancel(true)?; + } + } + Ok(()) + })), + &mut token, + )?; + // Never hand a page's link to another app (zeron://, ms-*, mailto:) + // from inside Zeron, prompt or not. + if let Ok(webview18) = webview.cast::() { + webview18.add_LaunchingExternalUriScheme( + &LaunchingExternalUriSchemeEventHandler::create(Box::new(move |_, args| { + if let Some(args) = args { + args.SetCancel(true)?; + } + Ok(()) + })), + &mut token, + )?; + } + // Camera, microphone, location, notifications, clipboard reads: + // nothing a preview needs, and no prompt that looks like Zeron's. + webview.add_PermissionRequested( + &PermissionRequestedEventHandler::create(Box::new(move |_, args| { + if let Some(args) = args { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)?; + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_SourceChanged( + &SourceChangedEventHandler::create(Box::new(move |_, _| { + if let Some(host) = weak.upgrade() { + host.requested_url.borrow_mut().take(); + host.changed(); + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_HistoryChanged( + &HistoryChangedEventHandler::create(Box::new(move |_, _| { + if let Some(host) = weak.upgrade() { + host.changed(); + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_DocumentTitleChanged( + &DocumentTitleChangedEventHandler::create(Box::new(move |_, _| { + if let Some(host) = weak.upgrade() { + host.changed(); + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_NavigationCompleted( + &NavigationCompletedEventHandler::create(Box::new(move |_, args| { + let Some(host) = weak.upgrade() else { + return Ok(()); + }; + host.loading.set(false); + if let Some(args) = args { + let mut success = windows::core::BOOL::default(); + args.IsSuccess(&mut success)?; + if !success.as_bool() { + let mut status = COREWEBVIEW2_WEB_ERROR_STATUS::default(); + args.WebErrorStatus(&mut status)?; + if let Some(message) = navigation_error(status) { + tracing::warn!(status = status.0, "browser navigation failed"); + *host.error.borrow_mut() = Some(message.into()); + host.update_visibility(); + } + } + } + host.changed(); + let _ = host.tx.try_send(NativeEvent::Finished); + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_NewWindowRequested( + &NewWindowRequestedEventHandler::create(Box::new(move |_, args| { + if let (Some(host), Some(args)) = (weak.upgrade(), args) { + // Handled first: no default popup window, whatever + // fails below. Only a click opens a tab — WebView2 + // has no popup blocker, and each opened tab could + // open more. + args.SetHandled(true)?; + let mut clicked = windows::core::BOOL::default(); + args.IsUserInitiated(&mut clicked)?; + let mut uri = PWSTR::null(); + args.Uri(&mut uri)?; + let uri = take_pwstr(uri); + if clicked.as_bool() && allowed_navigation(&uri) { + let _ = host.tx.try_send(NativeEvent::NewTab(uri)); + } + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + webview.add_ProcessFailed( + &ProcessFailedEventHandler::create(Box::new(move |_, args| { + let (Some(host), Some(args)) = (weak.upgrade(), args) else { + return Ok(()); + }; + let mut kind = COREWEBVIEW2_PROCESS_FAILED_KIND::default(); + args.ProcessFailedKind(&mut kind)?; + match kind { + // The whole environment is gone: every page of it + // needs a new one, which Reload then creates. + COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED => { + host.browser_exited(); + } + // Reload recreates the renderer. + COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED + | COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE => { + host.fail("The page stopped responding. Reload to continue."); + } + // GPU, utility and subframe processes are restarted + // by WebView2; the page keeps working. + kind => tracing::debug!(kind = kind.0, "browser helper process failed"), + } + Ok(()) + })), + &mut token, + )?; + if let Ok(webview4) = webview.cast::() { + let weak = Rc::downgrade(host); + webview4.add_DownloadStarting( + &DownloadStartingEventHandler::create(Box::new(move |_, args| { + let Some(args) = args else { + return Ok(()); + }; + args.SetCancel(true)?; + let mut uri = PWSTR::null(); + let uri = args + .DownloadOperation() + .and_then(|download| download.Uri(&mut uri)) + .map(|()| take_pwstr(uri)) + .unwrap_or_default(); + let Some(host) = weak.upgrade() else { + return Ok(()); + }; + // Only a page that turned out to be a file is an error + // (as on macOS); a download the page started leaves it + // as it was. + let requested = host.requested_url.borrow().clone(); + if requested.is_some_and(|requested| requested == uri) { + host.fail( + "This file can’t be previewed here. Open it in your default browser.", + ); + } else { + host.loading.set(false); + host.changed(); + } + Ok(()) + })), + &mut token, + )?; + } + if let Ok(webview15) = webview.cast::() { + let weak = Rc::downgrade(host); + webview15.add_FaviconChanged( + &FaviconChangedEventHandler::create(Box::new(move |sender, _| { + let (Some(host), Some(sender)) = (weak.upgrade(), sender) else { + return Ok(()); + }; + let mut uri = PWSTR::null(); + let mut source = PWSTR::null(); + if let Ok(sender) = sender.cast::() { + sender.FaviconUri(&mut uri)?; + } + sender.Source(&mut source)?; + let (url, page) = (take_pwstr(uri), take_pwstr(source)); + if !url.is_empty() { + let _ = host.tx.try_send(NativeEvent::Favicon { page, url }); + } + Ok(()) + })), + &mut token, + )?; + } + let weak = Rc::downgrade(host); + composition.add_CursorChanged( + &CursorChangedEventHandler::create(Box::new(move |sender, _| { + if let (Some(host), Some(sender)) = (weak.upgrade(), sender) { + let mut id = 0u32; + sender.SystemCursorId(&mut id)?; + let _ = host.tx.try_send(NativeEvent::Cursor(cursor_style(id))); + } + Ok(()) + })), + &mut token, + )?; + let weak = Rc::downgrade(host); + controller.add_AcceleratorKeyPressed( + &AcceleratorKeyPressedEventHandler::create(Box::new(move |_, args| { + let (Some(host), Some(args)) = (weak.upgrade(), args) else { + return Ok(()); + }; + let mut kind = COREWEBVIEW2_KEY_EVENT_KIND::default(); + args.KeyEventKind(&mut kind)?; + if kind != COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN + && kind != COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN + { + return Ok(()); + } + let mut key = 0u32; + args.VirtualKey(&mut key)?; + if let Some(keystroke) = accelerator(key) + && host.claims(&keystroke) + && host.tx.try_send(NativeEvent::Key(keystroke)).is_ok() + { + args.SetHandled(true)?; + } + Ok(()) + })), + &mut token, + )?; + } + *host.page.borrow_mut() = Some(Page { + environment: environment.clone(), + composition, + controller, + webview, + visual: None, + mounted: false, + applied: None, + shown: None, + }); + host.apply_geometry(); + host.update_visibility(); + if let Some(url) = host.pending_url.borrow_mut().take() + && let Some(page) = &*host.page.borrow() + { + unsafe { + let _ = page.webview.Navigate(&HSTRING::from(url)); + } + } + host.changed(); + Ok(()) + } + + fn page_url(&self) -> Option { + let page = self.page.borrow(); + let page = page.as_ref()?; + let mut source = PWSTR::null(); + unsafe { page.webview.Source(&mut source) }.ok()?; + Some(take_pwstr(source)).filter(|url| !url.is_empty() && url != "about:blank") + } + + fn changed(&self) { + if !self.changed_pending.replace(true) && self.tx.try_send(NativeEvent::Changed).is_err() { + self.changed_pending.set(false); + } + } + + fn fail(&self, message: &str) { + *self.error.borrow_mut() = Some(message.into()); + self.loading.set(false); + self.update_visibility(); + self.changed(); + } + + /// The page's browser process exited: release the dead page and its + /// environment, and keep the URL so Reload rebuilds the page there. + fn browser_exited(&self) { + let url = self + .requested_url + .borrow() + .clone() + .or_else(|| self.page_url()); + if let Some(page) = self.close_page() { + self.data.forget(&page.environment); + } + *self.pending_url.borrow_mut() = url; + self.fail("The browser stopped. Reload to continue."); + } + + fn close_page(&self) -> Option { + let page = self.page.borrow_mut().take()?; + unsafe { + if let Some((visual, layer, _)) = &page.visual + && page.mounted + { + let _ = layer.RemoveVisual(visual); + } + let _ = page.controller.Close(); + } + Some(page) + } + + /// Browser and app shortcuts go to GPUI even while the page has focus. + fn claims(&self, keystroke: &Keystroke) -> bool { + let browser = [ + "ctrl-l", "ctrl-t", "ctrl-w", "ctrl-[", "ctrl-]", "ctrl-k", "ctrl-,", + ] + .iter() + .any(|combo| Keystroke::parse(combo).is_ok_and(|k| &k == keystroke)); + browser || self.shortcuts.borrow().iter().any(|k| k == keystroke) + } + + /// Mount, position and clip the page for a painted frame. Runs from the + /// frame's presentation callback; the renderer commits it with the frame. + pub fn sync( + &self, + bounds: Bounds, + mask: Bounds, + scale: f32, + composition: Option, + ) { + let visible = bounds.intersect(&mask); + { + let mut geometry = self.geometry.borrow_mut(); + let x = f32::from(bounds.origin.x) * scale; + let y = f32::from(bounds.origin.y) * scale; + geometry.origin = (x.round(), y.round()); + geometry.size = ( + (f32::from(bounds.size.width) * scale).round().max(0.) as i32, + (f32::from(bounds.size.height) * scale).round().max(0.) as i32, + ); + geometry.clip = D2D_RECT_F { + left: (f32::from(visible.origin.x) * scale - x).max(0.), + top: (f32::from(visible.origin.y) * scale - y).max(0.), + right: (f32::from(visible.origin.x + visible.size.width) * scale - x).max(0.), + bottom: (f32::from(visible.origin.y + visible.size.height) * scale - y).max(0.), + }; + geometry.scale = scale; + } + self.has_area + .set(f32::from(visible.size.width) > 0. && f32::from(visible.size.height) > 0.); + if let Some(composition) = composition { + self.remount(composition); + } + self.apply_geometry(); + self.update_visibility(); + } + + /// (Re)create the page visual when the renderer's device generation + /// changes (first frame, or GPU recovery). + fn remount(&self, composition: gpui::NativeComposition) { + let mut page = self.page.borrow_mut(); + let Some(page) = page.as_mut() else { return }; + // Handles are only valid for their generation, and the renderer frees + // older ones; a callback carrying an older generation than the mounted + // one must not touch its pointers. + if page + .visual + .as_ref() + .is_some_and(|(_, _, generation)| *generation >= composition.generation) + { + return; + } + let mounted = unsafe { + let (Some(device), Some(layer)) = ( + IDCompositionDevice::from_raw_borrowed(&composition.device), + IDCompositionVisual::from_raw_borrowed(&composition.layer), + ) else { + return; + }; + device.CreateVisual().and_then(|visual| { + page.composition.SetRootVisualTarget(&visual)?; + Ok((visual, layer.clone())) + }) + }; + match mounted { + Ok((visual, layer)) => { + page.visual = Some((visual, layer, composition.generation)); + page.mounted = false; + page.applied = None; + } + Err(error) => tracing::warn!(%error, "browser visual unavailable"), + } + } + + fn apply_geometry(&self) { + let mut page = self.page.borrow_mut(); + let Some(page) = page.as_mut() else { return }; + let geometry = self.geometry.borrow(); + let next = Applied { + origin: geometry.origin, + size: geometry.size, + clip: [ + geometry.clip.left, + geometry.clip.top, + geometry.clip.right, + geometry.clip.bottom, + ], + scale: geometry.scale, + }; + let previous = page.applied.replace(next); + if previous == Some(next) { + return; + } + unsafe { + if let Some((visual, _, _)) = &page.visual { + if previous.is_none_or(|p| p.origin != next.origin) { + let _ = visual.SetOffsetX2(next.origin.0); + let _ = visual.SetOffsetY2(next.origin.1); + } + if previous.is_none_or(|p| p.clip != next.clip) { + let _ = visual.SetClip2(&geometry.clip); + } + } + if previous.is_none_or(|p| p.size != next.size) { + let _ = page.controller.SetBounds(RECT { + left: 0, + top: 0, + right: next.size.0, + bottom: next.size.1, + }); + } + if next.scale > 0. + && previous.is_none_or(|p| p.scale != next.scale) + && let Ok(controller) = page.controller.cast::() + { + let _ = controller.SetRasterizationScale(f64::from(next.scale)); + } + } + } + + /// Visible pages render and mount; hidden ones unmount and let + /// Chromium throttle them (timers, painting) like a background tab. + fn update_visibility(&self) { + let visible = self.presentation.get() != Presentation::Hidden + && self.error.borrow().is_none() + && self.has_area.get(); + let mut page = self.page.borrow_mut(); + let Some(page) = page.as_mut() else { return }; + unsafe { + if page.shown.replace(visible) != Some(visible) { + let _ = page.controller.SetIsVisible(visible); + } + if let Some((visual, layer, _)) = &page.visual + && page.mounted != visible + { + let _ = if visible { + layer.AddVisual(visual, true, None::<&IDCompositionVisual>) + } else { + layer.RemoveVisual(visual) + }; + page.mounted = visible; + } + } + if !visible { + self.pressed.set(0); + } + } + + fn local_point(&self, position: Point) -> POINT { + let geometry = self.geometry.borrow(); + POINT { + x: (f32::from(position.x) * geometry.scale - geometry.origin.0).round() as i32, + y: (f32::from(position.y) * geometry.scale - geometry.origin.1).round() as i32, + } + } + + fn pointer( + &self, + kind: PointerKind, + position: Point, + button: Option, + modifiers: Modifiers, + click_count: usize, + ) { + let page = self.page.borrow(); + let Some(page) = page.as_ref() else { return }; + let bit = match button { + Some(MouseButton::Left) => 1, + Some(MouseButton::Right) => 2, + Some(MouseButton::Middle) => 4, + _ => 0, + }; + let pressed = match kind { + PointerKind::Down => self.pressed.get() | bit, + PointerKind::Up => self.pressed.get() & !bit, + PointerKind::Move => self.pressed.get(), + }; + let event = match (kind, button) { + (PointerKind::Move, _) => COREWEBVIEW2_MOUSE_EVENT_KIND_MOVE, + (PointerKind::Down, Some(MouseButton::Left)) if click_count % 2 == 0 => { + COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOUBLE_CLICK + } + (PointerKind::Down, Some(MouseButton::Left)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOWN + } + (PointerKind::Up, Some(MouseButton::Left)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_UP + } + (PointerKind::Down, Some(MouseButton::Right)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_DOWN + } + (PointerKind::Up, Some(MouseButton::Right)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_UP + } + (PointerKind::Down, Some(MouseButton::Middle)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_DOWN + } + (PointerKind::Up, Some(MouseButton::Middle)) => { + COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_UP + } + _ => return, + }; + self.pressed.set(pressed); + self.hovered.set(true); + unsafe { + let _ = page.composition.SendMouseInput( + event, + virtual_keys(pressed, modifiers), + 0, + self.local_point(position), + ); + if kind == PointerKind::Down { + let _ = page + .controller + .MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC); + } + } + } + + /// GPUI line deltas use WHEEL_DELTA per three lines; precise (pixel) + /// deltas map 100 px to one notch, matching Chromium's own scaling. + fn wheel(&self, position: Point, delta: Point, modifiers: Modifiers) { + let page = self.page.borrow(); + let Some(page) = page.as_ref() else { return }; + let point = self.local_point(position); + let keys = virtual_keys(self.pressed.get(), modifiers); + unsafe { + if delta.y != 0. { + let _ = page.composition.SendMouseInput( + COREWEBVIEW2_MOUSE_EVENT_KIND_WHEEL, + keys, + (delta.y.round() as i32) as u32, + point, + ); + } + if delta.x != 0. { + let _ = page.composition.SendMouseInput( + COREWEBVIEW2_MOUSE_EVENT_KIND_HORIZONTAL_WHEEL, + keys, + (-(delta.x.round()) as i32) as u32, + point, + ); + } + } + } + + fn leave(&self) { + if !self.hovered.replace(false) { + return; + } + if let Some(page) = &*self.page.borrow() { + unsafe { + let _ = page.composition.SendMouseInput( + COREWEBVIEW2_MOUSE_EVENT_KIND_LEAVE, + COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_NONE, + 0, + POINT::default(), + ); + } + } + } +} + +impl Drop for Host { + fn drop(&mut self) { + self.close_page(); + } +} + +/// Wheel units: GPUI lines (3 per notch) or pixels (100 per notch). +pub(super) fn wheel_units(delta: gpui::ScrollDelta) -> Point { + const WHEEL_DELTA: f32 = 120.; + match delta { + gpui::ScrollDelta::Lines(lines) => { + gpui::point(lines.x * WHEEL_DELTA / 3., lines.y * WHEEL_DELTA / 3.) + } + gpui::ScrollDelta::Pixels(pixels) => gpui::point( + f32::from(pixels.x) * WHEEL_DELTA / 100., + f32::from(pixels.y) * WHEEL_DELTA / 100., + ), + } +} + +fn virtual_keys(pressed: u32, modifiers: Modifiers) -> COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS { + let mut keys = COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_NONE.0; + if pressed & 1 != 0 { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_LEFT_BUTTON.0; + } + if pressed & 2 != 0 { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_RIGHT_BUTTON.0; + } + if pressed & 4 != 0 { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_MIDDLE_BUTTON.0; + } + if modifiers.shift { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_SHIFT.0; + } + if modifiers.control { + keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_CONTROL.0; + } + COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS(keys) +} + +/// User-facing copy for a failed main-frame navigation; cancellations +/// (a newer navigation, a blocked scheme) are silent. +fn navigation_error(status: COREWEBVIEW2_WEB_ERROR_STATUS) -> Option<&'static str> { + match status { + COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED => None, + COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_ABORTED + | COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_RESET + | COREWEBVIEW2_WEB_ERROR_STATUS_DISCONNECTED => { + Some("The connection was interrupted. Try loading this page again.") + } + _ => Some("Check the address and make sure your server is running, then try again."), + } +} + +/// Windows system cursor ids (`IDC_*`) as GPUI cursor styles. +fn cursor_style(id: u32) -> CursorStyle { + match id { + 32513 => CursorStyle::IBeam, + 32515 => CursorStyle::Crosshair, + 32642 => CursorStyle::ResizeUpLeftDownRight, + 32643 => CursorStyle::ResizeUpRightDownLeft, + 32644 => CursorStyle::ResizeLeftRight, + 32645 => CursorStyle::ResizeUpDown, + 32648 => CursorStyle::OperationNotAllowed, + 32649 => CursorStyle::PointingHand, + _ => CursorStyle::Arrow, + } +} + +/// The GPUI keystroke for a key-down the page received, when it is one an +/// app shortcut could use (a modifier other than Shift, or a function key). +fn accelerator(key: u32) -> Option { + let down = |vk: VIRTUAL_KEY| unsafe { GetKeyState(i32::from(vk.0)) } < 0; + let control = down(VK_CONTROL); + let alt = down(VK_MENU); + let platform = down(VK_LWIN) || down(VK_RWIN); + let shift = down(VK_SHIFT); + let name = match key { + 0x30..=0x39 | 0x41..=0x5A => char::from_u32(key)?.to_ascii_lowercase().to_string(), + 0x70..=0x7B => format!("f{}", key - 0x6F), + 0x09 => "tab".into(), + 0xBC => ",".into(), + 0xBE => ".".into(), + 0xBF => "/".into(), + 0xDB => "[".into(), + 0xDD => "]".into(), + 0xBA => ";".into(), + 0xDE => "'".into(), + 0xC0 => "`".into(), + 0xBD => "-".into(), + 0xBB => "=".into(), + 0xDC => "\\".into(), + _ => return None, + }; + if !(control || alt || platform || matches!(key, 0x70..=0x7B)) { + return None; + } + let mut combo = String::new(); + for (held, prefix) in [ + (control, "ctrl-"), + (alt, "alt-"), + (platform, "win-"), + (shift, "shift-"), + ] { + if held { + combo.push_str(prefix); + } + } + combo.push_str(&name); + Keystroke::parse(&combo).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_units_match_win32_notches() { + let lines = wheel_units(gpui::ScrollDelta::Lines(gpui::point(0., 3.))); + assert_eq!(lines.y, 120.); + let pixels = wheel_units(gpui::ScrollDelta::Pixels(gpui::point( + gpui::px(-50.), + gpui::px(100.), + ))); + assert_eq!((pixels.x, pixels.y), (-60., 120.)); + } + + #[test] + fn cursors_and_errors_map_to_gpui_and_copy() { + assert_eq!(cursor_style(32649), CursorStyle::PointingHand); + assert_eq!(cursor_style(32513), CursorStyle::IBeam); + assert_eq!(cursor_style(1), CursorStyle::Arrow); + assert!(navigation_error(COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED).is_none()); + assert!( + navigation_error(COREWEBVIEW2_WEB_ERROR_STATUS_CANNOT_CONNECT) + .unwrap() + .contains("server") + ); + } + + #[test] + fn virtual_keys_carry_buttons_and_modifiers() { + let keys = virtual_keys( + 1 | 4, + Modifiers { + shift: true, + ..Default::default() + }, + ); + assert_eq!( + keys.0, + COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_LEFT_BUTTON.0 + | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_MIDDLE_BUTTON.0 + | COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_SHIFT.0 + ); + } +} diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 24b76c82e4..e72a6cf4e5 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -9747,6 +9747,24 @@ impl Composer { let Some(question) = wizard.current().cloned() else { return gpui::Empty.into_any_element(); }; + // The answer borrows the composer's editor, but the composer's own + // sizing does not run while the panel shows. Without a limit of its + // own the editor kept a stale one-line viewport, so new lines painted + // below the card instead of growing it. Grow up to the composer's cap. + let answer_cap = TEXTAREA_MAX - TEXTAREA_PAD_V; + self.input.update(cx, |input, cx| { + if input.viewport_height != Some(answer_cap) + || input.settled_viewport_height != Some(answer_cap) + || input.resizing + || input.overflow_top_padding != 0.0 + { + input.viewport_height = Some(answer_cap); + input.settled_viewport_height = Some(answer_cap); + input.resizing = false; + input.overflow_top_padding = 0.0; + cx.notify(); + } + }); let page = wizard.page; let last = page + 1 >= wizard.questions.len(); let typed_empty = self.input.read(cx).is_empty(); diff --git a/crates/ui/src/image_media.rs b/crates/ui/src/image_media.rs index 7f862be142..dc8b08543f 100644 --- a/crates/ui/src/image_media.rs +++ b/crates/ui/src/image_media.rs @@ -254,6 +254,16 @@ pub(crate) fn decode_raster_image(bytes: Vec, max_bytes: usize) -> Result image::Limits { + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(64 * 1024 * 1024); + limits +} + fn decode_raster_image_bounded( bytes: Vec, max_bytes: usize, @@ -265,11 +275,7 @@ fn decode_raster_image_bounded( let mut reader = image::ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|e| e.to_string())?; - let mut limits = image::Limits::default(); - limits.max_image_width = Some(4096); - limits.max_image_height = Some(4096); - limits.max_alloc = Some(64 * 1024 * 1024); - reader.limits(limits); + reader.limits(raster_limits()); // Decode one frame and encode a static PNG so GPUI cannot expand unbounded animation frames. let decoded = reader.decode().map_err(|e| e.to_string())?; // Generated previews retain one bounded 8-bit frame. This keeps each diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 218eeccb7f..ad81f2eaf7 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -63,6 +63,7 @@ pub(crate) mod surface_chrome; pub mod syntax_cache; pub mod terminal; mod todo_panel; +pub(crate) mod tool_images; pub mod theme; pub mod theme_library; pub mod transcript; diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index ceb642fa21..e3d07d3009 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -1407,7 +1407,7 @@ pub(crate) fn selectable_text_wrap() -> Div { div().relative().cursor(CursorStyle::IBeam) } -fn selectable_text_element( +pub(crate) fn selectable_text_element( key: std::sync::Arc, text: SharedString, runs: Vec, diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index c2eb984c81..d8d1a83def 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3595,7 +3595,7 @@ impl Shell { LinkAction::External }; } - let outcome = resolved.web_outcome(cfg!(any(target_os = "macos", target_os = "linux"))); + let outcome = resolved.web_outcome(cfg!(any(target_os = "macos", target_os = "linux", windows))); if outcome == LinkOutcome::Internal { self.set_surfaces_open(true, cx); self.add_browser_surface(activation.target.navigation.clone().ok(), window, cx); @@ -12729,7 +12729,7 @@ impl Render for Shell { } else { px(0.0) }; - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", windows))] let browser_overlay_width = px(if self.files_visible_width(cx) > 0.0 { self.files_visible_width(cx) + PANE_RESIZE_HITBOX_HALF_WIDTH } else { @@ -12743,10 +12743,9 @@ impl Render for Shell { ); browser.update(cx, |browser, cx| { #[cfg(target_os = "macos")] - { - browser.set_resize_inset(browser_resize_inset, cx); - browser.set_right_occlusion(browser_overlay_width, cx); - } + browser.set_resize_inset(browser_resize_inset, cx); + #[cfg(any(target_os = "macos", windows))] + browser.set_right_occlusion(browser_overlay_width, cx); browser.set_shortcuts(&self.settings.keymap); browser.set_presentation(presentation, cx); }); @@ -17055,6 +17054,9 @@ impl Shell { self.transcript .update(cx, |t, cx| t.fixture_appshots_start(cx)); } + pub fn fixture_transcript(&self) -> Entity { + self.transcript.clone() + } } #[cfg(test)] diff --git a/crates/ui/src/tool_images.rs b/crates/ui/src/tool_images.rs new file mode 100644 index 0000000000..a51f07a0fc --- /dev/null +++ b/crates/ui/src/tool_images.rs @@ -0,0 +1,902 @@ +//! Image previews inside expanded tool chips: files a tool read, wrote or +//! printed. Pixels exist only while their chip is expanded. Opening reads the +//! file from the agent's device and decodes a bounded BGRA thumbnail off the +//! UI thread (handed straight to GPUI — no PNG re-encode, no asset cache). +//! Collapsing starts a short grace; when it lapses the CPU frame and the GPU +//! atlas tiles are released. Rapid toggling inside the grace reuses the +//! texture instead of re-reading and re-decoding the file. + +use std::{ + collections::HashMap, + io::Cursor, + sync::Arc, + time::{Duration, Instant}, +}; + +use gpui::{Context, RenderImage, SharedString, Task}; +use zeron_proto::ToolCall; + +use crate::state::EngineHandle; + +/// Previews per chip; further paths stay in the text output. +pub(crate) const MAX_IMAGES_PER_TOOL: usize = 4; +/// Decoded thumbnail bounds: the 560×220 frame at 2× density (≤ 1.9 MiB +/// BGRA per copy). +const THUMB_MAX: (u32, u32) = (1120, 440); +/// Collapsed chips keep pixels this long, so open/close spam never re-decodes. +pub(crate) const RELEASE_GRACE: Duration = Duration::from_millis(600); +/// Expanded-but-offscreen previews may be dropped past this (CPU + GPU bytes). +const BUDGET_BYTES: usize = 64 * 1024 * 1024; +/// Only previews unpainted this long count as offscreen. List overdraw paints +/// rows beyond the viewport, so a frame-based rule evicted visible previews +/// and re-decoded them in a loop once the painted set outgrew the budget. +const STALE_AFTER: Duration = Duration::from_secs(2); + +const IMAGE_EXTENSIONS: [&str; 9] = [ + "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "tif", "tiff", +]; + +pub(crate) fn is_image_path(path: &str) -> bool { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + name.rsplit_once('.').is_some_and(|(stem, ext)| { + !stem.is_empty() && IMAGE_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)) + }) +} + +/// UNC (`\\host\share`, `//host/share`), device and verbatim (`\\.\`, +/// `\\?\`, `\??\`) and drive-relative (`C:x.png`) forms. Agent output names +/// them freely, and merely opening one can reach another host (on Windows +/// with the user's NTLM credentials), so they are never previewed. The +/// engine's jail refuses them as well. +fn is_remote_or_device_path(path: &str) -> bool { + let bytes = path.as_bytes(); + let separator = |b: Option<&u8>| matches!(b, Some(b'/' | b'\\')); + (separator(bytes.first()) && separator(bytes.get(1))) + || path.starts_with(r"\??\") + || (bytes.len() >= 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && !separator(bytes.get(2))) +} + +fn push_path(out: &mut Vec, path: &str) { + if out.len() < MAX_IMAGES_PER_TOOL + && is_image_path(path) + && !is_remote_or_device_path(path) + && !out.iter().any(|known| known.as_ref() == path) + { + out.push(SharedString::from(path.to_owned())); + } +} + +/// Image-looking local paths in free text (commands, output, JSON strings). +/// URLs and glob patterns are skipped; trailing sentence punctuation is not +/// part of a path. +fn push_text_paths(out: &mut Vec, text: &str) { + let separators = |c: char| { + c.is_whitespace() + || matches!( + c, + '"' | '\'' + | '`' + | '<' + | '>' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | ',' + | ';' + | '|' + | '=' + ) + }; + for token in text.split(separators) { + if out.len() >= MAX_IMAGES_PER_TOOL { + return; + } + let token = token.trim_end_matches(['.', ':', '!', '?']); + let token = token.strip_prefix("file://").unwrap_or(token); + if token.contains("://") || token.starts_with("data:") || token.contains(['*', '?']) { + continue; + } + push_path(out, token); + } +} + +fn push_json_paths(out: &mut Vec, value: &serde_json::Value) { + match value { + serde_json::Value::String(text) => push_text_paths(out, text), + serde_json::Value::Array(items) => items.iter().for_each(|v| push_json_paths(out, v)), + serde_json::Value::Object(map) => map.values().for_each(|v| push_json_paths(out, v)), + _ => {} + } +} + +/// The image files a tool call touched, as reported (possibly relative to the +/// chat's working directory). Computed once per row build, never per paint. +pub(crate) fn image_paths(call: &ToolCall, output: Option<&str>) -> Arc<[SharedString]> { + let mut out = Vec::new(); + match call { + ToolCall::ReadFile { path } + | ToolCall::WriteFile { path, .. } + | ToolCall::EditFile { path, .. } + | ToolCall::ApplyPatch { path: Some(path) } => push_path(&mut out, path), + ToolCall::Exec { command } => push_text_paths(&mut out, command), + ToolCall::Mcp { + input: Some(input), .. + } + | ToolCall::Unknown { + input: Some(input), .. + } => push_json_paths(&mut out, input), + _ => {} + } + if let Some(output) = output { + push_text_paths(&mut out, output); + } + out.into() +} + +/// Absolute path on the owning host. Relative paths join the chat's cwd in +/// that host's separator style (the UI may run on another OS). +pub(crate) fn resolve_path(path: &str, cwd: Option<&str>) -> String { + let bytes = path.as_bytes(); + let absolute = path.starts_with(['/', '\\']) + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')); + match cwd.filter(|_| !absolute) { + Some(cwd) if cwd.contains('\\') => format!( + "{}\\{}", + cwd.trim_end_matches(['/', '\\']), + path.trim_start_matches("./").replace('/', "\\") + ), + Some(cwd) => format!( + "{}/{}", + cwd.trim_end_matches('/'), + path.trim_start_matches("./") + ), + None => path.to_owned(), + } +} + +/// One file on one device. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ImageKey { + pub device: String, + pub path: String, +} + +/// What a frame paints for one key. +#[derive(Clone)] +pub(crate) enum Thumb { + Loading, + Ready { + image: Arc, + aspect: f32, + }, + Failed, +} + +enum State { + /// The read/decode task; dropping it cancels the load. + Loading(#[allow(dead_code)] Task<()>), + Ready { + image: Arc, + aspect: f32, + bytes: usize, + painted: Instant, + }, + Failed, +} + +struct Entry { + state: State, + /// Set while no expanded chip holds the key. + release_at: Option, +} + +/// Where loads go: the engine and this device's id (remote owners relay). +pub(crate) struct Loader { + pub engine: Option, + pub local_device: Option, +} + +#[derive(Default)] +pub(crate) struct ToolImages { + entries: HashMap, + /// Expanded chip (`{row}#d{ix}`) → the keys it shows. + holders: HashMap>, + bytes: usize, + sweeper: Option>, +} + +impl ToolImages { + pub(crate) fn is_idle(&self) -> bool { + self.entries.is_empty() + } + + #[cfg(test)] + pub(crate) fn retained_bytes(&self) -> usize { + self.bytes + } + + #[cfg(feature = "appshots-fixture")] + pub(crate) fn stats(&self) -> (usize, usize) { + (self.entries.len(), self.bytes) + } + + /// An expanded chip shows `keys`: cancel their release and start any + /// missing loads. Returns one thumb per key. + pub(crate) fn hold( + &mut self, + chip: &SharedString, + keys: Vec, + loader: &Loader, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) -> Vec { + let now = cx.background_executor().now(); + let thumbs = keys + .iter() + .map(|key| { + if !self.entries.contains_key(key) { + self.start_load(key.clone(), loader, get, cx); + } + let entry = self.entries.get_mut(key).expect("inserted above"); + entry.release_at = None; + match &mut entry.state { + State::Loading(_) => Thumb::Loading, + State::Ready { + image, + aspect, + painted, + .. + } => { + *painted = now; + Thumb::Ready { + image: image.clone(), + aspect: *aspect, + } + } + State::Failed => Thumb::Failed, + } + }) + .collect(); + if self.holders.get(chip) != Some(&keys) { + // Keys that dropped out of the chip (its output changed) start the + // grace like a collapse; ones it still shows stay held. + if let Some(previous) = self.holders.insert(chip.clone(), keys) { + self.start_grace(previous, get, cx); + } + } + thumbs + } + + /// The chip collapsed (or left the transcript): its keys start the grace. + pub(crate) fn release( + &mut self, + chip: &SharedString, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + let Some(keys) = self.holders.remove(chip) else { + return; + }; + self.start_grace(keys, get, cx); + } + + /// Keys no expanded chip holds any more start the release grace. + fn start_grace( + &mut self, + keys: Vec, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + let at = cx.background_executor().now() + RELEASE_GRACE; + for key in keys { + if self.holders.values().any(|held| held.contains(&key)) { + continue; + } + if let Some(entry) = self.entries.get_mut(&key) { + entry.release_at = Some(at); + } + } + self.schedule_sweep(get, cx); + } + + /// Release every chip of a row whose body is hidden. + pub(crate) fn release_row( + &mut self, + row: &str, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + if self.holders.is_empty() { + return; + } + let chips: Vec = self + .holders + .keys() + .filter(|chip| chip_row(chip) == row) + .cloned() + .collect(); + for chip in chips { + self.release(&chip, get, cx); + } + } + + /// Release chips whose row left the transcript (compact folds, resets). + pub(crate) fn retain_rows( + &mut self, + live: impl Fn(&str) -> bool, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + if self.holders.is_empty() { + return; + } + let gone: Vec = self + .holders + .keys() + .filter(|chip| !live(chip_row(chip))) + .cloned() + .collect(); + for chip in gone { + self.release(&chip, get, cx); + } + } + + /// Drop everything now (chat switch). + pub(crate) fn clear(&mut self, cx: &mut gpui::App) { + self.holders.clear(); + self.sweeper = None; + let released: Vec<_> = self.entries.drain().map(|(_, e)| e.state).collect(); + self.bytes = 0; + free(released, cx); + } + + fn start_load( + &mut self, + key: ImageKey, + loader: &Loader, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + let Some(engine) = loader.engine.clone() else { + self.entries.insert( + key, + Entry { + state: State::Failed, + release_at: None, + }, + ); + return; + }; + let target = (loader.local_device.as_deref() != Some(key.device.as_str())) + .then(|| key.device.clone()); + let task_key = key.clone(); + let task = cx.spawn(async move |view, cx| { + let executor = cx.background_executor().clone(); + let raw = crate::attachments::read_attachment_bytes( + &engine, + &executor, + target.as_deref(), + &task_key.path, + None, + ) + .await; + let decoded = match raw { + Some((_, mime, bytes)) => { + executor + .spawn(async move { decode_thumbnail(&mime, bytes) }) + .await + } + None => None, + }; + view.update(cx, |view, cx| { + get(view).finish(task_key, decoded, cx); + cx.notify(); + }) + .ok(); + }); + self.entries.insert( + key, + Entry { + state: State::Loading(task), + release_at: None, + }, + ); + } + + fn finish( + &mut self, + key: ImageKey, + decoded: Option<(Arc, f32)>, + cx: &mut gpui::App, + ) { + // Released while loading: the result is simply dropped (never uploaded). + let Some(entry) = self.entries.get_mut(&key) else { + return; + }; + if !matches!(entry.state, State::Loading(_)) { + return; + } + entry.state = match decoded { + Some((image, aspect)) => { + let bytes = retained_bytes(&image); + self.bytes += bytes; + State::Ready { + image, + aspect, + bytes, + painted: cx.background_executor().now(), + } + } + None => State::Failed, + }; + tracing::debug!(path = %key.path, retained = self.bytes, "tool image decoded"); + self.enforce_budget(&key, cx); + } + + /// Over budget: drop the least recently painted offscreen previews. The + /// painted set itself may exceed the budget; it is never evicted. A held + /// preview reloads when it is painted again. + fn enforce_budget(&mut self, keep: &ImageKey, cx: &mut gpui::App) { + let mut released = Vec::new(); + let Some(stale) = cx.background_executor().now().checked_sub(STALE_AFTER) else { + return; + }; + while self.bytes > BUDGET_BYTES { + let oldest = self + .entries + .iter() + .filter_map(|(key, entry)| match entry.state { + State::Ready { painted, .. } if key != keep && painted < stale => { + Some((painted, key.clone())) + } + _ => None, + }) + .min_by_key(|(painted, _)| *painted); + let Some((_, key)) = oldest else { break }; + if let Some(entry) = self.entries.remove(&key) { + self.bytes -= state_bytes(&entry.state); + released.push(entry.state); + } + } + free(released, cx); + } + + fn sweep(&mut self, cx: &mut gpui::App) { + let now = cx.background_executor().now(); + let expired: Vec = self + .entries + .iter() + .filter(|(_, entry)| entry.release_at.is_some_and(|at| at <= now)) + .map(|(key, _)| key.clone()) + .collect(); + let mut released = Vec::with_capacity(expired.len()); + for key in expired { + if let Some(entry) = self.entries.remove(&key) { + self.bytes -= state_bytes(&entry.state); + released.push(entry.state); + } + } + if !released.is_empty() { + tracing::debug!( + released = released.len(), + retained = self.bytes, + "tool images released" + ); + } + free(released, cx); + } + + /// One timer for the earliest pending release. Grace is constant, so a + /// later release never precedes the one already scheduled. + fn schedule_sweep( + &mut self, + get: fn(&mut V) -> &mut ToolImages, + cx: &mut Context, + ) { + if self.sweeper.is_some() { + return; + } + let Some(at) = self.entries.values().filter_map(|e| e.release_at).min() else { + return; + }; + let delay = at.saturating_duration_since(cx.background_executor().now()); + self.sweeper = Some(cx.spawn(async move |view, cx| { + cx.background_executor().timer(delay).await; + view.update(cx, |view, cx| { + let images = get(view); + images.sweeper = None; + images.sweep(cx); + images.schedule_sweep(get, cx); + }) + .ok(); + })); + } +} + +fn chip_row(chip: &str) -> &str { + chip.rsplit_once("#d").map_or(chip, |(row, _)| row) +} + +fn retained_bytes(image: &RenderImage) -> usize { + // GPUI keeps the CPU frame for atlas re-uploads plus the GPU copy. + image.as_bytes(0).map_or(0, |b| b.len() * 2) +} + +fn state_bytes(state: &State) -> usize { + match state { + State::Ready { bytes, .. } => *bytes, + _ => 0, + } +} + +/// Free released pixels: dropping a Loading task cancels its read; a Ready +/// image leaves every window's sprite atlas once the current update ends. +fn free(states: Vec, cx: &mut gpui::App) { + let images: Vec> = states + .into_iter() + .filter_map(|state| match state { + State::Ready { image, .. } => Some(image), + _ => None, + }) + .collect(); + if !images.is_empty() { + cx.defer(move |cx| { + for image in images { + cx.drop_image(image, None); + } + }); + } +} + +/// Decode one static frame and downsample to [`THUMB_MAX`], as BGRA. +fn decode_thumbnail(mime: &str, bytes: Vec) -> Option<(Arc, f32)> { + if mime == "image/svg+xml" { + let media = crate::image_media::decode_image(mime, bytes).ok()?; + let image = media + .image + .to_image_data(gpui::SvgRenderer::new(Arc::new(crate::icons::Assets))) + .ok()?; + return Some((image, media.width / media.height.max(1.0))); + } + let mut reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .ok()?; + // Shared preview bounds: several chips may decode at once. + reader.limits(crate::image_media::raster_limits()); + let decoded = reader.decode().ok()?; + let (width, height) = (decoded.width(), decoded.height()); + if width == 0 || height == 0 { + return None; + } + let decoded = if width > THUMB_MAX.0 || height > THUMB_MAX.1 { + decoded.thumbnail(THUMB_MAX.0, THUMB_MAX.1) + } else { + decoded + }; + let mut pixels = decoded.into_rgba8(); + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Some(( + Arc::new(RenderImage::new(vec![image::Frame::new(pixels)])), + width as f32 / height as f32, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::AppContext as _; + + fn paths(call: ToolCall, output: Option<&str>) -> Vec { + image_paths(&call, output) + .iter() + .map(|p| p.to_string()) + .collect() + } + + #[test] + fn finds_images_in_paths_commands_output_and_json() { + assert_eq!( + paths( + ToolCall::ReadFile { + path: "shots/A.PNG".into() + }, + None + ), + ["shots/A.PNG"] + ); + assert!( + paths( + ToolCall::ReadFile { + path: "src/main.rs".into() + }, + None + ) + .is_empty() + ); + assert_eq!( + paths( + ToolCall::Exec { + command: "python plot.py --out=chart.png".into() + }, + Some("Saved to /tmp/x/chart.png.\nsee https://a.b/c.png and **/*.jpg") + ), + ["chart.png", "/tmp/x/chart.png"] + ); + assert_eq!( + paths( + ToolCall::Mcp { + server: "browser".into(), + tool: "screenshot".into(), + input: Some(serde_json::json!({"opts": {"file": r"C:\shots\home.webp"}})) + }, + None + ), + [r"C:\shots\home.webp"] + ); + let many = (0..9).map(|i| format!("{i}.jpg ")).collect::(); + assert_eq!( + paths(ToolCall::Exec { command: many }, None).len(), + MAX_IMAGES_PER_TOOL + ); + } + + #[test] + fn relative_paths_join_the_owning_hosts_cwd() { + assert_eq!(resolve_path("a/b.png", Some("/w")), "/w/a/b.png"); + assert_eq!(resolve_path("./b.png", Some("/w/")), "/w/b.png"); + assert_eq!(resolve_path("a/b.png", Some(r"C:\w")), r"C:\w\a\b.png"); + assert_eq!(resolve_path(r"D:\x.png", Some(r"C:\w")), r"D:\x.png"); + assert_eq!(resolve_path("/x.png", Some("/w")), "/x.png"); + assert_eq!(resolve_path("x.png", None), "x.png"); + assert_eq!(resolve_path("C:x.png", Some("/w")), "/w/C:x.png"); + } + + #[test] + fn network_device_and_drive_relative_paths_are_never_previewed() { + let output = [ + r"\\attacker\share\a.png", + "//attacker/share/b.png", + r"\/attacker\share\c.png", + "file:////attacker/share/d.png", + r"\\?\UNC\attacker\share\e.png", + r"\\?\C:\shots\f.png", + r"\\.\pipe\g.png", + r"\??\UNC\attacker\share\h.png", + "C:i.png", + ] + .join("\n"); + let found = paths( + ToolCall::Exec { + command: "open ok.png".into(), + }, + Some(&output), + ); + assert_eq!(found, ["ok.png"]); + assert!( + paths( + ToolCall::ReadFile { + path: r"\\attacker\share\a.png".into() + }, + None + ) + .is_empty() + ); + // Ordinary absolute paths in both styles still preview. + assert_eq!( + paths( + ToolCall::Exec { + command: r"cp /tmp/a.png C:\shots\b.png D:/c.png".into() + }, + None + ), + ["/tmp/a.png", r"C:\shots\b.png", "D:/c.png"] + ); + } + + #[test] + fn thumbnails_are_bounded_bgra() { + let mut png = Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(4000, 1000, image::Rgba([255, 0, 0, 255])) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let (image, aspect) = decode_thumbnail("image/png", png.into_inner()).unwrap(); + let size = image.size(0); + assert!(size.width.0 as u32 <= THUMB_MAX.0 && size.height.0 as u32 <= THUMB_MAX.1); + assert_eq!(aspect, 4.0); + // Red in RGBA is [0, 0, 255, 255] in BGRA. + assert_eq!(&image.as_bytes(0).unwrap()[..4], &[0, 0, 255, 255]); + assert!(decode_thumbnail("image/png", b"nope".to_vec()).is_none()); + // Past the shared decoder bounds: refused, never fully allocated. + let mut wide = Cursor::new(Vec::new()); + image::GrayImage::new(4097, 1) + .write_to(&mut wide, image::ImageFormat::Png) + .unwrap(); + assert!(decode_thumbnail("image/png", wide.into_inner()).is_none()); + } + + struct Host { + images: ToolImages, + } + + fn get(host: &mut Host) -> &mut ToolImages { + &mut host.images + } + + #[gpui::test] + fn collapse_spam_keeps_pixels_and_settled_collapse_frees_them(cx: &mut gpui::TestAppContext) { + let host = cx.new(|_| Host { + images: ToolImages::default(), + }); + let key = ImageKey { + device: "d".into(), + path: "/w/a.png".into(), + }; + let chip = SharedString::from("row#d0"); + let loader = Loader { + engine: None, + local_device: None, + }; + host.update(cx, |host, cx| { + let mut pixels = image::RgbaImage::new(100, 50); + pixels.fill(1); + host.images.entries.insert( + key.clone(), + Entry { + state: State::Loading(Task::ready(())), + release_at: None, + }, + ); + host.images.finish( + key.clone(), + Some(( + Arc::new(RenderImage::new(vec![image::Frame::new(pixels)])), + 2.0, + )), + cx, + ); + assert_eq!(host.images.retained_bytes(), 100 * 50 * 4 * 2); + // Spam: every reopen inside the grace reuses the same texture. + for _ in 0..50 { + let thumbs = host.images.hold(&chip, vec![key.clone()], &loader, get, cx); + assert!(matches!(thumbs[0], Thumb::Ready { aspect: 2.0, .. })); + host.images.release(&chip, get, cx); + } + assert!(!host.images.is_idle()); + }); + cx.executor().advance_clock(RELEASE_GRACE / 2); + cx.run_until_parked(); + host.update(cx, |host, _| assert!(!host.images.is_idle())); + cx.executor().advance_clock(RELEASE_GRACE); + cx.run_until_parked(); + host.update(cx, |host, _| { + assert!(host.images.is_idle(), "collapsed previews are freed"); + assert_eq!(host.images.retained_bytes(), 0); + }); + // A failed load (no engine) is retried after collapse + grace. + host.update(cx, |host, cx| { + let thumbs = host.images.hold(&chip, vec![key.clone()], &loader, get, cx); + assert!(matches!(thumbs[0], Thumb::Failed)); + host.images.release(&chip, get, cx); + }); + cx.executor().advance_clock(RELEASE_GRACE * 2); + cx.run_until_parked(); + host.update(cx, |host, _| assert!(host.images.is_idle())); + } + + #[gpui::test] + fn shared_keys_stay_while_another_chip_holds_them_and_rows_release( + cx: &mut gpui::TestAppContext, + ) { + let host = cx.new(|_| Host { + images: ToolImages::default(), + }); + let key = ImageKey { + device: "d".into(), + path: "/w/a.png".into(), + }; + let loader = Loader { + engine: None, + local_device: None, + }; + host.update(cx, |host, cx| { + host.images + .hold(&"r1#d0".into(), vec![key.clone()], &loader, get, cx); + host.images + .hold(&"r2#d3".into(), vec![key.clone()], &loader, get, cx); + host.images.release(&"r1#d0".into(), get, cx); + assert!(host.images.entries[&key].release_at.is_none()); + host.images.retain_rows(|row| row == "r1", get, cx); + assert!(host.images.entries[&key].release_at.is_some()); + host.images + .hold(&"r2#d3".into(), vec![key.clone()], &loader, get, cx); + host.images.release_row("r2", get, cx); + assert!(host.images.holders.is_empty()); + }); + cx.executor().advance_clock(RELEASE_GRACE * 2); + cx.run_until_parked(); + host.update(cx, |host, _| assert!(host.images.is_idle())); + } + + #[gpui::test] + fn keys_dropped_from_a_held_chip_are_released(cx: &mut gpui::TestAppContext) { + let host = cx.new(|_| Host { + images: ToolImages::default(), + }); + let key = |path: &str| ImageKey { + device: "d".into(), + path: path.into(), + }; + let (a, b) = (key("/w/a.png"), key("/w/b.png")); + let chip = SharedString::from("row#d0"); + let loader = Loader { + engine: None, + local_device: None, + }; + host.update(cx, |host, cx| { + host.images + .hold(&chip, vec![a.clone(), b.clone()], &loader, get, cx); + // The chip's output changed while expanded: `a` is gone from it. + host.images.hold(&chip, vec![b.clone()], &loader, get, cx); + assert!(host.images.entries[&a].release_at.is_some()); + assert!(host.images.entries[&b].release_at.is_none()); + }); + cx.executor().advance_clock(RELEASE_GRACE * 2); + cx.run_until_parked(); + host.update(cx, |host, _| { + assert!(!host.images.entries.contains_key(&a)); + assert!(host.images.entries.contains_key(&b)); + }); + } + + #[gpui::test] + fn budget_drops_only_previews_unpainted_for_a_while(cx: &mut gpui::TestAppContext) { + let host = cx.new(|_| Host { + images: ToolImages::default(), + }); + let insert = |host: &mut Host, i: usize, cx: &mut Context| { + let key = ImageKey { + device: "d".into(), + path: format!("/{i}.png"), + }; + host.images.entries.insert( + key.clone(), + Entry { + state: State::Loading(Task::ready(())), + release_at: None, + }, + ); + // 2048² BGRA = 16 MiB CPU + 16 MiB GPU per preview. + let image = + RenderImage::new(vec![image::Frame::new(image::RgbaImage::new(2048, 2048))]); + host.images.finish(key, Some((Arc::new(image), 1.0)), cx); + }; + host.update(cx, |host, cx| { + for i in 0..3 { + insert(host, i, cx); + } + // Recently painted previews survive even past the budget. + assert_eq!(host.images.entries.len(), 3); + assert!(host.images.retained_bytes() > BUDGET_BYTES); + }); + cx.executor().advance_clock(STALE_AFTER * 2); + host.update(cx, |host, cx| { + insert(host, 3, cx); + assert!(host.images.retained_bytes() <= BUDGET_BYTES); + assert!(host.images.entries.contains_key(&ImageKey { + device: "d".into(), + path: "/3.png".into(), + })); + }); + } +} diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 2e0f37ea0e..15f46a3fd4 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -380,6 +380,9 @@ pub struct ToolItem { /// is the `detail`; `resolved == false` means it is still streaming (the /// chip then defaults open). pub kind: ToolItemKind, + /// Image files the call read, wrote or printed (as reported; relative + /// paths resolve against the chat cwd). Expanding the chip previews them. + pub images: Arc<[SharedString]>, } /// Subagent spawn chips — [`ToolCall::is_subagent_spawn`], the shared genus @@ -729,6 +732,7 @@ fn thought_item(part_id: &str, tree: &BlockTree, live: bool) -> ToolItem { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Thought, + images: Arc::new([]), } } @@ -827,10 +831,7 @@ pub fn tool_detail( }); } let output = output?; - let mut lines: Vec = output - .lines() - .map(|l| SharedString::from(l.to_owned())) - .collect(); + let mut lines: Vec = output.lines().map(output_line).collect(); // Trim trailing blank output lines so the block hugs its content. while lines.last().is_some_and(|l| l.trim().is_empty()) { lines.pop(); @@ -846,6 +847,18 @@ pub fn tool_detail( }) } +/// Chars kept per output line. Lines clip rather than wrap, but each one is +/// still shaped whole for selection — a minified megabyte must not be. +const OUTPUT_LINE_MAX_CHARS: usize = 8 * 1024; + +/// One verbatim output line, cut to [`OUTPUT_LINE_MAX_CHARS`] with an ellipsis. +fn output_line(line: &str) -> SharedString { + match line.char_indices().nth(OUTPUT_LINE_MAX_CHARS) { + Some((cut, _)) => SharedString::from(format!("{}…", &line[..cut])), + None => SharedString::from(line.to_owned()), + } +} + /// Columns at which an invocation line soft-wraps into continuation lines. /// The wrap is char-counted, not measured — block heights must be analytic — /// so the budget is sized to fit the narrowest useful transcript pane. @@ -1143,6 +1156,7 @@ fn tool_fingerprint(tools: &[ToolItem], auto_open: bool) -> u64 { // Thought/note chips share `ToolCall::Unknown` — the kind is the // label/icon discriminator, so a flip must re-splice. acc.push(t.kind as u8); + acc.push(t.images.len() as u8); // Detail payload arriving (or growing) must re-splice the row even // when the resolved bit didn't change. match t.detail.as_deref() { @@ -1465,6 +1479,7 @@ pub fn rows_for_entry( subagent_status: *subagent_status, subagent_tail: subagent_tail.clone().map(SharedString::from), kind: ToolItemKind::Call, + images: crate::tool_images::image_paths(call, output.as_deref()), }; if compact { // Compact mode keeps ONE group for the whole turn — @@ -2158,6 +2173,26 @@ pub fn detail_height(detail: &ToolDetail) -> f32 { DETAIL_SEPARATOR + body } +/// Tool image preview frame height; widths follow each image's aspect. +const TOOL_IMAGE_HEIGHT: f32 = 220.0; +const TOOL_IMAGE_MAX_WIDTH: f32 = 560.0; +const TOOL_IMAGE_PAD: f32 = 8.0; +/// Analytic height an open chip's image strip adds (separator + frames). +const TOOL_IMAGE_STRIP_HEIGHT: f32 = DETAIL_SEPARATOR + TOOL_IMAGE_HEIGHT + 2.0 * TOOL_IMAGE_PAD; + +/// The open image context menu: what to copy and where it lives. +struct ImageMenu { + path: String, + devices: Vec, + position: Point, + active: Option, +} + +/// Spawn chips never expand, so they never preview images either. +fn chip_has_images(tool: &ToolItem) -> bool { + !tool.images.is_empty() && !is_spawn_link(tool) +} + /// Height of the "Show full output/diff" affordance row appended below an /// open detail whose full payload lives in the sidecar (chat2-sync A3). pub const BLOB_AFFORDANCE_HEIGHT: f32 = 24.0; @@ -2183,10 +2218,7 @@ fn blob_detail(text: &str, is_diff: bool) -> Option { let diff: zeron_proto::ToolDiff = serde_json::from_str(text).ok()?; return tool_detail(None, Some(&diff), None); } - let mut lines: Vec = text - .lines() - .map(|l| SharedString::from(l.to_owned())) - .collect(); + let mut lines: Vec = text.lines().map(output_line).collect(); while lines.last().is_some_and(|l| l.trim().is_empty()) { lines.pop(); } @@ -3295,6 +3327,12 @@ pub struct Transcript { /// Prepared diagram shown in the lightbox: its natural size frames the /// enlarged raster, which is released when the lightbox closes. diagram_zoom: Option, + /// Expanded tool chips' image previews; freed shortly after collapse. + tool_images: crate::tool_images::ToolImages, + /// Right-click menu over any transcript image (Copy path / Copy image). + image_menu: crate::popover::Popup, + image_menu_focus: gpui::FocusHandle, + image_copy: Option>, /// In-flight ReadAttachmentChunk loads, keyed by device, path and validation /// policy; results land in the global attachment cache. attachment_loads: HashMap>, @@ -3473,6 +3511,7 @@ impl Transcript { cx.on_release(|this: &mut Self, cx| { this.close_diagram_zoom(cx); crate::image_media::release_media(this.diagrams.borrow_mut().drain(), cx); + this.tool_images.clear(cx); }) .detach(); let text_changes = cx.subscribe( @@ -3579,6 +3618,10 @@ impl Transcript { diagram_media: None, diagram_worker: None, diagram_zoom: None, + tool_images: Default::default(), + image_menu: Default::default(), + image_menu_focus: cx.focus_handle(), + image_copy: None, attachment_loads: HashMap::new(), attachment_retries: HashMap::new(), blob_details: HashMap::new(), @@ -4644,6 +4687,7 @@ impl Transcript { } self.chat_id = selected; self.rows.clear(); + self.tool_images.clear(cx); self.row_cache.clear(); self.live_parsers.clear(); self.tree_cache.clear(); @@ -5011,6 +5055,7 @@ impl Transcript { None => { self.rows = new_rows; self.refresh_protected_attachments(cx); + self.release_departed_tool_images(cx); self.reconcile_own_turn_prompt(); // Replay readiness is independent of row content: an empty // reset (or one identical to optimistic rows) still resolves @@ -5063,6 +5108,7 @@ impl Transcript { } } self.refresh_protected_attachments(cx); + self.release_departed_tool_images(cx); self.reconcile_own_turn_prompt(); self.restore_pending_viewport(replay); self.promote_materialized_queued_turn(attached, cx); @@ -5792,6 +5838,351 @@ impl Transcript { overlays } + // ---- tool-call image previews + the image context menu ---- + + fn tool_images_of(this: &mut Self) -> &mut crate::tool_images::ToolImages { + &mut this.tool_images + } + + /// The chat whose agent produced these rows (a subagent tab borrows the + /// selected chat's host and cwd). + fn image_host(&self, cx: &gpui::App) -> (Option, Option) { + let state = self.state.read(cx); + let chat = self + .chat_id + .as_deref() + .and_then(|id| state.chats.iter().find(|chat| chat.id == id)) + .or_else(|| state.selected_chat_row()); + ( + chat.map(|chat| chat.device_id.clone()), + chat.and_then(|chat| chat.cwd.clone()), + ) + } + + /// Hold the previews of every chip whose body is mounted; release the + /// rest. Returns `(absolute path, thumb)` lists per chip index. + fn hold_tool_images( + &mut self, + row_id: &SharedString, + tools: &[ToolItem], + opens: &[bool], + folds: &[FoldState], + cx: &mut Context, + ) -> Vec>> { + if !tools.iter().any(chip_has_images) { + return vec![None; tools.len()]; + } + let (device, cwd) = self.image_host(cx); + let loader = crate::tool_images::Loader { + engine: self.state.read(cx).engine().cloned(), + local_device: self.state.read(cx).local_device_id.clone(), + }; + tools + .iter() + .enumerate() + .map(|(ix, tool)| { + if !chip_has_images(tool) { + return None; + } + let chip = SharedString::from(format!("{row_id}#d{ix}")); + let tweening = folds[ix].epoch > 0 + && folds[ix] + .toggled_at + .is_some_and(|at| at.elapsed() < FOLD_TWEEN_WINDOW); + if !opens[ix] && !tweening { + self.tool_images.release(&chip, Self::tool_images_of, cx); + return None; + } + let keys: Vec<_> = tool + .images + .iter() + .map(|path| crate::tool_images::ImageKey { + device: device.clone().unwrap_or_default(), + path: crate::tool_images::resolve_path(path, cwd.as_deref()), + }) + .collect(); + let paths: Vec = keys.iter().map(|key| key.path.clone()).collect(); + let loader = if device.is_some() { + &loader + } else { + &crate::tool_images::Loader { + engine: None, + local_device: None, + } + }; + let thumbs = self + .tool_images + .hold(&chip, keys, loader, Self::tool_images_of, cx); + Some(paths.into_iter().zip(thumbs).collect()) + }) + .collect() + } + + /// Rows left the transcript (compact fold settled, history rebuilt): + /// their chips' previews start the release grace. + fn release_departed_tool_images(&mut self, cx: &mut Context) { + if self.tool_images.is_idle() { + return; + } + let live: HashSet<&str> = self + .rows + .iter() + .filter(|row| matches!(row.kind, RowKind::ToolGroup { .. })) + .map(|row| row.id.as_ref()) + .collect(); + self.tool_images + .retain_rows(|row| live.contains(row), Self::tool_images_of, cx); + } + + fn render_tool_image_strip( + &self, + chip: &SharedString, + images: Vec<(String, crate::tool_images::Thumb)>, + collapses: bool, + theme: &Theme, + cx: &mut Context, + ) -> AnyElement { + use crate::tool_images::Thumb; + let devices: Rc<[String]> = self.image_host(cx).0.into_iter().collect(); + let frames = images.into_iter().enumerate().map(|(ix, (path, thumb))| { + let name = SharedString::from(file_badge_name(&path).to_owned()); + let width = match &thumb { + Thumb::Ready { aspect, .. } => { + (TOOL_IMAGE_HEIGHT * aspect).clamp(48.0, TOOL_IMAGE_MAX_WIDTH) + } + _ => TOOL_IMAGE_HEIGHT * 4.0 / 3.0, + }; + let menu_devices = devices.clone(); + let frame = div() + .id(SharedString::from(format!("{chip}-img{ix}"))) + .w(px(width)) + .h(px(TOOL_IMAGE_HEIGHT)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(8.0)) + .overflow_hidden() + .border_1() + .border_color(crate::theme::hairline(0.07)) + .bg(crate::theme::ink(0.035)) + .aria_label(name.clone()) + .on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.open_image_menu( + path.clone(), + menu_devices.to_vec(), + event.position, + window, + cx, + ); + }), + ); + match thumb { + // Explicit dims: img layout honors intrinsic aspect over a + // percent height (see the attachment strip). + Thumb::Ready { image, .. } => frame.child( + img(image) + .w(px(width - 2.0)) + .h(px(TOOL_IMAGE_HEIGHT - 2.0)) + .rounded(px(7.0)) + .object_fit(ObjectFit::Contain), + ), + Thumb::Loading => frame + .text_size(px(TOOL_TEXT_SIZE)) + .text_color(theme.text_faint) + .child("Loading image…"), + Thumb::Failed => frame + .flex_col() + .gap(px(4.0)) + .px(px(12.0)) + .text_size(px(TOOL_TEXT_SIZE)) + .text_color(theme.text_faint) + .child("Image unavailable") + .child(div().max_w_full().truncate().child(name)), + } + }); + div() + .h(px(TOOL_IMAGE_STRIP_HEIGHT)) + .flex_none() + .flex() + .flex_col() + .child( + div() + .h(px(DETAIL_SEPARATOR)) + .flex_none() + .when(!collapses, |line| line.bg(crate::theme::hairline(0.06))), + ) + .child( + div() + .id(SharedString::from(format!("{chip}-images"))) + .h(px(TOOL_IMAGE_HEIGHT + 2.0 * TOOL_IMAGE_PAD)) + .py(px(TOOL_IMAGE_PAD)) + .when(!collapses, |strip| strip.px(px(TOOL_IMAGE_PAD))) + .flex() + .flex_row() + .gap(px(TOOL_IMAGE_PAD)) + .overflow_x_scroll() + .children(frames), + ) + .into_any_element() + } + + /// Open the image menu at the pointer. `devices` are the candidate owners + /// of `path`, tried in order when copying the image. + fn open_image_menu( + &mut self, + path: String, + devices: Vec, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + self.image_menu.open(ImageMenu { + path, + devices, + position, + active: None, + }); + window.focus(&self.image_menu_focus, cx); + cx.notify(); + } + + fn close_image_menu(&mut self, cx: &mut Context) { + if self.image_menu.begin_close() { + crate::popover::reap_popup(cx, |this: &mut Self| &mut this.image_menu); + cx.notify(); + } + } + + fn image_menu_key(&mut self, event: &gpui::KeyDownEvent, cx: &mut Context) { + use crate::popover::MenuKey; + let Some(menu) = self.image_menu.open_mut() else { + return; + }; + match crate::popover::classify_key( + &event.keystroke.key, + event.keystroke.modifiers.platform, + event.keystroke.modifiers.control, + ) { + MenuKey::Escape => self.close_image_menu(cx), + MenuKey::Up => menu.active = crate::popover::menu_step(menu.active, 2, -1), + MenuKey::Down => menu.active = crate::popover::menu_step(menu.active, 2, 1), + MenuKey::Enter => { + if let Some(action) = menu.active { + self.dispatch_image_menu(action, cx); + } + } + _ => return, + } + cx.stop_propagation(); + cx.notify(); + } + + fn dispatch_image_menu(&mut self, action: usize, cx: &mut Context) { + let Some(menu) = self.image_menu.as_open() else { + return; + }; + let (path, devices) = (menu.path.clone(), menu.devices.clone()); + self.close_image_menu(cx); + match action { + 0 => cx.write_to_clipboard(gpui::ClipboardItem::new_string(path)), + 1 => self.copy_image(path, devices, cx), + _ => {} + } + } + + /// Copy the original file (not the preview) from its owning device. + fn copy_image(&mut self, path: String, devices: Vec, cx: &mut Context) { + let Some(engine) = self.state.read(cx).engine().cloned() else { + return; + }; + let local = self.state.read(cx).local_device_id.clone(); + self.image_copy = Some(cx.spawn(async move |this, cx| { + let executor = cx.background_executor().clone(); + for device in &devices { + let target = (local.as_deref() != Some(device.as_str())).then_some(device.as_str()); + if let Some((_, mime, bytes)) = crate::attachments::read_attachment_bytes( + &engine, &executor, target, &path, None, + ) + .await + { + let format = + gpui::ImageFormat::from_mime_type(&mime).unwrap_or(gpui::ImageFormat::Png); + // SVG is markup an agent may have written: copy the parsed, + // re-serialized tree (no scripts or external resources), + // as previews render it. + let bytes = if format == gpui::ImageFormat::Svg { + let svg = executor.spawn(async move { + usvg::Tree::from_data(&bytes, &crate::image_media::svg_options()) + .map(|tree| tree.to_string(&usvg::WriteOptions::default())) + }); + let Ok(svg) = svg.await else { + return; + }; + svg.into_bytes() + } else { + bytes + }; + let image = gpui::Image::from_bytes(format, bytes); + this.update(cx, |this, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_image(&image)); + this.image_copy = None; + }) + .ok(); + return; + } + } + })); + } + + fn render_image_menu(&self, cx: &mut Context) -> Option { + let menu = self.image_menu.get()?; + let theme = Theme::of(cx).for_popup(); + let mut card = crate::popover::popover_card(&theme) + .id("transcript-image-menu-card") + .track_focus(&self.image_menu_focus) + .role(gpui::Role::Menu) + .w(px(190.0)) + .flex() + .flex_col() + .on_key_down(cx.listener(|this, event, _, cx| this.image_menu_key(event, cx))) + .on_mouse_down_out(cx.listener(|this, _, _, cx| this.close_image_menu(cx))) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()); + for (index, (label, icon)) in [ + ("Copy path", crate::icons::COPY), + ("Copy image", crate::icons::FILE_IMAGE), + ] + .into_iter() + .enumerate() + { + let id = SharedString::from(format!("transcript-image-menu-{index}")); + card = card.child( + crate::popover::menu_row(&theme, menu.active == Some(index), id.clone()) + .id(id) + .role(gpui::Role::MenuItem) + .aria_label(label) + .on_click( + cx.listener(move |this, _, _, cx| this.dispatch_image_menu(index, cx)), + ) + .child( + crate::icons::icon(icon) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(label), + ); + } + Some(crate::popover::menu_at( + "transcript-image-menu", + menu.position, + card.into_any_element(), + self.image_menu.closing_since(), + )) + } + /// The inside of a user bubble: the prompt text, clipped to /// [`USER_COLLAPSED_LINES`] until expanded, plus the expander chevron for /// prompts past the cap. Returns the bubble's children in order. @@ -6211,7 +6602,21 @@ impl Transcript { .min(1.0); let preview = crate::attachments::PreviewImage::new(name.to_owned(), loaded.image.clone()); + let menu_path = path.to_owned(); frame + .on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.open_image_menu( + menu_path.clone(), + devices.clone(), + event.position, + window, + cx, + ); + }), + ) .w(px(dimensions.0 as f32 * scale)) .h(px(dimensions.1 as f32 * scale)) .role(gpui::Role::Button) @@ -6438,8 +6843,22 @@ impl Transcript { image.name.clone(), image.image.clone(), ); + let (menu_path, menu_devices) = (att.path.clone(), device_ids.clone()); frame .id(SharedString::from(format!("{row_id}#att{aix}"))) + .on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.open_image_menu( + menu_path.clone(), + menu_devices.clone(), + event.position, + window, + cx, + ); + }), + ) .relative() .border_1() .border_color(crate::theme::hairline(0.11)) @@ -7450,7 +7869,13 @@ impl Transcript { && fold .toggled_at .is_some_and(|at| at.elapsed() < TOOL_FOLD.total())); - let tools = if body_visible { tools.as_slice() } else { &[] }; + let tools = if body_visible { + tools.as_slice() + } else { + self.tool_images + .release_row(row_id, Self::tool_images_of, cx); + &[] + }; // Chips render their EFFECTIVE detail: the precomputed doc-resident // one, upgraded in place by a fetched sidecar blob (chat2-sync A3). // Resolved per paint (a HashMap probe per chip) so fetched content @@ -7545,7 +7970,7 @@ impl Transcript { .zip(&invocations) .enumerate() .map(|(ix, (detail, invocation))| { - if detail.is_none() && invocation.is_none() { + if detail.is_none() && invocation.is_none() && !chip_has_images(&tools[ix]) { return FoldState::default(); } self.tool_details @@ -7566,9 +7991,13 @@ impl Transcript { // it: nothing inside the fold opens itself. let default_open = tool.kind == ToolItemKind::Thought && !tool.resolved && !self.compact_mode; - (detail.is_some() || invocation.is_some()) && fold.open.unwrap_or(default_open) + (detail.is_some() || invocation.is_some() || chip_has_images(tool)) + && fold.open.unwrap_or(default_open) }) .collect(); + // Image previews exist only while their chip's body is mounted (open, + // or tweening shut). Every other chip releases its pixels. + let chip_images = self.hold_tool_images(row_id, tools, &detail_opens, &detail_folds, cx); let detail_highlights: Vec>> = details .iter() .enumerate() @@ -7591,36 +8020,44 @@ impl Transcript { .zip(&affordances) .zip(&detail_opens) .zip(&detail_folds) - .map(|((((detail, invocation), affordance), open), fold)| { - let target = if *open { - base_row_height - + invocation.as_deref().map_or(0.0, detail_height) - + detail.as_deref().map_or(0.0, detail_height) - + if affordance.is_some() { - BLOB_AFFORDANCE_HEIGHT - } else { - 0.0 - } - } else { - base_row_height - }; - if !cx.reduce_motion() { - if let Some(at) = fold.toggled_at { - let t = TOOL_FOLD - .curve - .eval(at.elapsed().as_secs_f32() / TOOL_FOLD.total().as_secs_f32()); - if t < 1.0 { - motion_active = true; + .zip(tools.iter()) + .map( + |(((((detail, invocation), affordance), open), fold), tool)| { + let target = if *open { + base_row_height + + invocation.as_deref().map_or(0.0, detail_height) + + if chip_has_images(tool) { + TOOL_IMAGE_STRIP_HEIGHT + } else { + 0.0 + } + + detail.as_deref().map_or(0.0, detail_height) + + if affordance.is_some() { + BLOB_AFFORDANCE_HEIGHT + } else { + 0.0 + } + } else { + base_row_height + }; + if !cx.reduce_motion() { + if let Some(at) = fold.toggled_at { + let t = TOOL_FOLD + .curve + .eval(at.elapsed().as_secs_f32() / TOOL_FOLD.total().as_secs_f32()); + if t < 1.0 { + motion_active = true; + } + return motion::lerp( + fold.from + base_row_height - CHIP_CARD_HEIGHT, + target, + t, + ); } - return motion::lerp( - fold.from + base_row_height - CHIP_CARD_HEIGHT, - target, - t, - ); } - } - target - }) + target + }, + ) .collect(); let reduce_motion = cx.reduce_motion(); let now = Instant::now(); @@ -7820,7 +8257,7 @@ impl Transcript { } let detail = details[ix].clone(); let invocation = invocations[ix].clone(); - if detail.is_none() && invocation.is_none() { + if detail.is_none() && invocation.is_none() && !chip_has_images(tool) { return reveal_tool_row( tool_chip( tool, @@ -7907,7 +8344,12 @@ impl Transcript { .flex_none() .when(!collapses, |line| line.bg(crate::theme::hairline(0.06))), ) - .child(detail_body(invocation, None, theme)); + .child(detail_body(invocation, None, &format!("{key}-call"), theme)); + } + if let Some(images) = chip_images[ix].clone() { + panel = panel.child( + self.render_tool_image_strip(&key, images, collapses, theme, cx), + ); } if let Some(detail) = detail.as_deref() { panel = panel @@ -7917,7 +8359,12 @@ impl Transcript { .flex_none() .when(!collapses, |line| line.bg(crate::theme::hairline(0.06))), ) - .child(detail_body(detail, detail_highlights[ix].clone(), theme)); + .child(detail_body( + detail, + detail_highlights[ix].clone(), + &format!("{key}-out"), + theme, + )); } if let Some(ChipAffordance { blob_ref, label }) = affordance { let loading = matches!( @@ -8600,9 +9047,36 @@ fn file_badge_name(path: &str) -> &str { fn detail_body( detail: &ToolDetail, diff_highlights: Option>, + selection_key: &str, theme: &Theme, ) -> AnyElement { let body = div().w_full().min_w_0().flex().flex_col().overflow_hidden(); + // Every line joins the transcript's selection, so paths, commands and + // output can be drag-selected (across lines too) and copied. Lines clip + // rather than ellipsize: selection maps the pointer onto the full text. + let selectable = |line_ix: usize, text: SharedString, runs: Vec| { + div() + .w_full() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .child(render::selectable_text_element( + format!("{selection_key}-{line_ix}").into(), + text, + runs, + theme.selection, + )) + }; + let plain = |text: &SharedString, color: gpui::Hsla| { + vec![TextRun { + len: text.len(), + font: gpui::font(theme.font_mono.clone()), + color, + background_color: None, + underline: None, + strikethrough: None, + }] + }; match detail { // No comment layer: an inline tool diff is a record of what the // agent already did, not a review surface. @@ -8617,7 +9091,9 @@ fn detail_body( .py(px(6.0)) .font_family(theme.font_mono.clone()) .text_size(px(TOOL_TEXT_SIZE)) - .children(stats.iter().map(|stat| { + .children(stats.iter().enumerate().map(|(stat_ix, stat)| { + let path = SharedString::from(stat.path.clone()); + let runs = plain(&path, theme.text_faint); div() .h(px(OUTPUT_LINE_HEIGHT)) .w_full() @@ -8637,9 +9113,7 @@ fn detail_body( div() .min_w_0() .flex_1() - .truncate() - .text_color(theme.text_faint) - .child(SharedString::from(stat.path.clone())), + .child(selectable(stat_ix, path, runs)), ) .child( div() @@ -8662,15 +9136,22 @@ fn detail_body( .py(px(6.0)) .font_family(theme.font_mono.clone()) .text_size(px(TOOL_TEXT_SIZE)) - .children(lines.iter().map(|line| { - div() + .children(lines.iter().enumerate().map(|(line_ix, line)| { + let row = div() .h(px(OUTPUT_LINE_HEIGHT)) .w_full() .min_w_0() .flex() .items_center() - .text_color(theme.text_faint) - .child(div().w_full().min_w_0().truncate().child(line.clone())) + .text_color(theme.text_faint); + if line.is_empty() { + return row; + } + row.child(selectable( + line_ix, + line.clone(), + plain(line, theme.text_faint), + )) })) .when(*truncated_by > 0, |block| { block.child(more_lines_row(*truncated_by, theme)) @@ -8682,7 +9163,7 @@ fn detail_body( } => body .py(px(6.0)) .text_size(px(TOOL_TEXT_SIZE)) - .children(lines.iter().map(|line| { + .children(lines.iter().enumerate().map(|(line_ix, line)| { let row = div() .h(px(OUTPUT_LINE_HEIGHT)) .w_full() @@ -8692,13 +9173,7 @@ fn detail_body( let Some((text, runs)) = thought_line_text(line, theme) else { return row; // blank separator row }; - row.child( - div() - .w_full() - .min_w_0() - .truncate() - .child(StyledText::new(text).with_runs(runs)), - ) + row.child(selectable(line_ix, text, runs)) })) .when(*truncated_by > 0, |block| { block.child(more_lines_row(*truncated_by, theme)) @@ -9663,6 +10138,7 @@ impl Render for Transcript { )) .child(content) .child(rail); + let root = root.children(self.render_image_menu(cx)); // Full-size viewer for a clicked user-bubble thumbnail // (AttachmentPreviewDialog: bare lightbox, click closes). if let Some(preview) = self.attachment_preview.clone() { @@ -11185,6 +11661,46 @@ mod tests { } } + #[gpui::test] + fn expanded_tool_call_text_drag_selects(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + }); + let command = "cat /workspace/src/main.rs"; + struct Detail; + impl Render for Detail { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).clone(); + div() + .w(px(600.0)) + .child(render::selection_frame_reset_for(7)) + .child(detail_body( + &ToolDetail::Output { + lines: vec!["cat /workspace/src/main.rs".into()], + truncated_by: 0, + }, + None, + "probe", + &theme, + )) + } + } + let (_, cx) = cx.add_window_view(|_, _| Detail); + cx.run_until_parked(); + let bounds = render::selection_test_bounds("probe-0"); + let start = bounds.origin + gpui::point(px(1.0), bounds.size.height / 2.0); + let end = start + gpui::point(bounds.size.width + px(40.0), px(0.0)); + cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move(end, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_up(end, MouseButton::Left, gpui::Modifiers::default()); + assert_eq!( + crate::markdown::selection::selected_text().as_deref(), + Some(command) + ); + crate::markdown::selection::clear_if_owner("probe-0"); + } + #[gpui::test] fn generated_image_click_opens_lightbox_and_escape_restores_focus( cx: &mut gpui::TestAppContext, @@ -14729,6 +15245,21 @@ mod tests { assert_eq!(truncated_by, 40 - OUTPUT_DETAIL_MAX_LINES); assert_eq!(lines[0].as_ref(), " indented 0"); + // A minified megabyte line is cut on a char boundary before shaping, + // in the summary and in a fetched full output alike. + let long = format!("{}\nshort", "é".repeat(1024 * 1024)); + for detail in [ + tool_detail(Some(&long), None, None), + blob_detail(&long, false), + ] { + let Some(ToolDetail::Output { lines, .. }) = detail else { + panic!("expected output detail"); + }; + assert_eq!(lines[0].chars().count(), OUTPUT_LINE_MAX_CHARS + 1); + assert!(lines[0].ends_with("é…")); + assert_eq!(lines[1].as_ref(), "short"); + } + // Nothing → no affordance. assert!(tool_detail(None, None, None).is_none()); assert!(tool_detail(Some("\n\n"), None, None).is_none()); @@ -14750,6 +15281,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }; let edit = |p: &str| ToolItem { part_id: "fixture".into(), @@ -14769,6 +15301,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }; let tools = vec![ exec("ls"), @@ -14804,6 +15337,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }, ToolItem { part_id: "fixture".into(), @@ -14821,6 +15355,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }, ToolItem { part_id: "fixture".into(), @@ -14836,6 +15371,7 @@ mod tests { subagent_status: None, subagent_tail: None, kind: ToolItemKind::Call, + images: Arc::new([]), }, ]; assert_eq!(tool_group_summary(&tools), "Read 1 file · searched 2 times"); @@ -15278,4 +15814,48 @@ impl Transcript { self.list.scroll_to(gpui::ListOffset::default()); cx.notify(); } + + /// Open or close every tool group and image chip, as clicks would. + pub fn fixture_tool_images(&mut self, open: bool, cx: &mut Context) { + let now = Instant::now(); + for row in &self.rows { + let RowKind::ToolGroup { tools, .. } = &row.kind else { + continue; + }; + let fold = self.folds.entry(row.id.clone()).or_default(); + fold.open = Some(open); + for (ix, tool) in tools.iter().enumerate() { + if chip_has_images(tool) { + let chip = self + .tool_details + .entry(SharedString::from(format!("{}#d{ix}", row.id))) + .or_default(); + chip.open = Some(open); + chip.epoch += 1; + chip.toggled_at = Some(now); + } + } + } + self.list.remeasure(); + cx.notify(); + } + + /// `(previews resident, retained bytes)`. + pub fn fixture_tool_image_stats(&self) -> (usize, usize) { + self.tool_images.stats() + } + + pub fn fixture_image_menu( + &mut self, + path: String, + action: Option, + window: &mut Window, + cx: &mut Context, + ) { + let devices = self.image_host(cx).0.into_iter().collect(); + self.open_image_menu(path, devices, gpui::point(px(420.), px(260.)), window, cx); + if let Some(action) = action { + self.dispatch_image_menu(action, cx); + } + } } diff --git a/docs/image-preview.md b/docs/image-preview.md index 2a060fa70c..d243bd823c 100644 --- a/docs/image-preview.md +++ b/docs/image-preview.md @@ -21,6 +21,16 @@ The existing transport limits remain: at most 8 MiB of image bytes, 384 KiB chun Each Files image surface admits at most 64 MiB of retained image resources, including its panel rendering variants. A generation guard rejects obsolete work. Collapsing the right panel or switching files, tabs or chats suspends image loads, clears media and schedules asset/atlas eviction. Resuming reloads from the owning workspace. Watcher updates do not reload hidden images; modifications, deletion and renaming invalidate the relevant image. Changing the target suspends the old view before clearing documents. Closing/disposal releases the image preview resources. Text documents retain their existing cache and editing lifecycle. Renaming an edited text file to an image extension preserves its buffer and pending save; image conversion waits until those edits are resolved. Watcher events continue through text conflict handling while edits remain. +## Tool-call image previews + +A tool chip whose call or output names an image file (PNG, JPEG, GIF, WebP, BMP, SVG, TIFF; at most four per chip) previews it when the chip is expanded. Relative paths resolve against the chat's cwd on the owning host. Bytes arrive through `ReadAttachmentChunk`, so its jail applies: the owning host's uploads directory (plus any read-only legacy uploads roots) and the working directory of every chat in the workspace on that host, not just the chat that ran the tool. A projectless chat's working directory is the home directory, so any image under home on that host is readable. Other paths show *Image unavailable*. Network and device paths (UNC `\\host\share`, `//host/share`, `\\?\UNC\…`, `\\.\…`) and drive-relative paths (`C:x.png`) are never previewed. The engine also jails each path lexically, resolving `.`/`..` without touching the filesystem, before it canonicalizes the path, so a path outside every root is refused without being opened. + +`tool_images.rs` decodes one static frame on the background executor within the shared raster limits (4096 pixels per side, 64 MiB decoder allocation), downsamples it to at most 1120×440 and hands BGRA pixels straight to GPUI as a `RenderImage` (no re-encode, no asset cache). Nothing is read or decoded for collapsed chips or hidden groups. Collapsing starts a 600 ms grace; then the CPU frame and the sprite-atlas tiles are released. Reopening inside the grace reuses the texture, so rapid toggling never re-reads or re-decodes. Previews that are expanded but unpainted for 2 s count against a 64 MiB budget; painted ones are never evicted. Chat switches and transcript disposal release everything. + +Right-clicking a tool preview, a user attachment thumbnail or a generated image opens **Copy path** / **Copy image**. Copy image fetches the original file from its owning device, not the preview; SVG is copied as the sanitized, re-serialized tree. + +`cargo run --locked -p zeron-ui --example tool-images-fixture --features appshots-fixture` opens an isolated session with mock images. `-- --capture ` runs the automated expand / toggle-spam / collapse / copy check instead. + ## Verification Automated coverage includes image format selection, owner-routed loading with and without synced checkout metadata, malformed/repeated/inconsistent/oversized chunks, bounded decoding, SVG sanitization, animation flattening, stale completion rejection, cancellation, disposal, and zoom geometry. A headless GPUI test dispatches actual wheel, pinch, drag, click and Escape events through the rendered lightbox. A rendered Files test loads through the owning-device client and verifies that clicking the image retains panel focus and that subsequent zoom still targets the file panel. Existing Markdown lightbox tests also verify opening and focus restoration. Regression coverage checks edited text renamed to an image extension across reopen, watcher and pending-save paths; suspension during panel collapse; wheel direction; and reuse of an unrelated cached view during zoom. diff --git a/docs/reference/windows-development.md b/docs/reference/windows-development.md index 49a92bbc83..a5b4f4be94 100644 --- a/docs/reference/windows-development.md +++ b/docs/reference/windows-development.md @@ -63,6 +63,30 @@ shutdown/drop log failures. Processes started through external services or brokers are outside this ownership. Closing a terminal or exiting its shell also terminates processes detached inside that job (including `start` children). +## Embedded browser + +Browser tabs embed Microsoft Edge WebView2 (the Evergreen runtime ships with +Windows 10/11) in visual-hosting mode. Each page is a DirectComposition visual +mounted in the zui overlay plane's native layer: GPUI's base content, then the +page, then deferred GPUI content (menus, tooltips, prompts) in a second swap +chain. DWM composites the page directly, without copies or child windows. A +frame without deferred content detaches the overlay and draws nothing extra. + +GPUI receives all pointer input, so anything drawn above the page occludes it +naturally; presses, moves and wheel events that reach the page element are +forwarded with `SendMouseInput`. The page owns the keyboard while focused; +browser and app shortcuts are intercepted with `AcceleratorKeyPressed` and +dispatched to GPUI. A press on GPUI content, or an interactive overlay opening, +returns keyboard focus to the window. + +All tabs of a window share one InPrivate environment (one browser process). +Hidden tabs are unmounted and set invisible, so Chromium throttles them. The +user-data folder (`%TEMP%\zeron-webview2`) holds only runtime caches; when +another process still holds it (a second Zeron, or one shutting down after a +quick restart), the window falls back to its own folder, and stale ones are +swept in the background. Per-frame updates touch WebView2 only when geometry +or visibility changed. + Frosted window chrome uses native Acrylic and requires Windows **Settings > Personalization > Colors > Transparency effects**. Content cards and popovers remain opaque because in-app backdrop blur is not supported. The pinned @@ -103,5 +127,5 @@ captures only its synthetic window. Results go to ignored `target/windows-lifecycle-*` and `target/windows-render-*` directories. Remaining acceptance work includes authenticated provider runs and shutdown, -broader GPU/DPI coverage, accessibility, native browser and cross-device parity, +broader GPU/DPI coverage, accessibility, cross-device parity, concurrent-launch log rotation, and invalid-window teardown diagnostics.