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
Цей коміт міститься в:
l0ng-ai committed 2026-10-24 22:16:28 +08:00
1 джерело c8cea62eab
коміт cacddee19d
41 files changed
+1445 -6

No files matched your search

Створено
+17
Переглянути файл
@@ -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",
+1
Переглянути файл
@@ -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"] }
+21
Переглянути файл
@@ -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"] }
+129
Переглянути файл
@@ -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());
}
}
+14
Переглянути файл
@@ -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;
+355
Переглянути файл
@@ -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"));
}
}
+1
Переглянути файл
@@ -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",
Створено
+30
Переглянути файл
@@ -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]: {}
+1
Переглянути файл
@@ -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" }
+4
Переглянути файл
@@ -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,
+29
Переглянути файл
@@ -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
}
+3
Переглянути файл
@@ -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,
+1 -1
Переглянути файл
@@ -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": {
+13
Переглянути файл
@@ -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 } |
+2
Переглянути файл
@@ -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] }),
],
};
}
+21
Переглянути файл
@@ -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;
}
+2
Переглянути файл
@@ -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],
+2
Переглянути файл
@@ -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" },
);
+67
Переглянути файл
@@ -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();
});
});
+3 -2
Переглянути файл
@@ -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);
},
},
]),
+13
Переглянути файл
@@ -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);
}
+56
Переглянути файл
@@ -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 &lt;{tag}&gt;, 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;
}
+32
Переглянути файл
@@ -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;
}
+220
Переглянути файл
@@ -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;
}
+41 -2
Переглянути файл
@@ -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…",
+52
Переглянути файл
@@ -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);
}
+97
Переглянути файл
@@ -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"
/>
);
}
+51
Переглянути файл
@@ -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",
});
}
}
+122
Переглянути файл
@@ -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());
}
+2
Переглянути файл
@@ -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"],
+2
Переглянути файл
@@ -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"],
+7
Переглянути файл
@@ -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",
+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+2 -1
Переглянути файл
@@ -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). */