feat(web): Open HTML in browsers with a built-in server; add Emmet
- ide-web: built-in static server on 127.0.0.1 that serves project files, plus detection and launching of installed browsers. - Open In › Browser in the editor, project view and tab menus; browser bar and built-in preview beside HTML editors; ⌃⇧R opens the default browser. - Emmet: Tab expansion in markup editors and Update Tag with Emmet. Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<PathBuf> {
|
||||
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<PathBuf> = ["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<PathBuf> = 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<Browser> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 (`/<token>/<key>/…`) → served directory.
|
||||
roots: RwLock<HashMap<String, PathBuf>>,
|
||||
}
|
||||
|
||||
/// The built-in web server; started on the first [`WebServer::url_for`].
|
||||
pub struct WebServer {
|
||||
shared: Arc<Shared>,
|
||||
port: tokio::sync::OnceCell<u16>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<u16> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
async fn read_head(sock: &mut TcpStream) -> std::io::Result<Option<Request>> {
|
||||
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<String> {
|
||||
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"), "<h1>hi</h1>").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("<h1>hi</h1>"));
|
||||
|
||||
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("<h1>hi</h1>"));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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/[email protected]':
|
||||
resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==}
|
||||
|
||||
'@emmetio/[email protected]':
|
||||
resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==}
|
||||
|
||||
'@emmetio/[email protected]':
|
||||
resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==}
|
||||
|
||||
'@eslint-community/[email protected]':
|
||||
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -1050,6 +1062,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -2156,6 +2171,16 @@ snapshots:
|
||||
|
||||
'@csstools/[email protected]': {}
|
||||
|
||||
'@emmetio/[email protected]':
|
||||
dependencies:
|
||||
'@emmetio/scanner': 1.0.4
|
||||
|
||||
'@emmetio/[email protected]':
|
||||
dependencies:
|
||||
'@emmetio/scanner': 1.0.4
|
||||
|
||||
'@emmetio/[email protected]': {}
|
||||
|
||||
'@eslint-community/[email protected]([email protected])':
|
||||
dependencies:
|
||||
eslint: 10.13.0
|
||||
@@ -2772,6 +2797,11 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@emmetio/abbreviation': 2.3.3
|
||||
'@emmetio/css-abbreviation': 2.1.8
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<Vec<Browser>> {
|
||||
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<String> {
|
||||
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<String>) -> IdeResult<()> {
|
||||
blocking(move || ide_web::open(&url, browser.as_deref())).await
|
||||
}
|
||||
@@ -37,6 +37,8 @@ pub struct AppState {
|
||||
pub debugger: Arc<DebugManager>,
|
||||
/// WASM plugins (`ide-plugin`), shared by all windows.
|
||||
pub plugins: Arc<ide_plugin::PluginHost>,
|
||||
/// 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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -511,6 +511,12 @@ export const commands = {
|
||||
coveragePrepare: (config: RunConfig) => typedError<CoveragePrepared, IdeError>(__TAURI_INVOKE("coverage_prepare", { config })),
|
||||
/** Reads the report a coverage run left behind. */
|
||||
coverageCollect: (report: CoverageReportSpec) => typedError<CoverageSuite, IdeError>(__TAURI_INVOKE("coverage_collect", { report })),
|
||||
/** The browsers installed on this machine. */
|
||||
webBrowsers: () => typedError<Browser[], IdeError>(__TAURI_INVOKE("web_browsers")),
|
||||
/** The built-in server's URL for `path` (started on first use). */
|
||||
webUrl: (path: IpcPath) => typedError<string, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("web_open", { url, browser })),
|
||||
/** How to run `config` under a sampling profiler, or which profiler is missing. */
|
||||
profilerPrepare: (config: RunConfig) => typedError<ProfilePrepared, IdeError>(__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 } |
|
||||
|
||||
@@ -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] }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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", "<body>\n ul>li")).toEqual({ type: "markup", syntax: "html" });
|
||||
expect(syntaxAt("html", "<div cla")).toBeNull();
|
||||
expect(syntaxAt("html", "<!-- div")).toBeNull();
|
||||
expect(syntaxAt("html", "<script>\n foo")).toBeNull();
|
||||
expect(syntaxAt("html", "<script></script>\ndiv")).toEqual({ type: "markup", syntax: "html" });
|
||||
expect(syntaxAt("typescript", "div")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses stylesheet syntax inside rules and <style>", () => {
|
||||
expect(syntaxAt("css", ".a {\n m10")).toEqual({ type: "stylesheet", syntax: "css" });
|
||||
expect(syntaxAt("css", "m10")).toBeNull();
|
||||
expect(syntaxAt("css", ".a { /* m10")).toBeNull();
|
||||
expect(syntaxAt("html", '<style lang="scss">\n.a { p5')).toEqual({ type: "stylesheet", syntax: "scss" });
|
||||
expect(syntaxAt("html", "<style>\n.a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("emmet expansion", () => {
|
||||
const html = { type: "markup", syntax: "html" } as const;
|
||||
|
||||
it("expands abbreviations into snippets with fields", () => {
|
||||
const line = " ul>li.item*2";
|
||||
const r = expandAt(line, line.length, html)!;
|
||||
expect(r.from).toBe(2);
|
||||
expect(r.to).toBe(line.length);
|
||||
expect(r.snippet).toBe('<ul>\n\t<li class="item">${1}</li>\n\t<li class="item">${2}</li>\n</ul>');
|
||||
});
|
||||
|
||||
it("fills attribute placeholders and escapes snippet syntax", () => {
|
||||
expect(expandAt("a", 1, html)!.snippet).toBe('<a href="${1}">${2}</a>');
|
||||
expect(expandAt("p{x#{y}}", 8, html)!.snippet).toBe("<p>x#\\{y}</p>");
|
||||
});
|
||||
|
||||
it("expands CSS properties and ignores unknown words", () => {
|
||||
const css = { type: "stylesheet", syntax: "css" } as const;
|
||||
expect(expandAt(" m10", 5, css)!.snippet).toBe("margin: 10px;");
|
||||
expect(expandAt(" 123", 5, css)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update tag with emmet", () => {
|
||||
const doc = '<div class="a" id="x">\n <p>hi</p>\n</div>';
|
||||
|
||||
it("finds the innermost element around the caret", () => {
|
||||
expect(enclosingTag(doc, doc.indexOf("hi"))?.name).toBe("p");
|
||||
expect(enclosingTag(doc, doc.indexOf(" <p>"))?.name).toBe("div");
|
||||
expect(enclosingTag("<ul><li>a<li>b</ul>", 10)?.name).toBe("li");
|
||||
expect(enclosingTag("<br><span>x</span>", 2)?.name).toBe("br");
|
||||
});
|
||||
|
||||
it("renames, adds classes and sets attributes", () => {
|
||||
const tag = enclosingTag(doc, 3)!;
|
||||
expect(apply(doc, updateTagEdits(tag, "section.b#y[data-k=v]")!)).toBe(
|
||||
'<section class="a b" id="y" data-k="v">\n <p>hi</p>\n</section>',
|
||||
);
|
||||
expect(apply(doc, updateTagEdits(tag, ".c")!)).toBe('<div class="a c" id="x">\n <p>hi</p>\n</div>');
|
||||
expect(updateTagEdits(tag, "ul>li")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "./postfix/model";
|
||||
import { usePostfixSettings } from "./postfix/store";
|
||||
import { showChooser } from "./popup/store";
|
||||
import { expandEmmetAtCaret } from "./emmet/editor";
|
||||
|
||||
export interface TemplateDocInfo {
|
||||
path: string;
|
||||
@@ -446,8 +447,8 @@ export function templateEditorExtension(ctx: EditorDocContext): Extension {
|
||||
run: (view) => {
|
||||
if (view.state.readOnly || completionStatus(view.state) === "active") return false;
|
||||
if (expandPostfixAtCaret(view)) return true;
|
||||
if (!useLiveTemplateOptions.getState().expandWithTab) return false;
|
||||
return expandLiveTemplateAtCaret(view);
|
||||
if (useLiveTemplateOptions.getState().expandWithTab && expandLiveTemplateAtCaret(view)) return true;
|
||||
return expandEmmetAtCaret(view, ctx.language);
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.label {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.label code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** The abbreviation prompt of Update Tag with Emmet. */
|
||||
import { useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { Dialog, Input } from "@/ui";
|
||||
import styles from "./Emmet.module.css";
|
||||
|
||||
interface Pending {
|
||||
tag: string;
|
||||
resolve: (abbreviation: string | null) => void;
|
||||
}
|
||||
|
||||
const usePrompt = create<{ pending: Pending | null }>(() => ({ pending: null }));
|
||||
|
||||
/** Asks for the abbreviation to apply to `tag`; null when cancelled. */
|
||||
export function promptAbbreviation(tag: string): Promise<string | null> {
|
||||
usePrompt.getState().pending?.resolve(null);
|
||||
return new Promise((resolve) => usePrompt.setState({ pending: { tag, resolve } }));
|
||||
}
|
||||
|
||||
function PromptDialog({ tag, resolve }: Pending) {
|
||||
const [value, setValue] = useState("");
|
||||
const done = (v: string | null) => {
|
||||
usePrompt.setState({ pending: null });
|
||||
resolve(v);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Update Tag with Emmet"
|
||||
onClose={() => done(null)}
|
||||
width={420}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", disabled: !value.trim(), onClick: () => done(value) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<label className={styles.label} htmlFor="emmet-update-tag">
|
||||
Abbreviation for <{tag}>, e.g. <code>section.card#main[data-x=1]</code>
|
||||
</label>
|
||||
<Input
|
||||
id="emmet-update-tag"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateTagDialogHost() {
|
||||
const pending = usePrompt((s) => s.pending);
|
||||
return pending ? <PromptDialog {...pending} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Editor side of Emmet: Tab expansion and Update Tag with Emmet. */
|
||||
import { snippet } from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { enclosingTag, expandAt, syntaxAt, updateTagEdits } from "./model";
|
||||
|
||||
/** Tab: expands the Emmet abbreviation before the caret in a `language` document. */
|
||||
export function expandEmmetAtCaret(view: EditorView, language: string): boolean {
|
||||
const { state } = view;
|
||||
const sel = state.selection.main;
|
||||
if (!sel.empty || state.selection.ranges.length > 1) return false;
|
||||
const syntax = syntaxAt(language, state.sliceDoc(0, sel.head));
|
||||
if (!syntax) return false;
|
||||
const line = state.doc.lineAt(sel.head);
|
||||
const found = expandAt(line.text, sel.head - line.from, syntax);
|
||||
if (!found) return false;
|
||||
snippet(found.snippet)(view, null, line.from + found.from, line.from + found.to);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The enclosing tag's name at the caret (for the prompt). */
|
||||
export function tagAtCaret(view: EditorView): string | null {
|
||||
return enclosingTag(view.state.doc.toString(), view.state.selection.main.head)?.name ?? null;
|
||||
}
|
||||
|
||||
/** Applies `abbreviation` to the tag enclosing the caret; false when nothing changed. */
|
||||
export function updateTag(view: EditorView, abbreviation: string): boolean {
|
||||
const tag = enclosingTag(view.state.doc.toString(), view.state.selection.main.head);
|
||||
const edits = tag && updateTagEdits(tag, abbreviation);
|
||||
if (!edits) return false;
|
||||
view.dispatch({ changes: edits, userEvent: "input.emmet", scrollIntoView: true });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Emmet, as in IDEA's Settings › Editor › Emmet: abbreviation expansion
|
||||
* (`ul>li.item*3` + Tab) in HTML-like and stylesheet files, and "Update Tag
|
||||
* with Emmet" on the enclosing tag. Pure functions over document text; the
|
||||
* editor glue lives in `./editor`.
|
||||
*/
|
||||
import expand, { extract, type UserConfig } from "emmet";
|
||||
|
||||
export interface EmmetSyntax {
|
||||
type: "markup" | "stylesheet";
|
||||
syntax: string;
|
||||
}
|
||||
|
||||
/** Languages with markup expansion; `<style>` / `<script>` regions are handled. */
|
||||
const MARKUP_LANGUAGES = new Set(["html", "vue", "svelte"]);
|
||||
const STYLESHEET_LANGUAGES: Record<string, string> = { css: "css", scss: "scss", less: "less", sass: "sass" };
|
||||
|
||||
/** Text before the caret that is scanned for context. */
|
||||
const CONTEXT_LIMIT = 200_000;
|
||||
|
||||
function lastMatch(text: string, re: RegExp): number {
|
||||
let at = -1;
|
||||
for (const m of text.matchAll(re)) at = m.index;
|
||||
return at;
|
||||
}
|
||||
|
||||
/** Inside an unclosed `<tag` of `before` (`<tag>` contents excluded). */
|
||||
function openRegion(before: string, tag: string): RegExpMatchArray | null {
|
||||
const open = lastMatch(before, new RegExp(`<${tag}\\b`, "gi"));
|
||||
if (open < 0 || open < lastMatch(before, new RegExp(`</${tag}\\b`, "gi"))) return null;
|
||||
const head = /^<\w+\b((?:[^>"']|"[^"]*"|'[^']*')*)>/.exec(before.slice(open));
|
||||
return head;
|
||||
}
|
||||
|
||||
function stylesheetContext(before: string): boolean {
|
||||
// Not in a comment, inside a rule, at the start of a declaration.
|
||||
if (lastMatch(before, /\/\*/g) > lastMatch(before, /\*\//g)) return false;
|
||||
let depth = 0;
|
||||
for (const c of before.replace(/"[^"\n]*"|'[^'\n]*'/g, "")) {
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") depth = Math.max(0, depth - 1);
|
||||
}
|
||||
return depth > 0;
|
||||
}
|
||||
|
||||
/** The Emmet syntax at the end of `before` (text up to the caret) in a `language` file, or null for none. */
|
||||
export function syntaxAt(language: string, before: string): EmmetSyntax | null {
|
||||
const text = before.length > CONTEXT_LIMIT ? before.slice(-CONTEXT_LIMIT) : before;
|
||||
const css = STYLESHEET_LANGUAGES[language];
|
||||
if (css) return stylesheetContext(text) ? { type: "stylesheet", syntax: css } : null;
|
||||
if (!MARKUP_LANGUAGES.has(language)) return null;
|
||||
if (lastMatch(text, /<!--/g) > lastMatch(text, /-->/g)) return null;
|
||||
if (openRegion(text, "script")) return null;
|
||||
const style = openRegion(text, "style");
|
||||
if (style) {
|
||||
const lang = /\blang\s*=\s*["']?(\w+)/i.exec(style[1] ?? "")?.[1]?.toLowerCase();
|
||||
const inner = text.slice(lastMatch(text, /<style\b/gi) + style[0].length);
|
||||
return stylesheetContext(inner) ? { type: "stylesheet", syntax: STYLESHEET_LANGUAGES[lang ?? ""] ?? "css" } : null;
|
||||
}
|
||||
// Not inside a tag (`<div cla|`).
|
||||
if (text.lastIndexOf("<") > text.lastIndexOf(">")) return null;
|
||||
return { type: "markup", syntax: "html" };
|
||||
}
|
||||
|
||||
/** CodeMirror snippet fields (`${1:name}`) for Emmet's tab stops. */
|
||||
function field(index: number, placeholder: string): string {
|
||||
const p = placeholder.replace(/[{}\\]/g, (c) => `\\${c}`);
|
||||
return p ? `\${${index}:${p}}` : `\${${index}}`;
|
||||
}
|
||||
|
||||
/** Escapes text so CodeMirror's snippet parser leaves it as is. */
|
||||
function escapeSnippet(text: string): string {
|
||||
return text.replace(/[$#]\{/g, (m) => `${m[0]}\\{`);
|
||||
}
|
||||
|
||||
function config(syntax: EmmetSyntax, fields: boolean): UserConfig {
|
||||
return {
|
||||
type: syntax.type,
|
||||
syntax: syntax.syntax,
|
||||
options: {
|
||||
"output.field": fields ? field : () => "",
|
||||
"output.text": fields ? escapeSnippet : (t: string) => t,
|
||||
// Snippets turn leading tabs into the editor's indent unit.
|
||||
"output.indent": "\t",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface Expansion {
|
||||
/** Offsets in the line text. */
|
||||
from: number;
|
||||
to: number;
|
||||
/** CodeMirror snippet template. */
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/** The abbreviation ending at `col` of `line` expanded, or null when there is none. */
|
||||
export function expandAt(line: string, col: number, syntax: EmmetSyntax): Expansion | null {
|
||||
const found = extract(line, col, { type: syntax.type, lookAhead: true });
|
||||
if (!found || !found.abbreviation.trim()) return null;
|
||||
const abbr = found.abbreviation;
|
||||
// A bare number or a closing-tag fragment is never meant as an abbreviation.
|
||||
if (/^\d/.test(abbr) || abbr.startsWith("/")) return null;
|
||||
let snippet: string;
|
||||
try {
|
||||
snippet = expand(abbr, config(syntax, true));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Stylesheets: unknown words expand to themselves (`foo` → `foo: ;`) or nothing.
|
||||
if (!snippet || (syntax.type === "stylesheet" && !/[:;]/.test(snippet))) return null;
|
||||
return { from: found.start, to: found.end, snippet };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update Tag with Emmet
|
||||
|
||||
interface Attr {
|
||||
name: string;
|
||||
/** null for a boolean attribute. */
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface TagRange {
|
||||
name: string;
|
||||
/** Start tag `<…>` offsets. */
|
||||
from: number;
|
||||
to: number;
|
||||
attrs: Attr[];
|
||||
selfClosing: boolean;
|
||||
/** Name offsets of the matching end tag. */
|
||||
close: { from: number; to: number } | null;
|
||||
}
|
||||
|
||||
const VOID = new Set("area base br col embed hr img input link meta param source track wbr".split(" "));
|
||||
const TAG_RE = /<!--[\s\S]*?-->|<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
|
||||
const ATTR_RE = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
||||
|
||||
export function parseAttrs(text: string): Attr[] {
|
||||
return [...text.matchAll(ATTR_RE)].map((m) => ({ name: m[1]!, value: m[2] ?? m[3] ?? m[4] ?? null }));
|
||||
}
|
||||
|
||||
/** The innermost element whose start tag, content or end tag contains `pos`. */
|
||||
export function enclosingTag(text: string, pos: number): TagRange | null {
|
||||
const stack: TagRange[] = [];
|
||||
let best: TagRange | null = null;
|
||||
const finish = (tag: TagRange, end: number) => {
|
||||
if (tag.from <= pos && pos <= end && (!best || tag.from > best.from)) best = tag;
|
||||
};
|
||||
TAG_RE.lastIndex = 0;
|
||||
for (let m = TAG_RE.exec(text); m; m = TAG_RE.exec(text)) {
|
||||
const name = m[2];
|
||||
if (!name) continue;
|
||||
const from = m.index;
|
||||
const to = from + m[0].length;
|
||||
if (m[1]) {
|
||||
const i = stack.map((t) => t.name.toLowerCase()).lastIndexOf(name.toLowerCase());
|
||||
if (i < 0) continue;
|
||||
// Unclosed elements end where their parent does.
|
||||
for (const open of stack.splice(i + 1)) finish(open, from);
|
||||
const open = stack.pop()!;
|
||||
open.close = { from: from + 2, to: from + 2 + name.length };
|
||||
finish(open, to);
|
||||
continue;
|
||||
}
|
||||
if (from > pos && !stack.some((t) => t.from <= pos)) break;
|
||||
const tag: TagRange = { name, from, to, attrs: parseAttrs(m[3] ?? ""), selfClosing: !!m[4], close: null };
|
||||
if (tag.selfClosing || VOID.has(name.toLowerCase())) {
|
||||
finish(tag, to);
|
||||
continue;
|
||||
}
|
||||
stack.push(tag);
|
||||
// Raw text: skip to the end tag.
|
||||
if (/^(script|style|textarea|title)$/i.test(name)) {
|
||||
const end = text.slice(to).search(new RegExp(`</${name}\\b`, "i"));
|
||||
if (end >= 0) TAG_RE.lastIndex = to + end;
|
||||
}
|
||||
}
|
||||
for (const open of stack) finish(open, text.length);
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface TextEdit {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits applying `abbreviation` to `tag`: a leading name renames the element
|
||||
* (start and end tag), `.class` adds classes, `#id` and `[attr=value]` set attributes.
|
||||
* Null when the abbreviation is not a single element.
|
||||
*/
|
||||
export function updateTagEdits(tag: TagRange, abbreviation: string): TextEdit[] | null {
|
||||
const abbr = abbreviation.trim();
|
||||
if (!abbr || /[>+^*{]/.test(abbr.replace(/\[[^\]]*\]/g, ""))) return null;
|
||||
let html: string;
|
||||
try {
|
||||
html = expand(abbr, config({ type: "markup", syntax: "html" }, false));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const m = /^<([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)\/?>/.exec(html);
|
||||
if (!m) return null;
|
||||
const name = /^[A-Za-z]/.test(abbr) ? m[1]! : tag.name;
|
||||
const attrs = tag.attrs.map((a) => ({ ...a }));
|
||||
for (const a of parseAttrs(m[2] ?? "")) {
|
||||
const old = attrs.find((x) => x.name.toLowerCase() === a.name.toLowerCase());
|
||||
if (!old) attrs.push(a);
|
||||
else if (a.name.toLowerCase() === "class" && old.value) {
|
||||
const have = old.value.split(/\s+/).filter(Boolean);
|
||||
old.value = [...have, ...(a.value ?? "").split(/\s+/).filter((c) => c && !have.includes(c))].join(" ");
|
||||
} else old.value = a.value;
|
||||
}
|
||||
const quote = (v: string) => (v.includes('"') ? `'${v}'` : `"${v}"`);
|
||||
const attrText = attrs.map((a) => (a.value === null ? ` ${a.name}` : ` ${a.name}=${quote(a.value)}`)).join("");
|
||||
const edits: TextEdit[] = [{ from: tag.from, to: tag.to, insert: `<${name}${attrText}${tag.selfClosing ? " /" : ""}>` }];
|
||||
if (tag.close && name !== tag.name) edits.push({ from: tag.close.from, to: tag.close.to, insert: name });
|
||||
return edits;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Live templates (⌘J, Tab expansion, Surround With ⌥⌘T), postfix completion
|
||||
* (`expr.if` …) and file templates ("New ▸ …").
|
||||
* (`expr.if` …), Emmet (Tab expansion, Update Tag with Emmet) and file
|
||||
* templates ("New ▸ …").
|
||||
*/
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerAction, registerActions, type Action } from "@/platform/actions";
|
||||
import { activeEditor, registerEditorContribution } from "@/editor/api";
|
||||
import { registerOverlay, registerSettingsPage } from "@/workbench/contributions";
|
||||
import { notifications, registerMenuGroup, registerOverlay, registerSettingsPage } from "@/workbench/contributions";
|
||||
import { insertLiveTemplate, surroundTarget, surroundWith, templateEditorExtension } from "./editor";
|
||||
import { ChooserHost } from "./popup/Chooser";
|
||||
import { NewFileDialogHost, openNewFileDialog } from "./files/NewFileDialog";
|
||||
@@ -13,10 +14,32 @@ import { defaultDirectory } from "./files/create";
|
||||
import { fileTemplates, useFileTemplates } from "./files/store";
|
||||
import type { FileTemplate } from "./files/model";
|
||||
import { syncTemplatesFromStorage } from "./sync";
|
||||
import { tagAtCaret, updateTag } from "./emmet/editor";
|
||||
import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog";
|
||||
|
||||
export { EXTRA_COMPLETION_KEY } from "./editor";
|
||||
|
||||
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || popupOpen || !toolWindowFocus)";
|
||||
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
|
||||
|
||||
async function updateTagWithEmmet() {
|
||||
const editor = activeEditor();
|
||||
if (!editor) return;
|
||||
const tag = tagAtCaret(editor.view);
|
||||
if (!tag) {
|
||||
notifications().notify({ level: "info", title: "No tag at the caret", key: "emmet-update-tag" });
|
||||
return;
|
||||
}
|
||||
const abbreviation = await promptAbbreviation(tag);
|
||||
editor.view.focus();
|
||||
if (abbreviation && !updateTag(editor.view, abbreviation))
|
||||
notifications().notify({
|
||||
level: "warning",
|
||||
title: "Cannot update the tag",
|
||||
body: `'${abbreviation}' does not describe a single element.`,
|
||||
key: "emmet-update-tag",
|
||||
});
|
||||
}
|
||||
|
||||
interface NewFileArg {
|
||||
directory?: string;
|
||||
@@ -106,6 +129,14 @@ export function activate(): () => void {
|
||||
registerEditorContribution({ id: "templates", order: 90, extensions: templateEditorExtension }),
|
||||
registerOverlay({ id: "templates.chooser", component: ChooserHost }),
|
||||
registerOverlay({ id: "templates.newFile", component: NewFileDialogHost }),
|
||||
registerOverlay({ id: "templates.emmetUpdateTag", component: UpdateTagDialogHost }),
|
||||
registerMenuGroup({
|
||||
id: "templates.emmet",
|
||||
menus: ["editor"],
|
||||
order: 60,
|
||||
items: (_menu, { paths: [path] }) =>
|
||||
path && MARKUP_PATH.test(path) ? [{ kind: "action", action: "emmet.updateTag" }] : [],
|
||||
}),
|
||||
registerActions([
|
||||
{
|
||||
id: "code.insertLiveTemplate",
|
||||
@@ -131,6 +162,14 @@ export function activate(): () => void {
|
||||
if (activeEditor()?.view === editor.view) surroundWith(editor.view, target);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "emmet.updateTag",
|
||||
title: "Update Tag with Emmet",
|
||||
category: "Code",
|
||||
when: `${EDITOR_WHEN} && (editorLangId == html || editorLangId == vue || editorLangId == svelte || editorLangId == xml)`,
|
||||
keywords: ["emmet", "tag", "rename", "class", "attribute"],
|
||||
run: updateTagWithEmmet,
|
||||
},
|
||||
{
|
||||
id: "templates.newFromTemplate",
|
||||
title: "New from Template…",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
.editor {
|
||||
flex: 1 1 50%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
.withPreview .editor {
|
||||
border-right: 1px solid var(--border-editor-tabs, var(--border));
|
||||
}
|
||||
|
||||
/* Below the inspection widget; shown while the pointer is over the editor, like IDEA. */
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
right: 20px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-popup);
|
||||
border-radius: var(--radius-toolbar-button);
|
||||
background: var(--bg-popup);
|
||||
box-shadow: var(--shadow-popup);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
.editor:hover .bar {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.bar:hover,
|
||||
.bar:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview {
|
||||
flex: 1 1 50%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: var(--bg-editor, var(--bg-app));
|
||||
}
|
||||
.message {
|
||||
padding: 16px;
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Text editor wrapper of HTML files: the browser bar at the top right (IDEA's
|
||||
* floating "open in browser" toolbar) and the built-in preview beside the
|
||||
* editor, served by the built-in server and reloaded when files are saved.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { IconButton } from "@/ui";
|
||||
import { browserIcon, openInBrowser, useBrowsers, useWebPreview, webUrl } from "./browsers";
|
||||
import styles from "./Web.module.css";
|
||||
|
||||
const RELOAD_DELAY = 250;
|
||||
|
||||
export function WebEditor({ path, children }: { path: string; tabId: string; children: ReactNode }) {
|
||||
const preview = useWebPreview((s) => !!s[path]);
|
||||
const browsers = useBrowsers((s) => s.list);
|
||||
return (
|
||||
<div className={clsx(styles.split, preview && styles.withPreview)}>
|
||||
<div className={styles.editor}>
|
||||
{children}
|
||||
<div className={styles.bar} role="toolbar" aria-label="Open in browser">
|
||||
<IconButton
|
||||
icon="eye"
|
||||
size="small"
|
||||
action="web.togglePreview"
|
||||
actionArg={path}
|
||||
pressed={preview}
|
||||
title="Built-in Preview"
|
||||
/>
|
||||
{browsers.map((b) => (
|
||||
<IconButton
|
||||
key={b.id}
|
||||
icon={browserIcon(b.id)}
|
||||
size="small"
|
||||
title={b.name}
|
||||
onClick={() => void openInBrowser(path, b.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{preview && <WebPreview path={path} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebPreview({ path }: { path: string }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [seq, setSeq] = useState(0);
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
webUrl(path).then(
|
||||
(u) => live && setUrl(u),
|
||||
(e: unknown) => live && setError(e instanceof Error ? e.message : String(e)),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [path]);
|
||||
|
||||
// Saved edits anywhere in the project (scripts, styles) reload the page.
|
||||
useEffect(() => {
|
||||
const scope = root ?? path.slice(0, path.lastIndexOf("/"));
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const reload = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => setSeq((n) => n + 1), RELOAD_DELAY);
|
||||
};
|
||||
const offFs = onIdeEvent("fsChanged", (e) => {
|
||||
if ([...e.created, ...e.modified, ...e.removed].some((p) => p === scope || p.startsWith(scope + "/"))) reload();
|
||||
});
|
||||
const offDirty = onIdeEvent("docDirty", (e) => {
|
||||
if (!e.dirty) reload();
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
offFs();
|
||||
offDirty();
|
||||
};
|
||||
}, [path, root]);
|
||||
|
||||
if (error) return <div className={clsx(styles.preview, styles.message)}>{error}</div>;
|
||||
if (!url) return <div className={styles.preview} />;
|
||||
return (
|
||||
<iframe
|
||||
key={seq}
|
||||
className={styles.preview}
|
||||
title="HTML preview"
|
||||
src={url}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Installed browsers, the built-in server's URLs, and per-file preview state. */
|
||||
import { create } from "zustand";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, isTauri, type Browser } from "@/platform/ipc";
|
||||
import { hasIcon } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
/** Files a browser can show (Open In › Browser). */
|
||||
export const isWebPath = (path: string) => /\.(html?|xhtml|shtml|svg)$/i.test(path);
|
||||
/** Files that get the browser bar and the built-in preview. */
|
||||
export const isHtmlPath = (path: string) => /\.(html?|xhtml|shtml)$/i.test(path);
|
||||
|
||||
export const useBrowsers = create<{ list: Browser[] }>(() => ({ list: [] }));
|
||||
|
||||
export async function loadBrowsers(): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
useBrowsers.setState({ list: await call(commands.webBrowsers()) });
|
||||
} catch (e) {
|
||||
console.warn("web browsers", e);
|
||||
}
|
||||
}
|
||||
|
||||
export const browserIcon = (id: string) => (hasIcon(`browser-${id}`) ? `browser-${id}` : "web");
|
||||
|
||||
/** Paths shown with the built-in preview beside the editor. */
|
||||
export const useWebPreview = create<Record<string, boolean>>(() => ({}));
|
||||
|
||||
export function togglePreview(path: string, on = !useWebPreview.getState()[path]) {
|
||||
useWebPreview.setState({ [path]: on });
|
||||
}
|
||||
|
||||
/** The built-in server's URL for `path`. */
|
||||
export function webUrl(path: string): Promise<string> {
|
||||
return call(commands.webUrl(path));
|
||||
}
|
||||
|
||||
/** Saves all documents (the server reads the disk), then opens `path` in `browser` or the default browser. */
|
||||
export async function openInBrowser(path: string, browser: string | null = null): Promise<void> {
|
||||
try {
|
||||
await executeAction("file.save");
|
||||
await call(commands.webOpen(await webUrl(path), browser));
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot open the browser",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: "web-open",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Web browsers (IDEA's Open In › Browser and the editor's browser bar): HTML
|
||||
* and SVG files open in an installed browser through the built-in server, and
|
||||
* HTML files get a built-in preview beside the editor. ⌃⇧R in an HTML editor
|
||||
* opens the default browser, like IDEA's Run for HTML files.
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { basename } from "@/platform/project";
|
||||
import { lazyComponent, type MenuItem } from "@/ui";
|
||||
import { maybeEditorService, registerMenuGroup, registerTextEditorWrapper } from "@/workbench/contributions";
|
||||
import {
|
||||
browserIcon,
|
||||
isHtmlPath,
|
||||
isWebPath,
|
||||
loadBrowsers,
|
||||
openInBrowser,
|
||||
togglePreview,
|
||||
useBrowsers,
|
||||
useWebPreview,
|
||||
} from "./browsers";
|
||||
|
||||
interface OpenArg {
|
||||
path?: string;
|
||||
browser?: string;
|
||||
}
|
||||
|
||||
function targetPath(arg: unknown): string | null {
|
||||
if (typeof arg === "string") return arg;
|
||||
const path = (arg as OpenArg | undefined)?.path ?? getContext().editorPath;
|
||||
return typeof path === "string" ? path : null;
|
||||
}
|
||||
|
||||
async function showPreview(path: string) {
|
||||
await maybeEditorService()?.openFile(path, { focus: true });
|
||||
togglePreview(path, true);
|
||||
}
|
||||
|
||||
function browserMenu(path: string): MenuItem {
|
||||
const html = isHtmlPath(path);
|
||||
return {
|
||||
label: "Browser",
|
||||
icon: "web",
|
||||
submenu: [
|
||||
{ kind: "action", action: "web.openInBrowser", arg: { path }, label: "Default Browser" },
|
||||
{ kind: "separator" },
|
||||
...(html ? [{ label: "Built-in Preview", icon: "eye", run: () => void showPreview(path) }] : []),
|
||||
...useBrowsers
|
||||
.getState()
|
||||
.list.map((b): MenuItem => ({ label: b.name, verbatim: true, icon: browserIcon(b.id), run: () => void openInBrowser(path, b.id) })),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
void loadBrowsers();
|
||||
const disposers = [
|
||||
registerTextEditorWrapper({
|
||||
id: "web.html",
|
||||
matches: isHtmlPath,
|
||||
component: lazyComponent(() => import("./WebEditor").then((m) => m.WebEditor)),
|
||||
}),
|
||||
registerActions([
|
||||
{
|
||||
id: "web.openInBrowser",
|
||||
title: "Open in Browser",
|
||||
description: "Open the HTML or SVG file in the default browser through the built-in server",
|
||||
category: "View",
|
||||
icon: "web",
|
||||
when: "projectOpen",
|
||||
keyWhen: "textEditorActive && editorLangId == html",
|
||||
keywords: ["browser", "html", "preview", "chrome", "firefox", "safari", "run"],
|
||||
run: ({ arg }) => {
|
||||
const path = targetPath(arg);
|
||||
if (path && isWebPath(path)) return openInBrowser(path, (arg as OpenArg | undefined)?.browser ?? null);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "web.togglePreview",
|
||||
title: "Built-in Preview",
|
||||
description: "Show the HTML file rendered beside the editor",
|
||||
category: "View",
|
||||
icon: "eye",
|
||||
when: "textEditorActive && editorLangId == html",
|
||||
keywords: ["browser", "html", "preview", "live"],
|
||||
toggled: () => {
|
||||
const path = getContext().editorPath;
|
||||
return typeof path === "string" && !!useWebPreview.getState()[path];
|
||||
},
|
||||
run: ({ arg }) => {
|
||||
const path = targetPath(arg);
|
||||
if (path && isHtmlPath(path)) togglePreview(path);
|
||||
},
|
||||
},
|
||||
]),
|
||||
registerMenuGroup({
|
||||
id: "web.open",
|
||||
menus: ["editor", "editorTab", "projectView"],
|
||||
order: 5,
|
||||
items: (_menu, { paths, dirs }) => {
|
||||
const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null;
|
||||
if (!path || !isHtmlPath(path)) return [];
|
||||
return [
|
||||
{
|
||||
kind: "action",
|
||||
action: "web.openInBrowser",
|
||||
arg: { path },
|
||||
label: `Open '${basename(path)}' in Browser`,
|
||||
icon: "web",
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
registerMenuGroup({
|
||||
id: "web.openIn",
|
||||
menus: ["openIn"],
|
||||
items: (_menu, { paths, dirs }) =>
|
||||
paths.length === 1 && !dirs?.[0] && isWebPath(paths[0]!) ? [browserMenu(paths[0]!)] : [],
|
||||
}),
|
||||
];
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -158,6 +158,8 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"run.run": ["Ctrl+R"],
|
||||
"run.debug": ["Ctrl+D"],
|
||||
"run.runContext": ["Ctrl+Shift+R"],
|
||||
// In HTML editors (keyWhen) Run opens the browser; listed later, so it wins over run.runContext.
|
||||
"web.openInBrowser": ["Ctrl+Shift+R"],
|
||||
"run.debugContext": ["Ctrl+Shift+D"],
|
||||
"run.stop": ["Meta+F2"],
|
||||
"run.chooseConfig": ["Ctrl+Alt+R"],
|
||||
|
||||
@@ -156,6 +156,8 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"run.run": ["Shift+F10"],
|
||||
"run.debug": ["Shift+F9"],
|
||||
"run.runContext": ["Ctrl+Shift+F10"],
|
||||
// In HTML editors (keyWhen) Run opens the browser; listed later, so it wins over run.runContext.
|
||||
"web.openInBrowser": ["Ctrl+Shift+F10"],
|
||||
"run.debugContext": [],
|
||||
"run.stop": ["Ctrl+F2"],
|
||||
"run.chooseConfig": ["Alt+Shift+F10"],
|
||||
|
||||
@@ -251,6 +251,13 @@ const ICONS = {
|
||||
"db-postgres": "openidea/database/postgres",
|
||||
"db-mysql": "openidea/database/mysql",
|
||||
export: "openidea/general/export",
|
||||
web: "openidea/general/web",
|
||||
"browser-chrome": "openidea/browsers/chrome",
|
||||
"browser-chromium": "openidea/browsers/chromium",
|
||||
"browser-firefox": "openidea/browsers/firefox",
|
||||
"browser-safari": "openidea/browsers/safari",
|
||||
"browser-edge": "openidea/browsers/edge",
|
||||
"browser-brave": "openidea/browsers/brave",
|
||||
// Tool-window-ish glyphs kept for actions and older callers
|
||||
structure: "toolwindows/structure",
|
||||
hierarchy: "toolwindows/hierarchy",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Brave -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.2 1.5H10.8L12.4 2.6L14 2.4L14.4 5.6L12.3 12.2L8 15L3.7 12.2L1.6 5.6L2 2.4L3.6 2.6Z" fill="#FB542B"/><path d="M8 7.2L10.2 6.4L9.4 9.6L8 10.6L6.6 9.6L5.8 6.4Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 315 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Chrome -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 8L1.94 4.5A7 7 0 0 1 14.06 4.5Z" fill="#EA4335"/><path d="M8 8L14.06 4.5A7 7 0 0 1 8 15Z" fill="#FBBC04"/><path d="M8 8L8 15A7 7 0 0 1 1.94 4.5Z" fill="#34A853"/><circle cx="8" cy="8" r="3.4" fill="#FFFFFF"/><circle cx="8" cy="8" r="2.6" fill="#1A73E8"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 396 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Chromium -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 8L1.94 4.5A7 7 0 0 1 14.06 4.5Z" fill="#4A8AF4"/><path d="M8 8L14.06 4.5A7 7 0 0 1 8 15Z" fill="#A1C2FA"/><path d="M8 8L8 15A7 7 0 0 1 1.94 4.5Z" fill="#7CACF8"/><circle cx="8" cy="8" r="3.4" fill="#FFFFFF"/><circle cx="8" cy="8" r="2.6" fill="#1967D2"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 398 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Edge -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1A7 7 0 0 0 1 8.4C2.5 6.4 5.3 5.6 7.8 6.1C10.4 6.6 11.7 8.1 11.8 9.3H15A7 7 0 0 0 8 1Z" fill="#1B9DE2"/><path d="M1 8.4C1.4 12.1 4.4 15 8.2 15C10.4 15 12.4 14 13.7 12.4C12 13.2 8.8 13.3 7.4 11.4C6.5 10.2 6.8 8.5 8.2 7.8C5.1 7.2 2.6 7.6 1 8.4Z" fill="#36C46B"/><path d="M11.8 9.3C11.8 10.6 10.6 11.6 9 11.6C8.2 11.6 7.7 11.5 7.4 11.4C8.8 13.3 12 13.2 13.7 12.4C14.5 11.5 15 10.4 15 9.3Z" fill="#0C59A4"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 543 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Firefox -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7" fill="#FF7139"/><circle cx="8.9" cy="7.1" r="4.3" fill="#9059FF"/><path d="M1.6 6.2C2.4 3.4 5 1.3 8 1.2C6.2 2 5.1 3.4 4.9 5.2C3.6 5.2 2.4 5.6 1.6 6.2Z" fill="#FFBD4F"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 327 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Safari -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7" fill="#1E88F7"/><circle cx="8" cy="8" r="5.6" stroke="#FFFFFF" stroke-opacity="0.6" stroke-dasharray="0.6 1.2"/><path d="M11.8 4.2L9 9L7 7Z" fill="#FF3B30"/><path d="M4.2 11.8L7 7L9 9Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 360 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: web / browser (globe) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6.5" stroke="#6C707E"/><ellipse cx="8" cy="8" rx="2.75" ry="6.5" stroke="#6C707E"/><path d="M1.5 8H14.5M2.6 4.75H13.4M2.6 11.25H13.4" stroke="#6C707E"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 322 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: web / browser (globe) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6.5" stroke="#CED0D6"/><ellipse cx="8" cy="8" rx="2.75" ry="6.5" stroke="#CED0D6"/><path d="M1.5 8H14.5M2.6 4.75H13.4M2.6 11.25H13.4" stroke="#CED0D6"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 322 B |
@@ -511,8 +511,9 @@ export function projectViewRoots(map: ReadonlyMap<string, ProjectViewRootContrib
|
||||
// Context menu groups: features add items to the editor, project view and
|
||||
// editor tab context menus (IDEA's EditorPopupMenu / ProjectViewPopupMenu /
|
||||
// EditorTabPopupMenu / EditorGutterPopupMenu groups), e.g. "Run 'hi.ts'" from features/run.
|
||||
// `openIn` is the Open In › submenu those three menus share (Browser › from features/web).
|
||||
|
||||
export type ContextMenuId = "editor" | "projectView" | "editorTab" | "editorGutter";
|
||||
export type ContextMenuId = "editor" | "projectView" | "editorTab" | "editorGutter" | "openIn";
|
||||
|
||||
export interface MenuGroupContext {
|
||||
/** Files / directories the menu is for (absolute paths; a virtual URI for virtual documents). */
|
||||
|
||||