diff --git a/Cargo.lock b/Cargo.lock index cb00d0786..2f4ae690d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4391,6 +4391,22 @@ dependencies = [ "trash", ] +[[package]] +name = "ide-web" +version = "0.1.12" +dependencies = [ + "ide-core", + "open", + "parking_lot", + "percent-encoding", + "serde", + "specta", + "tempfile", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "ide-workspace" version = "0.1.12" @@ -5844,6 +5860,7 @@ dependencies = [ "ide-tools", "ide-vcs", "ide-vfs", + "ide-web", "ide-workspace", "parking_lot", "serde", diff --git a/Cargo.toml b/Cargo.toml index e81dc55d2..51448a9e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ ide-coverage = { path = "crates/ide-coverage" } ide-profiler = { path = "crates/ide-profiler" } ide-http = { path = "crates/ide-http" } ide-db = { path = "crates/ide-db" } +ide-web = { path = "crates/ide-web" } # serialization & IPC typing serde = { version = "1", features = ["derive"] } diff --git a/crates/ide-web/Cargo.toml b/crates/ide-web/Cargo.toml new file mode 100644 index 000000000..e7130c2c0 --- /dev/null +++ b/crates/ide-web/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ide-web" +description = "Web browsers: the built-in static web server that serves project files on 127.0.0.1 (IDEA's built-in server) and detection / launching of installed browsers" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +open = "5" +parking_lot.workspace = true +percent-encoding = "2" +serde.workspace = true +specta.workspace = true +tokio = { workspace = true, features = ["net"] } +tracing.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] } diff --git a/crates/ide-web/src/browsers.rs b/crates/ide-web/src/browsers.rs new file mode 100644 index 000000000..1d8cfa915 --- /dev/null +++ b/crates/ide-web/src/browsers.rs @@ -0,0 +1,129 @@ +use ide_core::{IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::PathBuf; + +/// An installed browser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Browser { + /// `chrome`, `firefox`, `safari`, `edge`, `brave`, `chromium`. + pub id: String, + pub name: String, +} + +struct Known { + id: &'static str, + name: &'static str, + /// macOS application bundle names. + mac: &'static [&'static str], + /// Windows executables relative to Program Files / Local AppData. + windows: &'static [&'static str], + /// Linux commands on `PATH`. + linux: &'static [&'static str], +} + +const KNOWN: &[Known] = &[ + Known { + id: "chrome", + name: "Chrome", + mac: &["Google Chrome"], + windows: &[r"Google\Chrome\Application\chrome.exe"], + linux: &["google-chrome", "google-chrome-stable"], + }, + Known { + id: "firefox", + name: "Firefox", + mac: &["Firefox", "Firefox Developer Edition"], + windows: &[r"Mozilla Firefox\firefox.exe"], + linux: &["firefox"], + }, + Known { id: "safari", name: "Safari", mac: &["Safari"], windows: &[], linux: &[] }, + Known { + id: "edge", + name: "Edge", + mac: &["Microsoft Edge"], + windows: &[r"Microsoft\Edge\Application\msedge.exe"], + linux: &["microsoft-edge", "microsoft-edge-stable"], + }, + Known { + id: "brave", + name: "Brave", + mac: &["Brave Browser"], + windows: &[r"BraveSoftware\Brave-Browser\Application\brave.exe"], + linux: &["brave-browser", "brave"], + }, + Known { + id: "chromium", + name: "Chromium", + mac: &["Chromium"], + windows: &[r"Chromium\Application\chrome.exe"], + linux: &["chromium", "chromium-browser"], + }, +]; + +/// Where browser `k` is installed, if it is. +fn locate(k: &Known) -> Option { + if cfg!(target_os = "macos") { + let mut dirs = vec![PathBuf::from("/Applications")]; + if let Some(home) = std::env::var_os("HOME") { + dirs.push(PathBuf::from(home).join("Applications")); + } + // Safari moved into the cryptex; /Applications keeps a link to it. + dirs.push(PathBuf::from("/System/Volumes/Preboot/Cryptexes/App/System/Applications")); + k.mac.iter().flat_map(|app| dirs.iter().map(move |d| d.join(format!("{app}.app")))).find(|p| p.exists()) + } else if cfg!(windows) { + let bases: Vec = ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] + .iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .collect(); + k.windows.iter().flat_map(|exe| bases.iter().map(move |b| b.join(exe))).find(|p| p.is_file()) + } else { + let path = std::env::var_os("PATH")?; + let dirs: Vec = std::env::split_paths(&path).collect(); + k.linux.iter().flat_map(|cmd| dirs.iter().map(move |d| d.join(cmd))).find(|p| p.is_file()) + } +} + +/// The known browsers installed on this machine, in a fixed order. +pub fn installed() -> Vec { + KNOWN.iter().filter(|k| locate(k).is_some()).map(|k| Browser { id: k.id.into(), name: k.name.into() }).collect() +} + +/// Opens `url` in browser `id`, or in the system default browser. +pub fn open(url: &str, id: Option<&str>) -> IdeResult<()> { + if !(url.starts_with("http://") || url.starts_with("https://") || url.starts_with("file://")) { + return Err(IdeError::invalid(format!("not a web URL: {url}"))); + } + let result = match id { + None => open::that_detached(url), + Some(id) => { + let known = + KNOWN.iter().find(|k| k.id == id).ok_or_else(|| IdeError::invalid(format!("unknown browser: {id}")))?; + let app = locate(known).ok_or_else(|| IdeError::unavailable(format!("{} is not installed", known.name)))?; + open::with_detached(url, app.to_string_lossy().as_ref()) + } + }; + result.map_err(|e| IdeError::unavailable(format!("cannot open the browser: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_unique() { + let mut ids: Vec<_> = KNOWN.iter().map(|k| k.id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), KNOWN.len()); + } + + #[test] + fn refuses_non_web_urls() { + assert!(open("/etc/passwd", None).is_err()); + assert!(open("javascript:alert(1)", None).is_err()); + assert!(open("http://127.0.0.1/", Some("netscape")).is_err()); + } +} diff --git a/crates/ide-web/src/lib.rs b/crates/ide-web/src/lib.rs new file mode 100644 index 000000000..15d8cc927 --- /dev/null +++ b/crates/ide-web/src/lib.rs @@ -0,0 +1,14 @@ +//! Web browsers, like IDEA's "Open In › Browser" and the editor's browser bar. +//! +//! [`WebServer`] is the built-in server: it serves project files read-only on +//! `127.0.0.1`, so pages opened from the IDE load relative scripts, styles and +//! ES modules the way they would from a real server. Every URL starts with a +//! random per-run token, and requests whose `Host` is not the server itself are +//! refused (DNS rebinding). [`installed`] lists the browsers found on this +//! machine and [`open`] launches one, or the system default. + +mod browsers; +mod server; + +pub use browsers::{Browser, installed, open}; +pub use server::WebServer; diff --git a/crates/ide-web/src/server.rs b/crates/ide-web/src/server.rs new file mode 100644 index 000000000..b909bcdb3 --- /dev/null +++ b/crates/ide-web/src/server.rs @@ -0,0 +1,355 @@ +use ide_core::{IdeError, IdeResult}; +use parking_lot::RwLock; +use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; + +/// IDEA's built-in server port, tried first so bookmarked URLs keep working. +const PREFERRED_PORT: u16 = 63342; +const MAX_HEAD: usize = 16 * 1024; +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// Characters escaped in one URL path segment. +const SEGMENT: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'/') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + +struct Shared { + token: String, + /// URL key (`///…`) → served directory. + roots: RwLock>, +} + +/// The built-in web server; started on the first [`WebServer::url_for`]. +pub struct WebServer { + shared: Arc, + port: tokio::sync::OnceCell, +} + +impl Default for WebServer { + fn default() -> Self { + Self::new() + } +} + +impl WebServer { + pub fn new() -> Self { + Self { + shared: Arc::new(Shared { token: uuid::Uuid::new_v4().simple().to_string(), roots: RwLock::default() }), + port: tokio::sync::OnceCell::new(), + } + } + + /// The URL serving `file`: under `root` (the project) when it contains the + /// file, else under the file's directory. Starts the server if needed. + pub async fn url_for(&self, root: Option<&Path>, file: &Path) -> IdeResult { + let base = match root { + Some(r) if file.starts_with(r) && file != r => r, + _ => file.parent().ok_or_else(|| IdeError::invalid(format!("no directory: {}", file.display())))?, + }; + let rel = file.strip_prefix(base).map_err(|e| IdeError::internal(e.to_string()))?; + let port = self.port().await?; + let key = self.register(base); + let mut url = format!("http://127.0.0.1:{port}/{}/{}", self.shared.token, enc(&key)); + for c in rel.components() { + url.push('/'); + url.push_str(&enc(&c.as_os_str().to_string_lossy())); + } + Ok(url) + } + + /// The key `dir` is served under: its name, made unique. + fn register(&self, dir: &Path) -> String { + let mut roots = self.shared.roots.write(); + if let Some((k, _)) = roots.iter().find(|(_, d)| d.as_path() == dir) { + return k.clone(); + } + let name: String = dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + .chars() + .map(|c| if c.is_alphanumeric() || "._-".contains(c) { c } else { '_' }) + .collect(); + let name = if name.is_empty() || name == "." || name == ".." { "root".to_string() } else { name }; + let key = (1..) + .map(|i| if i == 1 { name.clone() } else { format!("{name}-{i}") }) + .find(|k| !roots.contains_key(k)) + .expect("unbounded"); + roots.insert(key.clone(), dir.to_path_buf()); + key + } + + async fn port(&self) -> IdeResult { + self.port + .get_or_try_init(|| async { + let listener = match TcpListener::bind(("127.0.0.1", PREFERRED_PORT)).await { + Ok(l) => l, + Err(_) => TcpListener::bind(("127.0.0.1", 0)).await?, + }; + let port = listener.local_addr()?.port(); + let shared = self.shared.clone(); + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((sock, _)) => { + let shared = shared.clone(); + tokio::spawn(async move { + if let Err(e) = serve(sock, &shared, port).await { + tracing::debug!("web server: {e}"); + } + }); + } + Err(e) => { + tracing::warn!("web server accept: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + }); + tracing::info!("built-in web server on 127.0.0.1:{port}"); + Ok::<_, std::io::Error>(port) + }) + .await + .copied() + .map_err(|e| IdeError::unavailable(format!("cannot start the built-in web server: {e}"))) + } +} + +fn enc(s: &str) -> String { + utf8_percent_encode(s, SEGMENT).to_string() +} + +struct Request { + method: String, + target: String, + host: Option, +} + +async fn read_head(sock: &mut TcpStream) -> std::io::Result> { + let mut reader = BufReader::new(sock).take(MAX_HEAD as u64); + let mut line = String::new(); + if reader.read_line(&mut line).await? == 0 { + return Ok(None); + } + let mut parts = line.split_whitespace(); + let (Some(method), Some(target)) = (parts.next(), parts.next()) else { return Ok(None) }; + let (method, target) = (method.to_string(), target.to_string()); + let mut host = None; + loop { + line.clear(); + if reader.read_line(&mut line).await? == 0 { + return Ok(None); + } + let l = line.trim_end(); + if l.is_empty() { + break; + } + if let Some((name, value)) = l.split_once(':') + && name.eq_ignore_ascii_case("host") + { + host = Some(value.trim().to_ascii_lowercase()); + } + } + Ok(Some(Request { method, target, host })) +} + +enum Response { + File { path: PathBuf, len: u64 }, + Redirect(String), + Error(u16, &'static str), +} + +async fn serve(mut sock: TcpStream, shared: &Shared, port: u16) -> std::io::Result<()> { + let Ok(head) = tokio::time::timeout(READ_TIMEOUT, read_head(&mut sock)).await else { return Ok(()) }; + let Some(req) = head? else { return Ok(()) }; + let head_only = req.method == "HEAD"; + let response = if !matches!(req.method.as_str(), "GET" | "HEAD") { + Response::Error(405, "Method Not Allowed") + } else if !host_allowed(req.host.as_deref(), port) { + Response::Error(403, "Forbidden") + } else { + resolve(shared, &req.target).await + }; + let mut out = String::new(); + match &response { + Response::File { path, len } => { + out.push_str("HTTP/1.1 200 OK\r\n"); + out.push_str(&format!("Content-Type: {}\r\nContent-Length: {len}\r\n", content_type(path))); + } + Response::Redirect(to) => { + out.push_str(&format!("HTTP/1.1 301 Moved Permanently\r\nLocation: {to}\r\nContent-Length: 0\r\n")); + } + Response::Error(code, reason) => { + out.push_str(&format!( + "HTTP/1.1 {code} {reason}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n", + reason.len() + )); + } + } + out.push_str("Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n"); + sock.write_all(out.as_bytes()).await?; + if !head_only { + match response { + Response::File { path, .. } => { + let mut file = tokio::fs::File::open(&path).await?; + tokio::io::copy(&mut file, &mut sock).await?; + } + Response::Error(_, reason) => sock.write_all(reason.as_bytes()).await?, + Response::Redirect(_) => {} + } + } + sock.shutdown().await +} + +fn host_allowed(host: Option<&str>, port: u16) -> bool { + host.is_some_and(|h| h == format!("127.0.0.1:{port}") || h == format!("localhost:{port}")) +} + +async fn resolve(shared: &Shared, target: &str) -> Response { + const NOT_FOUND: Response = Response::Error(404, "Not Found"); + let path = target.split(['?', '#']).next().unwrap_or(""); + let Some(rest) = path.strip_prefix('/') else { return NOT_FOUND }; + let mut segments = rest.split('/'); + if segments.next() != Some(shared.token.as_str()) { + return NOT_FOUND; + } + let Some(key) = segments.next().and_then(dec) else { return NOT_FOUND }; + let Some(mut file) = shared.roots.read().get(&key).cloned() else { return NOT_FOUND }; + let mut trailing_slash = true; + for seg in segments { + trailing_slash = seg.is_empty(); + if seg.is_empty() { + continue; + } + match dec(seg) { + Some(s) if s != "." && s != ".." && !s.contains(['/', '\\', ':', '\0']) => file.push(s), + _ => return NOT_FOUND, + } + } + let Ok(meta) = tokio::fs::metadata(&file).await else { return NOT_FOUND }; + if meta.is_dir() { + // Relative URLs in an index page resolve against the directory. + if !trailing_slash { + return Response::Redirect(format!("{path}/")); + } + file.push("index.html"); + return match tokio::fs::metadata(&file).await { + Ok(m) if m.is_file() => Response::File { path: file, len: m.len() }, + _ => NOT_FOUND, + }; + } + Response::File { path: file, len: meta.len() } +} + +fn dec(s: &str) -> Option { + percent_decode_str(s).decode_utf8().ok().map(|s| s.into_owned()) +} + +fn content_type(path: &Path) -> &'static str { + let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + match ext.as_str() { + "html" | "htm" | "xhtml" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" | "cjs" => "text/javascript; charset=utf-8", + "json" | "map" | "webmanifest" => "application/json; charset=utf-8", + "xml" => "application/xml; charset=utf-8", + "txt" | "md" | "csv" => "text/plain; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "avif" => "image/avif", + "ico" => "image/x-icon", + "bmp" => "image/bmp", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + "wasm" => "application/wasm", + "pdf" => "application/pdf", + "mp4" => "video/mp4", + "webm" => "video/webm", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "ogg" => "audio/ogg", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn get(url: &str, host: Option<&str>) -> String { + let rest = url.strip_prefix("http://").unwrap(); + let (authority, path) = rest.split_at(rest.find('/').unwrap()); + let mut sock = TcpStream::connect(authority).await.unwrap(); + let host = host.unwrap_or(authority); + sock.write_all(format!("GET {path} HTTP/1.1\r\nHost: {host}\r\n\r\n").as_bytes()).await.unwrap(); + let mut out = String::new(); + sock.read_to_string(&mut out).await.unwrap(); + out + } + + #[tokio::test] + async fn serves_project_files() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("my app"); + std::fs::create_dir_all(root.join("web/sub")).unwrap(); + std::fs::write(root.join("web/index.html"), "

hi

").unwrap(); + std::fs::write(root.join("web/sub/a b.js"), "export {}").unwrap(); + std::fs::write(dir.path().join("secret.txt"), "no").unwrap(); + + let server = WebServer::new(); + let url = server.url_for(Some(&root), &root.join("web/index.html")).await.unwrap(); + assert!(url.ends_with("/my_app/web/index.html"), "{url}"); + let page = get(&url, None).await; + assert!(page.starts_with("HTTP/1.1 200"), "{page}"); + assert!(page.contains("text/html") && page.ends_with("

hi

")); + + let base = url.trim_end_matches("/web/index.html"); + assert!(get(&format!("{base}/web/sub/a%20b.js"), None).await.contains("text/javascript")); + assert!(get(&format!("{base}/web"), None).await.contains("Location: /")); + assert!(get(&format!("{base}/web/"), None).await.ends_with("

hi

")); + assert!(get(&format!("{base}/../secret.txt"), None).await.starts_with("HTTP/1.1 404")); + assert!(get(&format!("{base}/%2e%2e/secret.txt"), None).await.starts_with("HTTP/1.1 404")); + assert!(get(&format!("{base}/web/..%2f..%2fsecret.txt"), None).await.starts_with("HTTP/1.1 404")); + // Wrong token, foreign Host (DNS rebinding). + let other = url.replacen(&server.shared.token, "0123", 1); + assert!(get(&other, None).await.starts_with("HTTP/1.1 404")); + assert!(get(&url, Some("evil.example:80")).await.starts_with("HTTP/1.1 403")); + } + + #[tokio::test] + async fn files_outside_the_project_get_their_own_root() { + let dir = tempfile::tempdir().unwrap(); + let (a, b) = (dir.path().join("x/site"), dir.path().join("y/site")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let server = WebServer::new(); + let ua = server.url_for(None, &a.join("p.html")).await.unwrap(); + let ub = server.url_for(Some(&a), &b.join("p.html")).await.unwrap(); + assert!(ua.ends_with("/site/p.html") && ub.ends_with("/site-2/p.html"), "{ua} {ub}"); + assert_eq!(server.url_for(None, &a.join("q.html")).await.unwrap(), ua.replace("p.html", "q.html")); + } +} diff --git a/package.json b/package.json index 89e8691d8..de18c4819 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "clsx": "^2.1.1", "codemirror": "^6.0.2", "dompurify": "^3.4.17", + "emmet": "2.4.11", "immer": "^11.1.21", "marked": "^18.1.1", "react": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76c7e499a..cedddec2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: dompurify: specifier: ^3.4.17 version: 3.4.17 + emmet: + specifier: 2.4.11 + version: 2.4.11 immer: specifier: ^11.1.21 version: 11.1.21 @@ -383,6 +386,15 @@ packages: resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} engines: {node: '>=20.19.0'} + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1050,6 +1062,9 @@ packages: electron-to-chromium@1.5.459: resolution: {integrity: sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==} + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + entities@8.1.0: resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} engines: {node: '>=20.19.0'} @@ -2156,6 +2171,16 @@ snapshots: '@csstools/css-tokenizer@4.0.2': {} + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + '@eslint-community/eslint-utils@4.10.1(eslint@10.13.0)': dependencies: eslint: 10.13.0 @@ -2772,6 +2797,11 @@ snapshots: electron-to-chromium@1.5.459: {} + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + entities@8.1.0: {} es-module-lexer@2.3.2: {} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0769b244c..df445e1f1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,7 @@ ide-coverage.workspace = true ide-profiler.workspace = true ide-http.workspace = true ide-db.workspace = true +ide-web.workspace = true ide-tools = { path = "../crates/ide-tools" } ide-plugin = { path = "../crates/ide-plugin" } ide-docker = { path = "../crates/ide-docker" } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 86ef21789..214bf3247 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -34,6 +34,7 @@ pub mod syntax; pub mod term; pub mod tools; pub mod vcs; +pub mod web; pub mod window; use tauri_specta::collect_commands; @@ -257,6 +258,9 @@ pub fn builder() -> tauri_specta::Builder { debug::debug_redefine_classes, coverage::coverage_prepare, coverage::coverage_collect, + web::web_browsers, + web::web_url, + web::web_open, profiler::profiler_prepare, profiler::profiler_collect, plugin::plugin_subscribe, diff --git a/src-tauri/src/commands/web.rs b/src-tauri/src/commands/web.rs new file mode 100644 index 000000000..0dd816320 --- /dev/null +++ b/src-tauri/src/commands/web.rs @@ -0,0 +1,29 @@ +//! Web browsers: the built-in server's URLs and launching browsers. + +use super::coverage::blocking; +use crate::state::AppState; +use ide_core::{IdeResult, IpcPath}; +use ide_web::Browser; +use tauri::{State, Window}; + +/// The browsers installed on this machine. +#[tauri::command] +#[specta::specta] +pub async fn web_browsers() -> IdeResult> { + blocking(|| Ok(ide_web::installed())).await +} + +/// The built-in server's URL for `path` (started on first use). +#[tauri::command] +#[specta::specta] +pub async fn web_url(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult { + let root = state.project(window.label()).ok().map(|p| p.root.clone()); + state.web.url_for(root.as_deref(), &path).await +} + +/// Opens `url` in `browser` (an id from `web_browsers`), or the default browser. +#[tauri::command] +#[specta::specta] +pub async fn web_open(url: String, browser: Option) -> IdeResult<()> { + blocking(move || ide_web::open(&url, browser.as_deref())).await +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 402046c4c..7b4fc6348 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -37,6 +37,8 @@ pub struct AppState { pub debugger: Arc, /// WASM plugins (`ide-plugin`), shared by all windows. pub plugins: Arc, + /// The built-in web server (Open In › Browser, the editor's browser bar). + pub web: ide_web::WebServer, /// Broadcasts to every window. pub sink: SharedSink, /// One project per window, keyed by window label. @@ -121,6 +123,7 @@ impl AppState { debugger: DebugManager::new(languages.clone()), sink: global_sink, plugins: crate::commands::plugin::create_host(&app, &data_dir)?, + web: ide_web::WebServer::new(), syntax, startup: Startup { process_to_main, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f8eb792a1..32eb8138e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:" + "csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:*" } }, "bundle": { diff --git a/src/bindings.ts b/src/bindings.ts index 71749fbc3..e8799cd05 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -511,6 +511,12 @@ export const commands = { coveragePrepare: (config: RunConfig) => typedError(__TAURI_INVOKE("coverage_prepare", { config })), /** Reads the report a coverage run left behind. */ coverageCollect: (report: CoverageReportSpec) => typedError(__TAURI_INVOKE("coverage_collect", { report })), + /** The browsers installed on this machine. */ + webBrowsers: () => typedError(__TAURI_INVOKE("web_browsers")), + /** The built-in server's URL for `path` (started on first use). */ + webUrl: (path: IpcPath) => typedError(__TAURI_INVOKE("web_url", { path })), + /** Opens `url` in `browser` (an id from `web_browsers`), or the default browser. */ + webOpen: (url: string, browser: string | null) => typedError(__TAURI_INVOKE("web_open", { url, browser })), /** How to run `config` under a sampling profiler, or which profiler is missing. */ profilerPrepare: (config: RunConfig) => typedError(__TAURI_INVOKE("profiler_prepare", { config })), /** Reads the profile a profiled run left behind. */ @@ -826,6 +832,13 @@ export type BreakpointState = { message: string | null, }; +/** An installed browser. */ +export type Browser = { + /** `chrome`, `firefox`, `safari`, `edge`, `brave`, `chromium`. */ + id: string, + name: string, +}; + export type BuildEvent = /** The build starts: its tree root. */ { kind: "started"; system: BuildSystem; title: string } | diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index dec6394f5..cfdfd936e 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -1076,6 +1076,8 @@ function openInMenu(path: string): MenuItem { { kind: "action", action: "navigate.selectIn", arg: { path }, label: "Project View" }, { kind: "action", action: "project.revealInFinder", arg: path, label: revealLabel() }, { kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" }, + { kind: "separator" }, + ...menuGroupItems("openIn", { paths: [path] }), ], }; } diff --git a/src/emmet.d.ts b/src/emmet.d.ts new file mode 100644 index 000000000..dcd357ea1 --- /dev/null +++ b/src/emmet.d.ts @@ -0,0 +1,21 @@ +// `emmet` ships typings its package.json "exports" does not expose; the subset used here. +declare module "emmet" { + export type SyntaxType = "markup" | "stylesheet"; + export interface UserConfig { + type?: SyntaxType; + syntax?: string; + options?: Record; + } + export interface ExtractedAbbreviation { + abbreviation: string; + location: number; + start: number; + end: number; + } + export default function expandAbbreviation(abbr: string, config?: UserConfig): string; + export function extract( + line: string, + pos?: number, + options?: Partial<{ lookAhead: boolean; type: SyntaxType; prefix: string }>, + ): ExtractedAbbreviation | undefined; +} diff --git a/src/features/index.ts b/src/features/index.ts index c04196aad..387fee22f 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -26,6 +26,7 @@ import { activate as plugins, activateSettingsPages as pluginsPages } from "./pl import { activate as tools, activateSettingsPages as toolsPages } from "./tools"; import { activate as scratch } from "./scratch"; import { activate as viewers } from "./viewers"; +import { activate as web } from "./web"; import { activate as analysis } from "./analysis"; import { activate as services } from "./services"; import { activate as github } from "./github"; @@ -58,6 +59,7 @@ const features: Array<[string, Activate]> = [ ["tools", tools], ["scratch", scratch], ["viewers", viewers], + ["web", web], ["analysis", analysis], ["services", services], ["github", github], diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index 3c9d8609a..e7def9dbc 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -241,6 +241,8 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { folder && getAction("terminal.openAt") ? { id: "terminal", label: "Terminal", icon: "terminal", run: () => void delegate("terminal.openAt", folder) } : null, + { kind: "separator" }, + ...menuGroupItems("openIn", menuGroupContext(nodes)), ]), { kind: "separator" }, ); diff --git a/src/features/templates/__tests__/emmet.test.ts b/src/features/templates/__tests__/emmet.test.ts new file mode 100644 index 000000000..1ce65d33e --- /dev/null +++ b/src/features/templates/__tests__/emmet.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { enclosingTag, expandAt, syntaxAt, updateTagEdits, type TextEdit } from "../emmet/model"; + +const apply = (text: string, edits: TextEdit[]) => + [...edits].sort((a, b) => b.from - a.from).reduce((t, e) => t.slice(0, e.from) + e.insert + t.slice(e.to), text); + +describe("emmet context", () => { + it("expands markup in HTML text, not inside tags, comments or scripts", () => { + expect(syntaxAt("html", "\n ul>li")).toEqual({ type: "markup", syntax: "html" }); + expect(syntaxAt("html", "
\n foo")).toBeNull(); + expect(syntaxAt("html", "\ndiv")).toEqual({ type: "markup", syntax: "html" }); + expect(syntaxAt("typescript", "div")).toBeNull(); + }); + + it("uses stylesheet syntax inside rules and