mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5526ed5865 | ||
|
|
4b1614af1f | ||
|
|
bdeca5803b | ||
|
|
839bd0cdb0 | ||
|
|
ebf2c2f8e3 | ||
|
|
c599265ced | ||
|
|
867774b88a | ||
|
|
d54e852c38 | ||
|
|
efd03630b6 | ||
|
|
bbc6a2bc4c | ||
|
|
cc4a29fa5d | ||
|
|
5ad179a9e6 | ||
|
|
291fa9affe | ||
|
|
ecdd43a55a | ||
|
|
ec465964d2 | ||
|
|
86d082974f | ||
|
|
ec5302d838 | ||
|
|
dc14a46a17 | ||
|
|
d830d21d46 | ||
|
|
fd8161a5a7 | ||
|
|
bbb1be69a2 | ||
|
|
58ff3e93d0 | ||
|
|
795f8e85f1 |
No files matched your search
@@ -10,6 +10,7 @@
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
|
||||
Generated
+1
@@ -6153,6 +6153,7 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"gtk",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059).
|
||||
`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
|
||||
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
|
||||
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
|
||||
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
|
||||
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
|
||||
|
||||
@@ -158,8 +158,11 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
}
|
||||
|
||||
/// `root/rel`, without a trailing separator for the root itself.
|
||||
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
|
||||
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
|
||||
/// as other tools (JDT LS) spell it.
|
||||
fn subdir(root: &Path, rel: &str) -> PathBuf {
|
||||
if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }
|
||||
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
|
||||
}
|
||||
|
||||
fn rel_str(root: &Path, dir: &Path) -> String {
|
||||
@@ -737,11 +740,11 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"]
|
||||
.map(|d| module.join("build").join(d).join(s))
|
||||
.map(|d| subdir(module, &format!("build/{d}/{s}")))
|
||||
})
|
||||
.collect(),
|
||||
BuildSystem::Maven if test => vec![module.join("target/test-classes"), module.join("target/classes")],
|
||||
BuildSystem::Maven => vec![module.join("target/classes")],
|
||||
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
|
||||
BuildSystem::Maven => vec![subdir(module, "target/classes")],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
dirs.into_iter().filter(|d| d.is_dir()).collect()
|
||||
@@ -751,6 +754,30 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Launch outputs use the platform separator throughout, so they compare equal to
|
||||
/// the same directories as JDT LS reports them.
|
||||
#[test]
|
||||
fn launch_dirs_use_one_separator() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let module = subdir(root.path(), "parent/app");
|
||||
for dir in ["target/classes", "target/test-classes", "build/classes/java/main", "build/resources/test"] {
|
||||
std::fs::create_dir_all(subdir(&module, dir)).unwrap();
|
||||
}
|
||||
let foreign = if std::path::MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
|
||||
let dirs = [
|
||||
launch_class_dirs(BuildSystem::Maven, &module, true),
|
||||
launch_class_dirs(BuildSystem::Gradle, &module, true),
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(dirs.len(), 4, "{dirs:?}");
|
||||
for dir in &dirs {
|
||||
let suffix = dir.strip_prefix(root.path()).unwrap().to_string_lossy().into_owned();
|
||||
assert!(!suffix.contains(foreign), "{suffix}");
|
||||
}
|
||||
assert_eq!(subdir(root.path(), ""), root.path());
|
||||
assert_eq!(subdir(root.path(), "a//b/"), root.path().join("a").join("b"));
|
||||
}
|
||||
|
||||
fn write(root: &Path, rel: &str, text: &str) {
|
||||
let p = root.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
|
||||
@@ -86,7 +86,7 @@ async fn resolve_launch(
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
@@ -131,15 +131,44 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
|
||||
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
|
||||
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
|
||||
/// classpath next to its launch snapshot and every resource was found twice.
|
||||
fn entry_key(path: &str, windows: bool) -> String {
|
||||
let path = match path.strip_prefix(r"\\?\UNC\") {
|
||||
Some(share) => format!(r"\\{share}"),
|
||||
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
|
||||
};
|
||||
let mut key = if windows { path.replace('\\', "/") } else { path };
|
||||
while key.len() > 1 && key.ends_with('/') {
|
||||
key.pop();
|
||||
}
|
||||
if windows { key.to_lowercase() } else { key }
|
||||
}
|
||||
|
||||
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
|
||||
a == b || entry_key(a, windows) == entry_key(b, windows)
|
||||
}
|
||||
|
||||
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
|
||||
use_snapshot_on(info, built, snapshot, cfg!(windows))
|
||||
}
|
||||
|
||||
fn use_snapshot_on(
|
||||
info: &mut JavaLaunchInfo,
|
||||
built: &[PathBuf],
|
||||
snapshot: &std::path::Path,
|
||||
windows: bool,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut copies = Vec::new();
|
||||
for (index, source) in built.iter().enumerate() {
|
||||
let dest = snapshot.join(index.to_string());
|
||||
let original = source.to_string_lossy();
|
||||
info.class_paths.retain(|p| p != original.as_ref());
|
||||
info.class_paths.retain(|p| !same_entry(p, &original, windows));
|
||||
let mut modular = false;
|
||||
for path in &mut info.module_paths {
|
||||
if path == original.as_ref() {
|
||||
if same_entry(path, &original, windows) {
|
||||
*path = dest.to_string_lossy().into_owned();
|
||||
modular = true;
|
||||
}
|
||||
@@ -185,6 +214,39 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// JDT LS and our build model spell a module output differently on Windows; the
|
||||
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
|
||||
#[test]
|
||||
fn snapshots_replace_outputs_however_windows_spells_them() {
|
||||
let built = PathBuf::from(r"D:\workspace\arm\arm-web\target/classes");
|
||||
let jdtls = [
|
||||
r"D:\workspace\arm\arm-web\target\classes",
|
||||
r"d:\workspace\arm\arm-web\target\classes",
|
||||
r"D:/workspace/arm/arm-web/target/classes/",
|
||||
r"\\?\D:\workspace\arm\arm-web\target\classes",
|
||||
];
|
||||
for spelled in jdtls {
|
||||
let mut info = JavaLaunchInfo {
|
||||
class_paths: vec![spelled.into(), r"D:\m2\mybatis.jar".into()],
|
||||
module_paths: vec![spelled.into()],
|
||||
..Default::default()
|
||||
};
|
||||
use_snapshot_on(&mut info, std::slice::from_ref(&built), std::path::Path::new(r"C:\cache\launch-x"), true);
|
||||
assert_eq!(info.class_paths, [r"D:\m2\mybatis.jar"], "{spelled}");
|
||||
assert_eq!(
|
||||
info.module_paths,
|
||||
[std::path::Path::new(r"C:\cache\launch-x").join("0").to_string_lossy()],
|
||||
"{spelled}"
|
||||
);
|
||||
}
|
||||
assert!(same_entry(r"\\?\UNC\server\share\app\target\classes", r"\\server\share\app\target/classes", true));
|
||||
// Different directories stay different, and other platforms keep case and `\`.
|
||||
assert!(!same_entry(r"D:\a\target\classes", r"D:\a\target\test-classes", true));
|
||||
assert!(!same_entry("/work/App/target/classes", "/work/app/target/classes", false));
|
||||
assert!(!same_entry(r"/work/a\b", "/work/a/b", false));
|
||||
assert!(same_entry("/work/app/target/classes/", "/work/app/target/classes", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -62,13 +62,24 @@ fn detected_and_saved(
|
||||
all
|
||||
}
|
||||
|
||||
/// Saves the user configurations. They come from [`configs`], toolchains applied, so what
|
||||
/// settings derived is dropped again: only the user's own choices reach `run.toml`.
|
||||
pub fn save_configs(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
configs: &[RunConfig],
|
||||
removed_detected_ids: &[String],
|
||||
) -> IdeResult<()> {
|
||||
ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids)
|
||||
let project = host.project(window)?;
|
||||
let settings = project.settings.effective();
|
||||
let configs: Vec<RunConfig> = configs
|
||||
.iter()
|
||||
.map(|c| match c.kind.as_str() {
|
||||
"gradle" => c.clone(),
|
||||
_ => ide_sdk::apply::without_derived(c, &project.root, &settings),
|
||||
})
|
||||
.collect();
|
||||
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
|
||||
@@ -365,6 +365,13 @@ impl Host {
|
||||
}),
|
||||
)?
|
||||
};
|
||||
if let Some(reason) = watcher.polling() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(reason.into()),
|
||||
});
|
||||
}
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
|
||||
@@ -57,6 +57,16 @@ pub fn shared() -> IdeResult<reqwest::Client> {
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// `url`, with `http://` in front unless it starts with a scheme: a `://` later
|
||||
/// on (`localhost:8080/login?next=http://…`) does not count.
|
||||
pub(crate) fn with_scheme(url: &str) -> String {
|
||||
let has_scheme = url.split_once("://").is_some_and(|(scheme, _)| {
|
||||
scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
});
|
||||
if has_scheme { url.to_owned() } else { format!("http://{url}") }
|
||||
}
|
||||
|
||||
/// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as
|
||||
/// edited, not yet applied). Any response but `407` counts as connected.
|
||||
pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
@@ -64,7 +74,7 @@ pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &Cancell
|
||||
return Err(IdeError::invalid(problem));
|
||||
}
|
||||
let url = url.trim();
|
||||
let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") };
|
||||
let url = with_scheme(url);
|
||||
let client = build(proxy)?;
|
||||
let request = client.get(&url).timeout(Duration::from_secs(30));
|
||||
let response = tokio::select! {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Preparing (substitution, body includes) and executing requests.
|
||||
|
||||
use crate::client::describe;
|
||||
use crate::client::{describe, with_scheme};
|
||||
use crate::env::{Environments, Resolver};
|
||||
use crate::parse::{BodyPart, RawRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
@@ -84,10 +84,8 @@ pub fn prepare(
|
||||
Some((_, host)) => format!("http://{host}{url}"),
|
||||
None => return Err(IdeError::invalid(format!("relative URL {url} needs a Host header"))),
|
||||
}
|
||||
} else if !url.contains("://") {
|
||||
format!("http://{url}")
|
||||
} else {
|
||||
url
|
||||
with_scheme(&url)
|
||||
};
|
||||
Ok(PreparedRequest {
|
||||
name: raw.block.name.clone(),
|
||||
@@ -214,6 +212,20 @@ mod tests {
|
||||
assert_eq!(p.url, "http://localhost:9000/status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_without_a_scheme_default_to_http() {
|
||||
let url = |text: &str| {
|
||||
let f = parse(text);
|
||||
prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap().url
|
||||
};
|
||||
assert_eq!(url("GET localhost:8080/health\n"), "http://localhost:8080/health");
|
||||
assert_eq!(
|
||||
url("GET localhost:8080/login?next=http://localhost:3000/\n"),
|
||||
"http://localhost:8080/login?next=http://localhost:3000/"
|
||||
);
|
||||
assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_against_a_local_server() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@@ -45,6 +45,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("javascriptreact", include_str!("../../../languages/javascriptreact.toml")),
|
||||
("json", include_str!("../../../languages/json.toml")),
|
||||
("jsonc", include_str!("../../../languages/jsonc.toml")),
|
||||
("jsp", include_str!("../../../languages/jsp.toml")),
|
||||
("julia", include_str!("../../../languages/julia.toml")),
|
||||
("kotlin", include_str!("../../../languages/kotlin.toml")),
|
||||
("latex", include_str!("../../../languages/latex.toml")),
|
||||
@@ -522,6 +523,7 @@ mod tests {
|
||||
"Vue",
|
||||
"Angular Template",
|
||||
"ERB",
|
||||
"JSP",
|
||||
];
|
||||
|
||||
fn reg() -> LanguageRegistry {
|
||||
@@ -803,6 +805,8 @@ mod tests {
|
||||
("a.less", "less"),
|
||||
("App.vue", "vue"),
|
||||
("App.svelte", "svelte"),
|
||||
("index.jsp", "jsp"),
|
||||
("WEB-INF/tags/card.tag", "jsp"),
|
||||
("package.json", "json"),
|
||||
("tsconfig.json", "jsonc"),
|
||||
("tsconfig.app.json", "jsonc"),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Source ranges for Spring-style configuration keys. This is a syntax index,
|
||||
//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are
|
||||
//! intentionally left to the YAML server / application.
|
||||
use super::lines::LineIndex;
|
||||
use super::lines::{LineIndex, byte_at};
|
||||
use super::{ParsedTree, Syntax, TreeError};
|
||||
use ide_core::{LanguageId, Range};
|
||||
use ide_core::{LanguageId, Pos, Range};
|
||||
use tree_sitter::Node;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -26,6 +26,40 @@ impl Syntax {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spring's property lookups whose first argument names a configuration key.
|
||||
const PROPERTY_LOOKUPS: [&str; 2] = ["getProperty", "getRequiredProperty"];
|
||||
|
||||
impl Syntax {
|
||||
/// Whether `pos` in Java `text` lies where a configuration key can be written:
|
||||
/// inside the arguments of a `@Value` annotation or of a `getProperty` /
|
||||
/// `getRequiredProperty` call. Everywhere else Go to Definition is ordinary
|
||||
/// Java navigation and need not consult the configuration index. A file the
|
||||
/// grammar cannot parse answers false.
|
||||
pub fn java_configuration_site(&self, text: &str, pos: Pos, cancelled: &dyn Fn() -> bool) -> bool {
|
||||
let Some(at) = byte_at(text, pos) else { return false };
|
||||
self.with_parsed(&LanguageId::new("java"), text, cancelled, |tree| {
|
||||
let mut node = tree.tree.root_node().descendant_for_byte_range(at, at);
|
||||
while let Some(n) = node {
|
||||
let Some(parent) = n.parent() else { break };
|
||||
let callee = parent.child_by_field_name("name").map(|name| &text[name.byte_range()]);
|
||||
match (n.kind(), parent.kind(), callee) {
|
||||
("annotation_argument_list", "annotation", Some(name)) => {
|
||||
return name.rsplit('.').next() == Some("Value");
|
||||
}
|
||||
("argument_list", "method_invocation", Some(name)) if PROPERTY_LOOKUPS.contains(&name) => {
|
||||
return true;
|
||||
}
|
||||
// Keys are expressions; nothing above a body or declaration can hold one.
|
||||
(_, "block" | "class_body" | "interface_body" | "program", _) => return false,
|
||||
_ => node = Some(parent),
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl ParsedTree<'_> {
|
||||
pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
|
||||
let mut out = Vec::new();
|
||||
@@ -188,6 +222,50 @@ fn unescape_property(raw: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn java_configuration_sites() {
|
||||
let text = r#"package a;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
class A {
|
||||
@Value("${app.port:80}") int port;
|
||||
@org.springframework.beans.factory.annotation.Value(value = Keys.NAME) String name;
|
||||
@Values("x") int other;
|
||||
@Override public String toString() {
|
||||
String v = env.getProperty("app.host", "h") + env.getRequiredProperty(Keys.USER);
|
||||
return helper.getValue("😀 app.port") + String.valueOf(port) + env.getProperty("k").trim();
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let syntax = Syntax::new();
|
||||
let at = |needle: &str, offset: u32| {
|
||||
let line = text.lines().position(|l| l.contains(needle)).unwrap();
|
||||
let byte = text.lines().nth(line).unwrap().find(needle).unwrap();
|
||||
let col = text.lines().nth(line).unwrap()[..byte].encode_utf16().count() as u32 + offset;
|
||||
syntax.java_configuration_site(text, Pos::new(line as u32, col), &|| false)
|
||||
};
|
||||
// @Value arguments (literal, qualified annotation, constant key).
|
||||
assert!(at("${app.port", 2));
|
||||
assert!(at("Keys.NAME", 6));
|
||||
// Lookup arguments, including a constant and the default value.
|
||||
assert!(at("\"app.host\"", 3));
|
||||
assert!(at("\"h\"", 1));
|
||||
assert!(at("Keys.USER", 1));
|
||||
assert!(at("\"k\"", 1));
|
||||
// Ordinary Java: names, other annotations, other calls, the lookup's own name and receiver.
|
||||
assert!(!at("@Value(\"$", 2));
|
||||
assert!(!at("int port", 4));
|
||||
assert!(!at("@Values", 9));
|
||||
assert!(!at("@Override", 2));
|
||||
assert!(!at("getProperty(\"app", 3));
|
||||
assert!(!at("env.getProperty(\"app", 1));
|
||||
assert!(!at("app.port\")", 1));
|
||||
assert!(!at("valueOf", 2));
|
||||
assert!(!at(".trim", 2));
|
||||
// Out of range and non-Java input.
|
||||
assert!(!syntax.java_configuration_site(text, Pos::new(99, 0), &|| false));
|
||||
assert!(!syntax.java_configuration_site("", Pos::new(0, 0), &|| false));
|
||||
}
|
||||
#[test]
|
||||
fn yaml_structures_and_utf16() {
|
||||
let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n";
|
||||
|
||||
@@ -86,6 +86,16 @@ pub(crate) fn utf16_len(s: &str) -> u32 {
|
||||
if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() }
|
||||
}
|
||||
|
||||
/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line.
|
||||
pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
|
||||
let mut start = 0;
|
||||
for _ in 0..pos.line {
|
||||
start += text[start..].find('\n')? + 1;
|
||||
}
|
||||
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
|
||||
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
|
||||
}
|
||||
|
||||
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
|
||||
let mut units = 0;
|
||||
for (i, c) in s.char_indices() {
|
||||
|
||||
@@ -201,11 +201,11 @@ impl Syntax {
|
||||
|
||||
/// The grammar web features parse `lang` with: its own for HTML and CSS, the
|
||||
/// CSS grammar for SCSS / Less (selectors and custom properties parse alike),
|
||||
/// HTML for Vue / Svelte single-file components.
|
||||
/// HTML for Vue / Svelte single-file components and JSP pages.
|
||||
fn web_grammar(&self, lang: &LanguageId) -> Option<&Grammar> {
|
||||
let name = match lang.as_str() {
|
||||
"scss" | "less" => "css",
|
||||
"vue" | "svelte" => "html",
|
||||
"vue" | "svelte" | "jsp" => "html",
|
||||
_ => self.grammar(lang).map(|g| g.spec.name)?,
|
||||
};
|
||||
web::is_web_grammar(name).then(|| self.grammar(&LanguageId::new(name))).flatten()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! `<style>` elements of HTML documents and feed the symbol index (see
|
||||
//! [`super::Syntax::tags`]), so a lookup is an exact index query.
|
||||
|
||||
use super::lines::{LineIndex, utf16_to_byte};
|
||||
use super::lines::{LineIndex, byte_at};
|
||||
use super::{Grammar, parse};
|
||||
use crate::SymbolTag;
|
||||
use ide_core::{Pos, Range, SymbolKind};
|
||||
@@ -130,15 +130,6 @@ pub(crate) fn definitions(g: &Grammar, css: Option<&Grammar>, tree: &Tree, text:
|
||||
out
|
||||
}
|
||||
|
||||
fn byte_at(text: &str, pos: Pos) -> Option<usize> {
|
||||
let mut start = 0;
|
||||
for _ in 0..pos.line {
|
||||
start += text[start..].find('\n')? + 1;
|
||||
}
|
||||
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
|
||||
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
|
||||
}
|
||||
|
||||
/// The whitespace-separated token of `value` (starting at byte `base`) around byte `at`.
|
||||
fn token_at(value: &str, base: usize, at: usize) -> Option<(usize, &str)> {
|
||||
let rel = at.checked_sub(base)?;
|
||||
|
||||
@@ -117,13 +117,25 @@ impl LspManager {
|
||||
return Ok(None);
|
||||
}
|
||||
// Definition in ordinary Java code remains JDT's definition. Reverse
|
||||
// configuration navigation is offered on string expressions; field/config
|
||||
// accessor usages are merged into the ordinary Find Usages response.
|
||||
// configuration navigation is offered only inside `@Value` / property
|
||||
// lookup arguments: the command builds a project-wide index, and every
|
||||
// Ctrl+click and Ctrl+hover probe would otherwise wait on it before JDT
|
||||
// answers. Field/config accessor usages are merged into Find Usages.
|
||||
if !config && !usages {
|
||||
let text = snap.text.as_str();
|
||||
if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = text.to_owned();
|
||||
let cancelled = cancel.clone();
|
||||
let site = tokio::task::spawn_blocking(move || {
|
||||
syntax().java_configuration_site(&text, pos, &|| cancelled.is_cancelled())
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !site {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let key = if config {
|
||||
let text = snap.text.as_str().to_owned();
|
||||
|
||||
@@ -3035,7 +3035,40 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
"start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6}
|
||||
}}),
|
||||
);
|
||||
let result = h.mgr.goto(doc, Pos::new(0, 2), GotoKind::Definition).await.unwrap();
|
||||
let result = h.mgr.goto(doc, Pos::new(0, 12), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(s.requested("workspace/executeCommand").len(), 1);
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1);
|
||||
}
|
||||
|
||||
/// The configuration command indexes the whole project, so Go to Definition (and
|
||||
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
|
||||
/// symbols in a Spring file go straight to JDT.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_definition_consults_configuration_only_inside_key_arguments() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let text = "class A {\n @Value(\"${app.port}\") int port;\n String a() { return env.getProperty(Keys.HOST) + x.getValue(); }\n}\n";
|
||||
let (doc, path) = h.open("A.java", text);
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
s.respond("workspace/executeCommand", json!({"locations": [], "keys": [], "roots": []}));
|
||||
s.respond(
|
||||
"textDocument/definition",
|
||||
json!({"uri": uri_of(&path), "range": {
|
||||
"start": {"line": 0, "character": 6}, "end": {"line": 0, "character": 7}
|
||||
}}),
|
||||
);
|
||||
// Field type, field name, method name, the lookup's receiver and name, another call.
|
||||
for pos in [Pos::new(1, 24), Pos::new(1, 28), Pos::new(2, 9), Pos::new(2, 22), Pos::new(2, 28), Pos::new(2, 53)] {
|
||||
assert_eq!(h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap().len(), 1, "{pos:?}");
|
||||
}
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 6);
|
||||
// Inside a placeholder and a constant lookup key: configuration first, JDT as fallback.
|
||||
for pos in [Pos::new(1, 13), Pos::new(2, 41)] {
|
||||
h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap();
|
||||
}
|
||||
assert_eq!(s.requested("workspace/executeCommand").len(), 2);
|
||||
}
|
||||
@@ -769,6 +769,47 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
}
|
||||
}
|
||||
|
||||
/// `config` as it should be saved: without what [`apply_to_run_configs`] derives from
|
||||
/// settings, so a configuration read from the run list, edited and saved keeps following
|
||||
/// Languages & Frameworks instead of freezing today's toolchain (and the IDE's whole
|
||||
/// `PATH`) into `run.toml`. A value counts as derived when applying settings to the
|
||||
/// configuration without it produces it again: an environment entry (`PATH`,
|
||||
/// `VIRTUAL_ENV`, `JAVA_HOME`, `GOROOT`, …) or an absolute command its bare name
|
||||
/// resolves to (the project interpreter, a toolchain's `cargo` / `go`, Maven's `mvn`).
|
||||
pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConfig {
|
||||
let applied = |c: &RunConfig| {
|
||||
let mut c = c.clone();
|
||||
apply_to_run_configs(std::slice::from_mut(&mut c), root, s);
|
||||
c
|
||||
};
|
||||
let mut out = config.clone();
|
||||
// Until nothing changes: some entries are derived only once others are gone
|
||||
// (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`).
|
||||
while let Some(key) = out
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, value)| {
|
||||
let mut probe = out.clone();
|
||||
probe.env.remove(*key);
|
||||
applied(&probe).env.get(*key) == Some(*value)
|
||||
})
|
||||
.map(|(key, _)| key.clone())
|
||||
{
|
||||
out.env.remove(&key);
|
||||
}
|
||||
let path = Path::new(&config.command);
|
||||
if path.is_absolute()
|
||||
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
{
|
||||
let mut probe = out.clone();
|
||||
probe.command = name.clone();
|
||||
if applied(&probe).command == config.command {
|
||||
out.command = name;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sh_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
@@ -884,7 +884,7 @@ mod tests {
|
||||
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
|
||||
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
|
||||
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
|
||||
let patched = Path::new("/managed/lombok-1.18.48-openidea.1.jar");
|
||||
let patched = Path::new("/managed/lombok-1.18.48-openidea.2.jar");
|
||||
let mut fixed = jdtls();
|
||||
configure_jdtls(
|
||||
&mut fixed,
|
||||
@@ -1173,7 +1173,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.2.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.2.1.jar");
|
||||
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -507,7 +507,7 @@ mod tests {
|
||||
let cache = repo.join("org/projectlombok/lombok/1.18.48");
|
||||
fs::create_dir_all(&cache).unwrap();
|
||||
fs::write(cache.join("lombok-1.18.48.jar"), b"official agent").unwrap();
|
||||
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.1.jar");
|
||||
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.2.jar");
|
||||
let selected = lombok_agent(tmp.path(), None, Some(patched.clone()), Some(repo.clone()));
|
||||
assert_eq!(selected.jar(), Some(patched.as_path()));
|
||||
assert_eq!(selected.problem(), None);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::apply::{
|
||||
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
|
||||
terminal_activation, tsdk,
|
||||
terminal_activation, tsdk, without_derived,
|
||||
};
|
||||
use crate::python::parse_pip_list;
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
@@ -1108,3 +1108,84 @@ fn go_modules_configure_gopls_in_place_and_commands_with_explicit_run_overrides(
|
||||
configure_server(&mut cleared, tmp.path(), tmp.path(), &Settings::default());
|
||||
assert_eq!(cleared.settings.unwrap()["env"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_a_loaded_run_configuration_drops_what_settings_derived() {
|
||||
if cfg!(windows) {
|
||||
return; // Unix paths
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let touch = |rel: &str| {
|
||||
let p = root.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, "").unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
};
|
||||
let mut s = Settings::default();
|
||||
s.languages.python.interpreter = fake_venv(root, ".venv").to_string_lossy().into_owned();
|
||||
s.languages.node.interpreter = touch("node/bin/node");
|
||||
touch("rustup/toolchains/stable/bin/cargo");
|
||||
s.languages.rust.toolchain = root.join("rustup/toolchains/stable").to_string_lossy().into_owned();
|
||||
touch("go/bin/go");
|
||||
s.languages.go.goroot = root.join("go").to_string_lossy().into_owned();
|
||||
s.languages.go.gopath = root.join("gopath").to_string_lossy().into_owned();
|
||||
s.languages.go.modules.env = BTreeMap::from([("GOPROXY".into(), "https://proxy.example".into())]);
|
||||
fake_jdk(&root.join("jdk17"));
|
||||
s.languages.java.jdk = root.join("jdk17").to_string_lossy().into_owned();
|
||||
touch("maven/bin/mvn");
|
||||
s.languages.java.maven.home = root.join("maven").to_string_lossy().into_owned();
|
||||
s.languages.ruby.interpreter = touch("ruby/bin/ruby");
|
||||
touch("ruby/bin/bundle");
|
||||
s.tools.insert("godot".into(), ide_core::settings::ToolSettings { path: touch("Godot/godot") });
|
||||
|
||||
let saved = |kind: &str, command: &str, args: &[&str], env: &[(&str, &str)]| {
|
||||
let mut c = run(kind, command, args);
|
||||
c.detected = false;
|
||||
c.env = env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
|
||||
c
|
||||
};
|
||||
// As the user saved them: bare tools, only their own environment.
|
||||
let configs = [
|
||||
saved("python", "python", &["app.py"], &[]),
|
||||
saved("pytest", ".venv/bin/pytest", &["-x"], &[("FOO", "1")]),
|
||||
saved("npm", "npm", &["run", "dev"], &[]),
|
||||
saved("cargo", "cargo", &["run"], &[]),
|
||||
saved("go", "go", &["run", "."], &[]),
|
||||
saved("go", "go", &["mod", "tidy"], &[("GOPROXY", "off")]),
|
||||
saved("maven", "mvn", &["package"], &[]),
|
||||
saved("tomcat", "catalina", &[], &[]),
|
||||
saved("ruby", "ruby", &["app.rb"], &[]),
|
||||
saved("rake", "bundle", &["exec", "rake"], &[("RAILS_ENV", "test")]),
|
||||
saved("godot", "godot", &["--path", "."], &[]),
|
||||
// The user's own values that merely look like derived ones stay.
|
||||
saved("cargo", "/opt/rust/bin/cargo", &["build"], &[("PATH", "/custom/bin")]),
|
||||
saved("maven", "mvn", &["verify"], &[("JAVA_HOME", "/other/jdk")]),
|
||||
saved("python", "python", &["x.py"], &[("VIRTUAL_ENV", "/elsewhere/venv")]),
|
||||
];
|
||||
for user in configs {
|
||||
let mut loaded = user.clone();
|
||||
apply_to_run_configs(std::slice::from_mut(&mut loaded), root, &s);
|
||||
assert_ne!(loaded, user, "{} {}: settings apply to it", user.kind, user.command);
|
||||
let stored = without_derived(&loaded, root, &s);
|
||||
assert_eq!(stored, user, "{} {}: saved as the user left it", user.kind, user.command);
|
||||
let mut reloaded = stored;
|
||||
apply_to_run_configs(std::slice::from_mut(&mut reloaded), root, &s);
|
||||
assert_eq!(reloaded, loaded, "{} {}: runs the same after a save", user.kind, user.command);
|
||||
}
|
||||
|
||||
// Another bare name for the interpreter comes back as the interpreter's own.
|
||||
let mut loaded = saved("python", "python3", &["app.py"], &[]);
|
||||
apply_to_run_configs(std::slice::from_mut(&mut loaded), root, &s);
|
||||
assert_eq!(without_derived(&loaded, root, &s).command, exe("python"));
|
||||
|
||||
// A saved configuration follows a later interpreter change instead of a frozen PATH.
|
||||
let mut loaded = saved("ruby", "ruby", &["app.rb"], &[]);
|
||||
apply_to_run_configs(std::slice::from_mut(&mut loaded), root, &s);
|
||||
let mut next = without_derived(&loaded, root, &s);
|
||||
s.languages.ruby.interpreter = touch("ruby2/bin/ruby");
|
||||
apply_to_run_configs(std::slice::from_mut(&mut next), root, &s);
|
||||
let ruby2 = root.join("ruby2/bin");
|
||||
assert_eq!(next.command, ruby2.join("ruby").to_string_lossy());
|
||||
assert!(next.env["PATH"].starts_with(&*ruby2.to_string_lossy()), "{}", next.env["PATH"]);
|
||||
}
|
||||
@@ -18,7 +18,8 @@ use std::time::SystemTime;
|
||||
|
||||
pub use words::{MIN_WORD_CHARS, WordSpan, words};
|
||||
|
||||
/// Where a region's words come from; strings and comments understand escapes.
|
||||
/// Where a region's words come from; strings understand escapes, comments and
|
||||
/// prose skip backslash commands (`\brief`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RegionKind {
|
||||
Comment,
|
||||
@@ -421,15 +422,22 @@ fn capitalize(s: &str) -> String {
|
||||
/// blocks and inline code spans.
|
||||
pub fn prose_regions(text: &str) -> Vec<Region> {
|
||||
let mut out = Vec::new();
|
||||
let mut fence: Option<&str> = None;
|
||||
// The open fence: its character and length.
|
||||
let mut fence: Option<(char, usize)> = None;
|
||||
let mut offset = 0;
|
||||
for line in text.split_inclusive('\n') {
|
||||
let start = offset;
|
||||
offset += line.len();
|
||||
let trimmed = line.trim_start();
|
||||
let marker = ["```", "~~~"].into_iter().find(|m| trimmed.starts_with(m));
|
||||
let marker = ['`', '~'].into_iter().find_map(|c| {
|
||||
let n = trimmed.len() - trimmed.trim_start_matches(c).len();
|
||||
(n >= 3).then_some((c, n))
|
||||
});
|
||||
match (fence, marker) {
|
||||
(Some(open), Some(m)) if m == open => {
|
||||
// CommonMark: a closing fence uses the same character, is at least as
|
||||
// long as the opening one and has no info string, so a ```` fence can
|
||||
// show a ``` block.
|
||||
(Some((open, open_len)), Some((c, n))) if c == open && n >= open_len && trimmed[n..].trim().is_empty() => {
|
||||
fence = None;
|
||||
continue;
|
||||
}
|
||||
@@ -720,6 +728,14 @@ mod tests {
|
||||
assert_eq!(spans, ["Some prose ", " here\n", "more\n"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_fences_close_on_a_matching_fence_only() {
|
||||
let text = "Example:\n````markdown\n```rust\nfn wrng() {}\n```\n````\nmore\n~~~\n```\nnot clsed\n~~~~\nend\n";
|
||||
let regions = prose_regions(text);
|
||||
let spans: Vec<_> = regions.iter().map(|r| &text[r.start..r.end]).collect();
|
||||
assert_eq!(spans, ["Example:\n", "more\n", "end\n"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation() {
|
||||
let s = Speller::default();
|
||||
|
||||
@@ -17,7 +17,11 @@ pub struct WordSpan {
|
||||
/// Words of `text[start..end]` in order, as absolute byte ranges.
|
||||
pub fn words(text: &str, start: usize, end: usize, kind: RegionKind, out: &mut Vec<WordSpan>) {
|
||||
let Some(slice) = text.get(start..end) else { return };
|
||||
let escapes = !matches!(kind, RegionKind::Name);
|
||||
let escapes = match kind {
|
||||
RegionKind::Name => Escapes::None,
|
||||
RegionKind::String => Escapes::Char,
|
||||
RegionKind::Comment | RegionKind::Prose => Escapes::Word,
|
||||
};
|
||||
let mut chunk_start = None;
|
||||
for (i, c) in slice.char_indices().chain(std::iter::once((slice.len(), ' '))) {
|
||||
if c.is_whitespace() {
|
||||
@@ -30,6 +34,18 @@ pub fn words(text: &str, start: usize, end: usize, kind: RegionKind, out: &mut V
|
||||
}
|
||||
}
|
||||
|
||||
/// What a `\` hides from the spell checker.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Escapes {
|
||||
/// Identifiers have no escapes.
|
||||
None,
|
||||
/// String literals: the escape letter (`\n`, `\t`), the rest of the word is text.
|
||||
Char,
|
||||
/// Comments and prose: the whole word, a Doxygen / TeX command (`\brief`,
|
||||
/// `\param`) or an escape quoted in a comment (`\n`).
|
||||
Word,
|
||||
}
|
||||
|
||||
/// URLs, e-mail addresses and the like are not prose.
|
||||
fn skip_chunk(chunk: &str) -> bool {
|
||||
if chunk.contains("://") || chunk.starts_with("www.") {
|
||||
@@ -41,7 +57,7 @@ fn skip_chunk(chunk: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk(slice: &str, from: usize, to: usize, escapes: bool, base: usize, out: &mut Vec<WordSpan>) {
|
||||
fn chunk(slice: &str, from: usize, to: usize, escapes: Escapes, base: usize, out: &mut Vec<WordSpan>) {
|
||||
let text = &slice[from..to];
|
||||
if skip_chunk(text) {
|
||||
return;
|
||||
@@ -57,9 +73,15 @@ fn chunk(slice: &str, from: usize, to: usize, escapes: bool, base: usize, out: &
|
||||
if let Some(s) = run.take() {
|
||||
token(text, s, i, base + from, out);
|
||||
}
|
||||
// `\n`, `\t`, `é`: the escape letter is not part of the next word.
|
||||
if escapes && c == '\\' {
|
||||
chars.next();
|
||||
if c == '\\' {
|
||||
match escapes {
|
||||
Escapes::None => {}
|
||||
// `\n`, `\t`, `é`: the escape letter is not part of the next word.
|
||||
Escapes::Char => {
|
||||
chars.next();
|
||||
}
|
||||
Escapes::Word => while chars.next_if(|&(_, c)| c.is_alphanumeric() || c == '_').is_some() {},
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = run {
|
||||
@@ -164,6 +186,14 @@ mod tests {
|
||||
assert_eq!(split("// 中文注释 naïve", RegionKind::Comment), ["naïve"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_backslash_commands_in_comments() {
|
||||
assert_eq!(split("/// \\brief Returns the sum.", RegionKind::Comment), ["Returns", "the", "sum"]);
|
||||
assert_eq!(split("@see \\ref Foo, \\param[in] count", RegionKind::Comment), ["see", "Foo", "count"]);
|
||||
assert_eq!(split("prints \\nthe line", RegionKind::Comment), ["prints", "line"]);
|
||||
assert_eq!(split("\\emph{word} text", RegionKind::Prose), ["word", "text"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_apostrophes_inside_words() {
|
||||
assert_eq!(split("'don't' shouldn't", RegionKind::Comment), ["don't", "shouldn't"]);
|
||||
|
||||
@@ -637,13 +637,15 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
display: "Lombok (jdtls agent)",
|
||||
kind: Plugin,
|
||||
commands: &["lombok.jar"],
|
||||
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup.
|
||||
// Return to Maven Central once an official release includes lombok#4059.
|
||||
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup (lombok#4059);
|
||||
// ECJ 3.46.200 keeps the constructor call in a constructor's statements, which
|
||||
// @SneakyThrows / @NonNull must not wrap or precede (lombok#4089).
|
||||
// Return to Maven Central once an official release includes both.
|
||||
// Source commit, build instructions and checksum: plugins/lombok/README.md.
|
||||
method: Method::Bundled {
|
||||
filename: "lombok-1.18.48-openidea.1.jar",
|
||||
version: "1.18.48-openidea.1",
|
||||
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.1.jar"),
|
||||
filename: "lombok-1.18.48-openidea.2.jar",
|
||||
version: "1.18.48-openidea.2",
|
||||
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.2.jar"),
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
|
||||
@@ -56,9 +56,9 @@ fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads()
|
||||
let info = tools.info("lombok", &[]).unwrap();
|
||||
let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed);
|
||||
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.1"));
|
||||
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.2"));
|
||||
assert_eq!(info.path, Some(path.clone().into()));
|
||||
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.1.jar");
|
||||
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.2.jar");
|
||||
assert!(!info.can_install && !info.can_uninstall);
|
||||
assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None);
|
||||
assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it");
|
||||
|
||||
+130
-29
@@ -5,12 +5,15 @@
|
||||
//! that skips excluded / git-ignored directories (Linux, where a recursive
|
||||
//! inotify watch would register every directory of `node_modules` and
|
||||
//! `target` and exhaust `max_user_watches`).
|
||||
//!
|
||||
//! When the OS watcher cannot start (inotify limits used up, a file system
|
||||
//! without change notifications), the same directories are polled instead.
|
||||
|
||||
use crate::FsBatch;
|
||||
use crate::rules::{ExcludeMatcher, GitignoreStack};
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
|
||||
use notify_debouncer_full::notify::{self, EventKind, RecommendedWatcher, RecursiveMode, Watcher as _};
|
||||
use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
@@ -23,6 +26,8 @@ use std::time::{Duration, Instant};
|
||||
const DEBOUNCE: Duration = Duration::from_millis(100);
|
||||
/// Upper bound on how long a continuous stream of events is held back.
|
||||
const MAX_DELAY: Duration = Duration::from_millis(1000);
|
||||
/// How often the polling fallback rescans the watched directories.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How directories are registered with the OS.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -43,6 +48,7 @@ impl Default for WatchMode {
|
||||
}
|
||||
|
||||
type OnChange = Box<dyn Fn(&FsBatch) + Send + Sync>;
|
||||
type Emit = Arc<dyn Fn(&FsBatch) + Send + Sync>;
|
||||
|
||||
/// Debounced watcher. Emits `IdeEvent::FsChanged` to `sink` and invokes
|
||||
/// `on_change` (used by the index) with the same batch. Stops when dropped;
|
||||
@@ -51,12 +57,14 @@ pub struct Watcher {
|
||||
inner: Inner,
|
||||
excludes: SharedExcludes,
|
||||
rules: Arc<GitignoreStack>,
|
||||
polling: Option<String>,
|
||||
}
|
||||
|
||||
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
|
||||
|
||||
enum Inner {
|
||||
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
|
||||
Polling(#[allow(dead_code)] Debouncer<PollWatcher, NoCache>),
|
||||
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
|
||||
}
|
||||
|
||||
@@ -76,7 +84,7 @@ impl Watcher {
|
||||
let filter = EventFilter::new(root.clone(), &excludes);
|
||||
let shared = filter.excludes.clone();
|
||||
let rules = filter.gitignore.clone();
|
||||
let emit = move |batch: &FsBatch| {
|
||||
let emit: Emit = Arc::new(move |batch: &FsBatch| {
|
||||
// VCS-metadata-only batches (`batch.vcs`) carry no worktree paths:
|
||||
// consumers that refresh on any `fsChanged` would reschedule purely
|
||||
// from the backend's own `.git/index` write.
|
||||
@@ -91,13 +99,21 @@ impl Watcher {
|
||||
sink.emit(IdeEvent::ExcludedDirsChanged { dirs: batch.excluded_dirs.iter().map(Into::into).collect() });
|
||||
}
|
||||
on_change(batch);
|
||||
});
|
||||
let (inner, polling) = match Self::start_native(&root, filter.clone(), emit.clone(), mode) {
|
||||
Ok(inner) => (inner, None),
|
||||
// Rather than failing to open the project, keep it in sync a little later.
|
||||
Err(e) => {
|
||||
tracing::warn!("{e}; polling {} for changes instead", root.display());
|
||||
(Self::start_polling(&root, filter, emit, mode)?, Some(e.message))
|
||||
}
|
||||
};
|
||||
let inner = match mode {
|
||||
WatchMode::Recursive => Self::start_recursive(root, filter, emit, RecursiveMode::Recursive),
|
||||
WatchMode::Flat => Self::start_recursive(root, filter, emit, RecursiveMode::NonRecursive),
|
||||
WatchMode::PerDirectory => Self::start_per_directory(root, filter, emit),
|
||||
}?;
|
||||
Ok(Self { inner, excludes: shared, rules })
|
||||
Ok(Self { inner, excludes: shared, rules, polling })
|
||||
}
|
||||
|
||||
/// Why changes are polled for instead of reported by the OS; `None` when they are.
|
||||
pub fn polling(&self) -> Option<&str> {
|
||||
self.polling.as_deref()
|
||||
}
|
||||
|
||||
/// Replaces the exclude globs (Settings › Project Structure / File Types
|
||||
@@ -116,21 +132,53 @@ impl Watcher {
|
||||
/// Number of OS-level watches (1 in recursive and flat mode).
|
||||
pub fn watch_count(&self) -> usize {
|
||||
match &self.inner {
|
||||
Inner::Recursive(_) => 1,
|
||||
Inner::Recursive(_) | Inner::Polling(_) => 1,
|
||||
Inner::PerDirectory { state, .. } => state.watched.lock().len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_recursive(
|
||||
root: PathBuf,
|
||||
fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
|
||||
let config = notify::Config::default();
|
||||
match mode {
|
||||
WatchMode::Recursive => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive)
|
||||
}
|
||||
WatchMode::Flat => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive)
|
||||
}
|
||||
WatchMode::PerDirectory => Self::start_per_directory::<RecommendedWatcher>(root, filter, emit, config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls per directory unless flat: a recursive poll would rescan
|
||||
/// `node_modules` and `target` every round.
|
||||
fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
|
||||
let config = notify::Config::default().with_poll_interval(POLL_INTERVAL);
|
||||
match mode {
|
||||
WatchMode::Flat => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling)
|
||||
}
|
||||
WatchMode::Recursive | WatchMode::PerDirectory => {
|
||||
Self::start_per_directory::<PollWatcher>(root, filter, emit, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_debounced<W: notify::Watcher>(
|
||||
root: &Path,
|
||||
filter: EventFilter,
|
||||
emit: impl Fn(&FsBatch) + Send + 'static,
|
||||
emit: Emit,
|
||||
recursive: RecursiveMode,
|
||||
) -> IdeResult<Inner> {
|
||||
config: notify::Config,
|
||||
) -> IdeResult<Debouncer<W, NoCache>> {
|
||||
let polled = W::kind() == WatcherKind::PollWatcher;
|
||||
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
|
||||
// which made opening large projects block for seconds. Renames arrive as remove + create.
|
||||
let handler = move |result: DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
Ok(mut events) => {
|
||||
if polled {
|
||||
events.retain(|e| !is_dir_touch(&e.event));
|
||||
}
|
||||
let (batch, _) = filter.batch_detailed(&events);
|
||||
if !batch.is_empty() {
|
||||
emit(&batch);
|
||||
@@ -142,18 +190,19 @@ impl Watcher {
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut debouncer =
|
||||
new_debouncer_opt::<_, RecommendedWatcher, _>(DEBOUNCE, None, handler, NoCache, notify::Config::default())
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
debouncer.watch(&root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
|
||||
Ok(Inner::Recursive(debouncer))
|
||||
let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config)
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
fn start_per_directory(
|
||||
root: PathBuf,
|
||||
fn start_per_directory<W: notify::Watcher + Send + 'static>(
|
||||
root: &Path,
|
||||
filter: EventFilter,
|
||||
emit: impl Fn(&FsBatch) + Send + 'static,
|
||||
emit: Emit,
|
||||
config: notify::Config,
|
||||
) -> IdeResult<Inner> {
|
||||
let polled = W::kind() == WatcherKind::PollWatcher;
|
||||
let state = Arc::new(DirState {
|
||||
filter,
|
||||
watcher: Mutex::new(None),
|
||||
@@ -163,10 +212,11 @@ impl Watcher {
|
||||
stop: AtomicBool::new(false),
|
||||
});
|
||||
let weak: Weak<DirState> = Arc::downgrade(&state);
|
||||
let watcher = RecommendedWatcher::new(
|
||||
let watcher = W::new(
|
||||
move |event: notify::Result<notify::Event>| {
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
match event {
|
||||
Ok(event) if polled && is_dir_touch(&event) => {}
|
||||
Ok(event) => {
|
||||
state.queue.lock().push((event, Instant::now()));
|
||||
state.wake.notify_one();
|
||||
@@ -174,12 +224,12 @@ impl Watcher {
|
||||
Err(e) => tracing::warn!("file watcher: {e}"),
|
||||
}
|
||||
},
|
||||
notify::Config::default(),
|
||||
config,
|
||||
)
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
*state.watcher.lock() = Some(watcher);
|
||||
*state.watcher.lock() = Some(Box::new(watcher));
|
||||
let mut created = Vec::new();
|
||||
state.watch_tree(&root, false, &mut created);
|
||||
state.watch_tree(root, false, &mut created);
|
||||
if state.watched.lock().is_empty() {
|
||||
return Err(io_error(format!("cannot watch {}", root.display())));
|
||||
}
|
||||
@@ -187,7 +237,7 @@ impl Watcher {
|
||||
let state = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("openidea-fs-watch".into())
|
||||
.spawn(move || state.run(emit))
|
||||
.spawn(move || state.run(&*emit))
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
|
||||
};
|
||||
Ok(Inner::PerDirectory { state, thread: Some(thread) })
|
||||
@@ -212,11 +262,17 @@ fn io_error(msg: String) -> IdeError {
|
||||
IdeError::new(ide_core::error::ErrorKind::Io, msg)
|
||||
}
|
||||
|
||||
/// A directory's own mtime moving as entries come and go: polling sees it, the
|
||||
/// OS watchers report only the entries (also seen, through that directory's watch).
|
||||
fn is_dir_touch(event: ¬ify::Event) -> bool {
|
||||
matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// State of the per-directory strategy, shared with the notify callback
|
||||
/// (weakly) and the debounce thread.
|
||||
struct DirState {
|
||||
filter: EventFilter,
|
||||
watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
watcher: Mutex<Option<Box<dyn notify::Watcher + Send>>>,
|
||||
watched: Mutex<HashSet<PathBuf>>,
|
||||
queue: Mutex<Vec<(notify::Event, Instant)>>,
|
||||
wake: Condvar,
|
||||
@@ -396,6 +452,7 @@ fn merge(prev: Option<Change>, next: Change) -> Change {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EventFilter {
|
||||
root: PathBuf,
|
||||
excludes: SharedExcludes,
|
||||
@@ -791,7 +848,7 @@ mod tests {
|
||||
fn watcher_state(w: &Watcher) -> &DirState {
|
||||
match &w.inner {
|
||||
Inner::PerDirectory { state, .. } => state,
|
||||
Inner::Recursive(_) => panic!("not per-directory"),
|
||||
Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,4 +1026,48 @@ mod tests {
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(!all_paths(&batches.lock()).contains(&root.join("sub/deep.txt")));
|
||||
}
|
||||
|
||||
/// The fallback used when the OS watcher cannot start.
|
||||
fn polling_watcher(root: &Path, excludes: Vec<String>, batches: Arc<Mutex<Vec<FsBatch>>>) -> Watcher {
|
||||
let filter = EventFilter::new(root.to_path_buf(), &excludes);
|
||||
let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone());
|
||||
let emit: Emit = Arc::new(move |b: &FsBatch| batches.lock().push(b.clone()));
|
||||
let inner = Watcher::start_polling(root, filter, emit, WatchMode::default()).unwrap();
|
||||
Watcher { inner, excludes, rules, polling: Some("cannot start file watcher".into()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polling_skips_excluded_dirs_and_follows_new_ones() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for d in ["src/a", "node_modules/x"] {
|
||||
std::fs::create_dir_all(root.join(d)).unwrap();
|
||||
}
|
||||
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
|
||||
let watcher = polling_watcher(&root, vec!["**/node_modules".into()], batches.clone());
|
||||
// root, src, src/a
|
||||
assert_eq!(watcher.watch_count(), 3);
|
||||
|
||||
std::fs::write(root.join("node_modules/x/z.js"), "x").unwrap();
|
||||
std::fs::write(root.join("src/a/f.rs"), "x").unwrap();
|
||||
std::fs::create_dir_all(root.join("src/new/inner")).unwrap();
|
||||
std::fs::write(root.join("src/new/inner/g.rs"), "x").unwrap();
|
||||
assert!(
|
||||
wait_for(&batches, |b| {
|
||||
let paths = all_paths(b);
|
||||
paths.contains(&root.join("src/a/f.rs")) && paths.contains(&root.join("src/new/inner/g.rs"))
|
||||
}),
|
||||
"{:?}",
|
||||
batches.lock()
|
||||
);
|
||||
let paths = all_paths(&batches.lock());
|
||||
assert!(!paths.iter().any(|p| p.starts_with(root.join("node_modules"))), "{paths:?}");
|
||||
// Folders whose entries changed aren't reported as modified, as with inotify.
|
||||
assert!(!paths.contains(&root.join("src")) && !paths.contains(&root.join("src/a")), "{paths:?}");
|
||||
assert_eq!(watcher.watch_count(), 5);
|
||||
|
||||
// Later writes inside a new folder are seen through its own poll.
|
||||
std::fs::write(root.join("src/new/inner/h.rs"), "x").unwrap();
|
||||
assert!(wait_for(&batches, |b| all_paths(b).contains(&root.join("src/new/inner/h.rs"))));
|
||||
}
|
||||
}
|
||||
@@ -241,9 +241,9 @@ async fn resolve(shared: &Shared, target: &str) -> Response {
|
||||
let Some(root) = shared.roots.read().get(&key).cloned() else { return NOT_FOUND };
|
||||
let Ok(real_root) = tokio::fs::canonicalize(&root).await else { return NOT_FOUND };
|
||||
let mut file = root;
|
||||
let mut trailing_slash = true;
|
||||
// `/<token>/<key>` names a directory too: it redirects like any other.
|
||||
let trailing_slash = path.ends_with('/');
|
||||
for seg in segments {
|
||||
trailing_slash = seg.is_empty();
|
||||
if seg.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -347,6 +347,10 @@ mod tests {
|
||||
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: /"));
|
||||
// The served root itself, so relative URLs of its index page resolve inside it.
|
||||
let page = get(base, None).await;
|
||||
let root_path = base.trim_start_matches("http://").split_once('/').unwrap().1;
|
||||
assert!(page.starts_with("HTTP/1.1 301") && page.contains(&format!("Location: /{root_path}/\r\n")), "{page}");
|
||||
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"));
|
||||
|
||||
@@ -28,7 +28,7 @@ The specialized Maven/Gradle tool-window model still describes the workspace dir
|
||||
|
||||
Open two project windows, then use **Window → Merge All Project Windows**. The project tabs occupy their own row below the main toolbar. Click a tab or use **Next/Previous Project Tab** to switch; drag tabs to reorder them. The default shortcuts are ⌥⌘PageDown/PageUp on macOS and Ctrl+Alt+PageDown/PageUp on Windows/Linux.
|
||||
|
||||
Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled.
|
||||
Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled.
|
||||
|
||||
The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const config: KnipConfig = {
|
||||
project: [
|
||||
"src/**/*.{ts,tsx,css}",
|
||||
"!src/editor/core/cm/angular/parser.terms.ts",
|
||||
"!src/editor/core/cm/jsp/parser.terms.ts",
|
||||
"scripts/**/*.ts",
|
||||
"perf/**/*.ts",
|
||||
"e2e/**/*.ts",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
id = "jsp"
|
||||
name = "JSP"
|
||||
extensions = ["jsp", "jspx", "jspf", "jsph", "tag", "tagf", "tagx"]
|
||||
block-comment = ["<%--", "--%>"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "JSP"
|
||||
@@ -19,6 +19,7 @@
|
||||
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
|
||||
"gen:bindings": "cargo test -p openidea --test export_bindings",
|
||||
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
|
||||
"gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts",
|
||||
"gen:theme-docs": "node scripts/theme/gen-docs.ts",
|
||||
"theme:import-intellij": "node scripts/theme/import-intellij-theme.ts",
|
||||
"dev:app": "node scripts/tauri.ts dev --config src-tauri/tauri.dev.conf.json",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
|
||||
@@ -53,14 +53,17 @@ files for disk discovery. It does not guess a global association from matching
|
||||
text in unrelated projects.
|
||||
|
||||
Indexing is lazy. The first explicit navigation/search builds a compact snapshot;
|
||||
ordinary typing, code-lens probes and bulk scoped reference analysis do not start indexing. At most eight project snapshots are
|
||||
ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go to Definition / Ctrl+hover outside a `@Value` or property-lookup argument (checked with the bundled tree-sitter grammar) do not start indexing. At most eight project snapshots are
|
||||
retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations;
|
||||
ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST
|
||||
construction for ordinary Java units, but every accepted association is resolved
|
||||
semantically. Warm requests reuse the snapshot. Java model/reconcile changes
|
||||
invalidate it conservatively, including dependent-type changes; open working-copy
|
||||
content fingerprints also prevent stale answers before reconciliation. This is
|
||||
**snapshot invalidation**, not a fully incremental Java semantic index.
|
||||
semantically. Warm requests reuse the snapshot. Java model changes (sources on
|
||||
disk, classpath, archives) invalidate it conservatively, including dependent-type
|
||||
changes; a fingerprint of working copies with unsaved changes covers edits before
|
||||
they are saved. Opening or closing an editor, reconciling, building, and non-Java
|
||||
resource changes (configuration files are parsed by the client; metadata files are
|
||||
checked by timestamp) keep it. This is **snapshot invalidation**, not a fully
|
||||
incremental Java semantic index.
|
||||
|
||||
Metadata/JAR timestamps are checked before reuse. The client caches parsed disk
|
||||
configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.2.0
|
||||
Bundle-Version: 1.2.1
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.2.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.2.1.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -70,6 +70,9 @@ and compare SHA-256. The prior 1.1.1 output was
|
||||
`1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`.
|
||||
Version 1.2.0 adds the configuration command; its reproducible SHA-256 is
|
||||
`9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`.
|
||||
Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of
|
||||
unrelated content and non-Java resource changes; its reproducible SHA-256 is
|
||||
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
|
||||
Binary file not shown.
@@ -27,9 +27,23 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
private static final String VALUE = "org.springframework.beans.factory.annotation.Value";
|
||||
private static final String RESOLVER = "org.springframework.core.env.PropertyResolver";
|
||||
private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024;
|
||||
private static final AtomicLong REVISION = new AtomicLong();
|
||||
private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong();
|
||||
private static final Map<String, Index> CACHE = new LinkedHashMap<>();
|
||||
static { JavaCore.addElementChangedListener(event -> REVISION.incrementAndGet(), ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); }
|
||||
/** Unsaved edits are covered by the working-copy digest, so reconciles never
|
||||
* invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and
|
||||
* non-Java resources, reported as F_CONTENT on a project, root or package
|
||||
* (configuration files are parsed by the client; metadata files carry their
|
||||
* own stamps), do not change the index either. */
|
||||
private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY
|
||||
| IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED;
|
||||
static { JavaCore.addElementChangedListener(event -> { if (affects(event.getDelta())) REVISION.incrementAndGet(); }, ElementChangedEvent.POST_CHANGE); }
|
||||
static boolean affects(IJavaElementDelta delta) {
|
||||
int flags = delta.getFlags() & ~IGNORED_FLAGS;
|
||||
if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT;
|
||||
if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true;
|
||||
for (IJavaElementDelta child : delta.getAffectedChildren()) if (affects(child)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private record Entry(String name, String handle, Location location, boolean relaxed) {}
|
||||
private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {}
|
||||
@@ -38,7 +52,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
catch (java.io.IOException e) { return new FileStamp(null, -1); }
|
||||
}
|
||||
private record Index(long revision, String workingCopies, List<Entry> entries, List<String> roots,
|
||||
Map<Path, FileStamp> files, boolean incomplete, int parsed) {}
|
||||
Map<Path, FileStamp> files, boolean incomplete, int parsed, long generation) {}
|
||||
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
|
||||
@@ -108,7 +122,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
}
|
||||
return Map.of("locations", locations, "keys", keys, "roots", index.roots,
|
||||
"incomplete", index.incomplete || locations.size() >= 2000,
|
||||
"parsedUnits", index.parsed, "revision", index.revision);
|
||||
"parsedUnits", index.parsed, "revision", index.revision, "generation", index.generation);
|
||||
}
|
||||
|
||||
private static boolean sameUri(String a, String b) {
|
||||
@@ -156,9 +170,12 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy);
|
||||
for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()))
|
||||
copies.put(copy.getPrimary().getHandleIdentifier(), copy);
|
||||
// Only unsaved contents differ from what the Java model reports for disk,
|
||||
// so merely opening or closing an editor keeps the snapshot.
|
||||
var digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
for (var entry : copies.entrySet()) {
|
||||
check(monitor);
|
||||
if (!entry.getValue().hasUnsavedChanges()) continue;
|
||||
digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
String source = entry.getValue().getSource();
|
||||
if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
@@ -209,7 +226,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
// Return project locations as well; the client excludes nested independent modules.
|
||||
roots.add(project.getProject().getLocationURI().toString());
|
||||
builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project);
|
||||
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed);
|
||||
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed, GENERATION.incrementAndGet());
|
||||
CACHE.remove(id);
|
||||
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
|
||||
|
||||
@@ -200,6 +200,33 @@ try:
|
||||
warm = []
|
||||
for _ in range(20):
|
||||
start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start)
|
||||
# The snapshot stays warm across editor activity that cannot change it:
|
||||
# opening/closing another file, a full build, and an edit once it is saved.
|
||||
generation = query('app.time-out')['generation']
|
||||
other = source_dir / 'demo/NotSpring.java'
|
||||
other_text = other.read_text()
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
|
||||
assert query('app.time-out')['generation'] == generation, 'opening an editor rebuilt the index'
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
|
||||
request('java/buildWorkspace', True)
|
||||
assert query('app.time-out')['generation'] == generation, 'closing an editor or building rebuilt the index'
|
||||
saved_text = other_text + '// saved\n'
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': other.as_uri(), 'version': 2}, 'contentChanges': [{'text': saved_text}]}})
|
||||
other.write_text(saved_text)
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didSave', 'params': {'textDocument': {'uri': other.as_uri()}}})
|
||||
after_save = query('app.time-out')['generation']
|
||||
assert query('app.time-out')['generation'] == after_save, 'a saved editor keeps rebuilding the index'
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
|
||||
after_close = query('app.time-out')['generation']
|
||||
assert query('app.time-out')['generation'] == after_close
|
||||
# A source added on disk still invalidates it.
|
||||
added = write('demo/Added.java', 'package demo; @org.springframework.boot.context.properties.ConfigurationProperties("added") class Added { int size; }')
|
||||
send({'jsonrpc': '2.0', 'method': 'workspace/didChangeWatchedFiles', 'params': {'changes': [{'uri': added.as_uri(), 'type': 1}]}})
|
||||
deadline = time.monotonic() + 30
|
||||
while not query('added.size')['locations']:
|
||||
assert time.monotonic() < deadline, 'a new source on disk did not reach the index'
|
||||
time.sleep(0.2)
|
||||
# Unsaved changes must replace old keys, and closing must restore disk keys.
|
||||
changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}')
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}})
|
||||
@@ -211,7 +238,7 @@ try:
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}})
|
||||
restored = query('app.nested.url')
|
||||
assert restored['locations'], restored
|
||||
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000}
|
||||
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
|
||||
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
|
||||
print(json.dumps(evidence, indent=2))
|
||||
finally:
|
||||
|
||||
+46
-14
@@ -1,8 +1,15 @@
|
||||
# Lombok agent for JDT LS
|
||||
|
||||
`lombok-1.18.48-openidea.1.jar` is the app-provided Java agent for JDT LS. It includes
|
||||
[projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores
|
||||
`lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`.
|
||||
`lombok-1.18.48-openidea.2.jar` is the app-provided Java agent for JDT LS. It includes:
|
||||
|
||||
- [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores
|
||||
`lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`.
|
||||
- [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089) (fixes
|
||||
lombok#4084 / #4094). ECJ 3.46.200, bundled with JDT LS 1.63, keeps a constructor's explicit or
|
||||
implicit `super()` / `this()` call as the first element of its statements. Lombok 1.18.48 wrapped
|
||||
that call in the `@SneakyThrows` try block (ECJ then fails with "Internal Error compiling …
|
||||
BlockScope cannot be cast to MethodScope" and the whole workspace build fails) and put `@NonNull`
|
||||
checks before it; constructors Lombok generates lacked the statement.
|
||||
|
||||
The jar is embedded by `ide-tools`, so every platform receives the same bytes without a download.
|
||||
Both Java server startup and launch diagnostics obtain it through `managed_bundle`. Tool detection
|
||||
@@ -12,21 +19,27 @@ The filename includes the patch version because JDT LS workspace keys include th
|
||||
changing agents must rebuild classes produced by the old agent. Old jars remain for running windows.
|
||||
|
||||
Replace this temporary bundle with an official Maven Central release once that release includes
|
||||
#4059. Restore the registry's Maven Central install method and remove the embedded jar. Keep the
|
||||
#4059 and #4089. Restore the registry's Maven Central install method and remove the embedded jar. Keep the
|
||||
workspace migration test when changing its path.
|
||||
|
||||
## Provenance and build
|
||||
|
||||
- Repository: https://github.com/projectlombok/lombok
|
||||
- Base tag: `v1.18.48` (`b48657c83ce44d027984f539bd36048293ce5e8e`).
|
||||
- Cherry-picked commits from PR #4059, in order:
|
||||
- `2d535482a913de46c27d06c5b0b294918ede3f9c`
|
||||
- `3098f4dd78b5349c2f382e7310e64db810dd380b`
|
||||
- Upstream version: `1.18.48`; `openidea.1` identifies this distribution of the patched agent.
|
||||
- Cherry-picked commits, in order:
|
||||
- PR #4059 (`chagong/lombok` `fix-equinox-bundle-wiring`):
|
||||
- `2d535482a913de46c27d06c5b0b294918ede3f9c`
|
||||
- `3098f4dd78b5349c2f382e7310e64db810dd380b`
|
||||
- PR #4089 (`Rawi01/lombok` `eclipse-constructor-call-statement`):
|
||||
- `7832faa49b35c839088866daf865384c82cd555f`
|
||||
- `3f3a80b857a969839ab1baba12767aec4965ac32`
|
||||
- Upstream version: `1.18.48`; `openidea.2` identifies this distribution of the patched agent
|
||||
(`openidea.1` had #4059 only).
|
||||
Upstream's own `java -jar ... --version` still reports `1.18.48`.
|
||||
- SHA-256: `10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4`
|
||||
- SHA-256: `2978d8b2d55dac1bb4936ba44562bc4549931d4e71a7ebfeaebccd60bcdf7379`
|
||||
- License: [MIT, including bundled component notices](LICENSE), also retained inside the jar.
|
||||
- Build environment: macOS arm64, OpenJDK 23.0.1, Apache Ant 1.10.15, upstream Ivy setup.
|
||||
- Build environment: macOS arm64, Temurin 25.0.5, Apache Ant 1.10.15, upstream Ivy setup
|
||||
(`openidea.1` was built with OpenJDK 23.0.1).
|
||||
|
||||
Build using the upstream Ant entry point described in its contributing guide:
|
||||
|
||||
@@ -35,16 +48,22 @@ git clone https://github.com/projectlombok/lombok.git
|
||||
cd lombok
|
||||
git checkout -b openidea-1.18.48 v1.18.48
|
||||
git fetch https://github.com/chagong/lombok.git fix-equinox-bundle-wiring
|
||||
git fetch https://github.com/Rawi01/lombok.git eclipse-constructor-call-statement
|
||||
git cherry-pick 2d535482a913de46c27d06c5b0b294918ede3f9c 3098f4dd78b5349c2f382e7310e64db810dd380b
|
||||
git cherry-pick 7832faa49b35c839088866daf865384c82cd555f 3f3a80b857a969839ab1baba12767aec4965ac32
|
||||
ant dist
|
||||
cp dist/lombok.jar /path/to/openidea/plugins/lombok/lombok-1.18.48-openidea.1.jar
|
||||
cp dist/lombok.jar /path/to/openidea/plugins/lombok/lombok-1.18.48-openidea.2.jar
|
||||
```
|
||||
|
||||
The changelog context conflicts because the first commit adds a release-note line under the
|
||||
older development heading and the second removes it. Resolve mechanically by adding then removing
|
||||
only that line, retaining the complete v1.18.48 changelog. The final source diff from v1.18.48 changes
|
||||
only `src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`,
|
||||
and `test/core/src/lombok/launch/PatchFixesHiderTest.java`; no other Java source changes were made.
|
||||
only that line, retaining the complete v1.18.48 changelog (the second commit is then empty and
|
||||
skipped). The #4089 commits apply cleanly. The final source diff from v1.18.48 changes
|
||||
`src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`,
|
||||
`test/core/src/lombok/launch/PatchFixesHiderTest.java` (#4059) and the Eclipse handlers
|
||||
`EclipseHandlerUtil`, `HandleConstructor`, `HandleFieldNameConstants`, `HandleNonNull`,
|
||||
`HandleSneakyThrows`, `HandleStandardException`, `HandleSuperBuilder`, `HandleUtilityClass` (#4089);
|
||||
no other Java source changes were made.
|
||||
In the build checkout, the two remote resolvers in
|
||||
`buildScripts/ivysettings.xml` were reordered to try Maven Central before Eclipse staging; the
|
||||
local resolver and all dependency coordinates were retained. The JVM was configured to use the
|
||||
@@ -66,6 +85,19 @@ On macOS arm64 with JDT LS 1.63.0 / Equinox 3.25.0, server OpenJDK 25 and projec
|
||||
identical `javap` output, including default `void` setters.
|
||||
- Upstream `PatchFixesHiderTest` passed both the standard OSGi and legacy Equinox lookup tests.
|
||||
|
||||
`openidea.2` (#4089) on macOS arm64, JDT LS 1.63.0-202610191322 (ECJ
|
||||
`compiler.batch_3.46.200.v20261008-1115`), server Temurin 25:
|
||||
|
||||
- ECJ batch with the agent, compliance 17 and 25, against ECJ 3.46.200 and 3.46.100: `@SneakyThrows`
|
||||
constructors with implicit `super()`, explicit `super()` and `this()`; `@NonNull` constructor
|
||||
parameters; `@RequiredArgsConstructor` with an initialized final field, `@AllArgsConstructor`,
|
||||
`@NoArgsConstructor`, `@Builder` record, `@Value @Builder`, `@UtilityClass`. `openidea.1` failed
|
||||
with "Internal Error compiling" on 3.46.200; `openidea.2` compiled on both ECJ versions, the
|
||||
programs ran with identical output, and `@NonNull` checks run after the constructor call again.
|
||||
- Real JDT LS `java/buildWorkspace(true)` of the same sources with the agent: `openidea.1` reported
|
||||
Failed with three internal errors and wrote no classes; `openidea.2` Succeeded with all classes.
|
||||
- `plugins/java-usages/tests/verify.py` and `read-write.py` passed with `openidea.2`.
|
||||
|
||||
Windows and Linux runtime validation has not been performed. Rust tests cover source selection
|
||||
for both supported architectures on all three OS variants, offline provisioning, corruption
|
||||
repair, concurrent windows, explicit user overrides, and JDT LS workspace migration.
|
||||
@@ -1 +1 @@
|
||||
10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4 lombok-1.18.48-openidea.1.jar
|
||||
2978d8b2d55dac1bb4936ba44562bc4549931d4e71a7ebfeaebccd60bcdf7379 lombok-1.18.48-openidea.2.jar
|
||||
BIN
Binary file not shown.
Generated
+3
@@ -17,6 +17,9 @@ importers:
|
||||
'@codemirror/lang-html':
|
||||
specifier: ^6.4.12
|
||||
version: 6.4.12
|
||||
'@codemirror/lang-java':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-javascript':
|
||||
specifier: ^6.2.5
|
||||
version: 6.2.5
|
||||
|
||||
@@ -79,6 +79,10 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
|
||||
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Wayland detection and the client size without GTK's CSD shadow (linux_window_state.rs).
|
||||
gtk = "0.18"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
|
||||
@@ -15,6 +15,8 @@ pub struct AppInfo {
|
||||
pub version: String,
|
||||
pub platform: String,
|
||||
pub config_dir: ide_core::IpcPath,
|
||||
/// Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges.
|
||||
pub wayland: bool,
|
||||
}
|
||||
|
||||
pub use ide_host::ops::project::ProjectInfo;
|
||||
@@ -27,6 +29,10 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo {
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
platform: std::env::consts::OS.into(),
|
||||
config_dir: state.config_dir.clone().into(),
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: crate::linux_window_state::is_wayland(),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
wayland: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window_group::window_group_select,
|
||||
window_group::window_group_detach,
|
||||
window_group::window_group_close,
|
||||
window_group::window_group_close_tabs,
|
||||
window_group::window_group_cancel_close,
|
||||
window_group::window_group_reorder,
|
||||
window_group::window_group_restore,
|
||||
app::light_edit_open,
|
||||
|
||||
@@ -41,6 +41,20 @@ pub fn window_group_close(window: WebviewWindow, label: String) -> IdeResult<()>
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn window_group_close_tabs(window: WebviewWindow, keep: String, labels: Vec<String>) -> IdeResult<()> {
|
||||
groups::close_tabs(window.app_handle(), window.label(), &keep, labels)
|
||||
}
|
||||
|
||||
/// This window kept its project open (unsaved-changes prompt cancelled); stops a batch close.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn window_group_cancel_close(window: WebviewWindow) {
|
||||
groups::cancel_close(window.app_handle(), window.label());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn window_group_reorder(window: WebviewWindow, labels: Vec<String>) -> IdeResult<()> {
|
||||
|
||||
@@ -14,6 +14,7 @@ mod runtime {
|
||||
use super::*;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin};
|
||||
|
||||
const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME;
|
||||
@@ -63,8 +64,42 @@ mod runtime {
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
static WAYLAND: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// GDK's backend, as tao tests it (`GDK_BACKEND=x11` under a Wayland session is X11).
|
||||
/// Known once the plugin is set up, before any window exists.
|
||||
pub fn is_wayland() -> bool {
|
||||
WAYLAND.get().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The client size that `set_size` (`gtk_window_resize`) takes. On Wayland, decorated windows
|
||||
/// get a GTK CSD shadow margin that tao's inner and outer sizes (the configure size) include.
|
||||
pub fn content_size(window: &Window) -> tauri::Result<tauri::PhysicalSize<u32>> {
|
||||
let inner = window.inner_size()?;
|
||||
if !is_wayland() {
|
||||
return Ok(inner);
|
||||
}
|
||||
// GTK objects belong to the main thread. `gtk_window_get_size` follows the configure
|
||||
// synchronously; the webview's allocation only catches up on the next layout.
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
let cloned = window.clone();
|
||||
window.run_on_main_thread(move || {
|
||||
let sizes = cloned.gtk_window().ok().zip(cloned.inner_size().ok()).zip(cloned.scale_factor().ok());
|
||||
let _ = tx.send(sizes.map(|((gtk, inner), scale)| {
|
||||
let (width, height) = gtk::prelude::GtkWindowExt::size(>k);
|
||||
let content = tauri::LogicalSize::new(width.max(0) as u32, height.max(0) as u32);
|
||||
(content.to_physical::<u32>(scale), inner)
|
||||
}));
|
||||
})?;
|
||||
let Some((content, inner)) = rx.recv().ok().flatten() else { return Ok(inner) };
|
||||
// An unmapped window reports its size request; never exceed the configure size.
|
||||
let usable =
|
||||
content.width > 1 && content.height > 1 && content.width <= inner.width && content.height <= inner.height;
|
||||
Ok(if usable { content } else { inner })
|
||||
}
|
||||
|
||||
fn frame(window: &Window) -> Option<Frame> {
|
||||
let size = window.inner_size().ok()?;
|
||||
let size = content_size(window).ok()?;
|
||||
Some(Frame {
|
||||
visible: window.is_visible().ok()?,
|
||||
size: Size(size.width, size.height),
|
||||
@@ -381,6 +416,9 @@ mod runtime {
|
||||
pub fn plugin() -> TauriPlugin<tauri::Wry> {
|
||||
tauri::plugin::Builder::new("linux-window-state")
|
||||
.setup(|app, _| {
|
||||
// Plugins are set up on the main thread once GTK is initialised, before any window exists.
|
||||
use gtk::gdk::prelude::DisplayExtManual;
|
||||
let _ = WAYLAND.set(gtk::gdk::Display::default().is_some_and(|d| d.backend().is_wayland()));
|
||||
let saved = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
@@ -466,7 +504,7 @@ mod runtime {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use runtime::{normal_size_requested, plugin, prepare, save_before_close};
|
||||
pub use runtime::{content_size, is_wayland, normal_size_requested, plugin, prepare, save_before_close};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::{
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
@@ -65,6 +66,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
if !maximized {
|
||||
to.unmaximize().map_err(err)?;
|
||||
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
|
||||
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let size = from.inner_size().map_err(err)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
|
||||
@@ -170,6 +175,82 @@ pub fn destroyed(app: &AppHandle, caller: &str) {
|
||||
});
|
||||
}
|
||||
changed(app);
|
||||
let advance = state.tab_closes.lock().as_mut().is_some_and(|batch| {
|
||||
let closing = batch.pending.front().is_some_and(|l| l == caller);
|
||||
if closing {
|
||||
batch.pending.pop_front();
|
||||
}
|
||||
closing
|
||||
});
|
||||
if advance {
|
||||
close_next(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tabs left to close; the first one is closing now.
|
||||
pub struct TabCloses {
|
||||
keep: String,
|
||||
pending: VecDeque<String>,
|
||||
}
|
||||
|
||||
/// Closes `labels` one at a time so each project's unsaved-changes prompt is shown in turn, then selects `keep`.
|
||||
pub fn close_tabs(app: &AppHandle, caller: &str, keep: &str, labels: Vec<String>) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
let members = state
|
||||
.groups
|
||||
.lock()
|
||||
.group(caller)
|
||||
.map(|g| g.members.clone())
|
||||
.ok_or_else(|| IdeError::not_found("This window is not grouped"))?;
|
||||
if !members.iter().any(|m| m == keep) {
|
||||
return Err(IdeError::invalid("The window is not in this group"));
|
||||
}
|
||||
let pending = members.into_iter().filter(|m| m != keep && labels.contains(m)).collect();
|
||||
*state.tab_closes.lock() = Some(TabCloses { keep: keep.to_owned(), pending });
|
||||
close_next(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The closing tab's project vetoed (its unsaved-changes prompt was cancelled): leave the rest open.
|
||||
pub fn cancel_close(app: &AppHandle, caller: &str) {
|
||||
let state = app.state::<WindowsState>();
|
||||
let mut batch = state.tab_closes.lock();
|
||||
if batch.as_ref().is_some_and(|b| b.pending.front().is_some_and(|l| l == caller)) {
|
||||
*batch = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn close_next(app: &AppHandle) {
|
||||
let state = app.state::<WindowsState>();
|
||||
loop {
|
||||
let next = {
|
||||
let mut batch = state.tab_closes.lock();
|
||||
let Some(b) = batch.as_mut() else { return };
|
||||
match b.pending.front() {
|
||||
Some(label) => Ok(label.clone()),
|
||||
None => Err(batch.take().unwrap().keep),
|
||||
}
|
||||
};
|
||||
match next {
|
||||
Ok(label) => {
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
windows::focus(&window);
|
||||
if window.close().is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(b) = state.tab_closes.lock().as_mut() {
|
||||
b.pending.pop_front();
|
||||
}
|
||||
}
|
||||
Err(keep) => {
|
||||
if let Some(window) = app.get_webview_window(&keep) {
|
||||
windows::focus(&window);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct WindowsState {
|
||||
last_focused: Mutex<Option<String>>,
|
||||
/// Set by Quit while windows close one by one.
|
||||
quitting: AtomicBool,
|
||||
/// Project tabs closing one by one (Close Other Tabs and friends).
|
||||
pub tab_closes: Mutex<Option<crate::window_groups::TabCloses>>,
|
||||
/// Relaunch once the quit completes (after installing an update).
|
||||
restart: AtomicBool,
|
||||
}
|
||||
@@ -109,7 +111,9 @@ impl WindowsState {
|
||||
|
||||
pub fn set_quitting(&self, on: bool) {
|
||||
self.quitting.store(on, Ordering::SeqCst);
|
||||
if !on {
|
||||
if on {
|
||||
*self.tab_closes.lock() = None;
|
||||
} else {
|
||||
self.restart.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
@@ -303,6 +307,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
|
||||
let window = WebviewWindowBuilder::from_config(app, &config)
|
||||
.and_then(|b| dev_font_preferences(b).build())
|
||||
.map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?;
|
||||
#[cfg(target_os = "linux")]
|
||||
decorate_csd(&window);
|
||||
apply_zoom(app, &window);
|
||||
#[cfg(target_os = "macos")]
|
||||
align_titlebar(app, &window);
|
||||
@@ -325,6 +331,17 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// Wayland: tao already makes frameless windows client-side decorated (an empty GTK title bar, so
|
||||
/// the compositor adds no frame), but undecorated GTK windows get no shadow or resize margin.
|
||||
/// Decorating keeps that empty title bar, so GTK adds the theme's shadow and resize edges and
|
||||
/// nothing else. X11 has no such title bar: decorating there would bring back the WM frame.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decorate_csd(window: &WebviewWindow) {
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
let _ = window.set_decorations(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the saved IDE zoom before the page loads; applied later by the frontend,
|
||||
/// it would visibly scale up the splash.
|
||||
fn apply_zoom(app: &AppHandle, window: &WebviewWindow) {
|
||||
@@ -796,13 +813,19 @@ pub fn open_settings_window(
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::linux_window_state::prepare(app, &mut config);
|
||||
let builder = WebviewWindowBuilder::from_config(app, &config);
|
||||
// AppKit child windows can disappear when dragged to another display's Space.
|
||||
// The settings label already tracks ownership and parent-close cleanup.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// Windows: an owned child (WS_POPUP) of a borderless parent gets no DWM
|
||||
// shadow, leaving the frameless settings window flat against its parent.
|
||||
// Ownership is tracked by the `settings-<parent>` label instead
|
||||
// (parent-close cleanup, menu modality), so only Linux keeps the native
|
||||
// parent link. AppKit child windows can disappear when dragged to another
|
||||
// display's Space, so macOS keeps it too.
|
||||
#[cfg(target_os = "linux")]
|
||||
let builder = builder.and_then(|b| b.parent(&parent));
|
||||
let window = builder
|
||||
.and_then(|b| dev_font_preferences(b).build())
|
||||
.map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?;
|
||||
#[cfg(target_os = "linux")]
|
||||
decorate_csd(&window);
|
||||
apply_zoom(app, &window);
|
||||
#[cfg(target_os = "macos")]
|
||||
align_titlebar(app, &window);
|
||||
@@ -883,7 +906,12 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind
|
||||
let size = parent.outer_size()?.to_logical::<f64>(scale);
|
||||
let own_scale = window.scale_factor()?;
|
||||
let own = window.outer_size()?.to_logical::<f64>(own_scale);
|
||||
let inner = window.inner_size()?.to_logical::<f64>(own_scale);
|
||||
// On Wayland, the frame is GTK's CSD shadow margin, which tao's inner size includes.
|
||||
#[cfg(target_os = "linux")]
|
||||
let inner = crate::linux_window_state::content_size(&window.as_ref().window())?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let inner = window.inner_size()?;
|
||||
let inner = inner.to_logical::<f64>(own_scale);
|
||||
let frame = (own.width - inner.width, own.height - inner.height);
|
||||
let bounds = fit_bounds(
|
||||
(pos.x, pos.y, size.width, size.height),
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"jsx",
|
||||
"vue",
|
||||
"svelte",
|
||||
"jsp",
|
||||
"dart",
|
||||
"cs",
|
||||
"fs",
|
||||
|
||||
+11
-5
@@ -23,6 +23,10 @@ export const commands = {
|
||||
windowGroupSelect: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_select", { label })),
|
||||
windowGroupDetach: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_detach")),
|
||||
windowGroupClose: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close", { label })),
|
||||
/** Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`. */
|
||||
windowGroupCloseTabs: (keep: string, labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })),
|
||||
/** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */
|
||||
windowGroupCancelClose: () => __TAURI_INVOKE<void>("window_group_cancel_close"),
|
||||
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
|
||||
windowGroupRestore: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_restore")),
|
||||
/**
|
||||
@@ -1386,6 +1390,8 @@ export type AppInfo = {
|
||||
version: string,
|
||||
platform: string,
|
||||
configDir: IpcPath,
|
||||
/** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */
|
||||
wayland: boolean,
|
||||
};
|
||||
|
||||
export type AppMetrics = {
|
||||
@@ -5326,16 +5332,16 @@ export type RevisionChange = {
|
||||
|
||||
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
|
||||
|
||||
export type RubySettings = {
|
||||
/** Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). */
|
||||
interpreter?: string,
|
||||
};
|
||||
|
||||
export type RouteSource = {
|
||||
path: string,
|
||||
text: string,
|
||||
};
|
||||
|
||||
export type RubySettings = {
|
||||
/** Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). */
|
||||
interpreter?: string,
|
||||
};
|
||||
|
||||
export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize;
|
||||
|
||||
export type RunConfig_Deserialize = {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* JSP pages, layered like IntelliJ's JSP view provider: `jsp.grammar` parses
|
||||
* the JSP layer, and the template text around its elements is one HTML
|
||||
* document. That is an overlay, so markup split by a scriptlet still nests
|
||||
* and `value="<%= id %>"` is an attribute with a hole. The scriptlets are
|
||||
* one Java method body, an overlay too (`<% for (…) { %> … <% } %>` balances
|
||||
* its braces), each expression and declaration Java of its own.
|
||||
*/
|
||||
import { LRLanguage, LanguageSupport, foldInside, foldNodeProp } from "@codemirror/language";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { javaLanguage } from "@codemirror/lang-java";
|
||||
import { styleTags, tags as t } from "@lezer/highlight";
|
||||
import {
|
||||
Parser,
|
||||
parseMixed,
|
||||
type Input,
|
||||
type NestedParse,
|
||||
type PartialParse,
|
||||
type SyntaxNode,
|
||||
type SyntaxNodeRef,
|
||||
type TreeFragment,
|
||||
} from "@lezer/common";
|
||||
import { JAVA_TAGS } from "../languages";
|
||||
import { parser } from "./parser";
|
||||
|
||||
const baseHTML = html({ selfClosingTags: true });
|
||||
|
||||
const JSP_COMMENT = { block: { open: "<%--", close: "--%>" } };
|
||||
|
||||
const baseParser = parser.configure({
|
||||
props: [
|
||||
styleTags({
|
||||
JspComment: t.blockComment,
|
||||
'"<%" "<%=" "<%!" "<%@" "%>"': t.processingInstruction,
|
||||
// Not `ELExpression/"}"`: the cursor highlighting a page does not match
|
||||
// that context once the expression's content is mounted.
|
||||
'"${" "#{" ELEnd/...': t.special(t.brace),
|
||||
DirectiveName: t.keyword,
|
||||
AttributeName: t.attributeName,
|
||||
AttributeValue: t.attributeValue,
|
||||
Is: t.definitionOperator,
|
||||
VariableName: t.variableName,
|
||||
PropertyName: t.propertyName,
|
||||
"CallExpression/MemberExpression/PropertyName": t.function(t.propertyName),
|
||||
"CallExpression/VariableName FunctionName": t.function(t.variableName),
|
||||
Namespace: t.namespace,
|
||||
Number: t.number,
|
||||
String: t.string,
|
||||
BooleanLiteral: t.bool,
|
||||
null: t.null,
|
||||
"not empty div mod lt gt le ge instanceof eq ne and or": t.operatorKeyword,
|
||||
ArithOp: t.arithmeticOperator,
|
||||
CompareOp: t.compareOperator,
|
||||
LogicOp: t.logicOperator,
|
||||
'"->"': t.operator,
|
||||
'"."': t.derefOperator,
|
||||
"( )": t.paren,
|
||||
"[ ]": t.squareBracket,
|
||||
'SetOrMapLiteral/"{" SetOrMapLiteral/"}"': t.brace,
|
||||
", ;": t.separator,
|
||||
'":"': t.punctuation,
|
||||
}),
|
||||
foldNodeProp.add({
|
||||
"Scriptlet Declaration JspExpression": foldInside,
|
||||
JspComment: (node, state) => ({
|
||||
from: node.from + 4,
|
||||
to: state.sliceDoc(node.to - 4, node.to) == "--%>" ? node.to - 4 : node.to,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const javaParser = javaLanguage.parser.configure({ props: [styleTags(JAVA_TAGS)] });
|
||||
|
||||
/** `input` with a `;` at `at`. */
|
||||
class TerminatedInput implements Input {
|
||||
constructor(
|
||||
private readonly input: Input,
|
||||
private readonly at: number,
|
||||
) {}
|
||||
|
||||
get length() {
|
||||
return this.input.length;
|
||||
}
|
||||
|
||||
get lineChunks() {
|
||||
return this.input.lineChunks;
|
||||
}
|
||||
|
||||
chunk(from: number): string {
|
||||
if (from == this.at) return ";";
|
||||
const text = this.input.chunk(from);
|
||||
return from < this.at && this.at < from + text.length ? text.slice(0, this.at - from) : text;
|
||||
}
|
||||
|
||||
read(from: number, to: number): string {
|
||||
const text = this.input.read(from, to);
|
||||
return from <= this.at && this.at < to
|
||||
? text.slice(0, this.at - from) + ";" + text.slice(this.at - from + 1)
|
||||
: text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Java has no expression top: an expression is parsed as a statement, its
|
||||
* trailing space read as the `;` (`<%= name %>`), so that a lone name is not
|
||||
* a call missing its arguments. Without one, it ends in an error node.
|
||||
*/
|
||||
class ExpressionParser extends Parser {
|
||||
private readonly java = javaParser.configure({});
|
||||
|
||||
createParse(
|
||||
input: Input,
|
||||
fragments: readonly TreeFragment[],
|
||||
ranges: readonly { from: number; to: number }[],
|
||||
): PartialParse {
|
||||
const end = ranges[ranges.length - 1]!.to;
|
||||
const terminated = /\s/.test(input.read(end - 1, end)) ? new TerminatedInput(input, end - 1) : input;
|
||||
return this.java.createParse(terminated, fragments, ranges);
|
||||
}
|
||||
}
|
||||
|
||||
// To reuse old inner trees, parseMixed takes those that the node at a mount's
|
||||
// start and its ancestors had with the same parser, so each kind of mount has
|
||||
// its own instance: an expression at the start of a page would otherwise take
|
||||
// the scriptlets' tree.
|
||||
const htmlMixed: NestedParse = { parser: baseHTML.language.parser, overlay: (node) => node.name == "Text" },
|
||||
scriptletParser = javaParser.configure({}),
|
||||
expressionMixed = { parser: new ExpressionParser() },
|
||||
declarationMixed = { parser: javaParser.configure({ top: "ClassContent" }) },
|
||||
elMixed = { parser: baseParser.configure({ top: "EL" }) };
|
||||
|
||||
/** The code of the scriptlets in `template`. */
|
||||
function scriptletCode(template: SyntaxNode): { from: number; to: number }[] {
|
||||
const code: { from: number; to: number }[] = [];
|
||||
for (const scriptlet of template.getChildren("Scriptlet")) {
|
||||
const java = scriptlet.getChild("JavaCode");
|
||||
if (java) code.push({ from: java.from, to: java.to });
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
// The HTML overlay is on the document and the scriptlets' on the template it
|
||||
// holds: a node mounts one tree, and an overlay's predicate does not see the
|
||||
// nodes of an overlay inside it.
|
||||
function mixJsp(node: SyntaxNodeRef): NestedParse | null {
|
||||
switch (node.name) {
|
||||
case "Document":
|
||||
return htmlMixed;
|
||||
case "Template": {
|
||||
const overlay = scriptletCode(node.node);
|
||||
return overlay.length ? { parser: scriptletParser, overlay } : null;
|
||||
}
|
||||
case "JavaCode": {
|
||||
const element = node.node.parent?.name;
|
||||
return element == "JspExpression" ? expressionMixed : element == "Declaration" ? declarationMixed : null;
|
||||
}
|
||||
case "ELContent":
|
||||
return elMixed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const jspLanguage = LRLanguage.define({
|
||||
name: "jsp",
|
||||
parser: baseParser.configure({ wrap: parseMixed(mixJsp) }),
|
||||
languageData: { commentTokens: JSP_COMMENT },
|
||||
});
|
||||
|
||||
export function jsp(): LanguageSupport {
|
||||
return new LanguageSupport(jspLanguage, [
|
||||
baseHTML.support,
|
||||
// Markup comments out of the page, with a JSP comment, as in IntelliJ.
|
||||
baseHTML.language.data.of({ commentTokens: JSP_COMMENT }),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// JSP pages: the JSP layer of a page, as IntelliJ's JSP view provider sees it.
|
||||
// Comments, directives, scripting elements and EL expressions are parsed here;
|
||||
// the template text between them is HTML and the scripting elements' code is
|
||||
// Java, both mounted by `./index.ts`. EL expressions (`${…}`, `#{…}`) are their
|
||||
// own top. Regenerate with `pnpm gen:jsp-grammar`.
|
||||
|
||||
@top Document { Template }
|
||||
|
||||
// The page's content, a node of its own: the HTML overlay is mounted on the
|
||||
// document and the scriptlets' Java overlay on this.
|
||||
Template {
|
||||
(Text | JspComment | Directive | Declaration | JspExpression | Scriptlet | ELExpression)*
|
||||
}
|
||||
|
||||
Scriptlet { "<%" JavaCode? "%>" }
|
||||
|
||||
JspExpression { "<%=" JavaCode? "%>" }
|
||||
|
||||
Declaration { "<%!" JavaCode? "%>" }
|
||||
|
||||
ELExpression { ("${" | "#{") ELContent? ELEnd { "}" } }
|
||||
|
||||
@skip { space } {
|
||||
Directive { "<%@" DirectiveName { name } Attribute* "%>" }
|
||||
|
||||
Attribute { AttributeName { name } (Is AttributeValue)? }
|
||||
|
||||
@top EL { expression? (";" expression?)* }
|
||||
|
||||
expression[@isGroup=Expression] {
|
||||
literal |
|
||||
VariableName |
|
||||
ParenthesizedExpression { "(" (expression ("," expression)*)? ")" } |
|
||||
ListLiteral { "[" (expression ("," expression)*)? "]" } |
|
||||
SetOrMapLiteral { "{" (entry ("," entry)*)? "}" } |
|
||||
MemberExpression { expression !member ("." PropertyName | "[" expression "]") } |
|
||||
CallExpression { expression !call ArgList } |
|
||||
FunctionCall { Namespace ":" FunctionName ArgList } |
|
||||
UnaryExpression { (kw<"not"> | kw<"empty"> | LogicOp<"!"> | ArithOp<"-">) !prefix expression } |
|
||||
BinaryExpression {
|
||||
expression !mult (ArithOp<"*" | "/" | "%"> | kw<"div"> | kw<"mod">) expression |
|
||||
expression !add ArithOp<"+" | "-"> expression |
|
||||
expression !concat ArithOp<"+="> expression |
|
||||
expression !rel (CompareOp<"<" | ">" | "<=" | ">="> | kw<"lt"> | kw<"gt"> | kw<"le"> | kw<"ge"> | kw<"instanceof">) expression |
|
||||
expression !equal (CompareOp<"==" | "!="> | kw<"eq"> | kw<"ne">) expression |
|
||||
expression !and (LogicOp<"&&"> | kw<"and">) expression |
|
||||
expression !or (LogicOp<"||"> | kw<"or">) expression
|
||||
} |
|
||||
ConditionalExpression { expression !ternary LogicOp<"?"> expression LogicOp<":"> expression } |
|
||||
LambdaExpression { expression !lambda "->" expression } |
|
||||
AssignmentExpression { expression !assign Is expression }
|
||||
}
|
||||
|
||||
entry { expression (":" expression)? }
|
||||
|
||||
ArgList { "(" (expression ("," expression)*)? ")" }
|
||||
}
|
||||
|
||||
@precedence {
|
||||
member,
|
||||
call,
|
||||
prefix @right,
|
||||
mult @left,
|
||||
add @left,
|
||||
concat @left,
|
||||
rel @left,
|
||||
equal @left,
|
||||
and @left,
|
||||
or @left,
|
||||
ternary @right,
|
||||
lambda @right,
|
||||
assign @right
|
||||
}
|
||||
|
||||
literal {
|
||||
Number |
|
||||
String |
|
||||
@specialize[@name=BooleanLiteral]<identifier, "true" | "false"> |
|
||||
kw<"null">
|
||||
}
|
||||
|
||||
VariableName { identifier }
|
||||
|
||||
PropertyName { identifier }
|
||||
|
||||
FunctionName { identifier }
|
||||
|
||||
Is { "=" }
|
||||
|
||||
kw<term> { @specialize[@name={term}]<identifier, term> }
|
||||
|
||||
ArithOp<expr> { expr }
|
||||
CompareOp<expr> { expr }
|
||||
LogicOp<expr> { expr }
|
||||
|
||||
// Tried in this order, before the tokens below: a comment before `<%`.
|
||||
@external tokens jspComment from "./tokens" { JspComment }
|
||||
@external tokens text from "./tokens" { Text }
|
||||
@external tokens javaCode from "./tokens" { JavaCode }
|
||||
@external tokens elContent from "./tokens" { ELContent }
|
||||
// `fn` of `fn:join(…)`, so that an identifier before a `:` stays a ternary's.
|
||||
@external tokens namespace from "./tokens" { Namespace }
|
||||
|
||||
@tokens {
|
||||
space { $[ \t\n\r]+ }
|
||||
|
||||
identifier { $[a-zA-Z_$\u{a1}-\u{10ffff}] $[a-zA-Z0-9_$\u{a1}-\u{10ffff}]* }
|
||||
|
||||
// Directive and attribute names: `include`, `pageEncoding`, `name-given`.
|
||||
name { $[a-zA-Z_] $[a-zA-Z0-9_\-]* }
|
||||
|
||||
Number {
|
||||
(@digit+ ("." @digit*)? | "." @digit+) ($[eE] $[+\-]? @digit+)?
|
||||
}
|
||||
|
||||
String {
|
||||
'"' (!["\\] | "\\" _)* '"'? |
|
||||
"'" (!['\\] | "\\" _)* "'"?
|
||||
}
|
||||
|
||||
AttributeValue {
|
||||
'"' !["]* '"'? |
|
||||
"'" ![']* "'"?
|
||||
}
|
||||
|
||||
@precedence { Number, "." }
|
||||
|
||||
"<%" "<%=" "<%!" "<%@" "%>" "${" "#{"
|
||||
"(" ")" "[" "]" "{" "}" "." "," ";" ":" "->"
|
||||
}
|
||||
|
||||
@detectDelim
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language";
|
||||
import type { Tree } from "@lezer/common";
|
||||
import { buildParserFile } from "@lezer/generator";
|
||||
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
|
||||
import { findLanguageDescription, loadSupport } from "../languages";
|
||||
import { jsp, jspLanguage } from "./index";
|
||||
|
||||
const highlighter = tagHighlighter([
|
||||
{ tag: t.processingInstruction, class: "pi" },
|
||||
{ tag: t.special(t.brace), class: "el" },
|
||||
{ tag: t.blockComment, class: "comment" },
|
||||
{ tag: t.operatorKeyword, class: "opkw" },
|
||||
{ tag: t.keyword, class: "keyword" },
|
||||
{ tag: t.bool, class: "bool" },
|
||||
{ tag: t.namespace, class: "ns" },
|
||||
{ tag: t.typeName, class: "type" },
|
||||
{ tag: t.definition(t.variableName), class: "def" },
|
||||
{ tag: t.function(t.variableName), class: "call" },
|
||||
{ tag: t.function(t.propertyName), class: "method" },
|
||||
{ tag: t.variableName, class: "var" },
|
||||
{ tag: t.propertyName, class: "prop" },
|
||||
{ tag: t.attributeName, class: "attr" },
|
||||
{ tag: t.attributeValue, class: "value" },
|
||||
{ tag: t.string, class: "str" },
|
||||
{ tag: t.number, class: "num" },
|
||||
{ tag: t.tagName, class: "tag" },
|
||||
]);
|
||||
|
||||
/** `text:class` of the styled tokens of `doc`, from `tree` or a fresh parse. */
|
||||
function tokens(doc: string, tree: Tree = jspLanguage.parser.parse(doc)): string[] {
|
||||
const out: string[] = [];
|
||||
highlightTree(tree, highlighter, (from, to, cls) => out.push(`${doc.slice(from, to)}:${cls}`));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The error nodes of `doc`'s tree, with the text they start. */
|
||||
function errors(doc: string): string[] {
|
||||
const out: string[] = [];
|
||||
jspLanguage.parser.parse(doc).iterate({
|
||||
enter: (node) => void (node.type.isError && out.push(doc.slice(node.from, node.from + 8))),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The tree of `state`, parsed to the end. */
|
||||
function stateTree(state: EditorState): Tree {
|
||||
const tree = ensureSyntaxTree(state, state.doc.length, 5000);
|
||||
if (!tree) throw new Error("parse did not finish");
|
||||
return tree;
|
||||
}
|
||||
|
||||
/** A ~8 KB page with directives, scriptlets around markup, expressions and EL. */
|
||||
function page(): string {
|
||||
let doc = `<%@ page contentType="text/html;charset=UTF-8" %>\n<%@ taglib prefix="c" uri="jakarta.tags.core" %>\n`;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
doc +=
|
||||
`<%-- row ${i} --%>\n<% if (rows${i} != null) { %>\n` +
|
||||
` <tr class="r${i}"><td><%= rows${i}.size() %></td><td>\${fn:length(names${i})}</td></tr>\n` +
|
||||
`<% } %>\n<c:out value="\${user${i}.name}"/>\n`;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe("JSP pages", () => {
|
||||
it("highlights directives and JSP comments", () => {
|
||||
expect(tokens(`<%@ page import="java.util.*" %><%-- <b>off</b> --%>`)).toEqual([
|
||||
"<%@:pi",
|
||||
"page:keyword",
|
||||
"import:attr",
|
||||
'"java.util.*":value',
|
||||
"%>:pi",
|
||||
"<%-- <b>off</b> --%>:comment",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses the scriptlets as one Java body, around the markup", () => {
|
||||
const doc = `<% for (String s : list) { %><li><%= s %></li><% } %>`;
|
||||
expect(errors(doc)).toEqual([]);
|
||||
expect(tokens(doc)).toEqual([
|
||||
"<%:pi",
|
||||
"for:keyword",
|
||||
"String:type",
|
||||
"s:def",
|
||||
"list:var",
|
||||
"%>:pi",
|
||||
"li:tag",
|
||||
"<%=:pi",
|
||||
"s:var",
|
||||
"%>:pi",
|
||||
"li:tag",
|
||||
"<%:pi",
|
||||
"%>:pi",
|
||||
]);
|
||||
const state = EditorState.create({ doc, extensions: jsp() });
|
||||
stateTree(state);
|
||||
const open = doc.indexOf("{");
|
||||
expect(matchBrackets(state, open, 1)?.end?.from).toBe(doc.lastIndexOf("}"));
|
||||
});
|
||||
|
||||
it("keeps markup in a scriptlet's strings out of the page", () => {
|
||||
const doc = `<% String cell = "<td>"; %><p>x</p>`;
|
||||
expect(errors(doc)).toEqual([]);
|
||||
expect(tokens(doc)).toContain('"<td>":str');
|
||||
expect(tokens(doc).filter((s) => s.endsWith(":tag"))).toEqual(["p:tag", "p:tag"]);
|
||||
});
|
||||
|
||||
it("highlights expressions and declarations as Java", () => {
|
||||
const doc = `<%= name %><%= req.getParameter("q") %><%! private int count = 0; %>`;
|
||||
expect(errors(doc)).toEqual([]);
|
||||
expect(tokens(doc)).toEqual([
|
||||
"<%=:pi",
|
||||
"name:var",
|
||||
"%>:pi",
|
||||
"<%=:pi",
|
||||
"req:var",
|
||||
"getParameter:call",
|
||||
'"q":str',
|
||||
"%>:pi",
|
||||
"<%!:pi",
|
||||
"private:keyword",
|
||||
"int:type",
|
||||
"count:def",
|
||||
"0:num",
|
||||
"%>:pi",
|
||||
]);
|
||||
});
|
||||
|
||||
it("highlights EL expressions", () => {
|
||||
const doc = `<p>\${fn:escapeXml(item.title)} #{a ? b : not empty c.d[0] and x.size() gt 2}</p>`;
|
||||
expect(errors(doc)).toEqual([]);
|
||||
expect(tokens(doc)).toEqual([
|
||||
"p:tag",
|
||||
"${:el",
|
||||
"fn:ns",
|
||||
"escapeXml:call",
|
||||
"item:var",
|
||||
"title:prop",
|
||||
"}:el",
|
||||
"#{:el",
|
||||
"a:var",
|
||||
"b:var",
|
||||
"not:opkw",
|
||||
"empty:opkw",
|
||||
"c:var",
|
||||
"d:prop",
|
||||
"0:num",
|
||||
"and:opkw",
|
||||
"x:var",
|
||||
"size:method",
|
||||
"gt:opkw",
|
||||
"2:num",
|
||||
"}:el",
|
||||
"p:tag",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends an EL expression at its own brace, past nested ones and strings", () => {
|
||||
const doc = `\${ {'k': '}'}.k == true }<b>`;
|
||||
expect(errors(doc)).toEqual([]);
|
||||
expect(tokens(doc)).toEqual(["${:el", "'k':str", "'}':str", "k:prop", "true:bool", "}:el", "b:tag"]);
|
||||
});
|
||||
|
||||
it("parses markup with elements in attribute values and custom tags", () => {
|
||||
const doc =
|
||||
`<c:forEach var="i" items="\${items}"><c:out value="\${i}"/></c:forEach>` +
|
||||
`<input value="<%= x %>" onclick="go('<%= id %>')"><jsp:include page="f.jspf"/><p></p>`;
|
||||
expect(errors(doc)).toEqual([]);
|
||||
expect(tokens(doc).slice(0, 8)).toEqual([
|
||||
"c:forEach:tag",
|
||||
"var:attr",
|
||||
'"i":value',
|
||||
"items:attr",
|
||||
'":value',
|
||||
"${:el",
|
||||
"items:var",
|
||||
"}:el",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses EL in a script as EL, like the container", () => {
|
||||
expect(tokens(`<script>let n = \`\${user.name}\`;</script>`)).toContain("user:var");
|
||||
});
|
||||
|
||||
it("keeps escaped elements as text", () => {
|
||||
expect(tokens(`<p>\\\${a} <\\% b %></p>`)).toEqual(["p:tag", "p:tag"]);
|
||||
});
|
||||
|
||||
it("comments with JSP comments in the markup and Java ones in scriptlets", () => {
|
||||
const doc = `<p>x</p><% int a; %>`;
|
||||
const state = EditorState.create({ doc, extensions: jsp() });
|
||||
stateTree(state);
|
||||
const at = (pos: number) =>
|
||||
state.languageDataAt<{ line?: string; block?: { open: string } }>("commentTokens", pos)[0];
|
||||
expect(at(4)?.block?.open).toBe("<%--");
|
||||
expect(at(doc.indexOf("int") + 1)?.line).toBe("//");
|
||||
});
|
||||
|
||||
it("folds JSP comments and scriptlets", () => {
|
||||
const doc = `<%--\n a\n--%>\n<%\n int a;\n%>`;
|
||||
const state = EditorState.create({ doc, extensions: jsp() });
|
||||
stateTree(state);
|
||||
expect(foldable(state, 0, 4)).toEqual({ from: 4, to: doc.indexOf("--%>") });
|
||||
const line = state.doc.line(4);
|
||||
expect(foldable(state, line.from, line.to)).toEqual({ from: line.to, to: doc.lastIndexOf("%>") });
|
||||
});
|
||||
|
||||
it("highlights like a fresh parse after resuming and editing", () => {
|
||||
const doc = page();
|
||||
// Opening parses the first 3000 characters; the rest resumes from that tree.
|
||||
let state = EditorState.create({ doc, extensions: jsp() });
|
||||
expect(tokens(doc, stateTree(state))).toEqual(tokens(doc));
|
||||
const at = (text: string, n = 1) => {
|
||||
let pos = -1;
|
||||
for (let i = 0; i < n; i++) pos = state.doc.toString().indexOf(text, pos + 1);
|
||||
return pos;
|
||||
};
|
||||
const edits = [
|
||||
() => ({ from: at("rows12.size()") + 4, insert: "x" }),
|
||||
() => ({ from: at(".size() %>", 15) + 7, insert: "+1" }),
|
||||
() => ({ from: at("<% } %>", 20), insert: "<% int k = 0; %>\n" }),
|
||||
() => ({ from: at("<%-- row 7"), to: at("<%-- row 7") + 4 }),
|
||||
() => ({ from: at("names3"), to: at("names3") + 6, insert: "a.b[0]" }),
|
||||
() => ({ from: 0, insert: "${top}\n" }),
|
||||
() => ({ from: at("<tr", 10), insert: "<%= " }),
|
||||
];
|
||||
for (const edit of edits) {
|
||||
state = state.update({ changes: edit() }).state;
|
||||
const text = state.doc.toString();
|
||||
expect(tokens(text, stateTree(state))).toEqual(tokens(text));
|
||||
}
|
||||
});
|
||||
|
||||
it("is generated from jsp.grammar", () => {
|
||||
const dir = import.meta.dirname;
|
||||
const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "jsp.grammar"), "utf8"), {
|
||||
fileName: "src/editor/core/cm/jsp/jsp.grammar",
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
});
|
||||
|
||||
it("is the JSP mode", async () => {
|
||||
const desc = findLanguageDescription("JSP", "/p/index.jsp");
|
||||
expect((await loadSupport(desc!))?.language).toBe(jspLanguage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
JspComment = 1,
|
||||
Text = 2,
|
||||
JavaCode = 3,
|
||||
ELContent = 4,
|
||||
Namespace = 5,
|
||||
Document = 6,
|
||||
Template = 7,
|
||||
Directive = 10,
|
||||
Attribute = 12,
|
||||
Is = 14,
|
||||
AttributeValue = 15,
|
||||
Declaration = 17,
|
||||
JspExpression = 19,
|
||||
Scriptlet = 21,
|
||||
ELExpression = 22,
|
||||
EL = 27,
|
||||
Number = 28,
|
||||
String = 29,
|
||||
BooleanLiteral = 30,
|
||||
_null = 31,
|
||||
VariableName = 32,
|
||||
PropertyName = 45,
|
||||
ArgList = 47,
|
||||
FunctionName = 49,
|
||||
not = 51,
|
||||
empty = 52,
|
||||
div = 57,
|
||||
mod = 58,
|
||||
lt = 62,
|
||||
gt = 63,
|
||||
le = 64,
|
||||
ge = 65,
|
||||
_instanceof = 66,
|
||||
eq = 68,
|
||||
ne = 69,
|
||||
and = 71,
|
||||
or = 73
|
||||
@@ -0,0 +1,24 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {jspComment, text, javaCode, elContent, namespace} from "./tokens"
|
||||
const spec_identifier = {__proto__:null,true:60, false:60, null:62, not:102, empty:104, div:114, mod:116, lt:124, gt:126, le:128, ge:130, instanceof:132, eq:136, ne:138, and:142, or:146}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "1WOqOsOOQ!sQ!rOOO!zQpO'#CfO#POtO'#CmO#XOtO'#CoO#aOtO'#CqO#iOxO'#CrOOOR'#EP'#EPO#qOsO'#CcQOOOOOOOQO'#E['#E[OOQO'#C|'#C|O#xQ!rO'#DPO$PQ!rO'#DTO$WQ!rO'#DVQ&bQ#tOOO&iQpO'#D^OOQ`'#Dc'#DcOOQ`'#Dd'#DdOxQ!rO'#D`OOQO'#EZ'#EZO&nQ!rO'#ETQ&xQpOOOOQO'#Cg'#CgO&}QpO,59QOOOR,59X,59XO'VOpO,59XOOOR,59Z,59ZO'[OpO,59ZOOOR,59],59]O'aOpO,59]OOOR'#Cu'#CuOOOR,59^,59^O#lOpO,59^OOOR-E7}-E7}OOQO,59k,59kO'fQ#tO,59kOOQO,59o,59oO'pQ#tO,59oO'zQ#tO'#E^OOQO,59q,59qO(XQpO,59qOOQ`'#Cj'#CjO(aQ!bO,59sOxQ!rO,59sO(fQ!rO'#D]OOQO,59v,59vOOQ`'#Df'#DfOOQ`'#Di'#DiOOQ`'#Dj'#DjOOQ`'#Dk'#DkOOQ`'#Dq'#DqOOQ`'#Dt'#DtOOQ`'#Dv'#DvOxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POOQ`'#Dy'#DyOxQ!rO,5:dOxQ!rO,5:gOxQ!rO,5:iO(mQ!bO,59xO(rQ#tO,59zO+[Q#tO,5:oOOQO-E8R-E8ROOQO'#Ci'#CiO+fQpO'#ChOOQO'#EQ'#EQO+qQpO1G.lOOOR1G.l1G.lOOOR1G.s1G.sOOOR1G.u1G.uOOOR1G.w1G.wOOOR1G.x1G.xOxQ!rO'#ERO+yQpO1G/VOOQO1G/V1G/VO,RQpO1G/ZOOQO1G/Z1G/ZOxQ!rO,5:xOxQ!rO'#ESO,ZQpO1G/]OOQO1G/]1G/]OOQO'#DZ'#DZOOQO1G/_1G/_O,cQ#tO1G/_O,jQ#tO,59wOOQO,59w,59wO.}Q#tO1G/kO/bQ#tO1G/kO2[Q#tO1G/kO2cQ#tO1G/kO4vQ#tO1G/kO5WQ#tO1G/kO7[Q#tO1G/kO8UQ#tO1G0OO8]Q#tO1G0RO9VQ#tO1G0TOOQO'#D_'#D_O9pQpO1G/dO9uQpO,59SOOQO-E8O-E8OOOOR7+$W7+$WO9zQ#tO,5:mOOQO-E8P-E8POOQO7+$q7+$qOOQO7+$u7+$uO:XQ#tO1G0dOOQO,5:n,5:nOOQO-E8Q-E8QOOQO7+$w7+$wOOQO7+$y7+$yO:cQpO1G/cOOQO1G/c1G/cOOQ`'#Dz'#DzOxQ!rO7+%jOOQO7+%O7+%OOOQO1G.n1G.nOOQO7+$}7+$}O:kQ#tO<<IU",
|
||||
stateData: ";h~O!zOS~OPVOQVOXQO`RObSOdTOgUOhUO~O!xVP~PVOT`OlYOmYOnYOoYOr[Ov]Ox^O!TcO!UcO#PZO#RaO#SbO~O!reO~PxO!{gO~ORjOWiO~ORlOWkO~ORnOWmO~OSqOjoO~O!xVX~PVOqsO~PxOuuO~PxOjxO~PxOr}Ov|O|{O!Z!WO![!WO!`!ZO!a!ZO!b!ZO!c!ZO!d!ZO!f![O!g![O!i!]O!k!^O!p!aO!|zO#S!QO#T!PO#U!PO#V!PO#W!QO#X!RO#Y!SO#Z!SO#[!SO#]!SO#^!TO#_!TO#`!UO#a!VO#b!_O~O!reO~P$_Oz!cO~O!r!wX!x!wX~PxO!reO~OW!kO!{!gO~OW!lO~OW!mO~OW!nO~Oq!rOt!pO~P$_Ot!pOu!tO~P$_Oz!uOj#QXt#QX~P$_Oj!xOt!vO~O#P!yO~Oq!}O~PxO#P#YO~Or}Ov|O|{O!Z!Sa![!Sa!`!Sa!a!Sa!b!Sa!c!Sa!d!Sa!f!Sa!g!Sa!i!Sa!k!Sa!p!Sa!r!Sa!x!Sa!|!Sa#S!Sa#T!Sa#U!Sa#V!Sa#W!Sa#X!Sa#Y!Sa#Z!Sa#[!Sa#]!Sa#^!Sa#_!Sa#`!Sa#a!Sa#b!Saq!Sat!Sau!Saj!Saz!Sa~O!r!wa!x!wa~P$_O!|zOW[X!{[X~OW#^O!{!gO~Oq#aOt!pO~Ot!pOu#bO~Oj#fOt!vO~Ou#gO~P$_Oq#iOt!pO~P$_Or}Ov|O|{O!`!Xi!a!Xi!b!Xi!c!Xi!d!Xi!f!Xi!g!Xi!i!Xi!k!Xi!p!Xi!r!Xi!x!Xi!|!Xi#S!Xi#W!Xi#X!Xi#Y!Xi#Z!Xi#[!Xi#]!Xi#^!Xi#_!Xi#`!Xi#a!Xi#b!Xiq!Xit!Xiu!Xij!Xiz!Xi~O!Z!Xi![!Xi#T!Xi#U!Xi#V!Xi~P,tO!Z!WO![!WO#T!PO#U!PO#V!PO~P,tOr}Ov|O|{O!Z!WO![!WO#S!QO#T!PO#U!PO#V!PO#W!QO!`!Xi!a!Xi!b!Xi!c!Xi!d!Xi!f!Xi!g!Xi!i!Xi!k!Xi!p!Xi!r!Xi!x!Xi!|!Xi#Y!Xi#Z!Xi#[!Xi#]!Xi#^!Xi#_!Xi#`!Xi#a!Xi#b!Xiq!Xit!Xiu!Xij!Xiz!Xi~O#X!Xi~P/uO#X!RO~P/uOr}Ov|O|{O!Z!WO![!WO!`!ZO!a!ZO!b!ZO!c!ZO!d!ZO#S!QO#T!PO#U!PO#V!PO#W!QO#X!RO#Y!SO#Z!SO#[!SO#]!SO!i!Xi!k!Xi!p!Xi!r!Xi!x!Xi!|!Xi#`!Xi#a!Xi#b!Xiq!Xit!Xiu!Xij!Xiz!Xi~O!f!Xi!g!Xi#^!Xi#_!Xi~P2jO!f![O!g![O#^!TO#_!TO~P2jOr}Ov|O|{O!Z!WO![!WO!`!ZO!a!ZO!b!ZO!c!ZO!d!ZO!f![O!g![O!i!]O#S!QO#T!PO#U!PO#V!PO#W!QO#X!RO#Y!SO#Z!SO#[!SO#]!SO#^!TO#_!TO#`!UO~O!k!Xi!p!Xi!r!Xi!x!Xi!|!Xi#a!Xi#b!Xiq!Xit!Xiu!Xij!Xiz!Xi~P5hOz#jO~P$_O!k!^O!p!aO#a!VO#b!_O!r!oi!x!oi!|!oiq!oit!oiu!oij!oiz!oi~P5hO!r!qi!x!qiq!qit!qiu!qij!qiz!qi~P$_Or}O~O_#mO~Oq!uat!uau!ua~P$_Oj#Qit#Qi~P$_Oq#nOt!pO~O!k!^O#a!VO#b!_O!p!ly!r!ly!x!ly!|!lyq!lyt!lyu!lyj!lyz!ly~P5hOl|~",
|
||||
goto: "+X#RPPPPPPP#SPP#V#Z#^#b#fPP#VP#VP#V#VPP#vPPPPPP#|PP#|PPP#|P#|P#|P$f#|$i#|%T#|PP%W%W#|%pPP&V&k'OPPPPP'bPP'sP(TP#|(d(s#|P#|P(v(|)S)b)hPPPPP)o#|P+RRXOTVOWRhQT!ih!jT!hh!jf!b_tvw!e!{!|#V#X#_#cR#[!hQpUR!oq}dP[]^ce|}!W!X!Y!Z![!]!^!`!a!b!p!u!v#kR!z{z!O_tvw!d!e!{!|#O#P#Q#R#S#T#U#V#W#X#_#c#oR#l#ZR#Z!c}cP[]^ce|}!W!X!Y!Z![!]!^!`!a!b!p!u!v#kw!W_tvw!e!{!|#P#Q#R#S#T#U#V#W#X#_#c#ou!X_tvw!e!{!|#Q#R#S#T#U#V#W#X#_#c#os!Y_tvw!e!{!|#R#S#T#U#V#W#X#_#c#oq!Z_tvw!e!{!|#S#T#U#V#W#X#_#c#oo![_tvw!e!{!|#T#U#V#W#X#_#c#om!]_tvw!e!{!|#U#V#W#X#_#c#ok!^_tvw!e!{!|#V#W#X#_#c#ok!`_tvw!e!{!|#V#W#X#_#c#oR#k#VQWORrWQ!jhR#]!jQ!qtQ!svU#`!q!s#hR#h!|Q!wyR#e!wSfP_R!ffQ_PQt[Qv]Sw^!vQ!dcQ!eeQ!{|Q!|}Q#O!WQ#P!XQ#Q!YQ#R!ZQ#S![Q#T!]Q#U!^Q#V!`Q#W!aQ#X!bQ#_!pQ#c!uR#o#kQy^R#d!v",
|
||||
nodeNames: "⚠ JspComment Text JavaCode ELContent Namespace Document Template %> <%@ Directive DirectiveName Attribute AttributeName Is AttributeValue <%! Declaration <%= JspExpression <% Scriptlet ELExpression ${ #{ ELEnd } EL Number String BooleanLiteral null VariableName ) ( ParenthesizedExpression , ] [ ListLiteral { SetOrMapLiteral : MemberExpression . PropertyName CallExpression ArgList FunctionCall FunctionName UnaryExpression not empty LogicOp ArithOp BinaryExpression ArithOp div mod ArithOp ArithOp CompareOp lt gt le ge instanceof CompareOp eq ne LogicOp and LogicOp or ConditionalExpression LogicOp LogicOp LambdaExpression -> AssignmentExpression ;",
|
||||
maxTerm: 110,
|
||||
nodeProps: [
|
||||
["openedBy", 8,"<%@ <%! <%= <%",26,"{",33,"(",37,"["],
|
||||
["closedBy", -4,9,16,18,20,"%>",34,")",38,"]",40,"}"],
|
||||
["group", -16,28,29,30,31,32,35,39,41,43,46,48,50,55,74,77,79,"Expression"]
|
||||
],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 5,
|
||||
tokenData: "5S~RuXY#fYZ#f]^#fpq#fqr#wrs$Ust(etu(puv*[vw*iwx*txy.wyz.|z{/R{|/W|}/e}!O/j!O!P/w!P!Q0y!Q![1O![!]1a!]!^1f!^!_1k!_!`2j!`!a2w!a!b3U!c!}3Z!}#O4c#P#Q4h#R#S3Z#T#o3Z#o#p4m#p#q4r#q#r4}$g;'S)b;'S;=`*P<%lO)b~#kS!z~XY#fYZ#f]^#fpq#fU#|P#RQ!_!`$PS$UO#_SR$]V_PmQOr$Urs$rs#O$U#O#P$y#P;'S$U;'S;=`(_<%lO$UR$yO_PmQR%OU_POr$Urs%bs;'S$U;'S;=`'p;=`<%l&O<%lO$UR%iV_PmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&TVmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&oOmQQ&rRO;'S&O;'S;=`&{;=`O&OQ'QWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l&O<%lO&OQ'mP;=`<%l&OR'uWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l$U<%lO&OR(bP;=`<%l$U~(hP#o#p(k~(pOh~V(uX#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b#o#p*V$g;'S)b;'S;=`*P<%lO)bU)gW#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b$g;'S)b;'S;=`*P<%lO)bU*SP;=`<%l)bP*[OgPT*aP#VS!`!a*dP*iOWP~*lPvw*o~*tO#`~R*{V_PmQOw*twx$rx#O*t#O#P+b#P;'S*t;'S;=`.q<%lO*tR+gU_POw*twx+yx;'S*t;'S;=`.S;=`<%l,g<%lO*tR,QV_PmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ,lVmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ-URO;'S,g;'S;=`-_;=`O,gQ-dWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l,g<%lO,gQ.PP;=`<%l,gR.XWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l*t<%lO,gR.tP;=`<%l*t~.|Or~~/ROq~~/WO#T~~/]P#W~!_!`/`~/eO#X~~/jOt~~/oP#S~!`!a/r~/wO!p~~/|P|~!Q![0P~0URl~!Q![0P!g!h0_#X#Y0_~0bR{|0k}!O0k!Q![0q~0nP!Q![0q~0vPl~!Q![0q~1OO#U~~1TSl~!O!P0P!Q![1O!g!h0_#X#Y0_~1fOz~~1kO!r~~1pQ#YSuv1v!_!`2eP1{RdPqr2U!_!`2Z!b!c2`P2ZO`PP2`ObPP2eOXP~2jO#[~~2oP!|~!_!`2r~2wO#^~~2|P#Z~!_!`3P~3UO#]~~3ZO#b~V3bX#PU!{Ptu)b}!O3}!Q![3Z!c!}3Z#R#S3Z#T#o3Z$g;'S)b;'S;=`*P<%lO)bP4ST!{P}!O3}!Q![3}!c!}3}#R#S3}#T#o3}~4hOv~~4mOu~~4rOx~~4uP#p#q4x~4}O#a~~5SOj~",
|
||||
tokenizers: [jspComment, text, javaCode, elContent, namespace, 0, 1, 2],
|
||||
topRules: {"Document":[0,6],"EL":[1,27]},
|
||||
specialized: [{term: 93, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
|
||||
tokenPrec: 1217
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* External tokens of `jsp.grammar`: the runs the JSP layer leaves to HTML and
|
||||
* Java, the comments, and the extent of EL expressions.
|
||||
*/
|
||||
import { ExternalTokenizer, type InputStream } from "@lezer/lr";
|
||||
import { ELContent, JavaCode, JspComment, Namespace, Text } from "./parser.terms";
|
||||
|
||||
const DOUBLE_QUOTE = 34,
|
||||
HASH = 35,
|
||||
DOLLAR = 36,
|
||||
PERCENT = 37,
|
||||
SINGLE_QUOTE = 39,
|
||||
PAREN_L = 40,
|
||||
DASH = 45,
|
||||
COLON = 58,
|
||||
LT = 60,
|
||||
GT = 62,
|
||||
BACKSLASH = 92,
|
||||
UNDERSCORE = 95,
|
||||
BRACE_L = 123,
|
||||
BRACE_R = 125;
|
||||
|
||||
/** Whether `<%` starts `offset` code units ahead. */
|
||||
function scriptStart(input: InputStream, offset = 0): boolean {
|
||||
return input.peek(offset) == LT && input.peek(offset + 1) == PERCENT;
|
||||
}
|
||||
|
||||
/** Whether `${` or `#{` starts `offset` code units ahead. */
|
||||
function elStart(input: InputStream, offset = 0): boolean {
|
||||
const ch = input.peek(offset);
|
||||
return (ch == DOLLAR || ch == HASH) && input.peek(offset + 1) == BRACE_L;
|
||||
}
|
||||
|
||||
/** `<%-- … --%>`, to the end of the page when unterminated. */
|
||||
export const jspComment = new ExternalTokenizer((input) => {
|
||||
if (!scriptStart(input) || input.peek(2) != DASH || input.peek(3) != DASH) return;
|
||||
input.advance(4);
|
||||
while (input.next >= 0) {
|
||||
if (input.next == DASH && input.peek(1) == DASH && input.peek(2) == PERCENT && input.peek(3) == GT) {
|
||||
input.advance(4);
|
||||
break;
|
||||
}
|
||||
input.advance();
|
||||
}
|
||||
input.acceptToken(JspComment);
|
||||
});
|
||||
|
||||
/**
|
||||
* Template text: up to a scripting element or an EL expression. Escaped ones
|
||||
* (`<\%`, `\${`, `\#{`) are text.
|
||||
*/
|
||||
export const text = new ExternalTokenizer((input) => {
|
||||
const start = input.pos;
|
||||
while (input.next >= 0 && !scriptStart(input) && !elStart(input)) {
|
||||
input.advance(input.next == BACKSLASH && elStart(input, 1) ? 2 : 1);
|
||||
}
|
||||
if (input.pos > start) input.acceptToken(Text);
|
||||
});
|
||||
|
||||
/** The code of a scripting element, up to its `%>` (`%\>` quotes one). */
|
||||
export const javaCode = new ExternalTokenizer((input) => {
|
||||
const start = input.pos;
|
||||
while (input.next >= 0 && !(input.next == PERCENT && input.peek(1) == GT)) {
|
||||
input.advance(input.next == PERCENT && input.peek(1) == BACKSLASH && input.peek(2) == GT ? 3 : 1);
|
||||
}
|
||||
if (input.pos > start) input.acceptToken(JavaCode);
|
||||
});
|
||||
|
||||
/** An EL expression's content: up to the `}` closing it, past nested braces and strings. */
|
||||
export const elContent = new ExternalTokenizer((input) => {
|
||||
const start = input.pos;
|
||||
let depth = 0;
|
||||
for (let ch; (ch = input.next) >= 0; input.advance()) {
|
||||
if (ch == BRACE_R) {
|
||||
if (!depth) break;
|
||||
depth--;
|
||||
} else if (ch == BRACE_L) {
|
||||
depth++;
|
||||
} else if (ch == DOUBLE_QUOTE || ch == SINGLE_QUOTE) {
|
||||
input.advance();
|
||||
while (input.next >= 0 && input.next != ch) input.advance(input.next == BACKSLASH ? 2 : 1);
|
||||
if (input.next < 0) break;
|
||||
}
|
||||
}
|
||||
if (input.pos > start) input.acceptToken(ELContent);
|
||||
});
|
||||
|
||||
const isIdentifierStart = (ch: number) =>
|
||||
(ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch == UNDERSCORE || ch == DOLLAR || ch >= 0xa1;
|
||||
const isIdentifier = (ch: number) => isIdentifierStart(ch) || (ch >= 48 && ch <= 57);
|
||||
|
||||
/** An identifier followed by `:name(`, a function's namespace (`fn:join(…)`). */
|
||||
export const namespace = new ExternalTokenizer((input, stack) => {
|
||||
if (!isIdentifierStart(input.next) || !stack.canShift(Namespace)) return;
|
||||
let i = 1;
|
||||
while (isIdentifier(input.peek(i))) i++;
|
||||
const end = i;
|
||||
if (input.peek(i++) != COLON || !isIdentifierStart(input.peek(i++))) return;
|
||||
while (isIdentifier(input.peek(i))) i++;
|
||||
if (input.peek(i) == PAREN_L) input.acceptToken(Namespace, end);
|
||||
});
|
||||
@@ -102,6 +102,7 @@ const OWN_MODES = [
|
||||
extensions: ["erb", "rhtml"],
|
||||
load: () => import("./erb").then((m) => m.erb()),
|
||||
}),
|
||||
LanguageDescription.of({ name: "JSP", load: () => import("./jsp").then((m) => m.jsp()) }),
|
||||
];
|
||||
|
||||
const BATCH_KEYWORDS = new Set([
|
||||
@@ -222,14 +223,18 @@ const CSS_TAGS: Parameters<typeof styleTags>[0] = {
|
||||
// @lezer/javascript styles `.` as a deref operator, but not `?.`.
|
||||
const JS_TAGS: Parameters<typeof styleTags>[0] = { "?.": t.derefOperator };
|
||||
|
||||
// @lezer/java tags annotations as plain identifiers.
|
||||
export const JAVA_TAGS: Parameters<typeof styleTags>[0] = {
|
||||
"MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation,
|
||||
};
|
||||
|
||||
/** Highlighting the stock grammars lack or colour unlike IntelliJ. */
|
||||
const EXTRA_TAGS: Record<string, Parameters<typeof styleTags>[0]> = {
|
||||
JavaScript: JS_TAGS,
|
||||
TypeScript: JS_TAGS,
|
||||
JSX: JS_TAGS,
|
||||
TSX: JS_TAGS,
|
||||
// @lezer/java tags annotations as plain identifiers.
|
||||
Java: { "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation },
|
||||
Java: JAVA_TAGS,
|
||||
CSS: CSS_TAGS,
|
||||
SCSS: CSS_TAGS,
|
||||
Sass: CSS_TAGS,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { language as languageFacet } from "@codemirror/language";
|
||||
import { indentUnit, language as languageFacet } from "@codemirror/language";
|
||||
import { registerSaveParticipant, remoteChange } from "@/editor/api";
|
||||
import { createMemoryBackend, type MemoryBackend } from "../backend";
|
||||
import { _resetLanguages } from "../cm/languages";
|
||||
@@ -56,6 +56,33 @@ describe("DocumentManager + slots", () => {
|
||||
expect(formatOptionsOf(slot.currentState())).toEqual({ tabSize: 1, insertSpaces: false });
|
||||
});
|
||||
|
||||
it("detects the indents again after Reformat Code rewrote them", async () => {
|
||||
const settings = {
|
||||
editor: {
|
||||
codeStyle: {
|
||||
languages: { java: { useTab: true, tabSize: 1, indent: 1 } },
|
||||
detectIndents: true,
|
||||
editorconfig: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const { manager } = setup({ "/p/A.java": "class A {\n int x;\n}\n" }, settings);
|
||||
const model = await manager.acquire("/p/A.java");
|
||||
const first = new EditorSlot(model, "t1", host);
|
||||
const split = new EditorSlot(model, "t2", host, { from: first });
|
||||
expect(first.currentState().tabSize).toBe(4);
|
||||
// Typing keeps the indents detected when the file was opened.
|
||||
first.dispatchLocal({ changes: { from: 10, to: 14, insert: "\t" }, userEvent: "input.type" });
|
||||
expect(first.currentState().tabSize).toBe(4);
|
||||
first.dispatchLocal({ changes: { from: 10, to: 11, insert: " " }, userEvent: "undo" });
|
||||
first.dispatchLocal({ changes: { from: 10, to: 14, insert: "\t" }, userEvent: "format.reformat" });
|
||||
for (const slot of [first, split]) {
|
||||
expect(slot.currentState().tabSize).toBe(1);
|
||||
expect(slot.currentState().facet(indentUnit)).toBe("\t");
|
||||
}
|
||||
expect(useDocMeta.getState()["/p/A.java"]?.indent).toEqual({ tabSize: 1, insertSpaces: false });
|
||||
});
|
||||
|
||||
it("shares one document between split slots and syncs edits to the backend", async () => {
|
||||
const { backend, manager, frame } = setup({ "/p/a.txt": "hello\n" });
|
||||
const model = await manager.acquire("/p/a.txt");
|
||||
|
||||
@@ -392,7 +392,7 @@ export class DocModel {
|
||||
|
||||
/** Per-file indentation chosen from the status bar; wins over settings until the file is closed. */
|
||||
indentOverride: { tabSize: number; insertSpaces: boolean } | null = null;
|
||||
/** Indentation detected in the file's text at open time. */
|
||||
/** Indentation detected in the file's text at open time and after Reformat Code. */
|
||||
detectedIndent: { insertSpaces: boolean; size?: number } | null = null;
|
||||
/** Last tree-sitter highlighting, for slots opened before the next refresh. */
|
||||
private highlights: { counter: number; value: SyntaxHighlights } | null = null;
|
||||
@@ -418,6 +418,19 @@ export class DocModel {
|
||||
return effectiveIndent(this.settings(), this.info, this.editorconfig, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the indents again after Reformat Code rewrote them: tabs written by
|
||||
* the formatter then render at the Code Style tab size and new lines indent
|
||||
* like them, instead of following the indents detected at open time.
|
||||
*/
|
||||
redetectIndent() {
|
||||
const detected = detectIndent(this.sync.localText.sliceString(0, 200_000));
|
||||
const current = this.detectedIndent;
|
||||
if (detected?.insertSpaces === current?.insertSpaces && detected?.size === current?.size) return;
|
||||
this.detectedIndent = detected;
|
||||
this.applySettings();
|
||||
}
|
||||
|
||||
/** Soft wraps toggled for this file (wins over settings until the file is closed). */
|
||||
softWrapOverride: boolean | null = null;
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ export class EditorSlot {
|
||||
private onTransactions(trs: readonly Transaction[], view: EditorView) {
|
||||
view.update(trs);
|
||||
let selection = false;
|
||||
let reformatted = false;
|
||||
let navigatedFrom: { line: number; col: number } | undefined;
|
||||
for (const tr of trs) {
|
||||
if (!navigatedFrom && tr.selection && isNavigation(tr)) {
|
||||
@@ -217,8 +218,10 @@ export class EditorSlot {
|
||||
)
|
||||
this.host.onUserEdit(this, tr);
|
||||
}
|
||||
if (tr.docChanged && tr.isUserEvent("format.reformat")) reformatted = true;
|
||||
if (tr.selection || tr.docChanged) selection = true;
|
||||
}
|
||||
if (reformatted) this.model.redetectIndent();
|
||||
if (selection) this.host.onSelection(this, navigatedFrom);
|
||||
}
|
||||
|
||||
@@ -231,6 +234,7 @@ export class EditorSlot {
|
||||
const tr = this.state.update(spec);
|
||||
this.state = tr.state;
|
||||
if (tr.docChanged) this.model.onLocal(tr.changes, this);
|
||||
if (tr.docChanged && tr.isUserEvent("format.reformat")) this.model.redetectIndent();
|
||||
}
|
||||
|
||||
/** Applies changes that are already in the model (other slot / backend). */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { targetLabel } from "./files";
|
||||
|
||||
describe("targetLabel", () => {
|
||||
const at = (line: string, needle: string) => targetLabel(line, line.indexOf(needle));
|
||||
|
||||
it.each([
|
||||
// Spring configuration consumers start at the string literal, not a name.
|
||||
[' @Value("${app.name}") private String appName;', '"${app', '"${app.name}"'],
|
||||
[" @Value('${app.name}') String appName", "'${app", "'${app.name}'"],
|
||||
[" const key = `app.${env}`;", "`app", "`app.${env}`"],
|
||||
[' String s = "say \\"hi\\"" + x;', '"say', '"say \\"hi\\""'],
|
||||
// Unterminated (multi-line) literals end at the line end.
|
||||
[' String block = "unterminated', '"unterminated', '"unterminated'],
|
||||
// Ordinary declarations keep the identifier.
|
||||
[" private String appName;", "appName", "appName"],
|
||||
[" public int getTimeOut() { return timeOut; }", "getTimeOut", "getTimeOut"],
|
||||
[" int $inner = 1;", "$inner", "$inner"],
|
||||
[" time-out: 30", "time", "time"],
|
||||
])("%s", (line, needle, expected) => {
|
||||
expect(at(line, needle)).toBe(expected);
|
||||
});
|
||||
|
||||
it("shortens long literals", () => {
|
||||
const line = `x("${"a".repeat(100)}")`;
|
||||
const label = targetLabel(line, 2)!;
|
||||
expect(label).toHaveLength(60);
|
||||
expect(label.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("has no label for empty literals, numbers or blank positions", () => {
|
||||
expect(targetLabel(' x = "";', 6)).toBeNull();
|
||||
expect(targetLabel(" port: 8080", 8)).toBeNull();
|
||||
expect(targetLabel(" ", 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
+21
-1
@@ -53,8 +53,28 @@ export async function withDocument<T>(path: string, fn: (doc: number) => Promise
|
||||
}
|
||||
}
|
||||
|
||||
/** Longest literal label before it is shortened with an ellipsis. */
|
||||
const MAX_LITERAL_LABEL = 60;
|
||||
|
||||
/**
|
||||
* Display name of a navigation target starting at `col` of `line`: the string
|
||||
* literal when the target is one (a Spring `@Value("${key}")` consumer, a
|
||||
* configuration key in quotes), else the identifier there; null when neither.
|
||||
*/
|
||||
export function targetLabel(line: string, col: number): string | null {
|
||||
const quote = line[col];
|
||||
if (quote === '"' || quote === "'" || quote === "`") {
|
||||
let end = col + 1;
|
||||
while (end < line.length && line[end] !== quote) end += line[end] === "\\" ? 2 : 1;
|
||||
const literal = line.slice(col, Math.min(end + 1, line.length));
|
||||
if (literal.length > 2)
|
||||
return literal.length > MAX_LITERAL_LABEL ? `${literal.slice(0, MAX_LITERAL_LABEL - 1)}…` : literal;
|
||||
}
|
||||
return identifierAt(line, col);
|
||||
}
|
||||
|
||||
/** The identifier at (or starting right after) `col` of `line`; null when there is none. */
|
||||
export function identifierAt(line: string, col: number): string | null {
|
||||
function identifierAt(line: string, col: number): string | null {
|
||||
const re = /[\p{L}\p{N}_$]+/gu;
|
||||
for (let m = re.exec(line); m; m = re.exec(line)) {
|
||||
const end = m.index + m[0].length;
|
||||
|
||||
@@ -161,7 +161,7 @@ async function format(
|
||||
}
|
||||
view.dispatch({
|
||||
changes,
|
||||
userEvent: "format",
|
||||
userEvent: "format.reformat",
|
||||
annotations: isolateHistory.of("full"),
|
||||
scrollIntoView: false,
|
||||
});
|
||||
@@ -235,7 +235,12 @@ function applyContributedFormat(
|
||||
if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted");
|
||||
return NOTHING;
|
||||
}
|
||||
view.dispatch({ changes, userEvent: "format", annotations: isolateHistory.of("full"), scrollIntoView: false });
|
||||
view.dispatch({
|
||||
changes,
|
||||
userEvent: "format.reformat",
|
||||
annotations: isolateHistory.of("full"),
|
||||
scrollIntoView: false,
|
||||
});
|
||||
return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) };
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { offsetToPos, rangeToOffsets } from "./positions";
|
||||
import { showHint } from "./hint";
|
||||
import { caretAnchor, focusPopup, openPopup, useLspPopup, type DefinitionTarget, type DocEntry } from "./popupStore";
|
||||
import { selectedLookupDoc, showLookupDocs } from "./completion";
|
||||
import { identifierAt, readFileLines, withDocument } from "./files";
|
||||
import { readFileLines, targetLabel, withDocument } from "./files";
|
||||
import { docWindowVisible, moveDocPopupToWindow, showInDocWindow } from "./docs";
|
||||
import { structure } from "@/editor/structure";
|
||||
import { basename } from "@/platform/project";
|
||||
@@ -326,7 +326,7 @@ async function definitionTarget(loc: Location, fallbackLanguage: string | null):
|
||||
const [lines, declaration] = await Promise.all([readFileLines(loc.path), declarationRange(loc)]);
|
||||
if (!lines) return null;
|
||||
const { first, last } = definitionSpan(loc, lines.length, declaration);
|
||||
const name = identifierAt(lines[loc.range.start.line] ?? "", loc.range.start.col) ?? basename(loc.path);
|
||||
const name = targetLabel(lines[loc.range.start.line] ?? "", loc.range.start.col) ?? basename(loc.path);
|
||||
return {
|
||||
location: loc,
|
||||
name,
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from "./syntaxFallback";
|
||||
import { showHint, showPendingHint } from "./hint";
|
||||
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
|
||||
import { identifierAt, readManyFileLines } from "./files";
|
||||
import { readManyFileLines, targetLabel } from "./files";
|
||||
import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links";
|
||||
import { featureBackoff } from "./availability";
|
||||
import { requestHover, showPhpConstructDocumentation } from "./hover";
|
||||
@@ -111,7 +111,7 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
|
||||
run: () => navigateTo(l),
|
||||
};
|
||||
// Server targets: the declared name (speed search matches it), file and line after it.
|
||||
const name = identifierAt(files.get(l.path)?.[l.range.start.line] ?? "", l.range.start.col);
|
||||
const name = targetLabel(files.get(l.path)?.[l.range.start.line] ?? "", l.range.start.col);
|
||||
return {
|
||||
id: `${i}`,
|
||||
label: name ?? basename(l.path),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import type { Location, OutlineItem, SymbolKind } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { identifierAt } from "@/editor/lsp/files";
|
||||
import { targetLabel } from "@/editor/lsp/files";
|
||||
import { baseName, type SuperMethod } from "./structureNav";
|
||||
|
||||
export type MarkerKind = "implemented" | "overridden" | "implementing" | "overriding";
|
||||
@@ -151,7 +151,7 @@ export function enclosingTypeName(lines: readonly string[] | null | undefined, t
|
||||
/** Display name of a target: the type itself for type markers, the type around it for methods. */
|
||||
export function targetName(lines: readonly string[] | null | undefined, target: Location, method: boolean): string {
|
||||
if (!method) {
|
||||
const name = identifierAt(lines?.[target.range.start.line] ?? "", target.range.start.col);
|
||||
const name = targetLabel(lines?.[target.range.start.line] ?? "", target.range.start.col);
|
||||
if (name) return name;
|
||||
}
|
||||
return enclosingTypeName(lines, target);
|
||||
|
||||
@@ -125,10 +125,11 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
},
|
||||
deps,
|
||||
);
|
||||
if (!ok && ctx.quitting) await commands.appCancelQuit();
|
||||
if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
|
||||
return ok;
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
void commands.windowGroupCancelClose().catch(() => {});
|
||||
deps.onSaveFailed(e);
|
||||
return false;
|
||||
} finally {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote";
|
||||
import { holdSplash } from "@/platform/splash";
|
||||
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { chooseProjectWindow } from "@/workbench/projectWindow";
|
||||
import { cycleWindowTab } from "@/platform/windowGroups";
|
||||
import { cycleWindowTab, useWindowGroup } from "@/platform/windowGroups";
|
||||
import { showWorkspaceDialog } from "./WorkspaceDialog";
|
||||
|
||||
export const LAUNCH_EVENT = "openidea://launch";
|
||||
@@ -222,6 +222,20 @@ export async function consumeLaunch(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes the project tabs on `side` of the `arg` tab (default: the current one), keeping that tab. */
|
||||
function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] {
|
||||
return async ({ arg }) => {
|
||||
const tabs = useWindowGroup.getState().tabs;
|
||||
const keep = typeof arg === "string" ? arg : tabs.find((tab) => tab.current)?.label;
|
||||
const index = tabs.findIndex((tab) => tab.label === keep);
|
||||
if (!keep || index < 0) return;
|
||||
const labels = tabs
|
||||
.filter((_, i) => (side === "left" ? i < index : side === "right" ? i > index : i !== index))
|
||||
.map((tab) => tab.label);
|
||||
if (labels.length) await call(commands.windowGroupCloseTabs(keep, labels));
|
||||
};
|
||||
}
|
||||
|
||||
export function windowActions(): Action[] {
|
||||
return [
|
||||
{
|
||||
@@ -302,6 +316,27 @@ export function windowActions(): Action[] {
|
||||
if (typeof arg === "string") await call(commands.windowGroupClose(arg));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "window.closeOtherTabs",
|
||||
title: "Close Other Project Tabs",
|
||||
category: t("app.category.window"),
|
||||
when: "windowGrouped",
|
||||
run: closeProjectTabs("others"),
|
||||
},
|
||||
{
|
||||
id: "window.closeTabsToLeft",
|
||||
title: "Close Project Tabs to the Left",
|
||||
category: t("app.category.window"),
|
||||
when: "windowGrouped",
|
||||
run: closeProjectTabs("left"),
|
||||
},
|
||||
{
|
||||
id: "window.closeTabsToRight",
|
||||
title: "Close Project Tabs to the Right",
|
||||
category: t("app.category.window"),
|
||||
when: "windowGrouped",
|
||||
run: closeProjectTabs("right"),
|
||||
},
|
||||
{
|
||||
id: "window.reorderTabs",
|
||||
title: "Reorder Project Tabs",
|
||||
|
||||
@@ -457,7 +457,9 @@ export function withParams(config: RunConfig, params: Record<string, string>, ty
|
||||
...(config.kind === "dotnet" && config.beforeLaunch === undefined
|
||||
? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] }
|
||||
: {}),
|
||||
...(config.kind === "dotnet-test" || config.kind === "django-test" || config.kind === "rspec" ? { isTest: true } : {}),
|
||||
...(config.kind === "dotnet-test" || config.kind === "django-test" || config.kind === "rspec"
|
||||
? { isTest: true }
|
||||
: {}),
|
||||
params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params,
|
||||
...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}),
|
||||
...(params.goKind ? { isTest: params.goKind === "test" } : {}),
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { registerAction, executeAction } from "@/platform/actions";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useKeymap } from "@/platform/keymap";
|
||||
import { ContextMenuHost } from "@/ui";
|
||||
@@ -29,6 +30,8 @@ import { PageContext, useSetting } from "../components/context";
|
||||
import { DEFAULT_SETTINGS, setAt } from "../model/schema";
|
||||
import { openSettings, stage, useSettingsDialog } from "../model/staging";
|
||||
import { SettingsDialog } from "../dialog/SettingsDialog";
|
||||
import { SettingsWindow } from "../window/SettingsWindow";
|
||||
import dialogStyles from "../dialog/SettingsDialog.module.css";
|
||||
import { activate } from "..";
|
||||
import { MessagesHost } from "@/workbench/messages";
|
||||
|
||||
@@ -471,6 +474,23 @@ describe("Settings dialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native Settings window root", () => {
|
||||
it("marks the window edge on Linux, whose frameless windows get no compositor shadow", () => {
|
||||
const saved = usePlatform.getState();
|
||||
usePlatform.setState({ os: "linux" });
|
||||
let view = render(<SettingsWindow />);
|
||||
let root = document.querySelector(`.${dialogStyles.window}`);
|
||||
expect(root).toHaveClass(dialogStyles.windowEdge!);
|
||||
view.unmount();
|
||||
usePlatform.setState({ os: "windows" });
|
||||
view = render(<SettingsWindow />);
|
||||
root = document.querySelector(`.${dialogStyles.window}`);
|
||||
expect(root).not.toHaveClass(dialogStyles.windowEdge!);
|
||||
view.unmount();
|
||||
usePlatform.setState(saved);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native Settings window background", () => {
|
||||
it.each([
|
||||
["a hex frame", "#26282c", "", "#26282c"],
|
||||
|
||||
@@ -203,6 +203,19 @@
|
||||
background: var(--bg-dialog, var(--bg-elev));
|
||||
color: var(--fg);
|
||||
}
|
||||
/* Linux: on Wayland GTK draws the frameless window's shadow (`decorate_csd` in
|
||||
windows.rs), but frameless X11 windows have none, so mark the edge in CSS. */
|
||||
.windowEdge {
|
||||
position: relative;
|
||||
}
|
||||
.windowEdge::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 0 1px var(--border-window);
|
||||
}
|
||||
.windowBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Content of the native Settings window: title bar, page tree + page, buttons. */
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { OverlayHost } from "@/workbench/OverlayHost";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
@@ -10,6 +11,7 @@ import styles from "../dialog/SettingsDialog.module.css";
|
||||
|
||||
export function SettingsWindow() {
|
||||
useApplyTheme();
|
||||
const os = usePlatform((s) => s.os);
|
||||
const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar);
|
||||
const open = useSettingsDialog((s) => s.open);
|
||||
const title = useSettingsTitle();
|
||||
@@ -20,8 +22,8 @@ export function SettingsWindow() {
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.window}>
|
||||
{/* macOS overlays traffic lights on it; Windows is frameless. Linux keeps the OS title bar. */}
|
||||
<div ref={ref} className={clsx(styles.window, os === "linux" && styles.windowEdge)}>
|
||||
{/* macOS overlays traffic lights on it; Windows and Linux are frameless (tauri.<os>.conf.json). */}
|
||||
{ownTitleBar && <TitleBar minimal dialog title={title} />}
|
||||
{open && (
|
||||
<>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Settings window ↔ its project window.
|
||||
*
|
||||
* Settings live in a native child window `settings-<parent>` (see
|
||||
* src-tauri/src/windows.rs) that only runs the settings pages. The action
|
||||
* Settings live in a separate window `settings-<parent>` (see
|
||||
* src-tauri/src/windows.rs) that only runs the settings pages (a native
|
||||
* child of the parent on macOS/Linux; top-level on Windows so DWM paints
|
||||
* its shadow). The action
|
||||
* registry stays in the project window: it sends the settings window a
|
||||
* description of every action (Keymap page, links that run actions) and runs
|
||||
* the actions the settings window forwards.
|
||||
|
||||
@@ -5008,6 +5008,9 @@
|
||||
"Window group": "窗口组",
|
||||
"Select Project Tab": "选择项目标签页",
|
||||
"Close Project Tab": "关闭项目标签页",
|
||||
"Close Other Project Tabs": "关闭其他项目标签页",
|
||||
"Close Project Tabs to the Left": "关闭左侧项目标签页",
|
||||
"Close Project Tabs to the Right": "关闭右侧项目标签页",
|
||||
"Reorder Project Tabs": "重新排列项目标签页",
|
||||
"Close {name}": "关闭 {name}",
|
||||
"Tomcat Server": "Tomcat 服务器",
|
||||
@@ -5547,5 +5550,6 @@
|
||||
"Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值",
|
||||
"e.g. --keepdb --parallel": "例如 --keepdb --parallel",
|
||||
"select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改",
|
||||
"select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改"
|
||||
"select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改",
|
||||
"External file changes sync may be slow": "外部文件更改同步可能较慢"
|
||||
}
|
||||
@@ -9,6 +9,8 @@ interface PlatformInfo {
|
||||
overlayTitleBar: boolean;
|
||||
/** Frameless window: the title bar draws the main menu and caption buttons (Tauri, Windows and Linux). */
|
||||
framelessTitleBar: boolean;
|
||||
/** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */
|
||||
wayland: boolean;
|
||||
}
|
||||
|
||||
/** OS from the user agent (before the backend answers, or in a browser). "darwin" must not read as Windows. */
|
||||
@@ -28,6 +30,7 @@ export const usePlatform = create<PlatformInfo>(() => {
|
||||
version: null,
|
||||
overlayTitleBar: isTauri() && os === "macos",
|
||||
framelessTitleBar: isTauri() && (os === "windows" || os === "linux"),
|
||||
wayland: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -40,6 +43,7 @@ export async function loadPlatformInfo() {
|
||||
version: info.version,
|
||||
overlayTitleBar: info.platform === "macos",
|
||||
framelessTitleBar: info.platform === "windows" || info.platform === "linux",
|
||||
wayland: info.wayland,
|
||||
});
|
||||
} catch {
|
||||
/* keep the user-agent guess */
|
||||
|
||||
@@ -172,6 +172,9 @@ describe("fileIconName", () => {
|
||||
["main.dart", "file-dart"],
|
||||
["App.vue", "file-vue"],
|
||||
["App.svelte", "file-svelte"],
|
||||
["index.jsp", "file-jsp"],
|
||||
["footer.jspf", "file-jsp"],
|
||||
["card.tag", "file-jsp"],
|
||||
["styles.scss", "file-scss"],
|
||||
["styles.sass", "file-sass"],
|
||||
["styles.less", "file-less"],
|
||||
|
||||
@@ -76,6 +76,7 @@ const ICONS = {
|
||||
"file-npm": "openidea/fileTypes/npm",
|
||||
"file-vue": "fileTypes/vue",
|
||||
"file-svelte": "openidea/fileTypes/svelte",
|
||||
"file-jsp": "openidea/fileTypes/jsp",
|
||||
"file-yaml": "fileTypes/yaml",
|
||||
"file-toml": "fileTypes/toml",
|
||||
"file-html": "fileTypes/html",
|
||||
@@ -822,6 +823,7 @@ const EXT_ICONS = table({
|
||||
"file-tsx": "tsx",
|
||||
"file-vue": "vue",
|
||||
"file-svelte": "svelte",
|
||||
"file-jsp": "jsp jspx jspf jsph tag tagf tagx",
|
||||
"file-json": "json jsonc json5 jsonl ndjson geojson topojson webmanifest har avsc code-workspace code-snippets map",
|
||||
"file-md": "md mdx markdown mdown mkd mkdn mdwn ronn workbook",
|
||||
"file-rst": "rst",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#E76F00"/>
|
||||
<path d="M4.5 5.5L3 8L4.5 10.5M11.5 5.5L13 8L11.5 10.5M9 5.5L7 10.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#F0883E"/>
|
||||
<path d="M4.5 5.5L3 8L4.5 10.5M11.5 5.5L13 8L11.5 10.5M9 5.5L7 10.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useWindowGroup } from "@/platform/windowGroups";
|
||||
import { useContextMenuStore } from "@/ui/Menu";
|
||||
import { WindowGroupTabs } from "./WindowGroupTabs";
|
||||
|
||||
vi.mock("@/platform/actions", async (importOriginal) => ({
|
||||
@@ -37,3 +38,29 @@ it("removes the tab row when the group dissolves", () => {
|
||||
render(<WindowGroupTabs />);
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers closing the other tabs and those on either side of the clicked tab", () => {
|
||||
useWindowGroup.setState({
|
||||
tabs: [
|
||||
{ label: "main", name: "Frontend", root: "/front", current: true },
|
||||
{ label: "project-1", name: "Backend", root: "/back", current: false },
|
||||
{ label: "project-2", name: "Docs", root: "/docs", current: false },
|
||||
],
|
||||
});
|
||||
render(<WindowGroupTabs />);
|
||||
const menuFor = (name: string) => {
|
||||
fireEvent.contextMenu(screen.getByRole("tab", { name }));
|
||||
const items = useContextMenuStore.getState().menu!.items;
|
||||
return (label: string) =>
|
||||
items.find((i) => "label" in i && i.label === label) as { disabled?: boolean; run?: () => unknown };
|
||||
};
|
||||
const first = menuFor("Frontend");
|
||||
expect(first("Close Tabs to the Left").disabled).toBe(true);
|
||||
expect(first("Close Tabs to the Right").disabled).toBe(false);
|
||||
const middle = menuFor("Backend");
|
||||
void middle("Close Other Tabs").run?.();
|
||||
expect(executeAction).toHaveBeenLastCalledWith("window.closeOtherTabs", "project-1");
|
||||
void middle("Close Tabs to the Left").run?.();
|
||||
expect(executeAction).toHaveBeenLastCalledWith("window.closeTabsToLeft", "project-1");
|
||||
expect(menuFor("Docs")("Close Tabs to the Right").disabled).toBe(true);
|
||||
});
|
||||
@@ -61,13 +61,26 @@ export function WindowGroupTabs() {
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const index = tabs.indexOf(tab);
|
||||
showContextMenu(e, [
|
||||
{
|
||||
label: "Move Tab to New Window",
|
||||
disabled: !tab.current,
|
||||
run: () => executeAction("window.detachTab"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ label: "Close Tab", run: () => executeAction("window.closeTab", tab.label) },
|
||||
{ label: "Close Other Tabs", run: () => executeAction("window.closeOtherTabs", tab.label) },
|
||||
{
|
||||
label: "Close Tabs to the Left",
|
||||
disabled: index <= 0,
|
||||
run: () => executeAction("window.closeTabsToLeft", tab.label),
|
||||
},
|
||||
{
|
||||
label: "Close Tabs to the Right",
|
||||
disabled: index >= tabs.length - 1,
|
||||
run: () => executeAction("window.closeTabsToRight", tab.label),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Frameless-window resize handles: Windows gives undecorated windows no
|
||||
* resize borders, so the title bar's frameless look leaves the window
|
||||
* stuck at its launch size. Eight invisible hit areas along the edges
|
||||
* Frameless-window resize handles: Windows and Linux on X11 give undecorated
|
||||
* windows no resize borders, so the title bar's frameless look leaves the
|
||||
* window stuck at its launch size. Eight invisible hit areas along the edges
|
||||
* start a native resize drag, like any other desktop app.
|
||||
*
|
||||
* macOS renders its own overlay title bar but still provides resize
|
||||
* handles, so nothing is added there.
|
||||
* handles, and on Wayland GTK draws resize edges in the client-side shadow
|
||||
* (`decorate_csd` in windows.rs), so nothing is added there.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
@@ -33,7 +34,8 @@ async function start(dir: Dir) {
|
||||
export function WindowResizeHandles() {
|
||||
// A frameless window needs them; a decorated one already has native borders.
|
||||
const frameless = usePlatform((s) => s.framelessTitleBar);
|
||||
const enabled = isTauri() && frameless;
|
||||
const gtkEdges = usePlatform((s) => s.os === "linux" && s.wayland);
|
||||
const enabled = isTauri() && frameless && !gtkEdges;
|
||||
// A maximized or full-screen window has nothing to resize; the edges belong to the content.
|
||||
const [sized, setSized] = useState(false);
|
||||
useEffect(() => {
|
||||
|
||||
Reference in new issue
Block a user