Цей коміт міститься в:
l0ng-ai committed 2026-11-02 20:18:16 +08:00
коміт f43b43aadd
85 files changed
+2719 -261

No files matched your search

Створено
+1
Переглянути файл
@@ -4086,6 +4086,7 @@ dependencies = [
"specta",
"tempfile",
"tracing",
"zip",
]
[[package]]
+1
Переглянути файл
@@ -8,6 +8,7 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
+13
Переглянути файл
@@ -185,8 +185,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
None => None,
};
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
@@ -814,6 +816,17 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
let siblings = tmp.path().join("siblings");
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
[siblings.join("core"), siblings.join("system")]
);
let none = tmp.path().join("none");
std::fs::create_dir_all(&none).unwrap();
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
+218 -11
Переглянути файл
@@ -11,7 +11,9 @@ use crate::xml::{self, Element};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
@@ -456,13 +458,20 @@ struct Resolver {
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
pub(crate) fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
&& let Some(repo) = settings.text_of("localRepository")
{
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
return Some(PathBuf::from(repo.replace("${user.home}", &home)));
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
return None;
}
Some(m2.join("repository"))
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
Some(PathBuf::from(repo.replace("${user.home}", &home)))
}
impl Resolver {
@@ -688,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String {
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
}
/// Goals of well-known plugins (their plugin descriptors are not read).
/// Goals of every plugin jar read so far, by the path of that jar.
static DESCRIPTOR: OnceLock<Mutex<HashMap<String, Vec<String>>>> = OnceLock::new();
/// `<goal>` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`),
/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar
/// is not in the local repository yet.
fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec<String> {
// Keyed by the jar itself, not by its coordinates: a repository can be replaced
// between runs, and a test's empty repository must not answer for another's.
// One `push` per group segment: the layout is `<repo>/<group as directories>/…` on
// every platform, Windows' separator included.
let mut dir = repo.to_path_buf();
dir.extend(group_id.split('.'));
dir.push(artifact_id);
dir.push(version);
let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() };
let key = jar.to_string_lossy().into_owned();
let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return cached.clone();
}
// A failed read is not remembered: the jar may be half written by Maven right now.
let Some(goals) = read_descriptor(&jar) else { return Vec::new() };
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone());
goals
}
/// The plugin's jar in its version directory: `<artifactId>-<version>.jar`, or — for a
/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest
/// `<artifactId>-*.jar` that is neither sources nor javadoc.
fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option<PathBuf> {
let exact = dir.join(format!("{artifact_id}-{version}.jar"));
if exact.is_file() {
return Some(exact);
}
let prefix = format!("{artifact_id}-");
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") {
continue;
}
if name.ends_with("-javadoc.jar") {
continue;
}
let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue };
if best.as_ref().is_none_or(|(at, _)| modified > *at) {
best = Some((modified, path));
}
}
best.map(|(_, p)| p)
}
fn read_descriptor(jar: &Path) -> Option<Vec<String>> {
let file = std::fs::File::open(jar).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?;
let mut text = String::new();
entry.read_to_string(&mut text).ok()?;
let root = xml::parse(&text)?;
let mut goals: Vec<String> =
root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect();
goals.retain(|g| !g.is_empty());
goals.sort();
goals.dedup();
Some(goals)
}
/// Goals of well-known plugins, for a jar that is not in the local repository yet.
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
match artifact_id {
"maven-clean-plugin" => &["clean", "help"],
@@ -840,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
}
}
/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option<String>, goals: &[String]) -> MavenPlugin {
/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for
/// the common plugins, and the ones the project binds in `<executions>`, sorted. IDEA lists
/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say)
/// still shows every goal it offers instead of only the bound one.
pub(crate) fn plugin(
group_id: &str,
artifact_id: &str,
version: Option<String>,
goals: &[String],
local_repo: Option<&Path>,
) -> MavenPlugin {
let mut all: Vec<String> = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo);
// An unresolved `${…}` version names no jar: the table and the bound goals stand in.
if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) {
for g in descriptor_goals(repo, group_id, artifact_id, version) {
if !all.contains(&g) {
all.push(g);
}
}
}
for g in goals {
if !all.contains(g) {
all.push(g.clone());
@@ -1007,7 +1103,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
.map(|v| i(&v))
.filter(|v| !v.contains("${"));
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref())
})
.collect();
@@ -1119,6 +1215,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
/// Projects and their plugins / dependencies from `mvn help:effective-pom` output
/// (`<project>` or `<projects>`), keyed `groupId:artifactId`.
pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
parse_effective_pom_in(text, None)
}
/// The same, with the local repository the project actually builds against (a
/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there,
/// rather than from the user's default one.
pub fn parse_effective_pom_in(
text: &str,
local_repo: Option<&Path>,
) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
let Some(root) = xml::parse(text) else { return Vec::new() };
let projects: Vec<&Element> =
if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] };
@@ -1130,7 +1236,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
let plugins = raw
.plugins
.iter()
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals))
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals, local_repo))
.collect();
let deps = raw
.deps
@@ -1261,6 +1367,107 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)
mod tests {
use super::*;
/// A plugin jar in `repo` whose descriptor declares `goals`.
fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
std::fs::create_dir_all(&dir).unwrap();
let mojos: String = goals.iter().map(|g| format!("<mojo><goal>{g}</goal></mojo>")).collect();
let descriptor = format!("<plugin><mojos>{mojos}</mojos></plugin>");
let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
}
#[test]
fn a_plugin_lists_the_goals_its_jar_declares() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
write_plugin_jar(
repo,
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
"3.3.1",
&["checkstyle", "checkstyle-aggregate", "help", "check"],
);
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(repo),
);
assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers");
assert_eq!(p.prefix, "checkstyle");
}
#[test]
fn a_settings_file_names_the_repository_to_read_goals_from() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join("settings.xml");
std::fs::write(
&settings,
"<settings><localRepository>${user.home}/repo-from-settings</localRepository></settings>",
)
.unwrap();
let repo = local_repository_of(&settings).unwrap();
assert!(repo.ends_with("repo-from-settings"), "{}", repo.display());
assert!(!repo.to_string_lossy().contains("${user.home}"));
// A settings file that names none leaves Maven's default alone.
let plain = tmp.path().join("plain.xml");
std::fs::write(&plain, "<settings><offline>true</offline></settings>").unwrap();
assert!(local_repository_of(&plain).is_none());
assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none());
}
#[test]
fn effective_pom_goals_come_from_the_local_repository_it_is_given() {
let tmp = tempfile::tempdir().unwrap();
write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]);
let text = "<project><groupId>g</groupId><artifactId>a</artifactId><build><plugins>\
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-demo-plugin</artifactId>\
<version>1.0</version><executions><execution><goals><goal>run</goal></goals></execution></executions>\
</plugin></plugins></build></project>";
let with_repo = parse_effective_pom_in(text, Some(tmp.path()));
assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first");
// Without that repository the plugin is unknown: only what the project binds.
assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]);
}
#[test]
fn a_snapshot_plugin_is_found_by_its_timestamped_jar() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT");
std::fs::create_dir_all(&dir).unwrap();
let descriptor = "<plugin><mojos><mojo><goal>run</goal></mojo></mojos></plugin>";
let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
// A sibling sources jar must not win.
std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap();
let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path()));
assert_eq!(p.goals, ["run"]);
}
#[test]
fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() {
let tmp = tempfile::tempdir().unwrap();
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(tmp.path()),
);
assert_eq!(p.goals, ["check"], "nothing beyond what the project binds");
let known =
plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path()));
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+74 -4
Переглянути файл
@@ -48,6 +48,11 @@ struct CachedMavenProject {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct MavenCache {
/// Bumped whenever what the cache holds stops matching what the model would be
/// built as now (plugin goals read from a descriptor, say): an older cache is
/// dropped instead of shadowing the model.
#[serde(default)]
version: u32,
hash: String,
at_ms: f64,
error: Option<String>,
@@ -66,6 +71,7 @@ struct GradleCache {
}
const MAVEN_CACHE: &str = "maven.json";
const MAVEN_CACHE_VERSION: u32 = 1;
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
@@ -121,6 +127,9 @@ fn now_ms() -> f64 {
/// Merges the cached effective build into `model`.
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
if cache.version != MAVEN_CACHE_VERSION {
return; // built by an older IDE: what it holds no longer matches the model
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
let changed = cache.hash != hash_files(&maven::build_files(root, model));
@@ -211,8 +220,31 @@ pub fn sync_maven(
let mut model = maven::load(root);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
let mut cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(root, &model)),
verbose: true,
..MavenCache::default()
};
// Where the build resolves its jars, and therefore where the plugin goals are read
// from: `-Dmaven.repo.local=` first, then `<localRepository>` of the `-s` settings,
// then the user's default repository (which `plugin()` resolves itself).
let local_repo: Option<PathBuf> =
options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else(
|| {
let mut args = options.maven_options.iter();
while let Some(arg) = args.next() {
let path = match arg.as_str() {
"-s" | "--settings" => args.next().map(String::as_str),
_ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")),
};
if let Some(path) = path {
return maven::local_repository_of(Path::new(path));
}
}
None
},
);
let mut errors: Vec<String> = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
@@ -241,8 +273,9 @@ pub fn sync_maven(
});
}
})?;
let effective_projects =
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default();
let effective_projects = std::fs::read_to_string(&effective)
.map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref()))
.unwrap_or_default();
let trees = maven::parse_dependency_tree(&out.text);
for (key, plugins, deps) in effective_projects {
let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps);
@@ -332,6 +365,7 @@ mod tests {
let mut model = maven::load_with_repo(&root, None);
assert!(model.synced_at.is_none());
let cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
@@ -343,6 +377,7 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -368,6 +403,41 @@ mod tests {
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
#[test]
fn a_cache_from_an_older_ide_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
.unwrap();
let mut model = maven::load_with_repo(&root, None);
let cache = MavenCache {
// What an older build wrote: no plugin descriptors were read back then, so
// its goal lists would shadow the ones the model now knows.
version: 0,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
verbose: true,
projects: vec![CachedMavenProject {
key: "g:a".into(),
plugins: vec![maven::plugin(
"org.apache.maven.plugins",
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
}],
};
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
apply_maven_cache(&root, &cache_dir, &mut model);
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
}
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();
+6
Переглянути файл
@@ -82,6 +82,12 @@ pub enum IdeEvent {
status: ServerStatus,
message: Option<String>,
},
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
LanguageServerWarning {
name: String,
message: String,
install_tool: String,
},
/// A stopped language server left the project's list (disabled in settings, or a
/// fallback that a preferred server took over from again).
LanguageServerRemoved {
+25
Переглянути файл
@@ -0,0 +1,25 @@
//! Capabilities needed by the JVM hosting Java language-server bundles.
use std::path::Path;
/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling.
const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"];
/// Require a full JDK, including the modules used by jdtls and its bundles.
/// Temurin JREs can list the build JDK's modules in `release` even when the runtime
/// image omits them, so module metadata alone does not establish suitability.
pub fn supports_java_bundles(home: &Path) -> bool {
let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false };
let property =
|key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"'));
let javac = if cfg!(windows) { "javac.exe" } else { "javac" };
if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre"))
|| !home.join("bin").join(javac).is_file()
{
return false;
}
match property("MODULES=") {
Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)),
None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()),
}
}
+1
Переглянути файл
@@ -11,6 +11,7 @@ pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod process;
+1 -1
Переглянути файл
@@ -235,7 +235,7 @@ impl Default for GeneralSettings {
fn default() -> Self {
Self {
reopen_last_project: true,
confirm_exit: false,
confirm_exit: true,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
+3
Переглянути файл
@@ -41,6 +41,8 @@ impl Default for VcsSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -71,6 +73,7 @@ pub struct GitSettings {
impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
+10
Переглянути файл
@@ -1389,6 +1389,16 @@ impl DebugManager {
})
}
/// Stops every session `key` started: the window that opened the project is closing.
pub async fn stop_key(&self, key: &str) {
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
let session = self.session(s)?;
+39 -11
Переглянути файл
@@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier
/// settings (languages, preference, on save) apply to both.
fn external_tool(file: &Path) -> &'static str {
let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
if oxfmt { "oxfmt" } else { "prettier" }
}
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
@@ -44,16 +54,19 @@ pub async fn format(
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
let tool = external_tool(&snap.path);
let name = formatter::display_name(tool);
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier");
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !config.range;
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(command, &snap.text, &Default::default())?;
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
.changes
@@ -69,13 +82,13 @@ pub async fn format(
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "Prettier".into(),
body: Some(
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
),
title: name.into(),
body: Some(format!(
"{name} does not support selection formatting for this language; formatted the whole file"
)),
});
}
edits.map_err(|e| e.with_tool("prettier"))
edits.map_err(|e| e.with_tool(tool))
}
#[cfg(test)]
@@ -104,4 +117,19 @@ mod tests {
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
}
#[test]
fn oxfmt_config_selects_oxfmt_over_prettier() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("web/src")).unwrap();
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
assert_eq!(external_tool(&web), "prettier");
for config in OXFMT_CONFIGS {
std::fs::write(root.join("web").join(config), "{}").unwrap();
assert_eq!(external_tool(&web), "oxfmt", "{config}");
assert_eq!(external_tool(&api), "prettier", "{config}");
std::fs::remove_file(root.join("web").join(config)).unwrap();
}
}
}
+21 -2
Переглянути файл
@@ -176,12 +176,21 @@ impl Host {
}
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
@@ -191,6 +200,8 @@ impl Host {
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) {
for bridge in &bridges {
bridge.stop();
@@ -208,6 +219,12 @@ impl Host {
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
});
if let Err(error) = worker.join() {
@@ -229,6 +246,8 @@ impl Host {
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
);
}
+2 -1
Переглянути файл
@@ -40,7 +40,8 @@ pub fn spawn(
.ok()
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
let key = host.owner_window(window).to_owned();
let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
if let Some(line) = activation {
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
}
+1 -1
Переглянути файл
@@ -513,7 +513,7 @@ impl Host {
sink.clone(),
);
let project = Arc::new(Project {
trusted: Arc::new(AtomicBool::new(true)),
trusted: Arc::new(AtomicBool::new(false)),
sink,
languages,
settings,
+1 -3
Переглянути файл
@@ -135,9 +135,7 @@ impl crate::Host {
for request in requests {
self.cancel_request(request);
}
for session in self.debugger.sessions(Some(&window)) {
let _ = self.debugger.stop(session.id).await;
}
self.debugger.stop_key(&window).await;
}
}
project.lsp.reconfigure().await;
+54
Переглянути файл
@@ -0,0 +1,54 @@
//! Closing a project takes the background processes of its window with it, as IDEA does,
//! and leaves the other windows' processes alone.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
cwd: root.to_path_buf().into(),
// A fixed shell: the test must not depend on the login shell of this machine.
shell: Some("/bin/sh".into()),
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
}
}
#[test]
fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
runtime.block_on(async {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let project_shell =
ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal");
let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal");
assert_eq!(host.terminals.list().len(), 2);
host.close_project("main");
let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect();
assert!(!left.contains(&project_shell.id), "the closing window's shell is gone");
assert!(left.contains(&other_shell.id), "another window keeps its shell");
host.terminals.kill_key("other");
});
}
+101
Переглянути файл
@@ -78,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() {
assert!(!applied.project_scope);
assert!(!dir.join(".openidea").exists(), "no project settings next to the file");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_windows_stay_restricted_when_folder_trust_changes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(root.join("one.py"), "print(1)\n").unwrap();
std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap();
let host = host(&tmp.path().join("app"));
host.global_settings
.set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true))
.unwrap();
let light = host.open_light("main", &root.join("one.py")).unwrap();
assert!(!light.is_trusted());
assert!(light.require_trust().is_err());
host.set_project_trust(&root, true, false).await.unwrap();
assert!(!light.is_trusted());
let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap();
assert!(!trusted_folder.is_trusted());
let again = host.open_light("main", &other.join("two.js")).unwrap();
assert!(Arc::ptr_eq(&light, &again));
assert!(!again.is_trusted());
host.set_project_trust(&root, true, true).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] {
assert!(host.authorize_command("main", command).is_err(), "{command}");
}
let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap();
let error = ops::lsp::lsp_format(
&host,
"main",
doc.id,
None,
ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false },
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("Restricted Mode"));
host.set_project_trust(&root, false, false).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
host.set_project_trust(&root, true, false).await.unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(!project.light);
assert!(project.is_trusted());
assert!(project.require_trust().is_ok());
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_notebooks_open_without_interpreter_detection_or_execution() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" });
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let python = bin.join("python");
std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap();
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let file = root.join("analysis.ipynb");
std::fs::write(
&file,
r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
)
.unwrap();
let host = host(&tmp.path().join("app"));
host.open_light("main", &file).unwrap();
let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap();
assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
let error = ops::notebook::execute(
&host,
"main",
notebook.id,
vec![notebook.cells[0].id.clone()],
None,
ide_core::RequestId(849),
ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(!bin.join("python.probed").exists());
host.shutdown().await;
}
+1
Переглянути файл
@@ -35,6 +35,7 @@ async fn storage_requires_a_registered_terminal() {
rows: 24,
shell_integration: false,
},
"main".into(),
Arc::new(NullSink),
Box::new(|_| {}),
)
+19 -1
Переглянути файл
@@ -576,7 +576,7 @@ mod tests {
};
// After the TypeScript fallback (an available server before it would hide
// it); before the HTML server (hover, rename and symbols take the first).
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]);
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]);
assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]);
for lang in ["typescript", "html"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
@@ -586,6 +586,24 @@ mod tests {
}
}
#[test]
fn oxlint_serves_js_files_only_below_its_config() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}");
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers);
assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}");
assert_eq!(served("apps/api/main.ts"), None, "{lang}");
}
}
#[test]
fn html_of_angular_projects_is_an_angular_template() {
let reg = reg();
+126
Переглянути файл
@@ -0,0 +1,126 @@
//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks.
use crate::{LspManager, Server};
use ide_core::IdeEvent;
use std::path::Path;
pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.";
fn is_apple_clangd(path: &Path) -> bool {
if path == Path::new("/usr/bin/clangd") {
return true;
}
if !path.ends_with("usr/bin/clangd") {
return false;
}
path.starts_with("/Library/Developer/CommandLineTools")
|| path.ancestors().any(|ancestor| {
ancestor.file_name().is_some_and(|name| name == "Toolchains")
&& ancestor.parent().is_some_and(|developer| {
developer.ends_with("Contents/Developer")
&& developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| {
let name = name.to_string_lossy();
name.starts_with("Xcode") && name.ends_with(".app")
})
})
})
}
fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool {
server_root.join(".clang-tidy").is_file()
|| file
.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|dir| dir.starts_with(project_root))
.any(|dir| dir.join(".clang-tidy").is_file())
}
impl LspManager {
pub(crate) async fn check_clang_tidy(&self, server: &Server) {
if server.name != "clangd"
|| !cfg!(target_os = "macos")
|| self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into()))
{
return;
}
let binary = server.resolved_binary.read().clone();
let Some(binary) = binary else { return };
let files: Vec<_> = self
.docs
.lock()
.values()
.filter(|doc| {
matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp")
&& doc.servers.iter().any(|attached| attached.server.id == server.id)
})
.map(|doc| doc.path.clone())
.collect();
let root = server.root.clone();
let Some(me) = self.me.upgrade() else { return };
let _ = tokio::task::spawn_blocking(move || {
if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) {
return;
}
if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire)
&& files.iter().any(|file| has_config(&root, &me.project_root_for(file), file))
&& me.first_message("clangd", MESSAGE)
{
me.sink.emit(IdeEvent::LanguageServerWarning {
name: "clangd".into(),
message: MESSAGE.into(),
install_tool: "clangd".into(),
});
}
})
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apple_paths() {
for path in [
"/usr/bin/clangd",
"/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd",
"/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd",
"/Library/Developer/CommandLineTools/usr/bin/clangd",
] {
assert!(is_apple_clangd(Path::new(path)), "{path}");
}
for path in [
"/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd",
"/opt/homebrew/opt/llvm/bin/clangd",
"/usr/local/bin/clangd",
"/usr/bin/clangd-custom",
"/Library/Developer/CommandLineTools-custom/usr/bin/clangd",
"/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd",
] {
assert!(!is_apple_clangd(Path::new(path)), "{path}");
}
}
#[test]
fn config_in_server_root_or_file_ancestors_stays_inside_project() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project");
let server = project.join("build");
let source = project.join("src/nested");
let file = source.join("main.cpp");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&server).unwrap();
std::fs::write(dir.path().join(".clang-tidy"), "").unwrap();
assert!(!has_config(&server, &project, &file));
for root in [&server, &source, &project.join("src"), &project] {
let config = root.join(".clang-tidy");
std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap();
assert!(has_config(&server, &project, &file), "{}", root.display());
std::fs::remove_file(config).unwrap();
}
std::fs::create_dir(source.join(".clang-tidy")).unwrap();
assert!(!has_config(&server, &project, &file));
}
}
+26 -1
Переглянути файл
@@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static {
self.launch(config, root, handler, on_stderr)
}
/// Starts a server and reports its executable when the launcher runs a local process.
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None))
}
/// Whether `config` could start without a `Missing` error.
fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool {
true
@@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<Peer, LaunchError> {
self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer)
}
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
let search = server_search_path(config);
let binary = ide_core::tool_path::find_command(&config.command, &search, root)
.and_then(|b| resolve_rustup_proxy(&config.command, b));
@@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher {
handler,
on_stderr,
cancel,
);
)
.map(|peer| (peer, None));
}
let binary = binary.ok_or(LaunchError::Missing)?;
Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr)
.map(|peer| (peer, Some(binary.clone())))
.map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display())))
}
+13 -2
Переглянути файл
@@ -8,6 +8,7 @@
mod angular;
mod caps;
mod clang_tidy;
mod convert;
mod external_project;
mod file_ops;
@@ -472,13 +473,16 @@ impl LspManager {
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
})
.await
.unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string())));
let peer = match launched {
Ok(peer) => peer,
Ok((peer, binary)) => {
*server.resolved_binary.write() = binary;
peer
}
Err(LaunchError::Missing) => {
let cfg = server.effective_config();
let message =
@@ -587,6 +591,7 @@ impl LspManager {
}
}
}
self.check_clang_tidy(&server).await;
server.refresh_busy_status(&self.sink);
server.announce_ready(generation);
self.spawn_supervisor(server, generation, peer);
@@ -810,6 +815,12 @@ impl LspManager {
}
for s in &servers {
self.ensure_started(s);
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
let server = s.clone();
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
}
}
}
+2
Переглянути файл
@@ -79,6 +79,7 @@ pub struct Server {
pub config: LanguageServerConfig,
/// `config` with the settings hook applied, as the current instance was launched.
effective: Mutex<Option<LanguageServerConfig>>,
pub resolved_binary: RwLock<Option<PathBuf>>,
languages: Mutex<Vec<LanguageId>>,
state: Mutex<State>,
/// Fired when the instance being started is abandoned (stop / restart while starting).
@@ -114,6 +115,7 @@ impl Server {
root,
config,
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
start_cancel: Mutex::new(CancellationToken::new()),
+78
Переглянути файл
@@ -21,6 +21,7 @@ use std::time::Duration;
struct MockState {
caps: Mutex<Value>,
missing: std::sync::atomic::AtomicBool,
binary: Mutex<Option<PathBuf>>,
launches: AtomicUsize,
notifications: Mutex<Vec<(String, Value)>>,
requests: Mutex<Vec<(String, Value)>>,
@@ -148,6 +149,18 @@ impl MockLauncher {
}
impl Launcher for MockLauncher {
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &ide_core::CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel)
.map(|peer| (peer, self.state(&config.name).binary.lock().clone()))
}
fn launch(
&self,
config: &LanguageServerConfig,
@@ -2836,3 +2849,68 @@ async fn formatting_provider_selection_tracks_dynamic_registration() {
.unwrap();
assert!(!h.mgr.can_format(doc).await.unwrap());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() {
for language in ["c", "cpp", "objective-c", "objective-cpp"] {
let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("main.src", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("clangd", ServerStatus::Ready).await;
h.open("other.src", "int *p = 0;");
h.mgr.shutdown_all().await;
let events = h.events.lock();
let warnings: Vec<_> = events
.iter()
.filter_map(|e| match e {
IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)),
_ => None,
})
.collect();
assert_eq!(warnings.len(), 1, "{language}");
assert_eq!(warnings[0].0, "clangd");
assert!(warnings[0].1.contains(".clang-tidy"));
assert_eq!(warnings[0].2, "clangd");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_warning_requires_apple_binary_and_a_config() {
for (binary, config) in [
("/usr/bin/clangd", false),
("/opt/homebrew/opt/llvm/bin/clangd", true),
("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true),
] {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary));
if config {
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
}
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
h.mgr.shutdown_all().await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })));
std::fs::create_dir(h.root.join("nested")).unwrap();
std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("nested/other.cpp", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
assert_eq!(h.mgr.servers().len(), 1);
h.mgr.shutdown_all().await;
}
+3
Переглянути файл
@@ -88,6 +88,9 @@ pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result<Option<crate::java::Jdk>, String> {
match jdk_setting(root, &s.languages.java.jdtls_jdk) {
None => Ok(None),
Some(Ok(jdk)) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => {
Err(crate::java::server_jdk_incomplete_message(&jdk, jdk.major.max(crate::java::JDTLS_MIN_JAVA)))
}
Some(Ok(jdk)) => Ok(Some(jdk)),
Some(Err(p)) => {
Err(format!("The language server JDK {} (Settings › Languages & Frameworks › Java).", not_a_jdk(&p)))
+175 -41
Переглянути файл
@@ -16,6 +16,13 @@ pub struct Jdk {
pub home: PathBuf,
}
pub(crate) fn server_jdk_incomplete_message(jdk: &Jdk, min_java: u32) -> String {
format!(
"The language server JDK ({}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {min_java} or newer. Download JDK {min_java}, choose a full JDK in {SERVER_JDK_SETTING}, or set it to Auto.",
jdk.home.display()
)
}
/// `JAVA_VERSION="21.0.3"` (or `"1.8.0_372"`) from a JDK's `release` file.
pub fn parse_release(release: &str) -> Option<u32> {
let line = release.lines().find_map(|l| l.trim().strip_prefix("JAVA_VERSION="))?;
@@ -199,7 +206,7 @@ pub const JDTLS_MIN_JAVA: u32 = 21;
/// The JDK to run jdtls with: the first found that is new enough (`JAVA_HOME` wins).
pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
jdks.iter().find(|j| j.major >= min_java)
jdks.iter().find(|j| j.major >= min_java && ide_core::java_runtime::supports_java_bundles(&j.home))
}
/// The Java version the jdtls that `cfg` launches needs: the highest any of its
@@ -313,21 +320,36 @@ fn text_key(text: &str) -> String {
format!("{h:016x}")
}
/// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it
/// carries: a value of theirs wins, and a second call adds nothing.
fn set_jvm_property(args: &mut Vec<String>, arg: &str) {
let key = arg.split('=').next().unwrap_or(arg);
let taken = args.iter().enumerate().any(|(i, a)| {
let value =
a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str()));
value.is_some_and(|a| a.split('=').next() == Some(key))
});
if !taken {
args.push(format!("--jvm-arg={arg}"));
}
}
fn add_jdtls_proxy_args(args: &mut Vec<String>, proxy: &ide_core::proxy_settings::ProxySettings) {
for arg in proxy.jvm_args() {
let key = arg.split('=').next().unwrap_or(arg.as_str());
if !args.iter().enumerate().any(|(i, a)| {
let value =
a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str()));
value.is_some_and(|a| a.split('=').next() == Some(key))
}) {
args.push(format!("--jvm-arg={arg}"));
}
set_jvm_property(args, &arg);
}
}
const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot";
/// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools
/// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is
/// not a valid ZIP file", the project using it gets a build path error, and every project
/// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven
/// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told
/// to behave the same.
const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true";
/// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data
/// directory, one per server root, and no Eclipse metadata is written into
/// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its
@@ -384,6 +406,10 @@ pub fn configure_jdtls(
// of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system
// properties. One the user set in their own JVM arguments wins.
add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy());
// ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25
// refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21
// accepts the same file.
set_jvm_property(&mut cfg.args, ZIP64_LENIENCY);
let managed = language_server_runtimes_dir(data_dir);
let managed = dunce::canonicalize(&managed).unwrap_or(managed);
let project_jdks: Vec<_> = project_jdk
@@ -402,6 +428,9 @@ pub fn configure_jdtls(
if !cfg.env.contains_key("JAVA_HOME") {
let runtime = match server_jdk {
Some(jdk) if jdk.major < min_java => Err(server_jdk_too_old_message(jdk, min_java)),
Some(jdk) if !ide_core::java_runtime::supports_java_bundles(&jdk.home) => {
Err(server_jdk_incomplete_message(jdk, min_java))
}
Some(jdk) => Ok(jdk),
None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)),
};
@@ -411,6 +440,10 @@ pub fn configure_jdtls(
}
Err(e) => cfg.launch_error = Some(e),
}
} else if let Some(jdk) = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)))
&& !ide_core::java_runtime::supports_java_bundles(&jdk.home)
{
cfg.launch_error = Some(server_jdk_incomplete_message(&jdk, min_java));
}
let mut runtimes = runtimes(&project_jdks);
if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) {
@@ -554,6 +587,19 @@ fn parse_java_version(v: &str) -> Option<u32> {
mod tests {
use super::*;
fn test_jdk(root: &Path, major: u32, name: &str) -> Jdk {
let home = root.join(name.trim_start_matches('/'));
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(
home.join("release"),
format!("JAVA_VERSION=\"{major}\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n"),
)
.unwrap();
std::fs::write(java_executable(&home), "").unwrap();
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
Jdk { major, home }
}
#[test]
fn unmanaged_source_models_have_separate_jdtls_workspaces() {
let data = tempfile::tempdir().unwrap();
@@ -580,8 +626,13 @@ mod tests {
let project_root = data.path().join("tools/jdks");
let project_home = project_root.join("temurin-21");
std::fs::create_dir_all(project_home.join("bin")).unwrap();
std::fs::write(project_home.join("release"), "JAVA_VERSION=\"21.0.1\"\n").unwrap();
std::fs::write(
project_home.join("release"),
"JAVA_VERSION=\"21.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(java_executable(&project_home), "").unwrap();
std::fs::write(project_home.join("bin").join(crate::exe("javac")), "").unwrap();
let project = jdk_at(&project_home).unwrap();
let discover = || jdk_homes(&project_root).filter_map(|home| jdk_at(&home)).collect::<Vec<_>>();
let installed = discover();
@@ -589,8 +640,13 @@ mod tests {
assert!(jdtls_runtime(&runtime_candidates(&installed, data.path()), 25).is_none());
let home = language_server_runtimes_dir(data.path()).join("temurin-25");
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(
home.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(java_executable(&home), "").unwrap();
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
let candidates = runtime_candidates(&installed, data.path());
assert_eq!(jdtls_runtime(&candidates, 25).unwrap().major, 25);
assert_eq!(jdtls_runtime(&candidates, 21), Some(&project));
@@ -609,11 +665,47 @@ mod tests {
);
}
#[test]
fn jdtls_rejects_jre_for_auto_explicit_and_managed_runtimes() {
let tmp = tempfile::tempdir().unwrap();
let jre = test_jdk(tmp.path(), 25, "jre");
std::fs::write(jre.home.join("release"), "JAVA_VERSION=\"25\"\nMODULES=\"java.base java.compiler\"\n").unwrap();
let jdk = test_jdk(tmp.path(), 25, "jdk");
let installed = [jre.clone(), jdk.clone()];
assert_eq!(jdtls_runtime(&installed, 25), Some(&jdk));
assert!(jdtls_runtime(std::slice::from_ref(&jre), 25).is_none());
let managed = language_server_runtimes_dir(tmp.path()).join("temurin-25");
std::fs::create_dir_all(managed.parent().unwrap()).unwrap();
std::fs::rename(&jre.home, &managed).unwrap();
assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_none());
let jre = jdk_at(&managed).unwrap();
for env_override in [false, true] {
let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
if env_override {
cfg.env.insert("JAVA_HOME".into(), jre.home.to_string_lossy().into_owned());
}
configure_jdtls(&mut cfg, tmp.path(), tmp.path(), &installed[1..], None, Some(&jre), 25, &[], None);
let err = cfg.launch_error.unwrap();
assert!(
err.contains("full JDK with jdk.compiler, jdk.jdi and jdk.unsupported")
&& err.contains("Download JDK 25")
&& err.contains(SERVER_JDK_SETTING),
"{err}"
);
}
std::fs::write(
managed.join("release"),
"JAVA_VERSION=\"25\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(jdtls_runtime(&runtime_candidates(&[], tmp.path()), 25).is_some());
}
#[test]
fn implicit_compiler_default_matches_project_pick_with_duplicate_versions() {
let installed =
[Jdk { major: 21, home: PathBuf::from("/jdk21a") }, Jdk { major: 21, home: PathBuf::from("/jdk21b") }];
let server = Jdk { major: 25, home: PathBuf::from("/custom-server-runtime") };
let fixtures = tempfile::tempdir().unwrap();
let installed = [test_jdk(fixtures.path(), 21, "/jdk21a"), test_jdk(fixtures.path(), 21, "/jdk21b")];
let server = test_jdk(fixtures.path(), 25, "/custom-server-runtime");
let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, Some(&server), 25, &[], None);
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
@@ -623,7 +715,7 @@ mod tests {
"name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true
}])
);
assert_eq!(cfg.env["JAVA_HOME"], "/custom-server-runtime");
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy());
}
#[test]
@@ -636,15 +728,16 @@ mod tests {
#[test]
fn jdtls_workspace_and_runtimes() {
let fixtures = tempfile::tempdir().unwrap();
if cfg!(windows) {
return; // Unix paths and shells
}
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
let jdks = [
Jdk { major: 25, home: PathBuf::from("/jdk25") },
Jdk { major: 21, home: PathBuf::from("/jdk21a") },
Jdk { major: 21, home: PathBuf::from("/jdk21b") },
Jdk { major: 8, home: PathBuf::from("/jdk8") },
test_jdk(fixtures.path(), 25, "/jdk25"),
test_jdk(fixtures.path(), 21, "/jdk21a"),
test_jdk(fixtures.path(), 21, "/jdk21b"),
test_jdk(fixtures.path(), 8, "/jdk8"),
];
let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")];
configure_jdtls(
@@ -664,13 +757,22 @@ mod tests {
assert_eq!(cfg.args[n], "-data");
assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]);
assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
assert!(
cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"),
"jars JDK 25 rejects must still load: {:?}",
cfg.args
);
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" }));
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21a" }));
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": "/jdk25", "default": true }));
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": fixtures.path().join("jdk8") }));
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21a") }));
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": fixtures.path().join("jdk25"), "default": true }));
assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"]));
assert_eq!(cfg.env["JAVA_HOME"], "/jdk25", "first JDK new enough to run jdtls");
assert_eq!(
cfg.env["JAVA_HOME"],
fixtures.path().join("jdk25").to_string_lossy(),
"first JDK new enough to run jdtls"
);
assert_eq!(cfg.launch_error, None);
// Deterministic (reconfigure compares configs) and idempotent.
@@ -689,7 +791,7 @@ mod tests {
assert_eq!(cfg, before);
// The project JDK is the default runtime, and runs the server when new enough.
let project = Jdk { major: 21, home: PathBuf::from("/jdk21b") };
let project = test_jdk(fixtures.path(), 21, "/jdk21b");
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
configure_jdtls(
&mut cfg,
@@ -704,8 +806,8 @@ mod tests {
);
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21b", "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], "/jdk21b");
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21b"), "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy());
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
configure_jdtls(
@@ -751,6 +853,25 @@ mod tests {
}
}
#[test]
fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() {
// aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the
// JVM that runs jdtls that is a build path error in every project using it.
let mut args = Vec::new();
set_jvm_property(&mut args, ZIP64_LENIENCY);
assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]);
set_jvm_property(&mut args, ZIP64_LENIENCY);
assert_eq!(args.len(), 1, "added once");
for user in [
"--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(),
"--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(),
] {
let mut own = vec![user.clone()];
set_jvm_property(&mut own, ZIP64_LENIENCY);
assert_eq!(own, [user], "the user decides");
}
}
#[test]
fn jdtls_lombok_agent() {
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
@@ -793,8 +914,9 @@ mod tests {
#[test]
fn jdtls_needs_a_new_enough_jdk_to_run() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }];
let old = [test_jdk(fixtures.path(), 17, "/jdk17"), test_jdk(fixtures.path(), 8, "/jdk8")];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None);
assert!(!cfg.env.contains_key("JAVA_HOME"));
@@ -813,7 +935,7 @@ mod tests {
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None));
let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }];
let mixed = [old[0].clone(), test_jdk(fixtures.path(), 21, "/jdk21")];
assert_eq!(jdtls_runtime(&mixed, 21).unwrap().major, 21);
// A jdtls whose bundles need 25 doesn't start on 21, and says what it needs.
@@ -826,11 +948,12 @@ mod tests {
#[test]
fn issue_49_jdtls_needs_25_with_8_17_21_installed() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let installed = [
Jdk { major: 21, home: PathBuf::from("/jdk21") },
Jdk { major: 17, home: PathBuf::from("/jdk17") },
Jdk { major: 8, home: PathBuf::from("/jdk8") },
test_jdk(fixtures.path(), 21, "/jdk21"),
test_jdk(fixtures.path(), 17, "/jdk17"),
test_jdk(fixtures.path(), 8, "/jdk8"),
];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
@@ -839,12 +962,11 @@ mod tests {
assert!(err.contains("Download JDK 25"), "{err}");
// Once a JDK 25 has been downloaded, Auto runs jdtls on it.
let downloaded =
[installed.as_slice(), &[Jdk { major: 25, home: PathBuf::from("/tools/jdks/temurin-25") }]].concat();
let downloaded = [installed.as_slice(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat();
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None);
assert_eq!(cfg.launch_error, None);
assert_eq!(cfg.env["JAVA_HOME"], Path::new("/tools/jdks/temurin-25").to_string_lossy());
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy());
}
#[test]
@@ -881,8 +1003,9 @@ mod tests {
#[test]
fn jdtls_runs_on_the_chosen_server_jdk() {
let fixtures = tempfile::tempdir().unwrap();
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let jdk = |major: u32, home: &str| Jdk { major, home: PathBuf::from(home) };
let jdk = |major: u32, home: &str| test_jdk(fixtures.path(), major, home);
let installed = [jdk(25, "/jdk25"), jdk(21, "/jdk21"), jdk(17, "/jdk17")];
let project = jdk(17, "/jdk17");
let run = |server: Option<&Jdk>, min_java: u32| {
@@ -903,24 +1026,35 @@ mod tests {
// Project on JDK 17, server on the chosen 21: each keeps its own JDK.
let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/jdk21", None));
assert_eq!(
(cfg.env["JAVA_HOME"].clone(), cfg.launch_error),
(fixtures.path().join("jdk21").to_string_lossy().into_owned(), None)
);
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[0], json!({ "name": "JavaSE-17", "path": "/jdk17", "default": true }), "still compiles on 17");
assert_eq!(
rt[0],
json!({ "name": "JavaSE-17", "path": fixtures.path().join("jdk17"), "default": true }),
"still compiles on 17"
);
// Not set: the first installed JDK new enough, as before.
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], "/jdk25");
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], fixtures.path().join("jdk25").to_string_lossy());
// A chosen JDK outside the scanned locations.
let own = jdk(25, "/opt/own-jdk");
let cfg = run(Some(&own), 25);
assert_eq!(cfg.env["JAVA_HOME"], "/opt/own-jdk");
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("opt/own-jdk").to_string_lossy());
// Too old for this jdtls: the user's choice is not overridden, it is explained.
let cfg = run(Some(&jdk(21, "/jdk21")), 25);
let old = jdk(21, "/jdk21");
let cfg = run(Some(&old), 25);
assert!(!cfg.env.contains_key("JAVA_HOME"));
let err = cfg.launch_error.unwrap();
assert!(err.contains("/jdk21") && err.contains("JDK 25 or newer"), "{err}");
// The message embeds jdk.home as written to disk, so match that instead of the
// "/jdk21" literal: its separators are platform-native since test_jdk
// materializes real directories.
assert!(err.contains(old.home.to_string_lossy().as_ref()) && err.contains("JDK 25 or newer"), "{err}");
assert!(err.contains(SERVER_JDK_SETTING), "both messages point at the same setting: {err}");
}
+19 -1
Переглянути файл
@@ -249,6 +249,10 @@ pub fn launch_directly(cfg: &mut LanguageServerConfig) {
}),
};
let Some((java, jdk)) = jdk.filter(|(java, _)| java.is_file()) else { return };
if !ide_core::java_runtime::supports_java_bundles(&jdk.home) {
cfg.launch_error = Some(crate::java::server_jdk_incomplete_message(&jdk, crate::java::jdtls_min_java(cfg)));
return;
}
cfg.args = java_args(&install, jdk.major, &cfg.args);
cfg.command = java.to_string_lossy().into_owned();
}
@@ -465,7 +469,12 @@ mod tests {
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let jdk = dir.path().join("jdk-25");
std::fs::create_dir_all(jdk.join("bin")).unwrap();
std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(
jdk.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(jdk.join("bin/javac"), "").unwrap();
std::fs::write(jdk.join("bin/java"), "").unwrap();
let cfg = |command: &str| -> LanguageServerConfig {
@@ -486,6 +495,15 @@ mod tests {
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"]));
let jre = dir.path().join("jre-25");
std::fs::create_dir_all(jre.join("bin")).unwrap();
std::fs::write(jre.join("bin/java"), "").unwrap();
std::fs::write(jre.join("release"), "JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\n").unwrap();
let mut overridden = cfg("jdtls");
overridden.args.push(format!("--java-executable={}", jre.join("bin/java").display()));
launch_directly(&mut overridden);
assert!(overridden.launch_error.unwrap().contains("jdk.jdi"));
// Someone's own wrapper, or no usable JDK: the command is kept.
std::fs::write(home.join("bin/my-jdtls"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(home.join("bin/my-jdtls"), std::os::unix::fs::PermissionsExt::from_mode(0o755))
+12 -1
Переглянути файл
@@ -166,7 +166,12 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
fn fake_jdk(dir: &Path) {
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin").join(exe("java")), "").unwrap();
std::fs::write(dir.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap();
std::fs::write(
dir.join("release"),
"JAVA_VERSION=\"17.0.9\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(dir.join("bin").join(crate::exe("javac")), "").unwrap();
}
#[test]
@@ -307,6 +312,12 @@ fn jdtls_jdk_setting() {
assert_eq!(jdtls_jdk(tmp.path(), &s), Ok(None));
s.languages.java.jdtls_jdk = "jdk17/bin".into();
assert_eq!(jdtls_jdk(tmp.path(), &s).unwrap().map(|j| j.major), Some(17));
std::fs::write(
tmp.path().join("jdk17/release"),
"JAVA_VERSION=\"25\"\nIMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("full JDK"));
// A chosen path that is not a JDK is an error, not a silent fallback.
std::fs::create_dir(tmp.path().join("not-a-jdk")).unwrap();
s.languages.java.jdtls_jdk = "not-a-jdk".into();
+15 -1
Переглянути файл
@@ -53,6 +53,8 @@ pub struct TerminalInfo {
}
struct Session {
/// The window that opened it: closing that window's project closes its terminals.
key: String,
info: TerminalInfo,
master: Mutex<Box<dyn MasterPty + Send>>,
writer: Mutex<Box<dyn Write + Send>>,
@@ -83,6 +85,7 @@ impl TerminalManager {
pub fn spawn(
&self,
options: TerminalOptions,
key: String,
sink: SharedSink,
on_output: Box<dyn Fn(String) + Send>,
) -> IdeResult<TerminalInfo> {
@@ -121,6 +124,7 @@ impl TerminalManager {
let info = TerminalInfo { id, title: shell_title(&shell), pid: child.process_id() };
let exited = Arc::new(AtomicBool::new(false));
let session = Arc::new(Session {
key,
info: info.clone(),
master: Mutex::new(pair.master),
writer: Mutex::new(writer),
@@ -160,6 +164,15 @@ impl TerminalManager {
.map_err(|e| IdeError::internal(format!("resize failed: {e}")))
}
/// Closes the terminals `key` opened: the project of that window is closing.
pub fn kill_key(&self, key: &str) {
let ids: Vec<TerminalId> =
self.sessions.lock().iter().filter(|(_, s)| s.key == key).map(|(id, _)| *id).collect();
for id in ids {
let _ = self.kill(id);
}
}
pub fn kill(&self, id: TerminalId) -> IdeResult<()> {
let session = self.sessions.lock().remove(&id).ok_or_else(|| not_found(id))?;
terminate(&session);
@@ -636,6 +649,7 @@ mod tests {
.manager
.spawn(
opts,
"test".into(),
h.sink.clone(),
Box::new(move |s| {
let _ = tx.send(s);
@@ -793,7 +807,7 @@ mod tests {
let h = harness();
let (other_sink, other_events) = channel_sink();
let (mine, _rx) = spawn_collect(&h, sh("exit 1"));
let theirs = h.manager.spawn(sh("exit 2"), other_sink, Box::new(|_| {})).expect("spawn");
let theirs = h.manager.spawn(sh("exit 2"), "other".into(), other_sink, Box::new(|_| {})).expect("spawn");
match h.events.recv_timeout(Duration::from_secs(10)).expect("exit event") {
IdeEvent::TerminalExited { terminal, code } => assert_eq!((terminal, code), (mine.id, Some(1))),
e => panic!("unexpected {e:?}"),
+96 -38
Переглянути файл
@@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
}
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
pub fn display_name(tool: &str) -> &str {
crate::registry::find(tool).map_or(tool, |t| t.display)
}
fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
let name = display_name(tool);
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
.map_err(|e| IdeError::invalid(format!("{name}: {e}")))?;
let bin = json["bin"]
.as_str()
.or_else(|| json["bin"]["prettier"].as_str())
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
.or_else(|| json["bin"][tool].as_str())
.ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?;
let path = package.join(bin);
if !path.is_file() {
return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display())));
return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display())));
}
Ok(path)
}
/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only.
pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool {
tool == "prettier" && config.range
}
impl Tools {
/// Explicit executable/script, then the nearest project package, then managed tools and PATH.
pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
@@ -34,22 +44,26 @@ impl Tools {
}
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
if tool != "prettier" {
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
}
let package = Path::new("node_modules").join(tool);
let manifest = package.join("package.json");
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
(package_bin(custom)?, true)
(package_bin(custom, tool)?, true)
} else if let Some(custom) = custom {
(custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs")))
} else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) {
(package_bin(manifest.parent().unwrap())?, true)
} else if self.root.join("node_modules/prettier/package.json").is_file() {
(package_bin(&self.root.join("node_modules/prettier"))?, true)
} else if let Some(manifest) = find_up(file, &manifest) {
(package_bin(manifest.parent().unwrap(), tool)?, true)
} else if self.root.join(&manifest).is_file() {
(package_bin(&self.root.join(&package), tool)?, true)
} else {
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
(self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false)
};
if node {
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
let name = display_name(tool);
let node =
self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?;
let mut cmd = self.command(node);
cmd.arg(path);
Ok(cmd)
@@ -59,34 +73,40 @@ impl Tools {
}
}
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
/// Both tools resolve their configuration and plugins themselves. Prettier's
/// project configuration overrides the editor indentation passed here; oxfmt has
/// no such flags and reads its configuration and `.editorconfig` only.
pub fn arguments(
tool: &str,
config: &FormatterConfig,
file: &Path,
tab_size: u32,
insert_spaces: bool,
range: Option<(u32, u32)>,
) -> Vec<OsString> {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
} else {
&config.parser
};
let mut args: Vec<OsString> = [
"--parser",
parser,
"--config-precedence",
"file-override",
"--tab-width",
&tab_size.max(1).to_string(),
"--use-tabs",
if insert_spaces { "false" } else { "true" },
]
.into_iter()
.map(Into::into)
.collect();
let mut args: Vec<OsString> = Vec::new();
if tool == "prettier" {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
} else {
&config.parser
};
args.extend(
[
"--parser",
parser,
"--config-precedence",
"file-override",
"--tab-width",
&tab_size.max(1).to_string(),
"--use-tabs",
if insert_spaces { "false" } else { "true" },
]
.map(OsString::from),
);
}
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
if config.range
if supports_range(tool, config)
&& let Some((start, end)) = range
{
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
@@ -100,8 +120,8 @@ pub fn arguments(
args
}
pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier"))
pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool))
}
#[cfg(test)]
@@ -118,7 +138,7 @@ mod tests {
assert_eq!(missing.kind, ErrorKind::NotFound);
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap();
let failure = run(custom, "text", &Default::default()).unwrap_err();
let failure = run("prettier", custom, "text", &Default::default()).unwrap_err();
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
@@ -127,7 +147,7 @@ mod tests {
fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() {
let mut command = Command::new("sh");
command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]);
let failure = run(command, "", &Default::default()).unwrap_err();
let failure = run("prettier", command, "", &Default::default()).unwrap_err();
assert_eq!(failure.message, "error\n".repeat(1000));
assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
@@ -161,7 +181,8 @@ mod tests {
("vue", true),
] {
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args =
arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
@@ -171,4 +192,41 @@ mod tests {
assert!(!args.contains(&"--write".into()));
}
}
#[test]
fn oxfmt_runs_the_nearest_package_without_prettier_flags() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap();
std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap();
std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap();
std::fs::write(root.join(".prettierignore"), "dist").unwrap();
let file = root.join("packages/web/src/app.ts");
let path = std::env::var_os("PATH").unwrap_or_default();
let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current());
let command = tools.formatter_command("oxfmt", &file, None).unwrap();
let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned();
assert_eq!(program, "node");
let script: Vec<_> = command.get_args().collect();
// Compare components: `package_bin` joins the manifest's `bin` entry
// (`bin/oxfmt`) onto a platform path, so separators differ on Windows.
assert_eq!(
Path::new(&script[0]).components().collect::<Vec<_>>(),
root.join("node_modules/oxfmt/bin/oxfmt").components().collect::<Vec<_>>()
);
let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true };
let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9)));
let ignore = root.join(".prettierignore").into_os_string();
assert_eq!(
args[..4],
["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore]
);
assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}");
assert!(!supports_range("oxfmt", &config));
let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err();
assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message);
}
}
+57 -49
Переглянути файл
@@ -108,26 +108,7 @@ fn adoptium_arch(arch: Arch) -> &'static str {
/// The latest Temurin JDK build of Java `feature` for `platform`.
pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String {
assets_url(feature, platform, ImageType::Jdk)
}
#[derive(Clone, Copy)]
enum ImageType {
Jdk,
Jre,
}
impl ImageType {
fn name(self) -> &'static str {
match self {
Self::Jdk => "jdk",
Self::Jre => "jre",
}
}
}
fn assets_url(feature: u32, platform: Platform, image: ImageType) -> String {
let image = image.name();
let image = "jdk";
format!(
"{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type={image}&os={}&vendor=eclipse",
adoptium_arch(platform.arch),
@@ -190,16 +171,12 @@ impl AssetVersion {
/// The JDK archive (`.tar.gz` on macOS / Linux, `.zip` on Windows) in an Adoptium
/// `assets/latest` response; `None` when there is no build for the platform.
pub fn adoptium_pick(json: &str, platform: Platform) -> IdeResult<Option<(Download, u32)>> {
pick_image(json, platform, ImageType::Jdk)
}
fn pick_image(json: &str, platform: Platform, image: ImageType) -> IdeResult<Option<(Download, u32)>> {
let assets: Vec<Asset> =
serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?;
let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" };
let os = adoptium_os(platform.os);
let found = assets.into_iter().find(|a| {
a.binary.image_type.as_deref().is_none_or(|t| t == image.name())
a.binary.image_type.as_deref().is_none_or(|t| t == "jdk")
&& a.binary.heap_size.as_deref().is_none_or(|h| h == "normal")
&& a.binary.os.as_deref().is_none_or(|o| o == os)
&& a.binary.package.name.ends_with(ext)
@@ -249,6 +226,10 @@ pub fn find_jdk_home(dir: &Path, platform: Platform) -> Option<PathBuf> {
subdirs.iter().flat_map(|d| candidates(d)).find(|h| is_jdk_home(h, platform))
}
fn usable_java_home(dir: &Path, platform: Platform, runtime: bool) -> Option<PathBuf> {
find_jdk_home(dir, platform).filter(|home| !runtime || ide_core::java_runtime::supports_java_bundles(home))
}
/// The directory to move into place: the archive's single top-level folder
/// holding the JDK, else everything that was unpacked.
fn unpacked_root(pkg: &Path, home: &Path) -> PathBuf {
@@ -283,8 +264,8 @@ impl Tools {
self.download_java(vendor, feature, location, false, cancel, emit)
}
/// Downloads a runtime for a language server, preferring a JRE. Only a
/// platform without a JRE build falls back to a full JDK.
/// Downloads a full JDK for a language server and its bundles (java-debug
/// needs `jdk.jdi`). Kept outside project JDK discovery roots.
pub fn download_java_runtime(
&self,
feature: u32,
@@ -310,19 +291,8 @@ impl Tools {
let http = self.http()?;
let location = location.map(Path::to_path_buf).unwrap_or_else(|| self.root.join("jdks"));
emit(InstallEvent::Line { text: format!("Resolving the latest Eclipse Temurin {feature}…") });
let mut picked = if runtime {
pick_image(
&http.get_text_or_empty_on_404(&assets_url(feature, self.platform, ImageType::Jre), cancel)?,
self.platform,
ImageType::Jre,
)?
} else {
None
};
if picked.is_none() {
picked =
adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?;
}
let mut picked =
adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?;
if picked.is_none() && matches!(self.platform.os, Os::Mac | Os::Windows) && self.platform.arch == Arch::Arm64 {
// Older JDKs may need the OS-provided x64 emulation.
let intel = Platform { os: self.platform.os, arch: Arch::X64 };
@@ -336,7 +306,7 @@ impl Tools {
})?;
let target = location.join(jdk_dir_name(vendor, &dl.version));
let done = |home: PathBuf| DownloadedJdk { home: home.into(), version: dl.version.clone(), major };
if let Some(home) = find_jdk_home(&target, self.platform) {
if let Some(home) = usable_java_home(&target, self.platform, runtime) {
emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) });
return Ok(done(home));
}
@@ -351,7 +321,7 @@ impl Tools {
emit(InstallEvent::Line { text: "Extracting…".into() });
let pkg = staging.join("pkg");
archive::extract(&archive, archive::ArchiveKind::from_name(&dl.name), &pkg, "java", cancel)?;
let home = find_jdk_home(&pkg, self.platform)
let home = usable_java_home(&pkg, self.platform, runtime)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", dl.name)))?;
// Last point to stop: from here the JDK replaces what is at `target`.
if cancel.is_cancelled() {
@@ -362,7 +332,7 @@ impl Tools {
}
std::fs::rename(unpacked_root(&pkg, &home), &target)?;
clear_quarantine(&target);
find_jdk_home(&target, self.platform)
usable_java_home(&target, self.platform, runtime)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", target.display())))
})();
let _ = std::fs::remove_dir_all(&staging);
@@ -437,18 +407,56 @@ mod tests {
}
#[test]
fn runtime_assets_use_jre_without_changing_jdk_downloads() {
fn runtime_assets_require_jdk_images() {
for platform in [MAC_ARM, LINUX_X64, WIN_X64] {
assert!(assets_url(25, platform, ImageType::Jre).contains("image_type=jre"));
assert!(adoptium_assets_url(25, platform).contains("image_type=jdk"));
let ext = if platform.os == Os::Windows { "zip" } else { "tar.gz" };
let jdk = asset(&format!("java.{ext}"), adoptium_os(platform.os), "normal", "");
let jre = jdk.replace("\"image_type\":\"jdk\"", "\"image_type\":\"jre\"");
assert!(pick_image(&format!("[{jre}]"), platform, ImageType::Jre).unwrap().is_some());
assert!(adoptium_pick(&format!("[{jre},{jdk}]"), platform).unwrap().is_some());
assert!(adoptium_pick(&format!("[{jre}]"), platform).unwrap().is_none());
assert!(pick_image(&format!("[{jdk}]"), platform, ImageType::Jre).unwrap().is_none());
assert!(pick_image("[]", platform, ImageType::Jre).unwrap().is_none());
assert!(pick_image("offline", platform, ImageType::Jre).is_err());
}
}
#[test]
fn stale_runtime_is_replaced_even_at_the_same_version() {
for platform in [MAC_ARM, LINUX_X64, WIN_X64] {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(jdk_dir_name(TEMURIN, "25.0.1"));
let home = if platform.os == Os::Mac { target.join("Contents/Home") } else { target.clone() };
fake_jdk(&home, platform);
let javac = home.join("bin").join(if cfg!(windows) { "javac.exe" } else { "javac" });
std::fs::write(&javac, "").unwrap();
for modules in [None, Some("java.base java.compiler jdk.jdix"), Some("java.base jdk.jdi.agent")] {
let line = modules.map(|m| format!("MODULES=\"{m}\"\n")).unwrap_or_default();
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"25.0.1\"\n{line}")).unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "{modules:?}");
assert_eq!(usable_java_home(&target, platform, false), Some(home.clone()));
}
std::fs::write(
home.join("release"),
"JAVA_VERSION=\"25.0.1\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert_eq!(usable_java_home(&target, platform, true), Some(home.clone()));
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::create_dir_all(home.join("jmods")).unwrap();
for module in ["jdk.compiler", "jdk.jdi", "jdk.unsupported"] {
std::fs::write(home.join("jmods").join(format!("{module}.jmod")), "").unwrap();
}
assert_eq!(usable_java_home(&target, platform, true), Some(home.clone()));
std::fs::write(
home.join("release"),
"IMAGE_TYPE=\"JRE\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "Temurin JRE release can list absent modules");
std::fs::write(home.join("release"), "MODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n")
.unwrap();
std::fs::remove_file(javac).unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "custom runtime without compiler");
std::fs::write(home.join("release"), "MODULES=\"java.base\"\n").unwrap();
assert!(usable_java_home(&target, platform, true).is_none(), "release describes the runtime image");
}
}
+24 -6
Переглянути файл
@@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[
needs: NODE,
homepage: "https://prettier.io",
},
ToolSpec {
name: "oxfmt",
display: "oxfmt",
kind: Formatter,
commands: &["oxfmt"],
method: Method::Npm { packages: &["oxfmt"] },
fallback: None,
version: ver("oxfmt"),
needs: NODE,
homepage: "https://oxc.rs/docs/guide/usage/formatter",
},
ToolSpec {
name: "oxlint",
display: "oxlint",
kind: LanguageServer,
commands: &["oxlint"],
method: Method::Npm { packages: &["oxlint"] },
fallback: None,
version: ver("oxlint"),
needs: NODE,
homepage: "https://oxc.rs/docs/guide/usage/linter",
},
ToolSpec {
name: "rust-analyzer",
display: "rust-analyzer",
@@ -965,12 +987,8 @@ mod tests {
known.insert((d.adapter.clone(), d.command.clone()));
}
}
for l in &langs {
if let Some(f) = &l.formatter {
known.insert((f.tool.clone(), f.tool.clone()));
}
}
for t in TOOLS.iter().filter(|t| t.kind != Plugin) {
// The host selects the formatter of each file (Prettier or oxfmt).
for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) {
assert!(
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
"{} / {} is not referenced by languages/*.toml",
+2 -2
Переглянути файл
@@ -19,8 +19,8 @@ fn format(
};
let mut cmd = tools.formatter_command("prettier", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments(&config, path, 4, true, range));
formatter::run(cmd, text, &CancellationToken::new())
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
formatter::run("prettier", cmd, text, &CancellationToken::new())
}
#[test]
+22 -9
Переглянути файл
@@ -89,15 +89,28 @@ pub(crate) fn blame(
return Ok(uncommitted_lines(line_count));
}
// Restricted blame reads committed blobs only. Feeding working-tree bytes to
// Git's --contents path would run configured clean filters.
let out = if crate::execution_allowed() {
run(
r.root(),
&["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel],
Some(&contents),
)?
// Git's --contents path would run configured clean filters, so it blames the
// revision plain. Git learned to combine `--contents` with a revision in 2.41;
// older releases reject the pair (`fatal: cannot use --contents with final
// commit object name`) and take the same plain path — the edited lines are
// marked uncommitted below either way (issue #755).
let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel];
let plain_args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel]
} else {
run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)?
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel]
};
let (out, contents_blame) = if crate::execution_allowed() {
let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?;
if out.code == Some(0) {
(out, true)
} else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") {
(run(r.root(), plain_args, None)?, false)
} else {
return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code));
}
} else {
(run(r.root(), plain_args, None)?, false)
};
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
for line in &mut lines {
@@ -106,7 +119,7 @@ pub(crate) fn blame(
line.summary = String::new();
}
}
if !crate::execution_allowed() {
if !contents_blame {
let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default();
for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() {
let start = (hunk.old_start as usize).min(lines.len());
+84
Переглянути файл
@@ -0,0 +1,84 @@
import { expect, test, type Page } from "@playwright/test";
/** Mount the real console with enough output to recycle rows while selecting. */
async function consoleOutput(page: Page, follow = false) {
await page.goto("/");
await page.evaluate(async (follow) => {
const reactPath = "/node_modules/.vite/deps/react.js";
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
const viewPath = "/src/features/run/ConsoleView.tsx";
const modelPath = "/src/features/run/console.ts";
const react = await import(reactPath);
const dom = await import(domPath);
const { createElement, useState } = react.default ?? react;
const { createRoot } = dom.default ?? dom;
const { ConsoleView } = await import(viewPath);
const { ConsoleModel } = await import(modelPath);
const model = new ConsoleModel();
model.append({ kind: "stdout", text: Array.from({ length: 500 }, (_, i) => `output ${i}`).join("\n") });
const host = document.createElement("div");
host.style.cssText = "position:fixed;inset:0;z-index:99999;display:flex;height:300px;background:white";
document.body.append(host);
function Console() {
const [following, setFollowing] = useState(follow);
return createElement(ConsoleView, {
model,
version: 1,
softWrap: false,
follow: following,
onFollowChange: setFollowing,
cwd: "/",
});
}
createRoot(host).render(createElement(Console));
}, follow);
const log = page.getByRole("log", { name: "Console", exact: true });
await expect(log.locator("[data-index]").first()).toBeVisible();
return log;
}
test("keeps a console selection intact when scrolling beyond the virtual window", async ({ page }) => {
const log = await consoleOutput(page);
await log.evaluate((el) => {
el.scrollTop = 200 * 20;
});
const start = log.locator('[data-index="205"]');
await expect(start).toBeVisible();
await start.evaluate((el) => {
const selection = window.getSelection()!;
selection.setBaseAndExtent(el.firstChild!.firstChild!, 2, el.nextElementSibling!.firstChild!.firstChild!, 5);
});
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
await log.evaluate((el) => {
el.scrollTop = 350 * 20;
});
await expect(log.locator('[data-index="350"]')).toBeVisible();
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await expect(start).toHaveCount(0);
});
test("preserves all lines in a drag selection across virtual windows", async ({ page }) => {
const log = await consoleOutput(page);
await log.evaluate((el) => {
el.scrollTop = 200 * 20;
});
const start = log.locator('[data-index="205"]');
await expect(start).toBeVisible();
const box = (await start.boundingBox())!;
await page.mouse.move(box.x + 20, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x + 60, box.y + 30, { steps: 5 });
await log.evaluate((el) => {
el.scrollTop = 280 * 20;
});
const end = log.locator('[data-index="285"]');
await expect(end).toBeVisible();
const endBox = (await end.boundingBox())!;
await page.mouse.move(endBox.x + 60, endBox.y + 10, { steps: 5 });
await page.mouse.up();
const text = await page.evaluate(() => window.getSelection()?.toString());
expect(text).toContain("output 206\noutput 207");
expect(text).toContain("output 250\noutput 251");
expect(text).toContain("output 284");
});
+30
Переглянути файл
@@ -0,0 +1,30 @@
import { expect, openFromTree, test } from "./fixtures";
// Headless Chromium hides scrollbars by default; this test drags one.
test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } });
test("the scrollbar thumb drags even where overview marks cover it (#752)", async ({ workbench: page }) => {
const editor = await openFromTree(page, "build.gradle.kts");
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val mark${i} = ${i}`).join("\n"));
// A match on every line covers the overview lane, as a new file's change mark does.
await page.keyboard.press("Meta+f");
await page.getByRole("textbox", { name: "Find" }).fill("mark");
await expect(editor.locator(".cm-openidea-overviewMark").first()).toBeVisible();
const scroller = editor.locator(".cm-scroller");
await scroller.evaluate((s) => (s.scrollTop = 0));
const box = (await scroller.boundingBox())!;
// Over a mark and inside the thumb, which is at the top of the track.
const x = box.x + box.width - 5;
const y = box.y + 12;
await page.mouse.move(x - 40, y);
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x, y + 150, { steps: 10 });
await page.mouse.up();
expect(await scroller.evaluate((s) => s.scrollTop)).toBeGreaterThan(0);
// Away from the thumb the marks take the pointer again.
await page.mouse.move(x, box.y + box.height - 20);
await expect(editor.locator(".cm-openidea-overview")).not.toHaveClass(/cm-openidea-overview-onThumb/);
});
+81
Переглянути файл
@@ -0,0 +1,81 @@
import { expect, test } from "./fixtures";
const themes = [
{ theme: "light", scheme: "theme" },
{ theme: "dark", scheme: "theme" },
{ theme: "dark-hc", scheme: "theme" },
{ theme: "light", scheme: "dark" },
{ theme: "dark", scheme: "light" },
] as const;
for (const { theme, scheme } of themes) {
test(`usage highlights have readable text with ${theme} theme and ${scheme} scheme`, async ({
workbench: page,
}, info) => {
await page.evaluate(
async ({ theme, scheme }) => {
const themePath = "/src/workbench/theme.ts";
const schemePath = "/src/platform/colorScheme.ts";
const usagesPath = "/src/features/code-insight/usages.ts";
const { applyThemeAttributes } = await import(themePath);
const { schemeCss } = await import(schemePath);
const { useUsagesPopup } = await import(usagesPath);
applyThemeAttributes(theme);
const style = document.createElement("style");
style.textContent = schemeCss(scheme);
document.head.append(style);
const symbol = "trainingSessionFetch";
const texts = [
`export function ${symbol}() {}`,
`import { ${symbol} } from './api';`,
`// Call ${symbol} here`,
`const name = '${symbol}';`,
];
useUsagesPopup.setState({
open: true,
anchor: { x: 100, y: 100 },
symbol,
groups: texts.map((text, index) => ({
path: `/contrast/example-${index}.ts`,
usages: [
{
path: `/contrast/example-${index}.ts`,
line: 0,
col: text.indexOf(symbol),
endCol: text.indexOf(symbol) + symbol.length,
text,
},
],
})),
note: null,
});
},
{ theme, scheme },
);
const popup = page.getByRole("dialog", { name: "Usages", exact: true });
await expect(popup.locator(".tok-comment").first()).toBeVisible();
await expect(popup.locator("mark")).toHaveCount(4);
const ratios = await popup.locator("mark").evaluateAll((marks) => {
const luminance = (color: string) => {
const channels = color
.match(/[\d.]+/g)!
.slice(0, 3)
.map(Number)
.map((value) => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
};
return marks.flatMap((mark) => {
const background = luminance(getComputedStyle(mark).backgroundColor);
return [mark, ...mark.querySelectorAll("*")].map((node) => {
const foreground = luminance(getComputedStyle(node).color);
return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05);
});
});
});
for (const ratio of ratios) expect(ratio).toBeGreaterThanOrEqual(4.5);
await popup.screenshot({ path: info.outputPath("usage-highlights.png") });
});
}
+8
Переглянути файл
@@ -27,6 +27,14 @@ args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
+8
Переглянути файл
@@ -26,6 +26,14 @@ args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
+8
Переглянути файл
@@ -13,3 +13,11 @@ command = "svelteserver"
args = ["--stdio"]
root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"]
install-hint = "npm i -g svelte-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
+8
Переглянути файл
@@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"]
require-root-marker = true
install-hint = "npm i -g @angular/language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
+8
Переглянути файл
@@ -26,6 +26,14 @@ args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
+8
Переглянути файл
@@ -22,3 +22,11 @@ command = "vtsls"
args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
install-hint = "npm i -g @vtsls/language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
+4
Переглянути файл
@@ -3350,6 +3350,8 @@ export type GitOperation =
{ op: "interactiveRebase"; base: string | null; entries: RebaseEntry[] };
export type GitSettings = {
/** Show blame after the current editor line. */
inlineBlame?: boolean,
/** Path to the git executable; empty = auto-detect. */
executable?: string,
/** Default of Update Project: `merge` | `rebase`. */
@@ -3619,6 +3621,8 @@ cancellable: boolean } |
{ type: "indexState"; indexing: boolean; files: number; symbols: number } |
/** Git status / branch information should be refreshed. */
{ type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } |
/** A running server lacks a capability; the UI offers the managed tool as a replacement. */
{ type: "languageServerWarning"; name: string; message: string; installTool: string } |
/**
* A stopped language server left the project's list (disabled in settings, or a
* fallback that a preferred server took over from again).
+3
Переглянути файл
@@ -32,6 +32,9 @@ import type { LanguageDescription } from "@codemirror/language";
import { create } from "zustand";
import { maybeEditorService } from "@/workbench/contributions";
/** Documents above this size (UTF-16 units) skip per-line decorations. */
export const HEAVY_LIMIT = 8_000_000;
export interface EditorDocContext {
doc: number;
path: string;
+11 -1
Переглянути файл
@@ -23,7 +23,7 @@ import {
foldSelectionEffects,
} from "./folding";
import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode";
import { nearestMark, unfoldAround } from "./lanes";
import { nearestMark, scrollbarThumb, unfoldAround } from "./lanes";
import { lineRangeSelection } from "./gutterDrag";
import { matchBraceTarget } from "./caretCommands";
import { overwriteField, toggleOverwrite } from "./overwrite";
@@ -291,6 +291,16 @@ describe("error stripe", () => {
expect(nearestMark(marks, 21)).toBeNull();
});
it("finds the scrollbar thumb the marks must let through", () => {
// Nothing to scroll: no thumb.
expect(scrollbarThumb(0, 400, 400)).toBeNull();
// A quarter of the document visible: a 100px thumb, at the top, then at the bottom.
expect(scrollbarThumb(0, 1600, 400)).toEqual({ top: 0, bottom: 100 });
expect(scrollbarThumb(1200, 1600, 400)).toEqual({ top: 300, bottom: 400 });
// A long document keeps the 32px minimum thumb; halfway down it sits mid-track.
expect(scrollbarThumb(49800, 100000, 400)).toEqual({ top: 184, bottom: 216 });
});
it("unfolds what hides the target line", () => {
const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({
effects: foldEffect.of({ from: 3, to: 8 }),
+61
Переглянути файл
@@ -27,6 +27,27 @@ export function nearestMark<M extends { top: number; bottom: number }>(marks: re
return best;
}
/** The smallest scrollbar thumb (`::-webkit-scrollbar-thumb` min-height in global.css). */
const MIN_THUMB = 32;
/**
* Where the vertical scrollbar thumb of a scroller is, in px from the top of its track, or null
* when nothing scrolls. The overview lane lies over the scrollbar, so its marks must not take
* the pointer where the thumb is (#752).
*/
export function scrollbarThumb(
scrollTop: number,
scrollHeight: number,
clientHeight: number,
minThumb = MIN_THUMB,
): { top: number; bottom: number } | null {
const max = scrollHeight - clientHeight;
if (max <= 0 || clientHeight <= 0) return null;
const length = Math.min(clientHeight, Math.max(minThumb, (clientHeight * clientHeight) / scrollHeight));
const top = (Math.min(Math.max(scrollTop, 0), max) / max) * (clientHeight - length);
return { top, bottom: top + length };
}
/** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */
export function unfoldAround(state: EditorState, pos: number): StateEffect<unknown>[] {
const lineEnd = state.doc.lineAt(pos).to;
@@ -41,12 +62,17 @@ class OverviewLane {
dom: HTMLElement;
private scheduled = false;
private lastKey = "";
/** The last pointer position over the editor, to re-check the thumb when it scrolls. */
private pointer: { x: number; y: number } | null = null;
constructor(private view: EditorView) {
this.dom = document.createElement("div");
this.dom.className = "cm-openidea-overview";
this.dom.setAttribute("aria-hidden", "true");
this.dom.addEventListener("mousedown", this.onMouseDown);
view.dom.addEventListener("mousemove", this.onPointerMove);
view.dom.addEventListener("mouseleave", this.onPointerLeave);
view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true });
view.dom.appendChild(this.dom);
this.schedule();
}
@@ -132,6 +158,38 @@ class OverviewLane {
});
}
/**
* Over the scrollbar thumb the marks let the pointer through, so the thumb can be dragged
* even where a mark covers it (a new file is one mark the height of the lane, #752).
*/
private updateThumbPassThrough() {
const p = this.pointer;
let over = false;
if (p) {
const lane = this.dom.getBoundingClientRect();
const s = this.view.scrollDOM;
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
const y = p.y - s.getBoundingClientRect().top;
over = !!thumb && p.x >= lane.left && p.x <= lane.right && y >= thumb.top && y <= thumb.bottom;
}
this.dom.classList.toggle("cm-openidea-overview-onThumb", over);
}
private onPointerMove = (e: MouseEvent) => {
this.pointer = { x: e.clientX, y: e.clientY };
// A thumb drag in progress keeps the marks out of the way until the button is released.
if (e.buttons === 0) this.updateThumbPassThrough();
};
private onPointerLeave = () => {
this.pointer = null;
this.updateThumbPassThrough();
};
private onScroll = () => {
if (this.pointer) this.updateThumbPassThrough();
};
private onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
const marks = [...this.dom.querySelectorAll<HTMLElement>(".cm-openidea-overviewMark")].map((el) => {
@@ -153,6 +211,9 @@ class OverviewLane {
destroy() {
this.dom.removeEventListener("mousedown", this.onMouseDown);
this.view.dom.removeEventListener("mousemove", this.onPointerMove);
this.view.dom.removeEventListener("mouseleave", this.onPointerLeave);
this.view.scrollDOM.removeEventListener("scroll", this.onScroll);
this.dom.remove();
}
}
+2 -3
Переглянути файл
@@ -28,7 +28,7 @@ import type { EditorConfigProps } from "@/bindings";
import type { EditorSettings } from "@/platform/ipc";
import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings";
import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts";
import { editorContributions, type EditorDocContext } from "@/editor/api";
import { editorContributions, HEAVY_LIMIT, type EditorDocContext } from "@/editor/api";
import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree";
import { openideaHighlightStyle } from "./highlight";
import { openideaTheme } from "./theme";
@@ -78,8 +78,7 @@ export function prefsOf(settings: EditorSettings | undefined): EditorExtras {
return editorPrefs({ editor: settings });
}
/** Documents above this size (UTF-16 units) skip per-line decorations. */
export const HEAVY_LIMIT = 8_000_000;
export { HEAVY_LIMIT } from "@/editor/api";
export interface EffectiveIndent {
tabSize: number;
+2
Переглянути файл
@@ -283,6 +283,8 @@ export const openideaTheme = EditorView.theme({
cursor: "pointer",
},
".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" },
// The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752).
".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" },
".cm-openidea-overviewCaret": {
position: "absolute",
left: "2px",
+8 -5
Переглянути файл
@@ -118,19 +118,22 @@ async function format(
try {
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave }));
} catch (e) {
if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") {
if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) {
const tool = e.data.tool;
if (e.kind === "notFound" && e.data.missing) {
await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave });
await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave });
} else if (e.kind !== "cancelled") {
notifications().notify({
group: "tools",
level: "error",
title: tx("Prettier formatting failed"),
title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"),
body:
e.kind === "timedOut"
? tx("Prettier formatting timed out")
? tool === "oxfmt"
? tx("oxfmt formatting timed out")
: tx("Prettier formatting timed out")
: createElement("span", { className: styles.diagnostics }, e.message),
key: `prettier-format-error:${doc.path}`,
key: `${tool}-format-error:${doc.path}`,
sticky: false,
});
}
+7 -6
Переглянути файл
@@ -18,7 +18,6 @@ import {
import { formatDocument } from "./format";
import * as lsp from "./lsp";
const tool = { type: "tool", tool: "prettier", missing: false } as const;
const views: EditorView[] = [];
const viewFor = (path = "/project/file.css") => {
const view = new EditorView({
@@ -43,7 +42,9 @@ afterEach(() => {
_resetI18nForTests();
});
describe("external formatter errors", () => {
describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => {
const tool = { type: "tool", tool: name, missing: false } as const;
const display = name === "prettier" ? "Prettier" : name;
it("uses structured missing-tool information to request the shared install prompt", async () => {
const prompt = vi.fn();
const dispose = registerAction({
@@ -54,9 +55,9 @@ describe("external formatter errors", () => {
try {
failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } });
await formatDocument(viewFor());
expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true });
expect(prompt).toHaveBeenCalledWith({ name, sticky: true });
await formatDocument(viewFor(), { explicit: false, onSave: true });
expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false });
expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false });
expect(useNotifications.getState().items).toHaveLength(0);
} finally {
dispose();
@@ -95,7 +96,7 @@ describe("external formatter errors", () => {
for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true });
const notes = useNotifications.getState().items;
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" });
expect(notes[0]).toMatchObject({ sticky: false, title: `${display} 格式化失败` });
expect(balloonTimeout(notes[0]!)).toBe(10000);
render(<Balloons />);
expect(screen.getByRole("alert")).toHaveTextContent(stderr);
@@ -115,7 +116,7 @@ describe("external formatter errors", () => {
const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool });
const view = viewFor();
await formatDocument(view);
expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false });
expect(useNotifications.getState().items[0]).toMatchObject({ body: `${display} 格式化超时`, sticky: false });
clearNotifications();
request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool }));
await formatDocument(view);
+1 -1
Переглянути файл
@@ -54,7 +54,7 @@ export function UsagePreview({ usage }: { usage: Usage }) {
return (
<span className={styles.preview}>
{tokenSpans(text, 0, from, tokens, "b:")}
<mark className={styles.match}>{tokenSpans(text, from, to, tokens, "m:")}</mark>
<mark className={styles.match}>{text.slice(from, to)}</mark>
{tokenSpans(text, to, text.length, tokens, "a:")}
</span>
);
+5 -2
Переглянути файл
@@ -17,14 +17,17 @@ describe("UsagePreview", () => {
);
await waitFor(() => expect(container.querySelector(".tok-keyword")?.textContent).toBe("const"));
expect(container.querySelector("mark")?.textContent).toBe("foo");
expect(container.querySelector("mark")?.childElementCount).toBe(0);
expect(container.textContent).toBe("const x = foo();");
});
it("colours a usage inside a doc comment as a comment", async () => {
it("keeps comment colours outside the match and the search foreground inside it", async () => {
const { container } = render(
<UsagePreview usage={{ path: "/p/a.ts", line: 1, col: 8, endCol: 11, text: " * Uses foo()" }} />,
);
await waitFor(() => expect(container.querySelector("mark .tok-comment")?.textContent).toBe("foo"));
await waitFor(() => expect(container.querySelector(".tok-comment")?.textContent).toBe("* Uses "));
expect(container.querySelector("mark")?.textContent).toBe("foo");
expect(container.querySelector("mark")?.childElementCount).toBe(0);
});
});
+3
Переглянути файл
@@ -40,6 +40,9 @@
color: var(--fg-strong);
font-weight: 600;
}
.moduleLabel {
font-weight: 600;
}
/* IDEA's "excluded" look: ignored / excluded entries are dimmed. */
.excluded {
color: var(--vcs-ignored);
+10 -1
Переглянути файл
@@ -48,7 +48,12 @@ import { badgeKind } from "@/ui/symbolKind";
import { contextMenuItems, menuGroupContext, newMenuItems } from "./contextMenu";
import { openNewPopup } from "./newPopupModel";
import { presentationIcon, useJvmPresentation } from "@/platform/filePresentation";
import { moduleFolderIcon, useProjectPresentation, type BuildPresentation } from "@/platform/projectPresentation";
import {
isModuleFolder,
moduleFolderIcon,
useProjectPresentation,
type BuildPresentation,
} from "@/platform/projectPresentation";
import { relativePath } from "@/platform/project";
import { folderKind } from "./folderKind";
import { useHomeDir } from "./home";
@@ -270,6 +275,8 @@ const Row = memo(function Row({
node.dir || node.placeholder || node.icon
? iconFor(node, roots, root, build, detected)
: presentationIcon(node.path, file, roots, root, build.spring);
const moduleFolder =
node.dir && !node.placeholder && !!root && isModuleFolder(relativePath(root, node.path), node.root, build.modules);
const isEditing =
!!editing && (node.placeholder ? editing.kind === "new" : editing.kind === "rename" && editing.key === node.key);
const faded = node.ignored || deco.faded;
@@ -310,6 +317,8 @@ const Row = memo(function Row({
node.root && styles.rootName,
// Only the project itself is bold (IDEA); contributed roots like Scratches are not.
node.root && !node.icon && styles.rootLabel,
// Maven / Gradle module folders are bold too, as in IDEA.
moduleFolder && styles.moduleLabel,
faded && !deco.color && styles.excluded,
deco.error && styles.error,
)}
+8
Переглянути файл
@@ -68,6 +68,14 @@ describe("lazy loading", () => {
expect([...useProjectTree.getState().expanded].sort()).toEqual(["/p", "/p/docs"]);
});
it("keeps a collapsed project root collapsed and an expanded node outside it open", async () => {
setExpanded(new Set(["/other/lib"]));
await new Promise((r) => setTimeout(r, 350));
initProjectTree(null);
initProjectTree(ROOT, "proj");
expect([...useProjectTree.getState().expanded]).toEqual(["/other/lib"]);
});
it("drops a folder that vanished and collapses it", async () => {
await loadDir(ROOT);
setExpanded(new Set([ROOT, "/p/gone"]));
+6 -5
Переглянути файл
@@ -301,11 +301,12 @@ function initialExpanded(root: string | null, mode: ViewMode, members = get().me
r.scope === "scratches" && r.path ? [r.path] : [],
),
);
const persisted = mode === "project" ? (readJson<string[]>(expandedKey(root)) ?? []) : [];
const roots = members ?? [root];
const expanded = new Set(persisted.filter((p) => roots.some((r) => isSameOrInside(p, r))));
roots.forEach((r) => expanded.add(r));
return expanded;
// The tree as the user left it, like IDEA: a collapsed project root stays collapsed, and
// nodes outside it (content roots, contributed roots) stay open.
// Keys of nodes that no longer exist are inert. Only a first open expands the roots.
const persisted = mode === "project" ? readJson<string[]>(expandedKey(root)) : null;
if (persisted) return new Set(persisted);
return new Set(members ?? [root]);
}
function persistExpanded() {
+5
Переглянути файл
@@ -21,6 +21,7 @@ import { findOutputLinks, type OutputLink } from "./links";
import { openTarget, openUrl } from "./navigate";
import { segmentLine, type SegmentMark } from "./segments";
import styles from "./Run.module.css";
import { useConsoleSelection } from "./useConsoleSelection";
import { hostBinding, formatBinding, hasPrimaryModifier, registerKeyClaim } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { ConsoleFindBar } from "./FindBar";
@@ -211,7 +212,9 @@ export function ConsoleView({
});
const programmatic = useRef(false);
const { rangeExtractor, selecting } = useConsoleSelection(scrollRef, () => onFollowChange(false));
const virtualizer = useVirtualizer({
rangeExtractor,
count,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW,
@@ -241,6 +244,7 @@ export function ConsoleView({
const el = scrollRef.current;
if (!el || programmatic.current) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < ROW;
if (atBottom && selecting) return;
if (atBottom !== follow) onFollowChange(atBottom);
};
@@ -537,6 +541,7 @@ export function ConsoleView({
<Line line={line} marks={marksByLine.get(row.index)} length={line.text.length} cwd={cwd} />
{row.fold && foldButton(row.fold, true)}
{inputInline && item.index === rows.length - 1 && pendingInput}
{"\n"}
</div>
);
})}
+34
Переглянути файл
@@ -252,6 +252,21 @@ describe("java command lines", () => {
).toEqual(["--module-path", "/m/x.jar"]);
});
it("colours a Spring Boot console unless the configuration sets the property", () => {
const boot = (args: string[]) => ({
...applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"),
kind: "spring-boot" as const,
args,
});
expect(resolveJavaCommand(boot(["-Xmx1g", "a.Main"]), info).args[0]).toBe("-Dspring.output.ansi.enabled=always");
expect(resolveJavaCommand(boot(["-Dspring.output.ansi.enabled=never", "a.Main"]), info).args).toEqual([
"-Dspring.output.ansi.enabled=never",
"a.Main",
]);
const plain = applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java");
expect(resolveJavaCommand(plain, info).args).not.toContain("-Dspring.output.ansi.enabled=always");
});
it("joins arguments for java-debug and reads JDWP's port", () => {
expect(joinJavaArgs(["a", "b c", 'q"'])).toBe('a "b c" "q\\""');
expect(jdwpListeningPort("Listening for transport dt_socket at address: 50123")).toBe(50123);
@@ -380,6 +395,25 @@ describe("temporary configurations", () => {
expect(useRun.getState().temporary).toEqual([]);
});
it("reuse a configuration of the same main class and module, whatever its id", () => {
const own = (module: string) =>
cfg({
id: `user:${module}`,
kind: "spring-boot",
name: `Mine ${module}`,
args: ["-Xmx1g", "-cp", "${classpath}", "a.Main"],
module,
detected: false,
});
useRun.setState({ configs: [own("other"), own("app")] });
expect(adoptTemporary(applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java")).name).toBe(
"Mine app",
);
expect(useRun.getState().temporary).toEqual([]);
const fresh = adoptTemporary(applicationConfig({ className: "a.Other" }, "app/src/main/java/a/Other.java"));
expect(useRun.getState().temporary).toEqual([fresh.id]);
});
it("offer Save in the chooser while selected", async () => {
const app = adoptTemporary(applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"));
const { configs, temporary } = useRun.getState();
+11 -1
Переглянути файл
@@ -256,6 +256,13 @@ function persistTemporary() {
);
}
/** IDEA's `isConfigurationFromContext` for an Application: the same main class of the same module. */
function sameApplication(a: RunConfig, b: RunConfig): boolean {
if (!isJvmApplication(a) || !isJvmApplication(b)) return false;
const main = parseJavaArgs(a.args).mainClass;
return main !== null && main === parseJavaArgs(b.args).mainClass && (a.module ?? a.cwd) === (b.module ?? b.cwd);
}
/**
* Selects the configuration for a gutter / context run: a saved or detected
* configuration with the same id is reused, otherwise `config` becomes a
@@ -263,7 +270,10 @@ function persistTemporary() {
*/
export function adoptTemporary(config: RunConfig): RunConfig {
const state = useRun.getState();
const existing = state.configs.find((c) => c.id === config.id);
// IDEA's `findExistingConfiguration`: a configuration the user keeps for this context wins.
const existing =
state.configs.find((c) => c.id === config.id) ??
state.configs.find((c) => !state.temporary.includes(c.id) && sameApplication(c, config));
if (existing && !state.temporary.includes(config.id)) {
selectConfig(existing.id);
return existing;
+94
Переглянути файл
@@ -0,0 +1,94 @@
import { act, fireEvent, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useConsoleSelection } from "./useConsoleSelection";
const viewport = { startIndex: 100, endIndex: 110, overscan: 2, count: 500 };
afterEach(() => {
window.getSelection()?.removeAllRanges();
document.body.replaceChildren();
});
function setup() {
const host = document.createElement("div");
document.body.append(host);
const rows = [5, 6, 7].map((index) => {
const row = document.createElement("div");
row.dataset.index = String(index);
row.textContent = `output ${index}`;
host.append(row);
return row;
});
const ref = { current: host };
const pause = vi.fn();
const hook = renderHook(({ pause }) => useConsoleSelection(ref, pause), { initialProps: { pause } });
const select = (backward = false) => {
act(() => {
const [anchor, focus] = backward ? [rows[2]!, rows[0]!] : [rows[0]!, rows[2]!];
window.getSelection()!.setBaseAndExtent(anchor.firstChild!, 1, focus.firstChild!, 3);
document.dispatchEvent(new Event("selectionchange"));
});
};
return { ...hook, host, rows, pause, select };
}
describe("console selection retention", () => {
it.each([false, true])("keeps the full selected interval mounted (backward=%s)", (backward) => {
const { result, select, pause } = setup();
select(backward);
const indices = result.current.rangeExtractor(viewport);
expect(indices.slice(0, 3)).toEqual([5, 6, 7]);
expect(indices).not.toContain(50);
expect(indices).toContain(100);
expect(pause).toHaveBeenCalled();
});
it("renders the intervening text when dragging beyond the viewport", () => {
const { result, rows, select } = setup();
fireEvent.mouseDown(rows[0]!, { button: 0 });
select();
const indices = result.current.rangeExtractor(viewport);
expect(indices[0]).toBe(5);
expect(indices).toHaveLength(108);
fireEvent.mouseUp(document);
expect(result.current.rangeExtractor(viewport)).not.toContain(50);
expect(result.current.rangeExtractor(viewport)).toContain(5);
});
it("releases pinned rows when the selection is cleared or moved outside the console", () => {
const { result, select } = setup();
select();
act(() => {
window.getSelection()!.removeAllRanges();
document.dispatchEvent(new Event("selectionchange"));
});
expect(result.current.selecting).toBe(false);
expect(result.current.rangeExtractor(viewport)[0]).toBe(98);
select();
const other = document.createTextNode("other output");
document.body.append(other);
act(() => {
window.getSelection()!.setBaseAndExtent(other, 0, other, 5);
document.dispatchEvent(new Event("selectionchange"));
});
expect(result.current.selecting).toBe(false);
expect(result.current.rangeExtractor(viewport)[0]).toBe(98);
});
it("keeps a drag active when incoming output rerenders the parent", () => {
const { result, rows, select, rerender } = setup();
fireEvent.mouseDown(rows[0]!, { button: 0 });
const pause = vi.fn();
rerender({ pause });
select();
expect(result.current.rangeExtractor(viewport)).toContain(50);
expect(pause).toHaveBeenCalled();
});
it("does not pin rows or pause output for a context click", () => {
const { result, rows, pause } = setup();
fireEvent.mouseDown(rows[0]!, { button: 2 });
expect(result.current.selecting).toBe(false);
expect(pause).not.toHaveBeenCalled();
});
});
+73
Переглянути файл
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useEffectEvent, useState, type RefObject } from "react";
import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual";
type SelectedRows = { start: number; end: number; dragging: boolean };
/** Keep the browser's selection endpoints, and the text between them, mounted. */
export function useConsoleSelection(host: RefObject<HTMLDivElement | null>, pauseFollow: () => void) {
const pause = useEffectEvent(pauseFollow);
const [selected, setSelected] = useState<SelectedRows | null>(null);
useEffect(() => {
const el = host.current;
if (!el) return;
let dragging = false;
const indexOf = (node: Node | null) => {
const element = node instanceof Element ? node : node?.parentElement;
const row = element?.closest<HTMLElement>("[data-index]");
return row && el.contains(row) ? Number(row.dataset.index) : null;
};
const update = () => {
const selection = window.getSelection();
const anchor = indexOf(selection?.anchorNode ?? null);
const focus = indexOf(selection?.focusNode ?? null);
const next =
selection && (!selection.isCollapsed || dragging) && anchor !== null && focus !== null
? { start: Math.min(anchor, focus), end: Math.max(anchor, focus), dragging }
: null;
setSelected((prev) =>
prev?.start === next?.start && prev?.end === next?.end && prev?.dragging === next?.dragging ? prev : next,
);
if (next && !selection!.isCollapsed) pause();
};
const down = (event: MouseEvent) => {
if (event.button !== 0) return;
const index = indexOf(event.target as Node);
if (index === null) return;
dragging = true;
// Pin the anchor before a scroll can recycle it, even before selectionchange fires.
setSelected({ start: index, end: index, dragging: true });
pause();
};
const up = () => {
dragging = false;
update();
};
el.addEventListener("mousedown", down);
document.addEventListener("mouseup", up);
document.addEventListener("selectionchange", update);
return () => {
el.removeEventListener("mousedown", down);
document.removeEventListener("mouseup", up);
document.removeEventListener("selectionchange", update);
};
}, [host]);
const rangeExtractor = useCallback(
(range: Range) => {
const visible = defaultRangeExtractor(range);
if (!selected || !visible.length) return visible;
// During a drag, scrolling must also render every line up to the pointer.
const start = Math.max(0, selected.dragging ? Math.min(selected.start, visible[0]!) : selected.start);
const end = Math.min(
range.count - 1,
selected.dragging ? Math.max(selected.end, visible[visible.length - 1]!) : selected.end,
);
const indices = new Set(visible);
for (let i = start; i <= end; i++) indices.add(i);
return [...indices].sort((a, b) => a - b);
},
[selected],
);
return { rangeExtractor, selecting: selected !== null };
}
+1 -1
Переглянути файл
@@ -118,7 +118,7 @@ function validateSpelling(raw: unknown): SpellingValid {
export { ACCENT_PRESETS } from "@/workbench/theme";
export const DEFAULT_SETTINGS: ValidSettings = {
general: { reopenLastProject: true, confirmExit: false, ...DEFAULT_GENERAL_EXTRAS },
general: { reopenLastProject: true, confirmExit: true, ...DEFAULT_GENERAL_EXTRAS },
appearance: { theme: "light", accent: "#3871e1", uiFontSize: 13, locale: "en", ...DEFAULT_APPEARANCE_EXTRAS },
editor: {
fontFamily: "JetBrains Mono",
+1
Переглянути файл
@@ -492,6 +492,7 @@ export const VCS_PAGES: PageSpec[] = [
keywords: ["Test", "git version"],
component: GitExecutableRow,
},
{ kind: "toggle", key: "vcs.git.inlineBlame", label: "Show inline blame for the current line" },
],
},
{
+47 -1
Переглянути файл
@@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks";
import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend";
import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store";
import { filterTools, LanguageServersPage } from "../LanguageServersPage";
import { installMissingServerPrompts, missingKey, promptInstall } from "../missing";
import { installMissingServerPrompts, installServerWarningPrompts, missingKey, promptInstall } from "../missing";
import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm";
type Note = Parameters<NotificationService["notify"]>[0];
@@ -273,3 +273,49 @@ describe("tools.install action", () => {
}
});
});
describe("language server capability warning", () => {
const event = {
type: "languageServerWarning",
name: "clangd",
message:
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.",
installTool: "clangd",
} as const;
it("offers a managed replacement for system clangd once and restarts it after installation", async () => {
backend.tools.push({
...sampleTools()[0]!,
name: "clangd",
displayName: "clangd",
command: "clangd",
status: "onPath",
path: "/usr/bin/clangd",
languages: ["C", "C++", "Objective-C", "Objective-C++"],
});
const off = installServerWarningPrompts();
try {
emitLocalIdeEvent(event);
emitLocalIdeEvent(event);
await waitFor(() => expect(notes).toHaveLength(1));
expect(notes[0]).toMatchObject({ title: "clangd", level: "warning", sticky: true, body: event.message });
expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]);
notes[0]!.actions![0]!.run();
await waitFor(() => expect(backend.restarted).toEqual(["clangd"]));
expect(notes.at(-1)).toMatchObject({ title: "clangd installed", key: notes[0]!.key });
} finally {
off();
}
});
it("keeps the Settings action when the tool cannot be installed", async () => {
const off = installServerWarningPrompts();
try {
emitLocalIdeEvent(event);
await waitFor(() => expect(notes).toHaveLength(1));
expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Language Servers…"]);
} finally {
off();
}
});
});
+2 -1
Переглянути файл
@@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui";
import { executeAction, registerActions } from "@/platform/actions";
import { registerSettingsPage } from "@/workbench/contributions";
import { installJdkPrompts, registerJdkActions } from "./jdk";
import { installMissingServerPrompts, promptInstall } from "./missing";
import { installMissingServerPrompts, installServerWarningPrompts, promptInstall } from "./missing";
import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store";
/** The Language Servers page (also used by the Settings window). */
@@ -79,6 +79,7 @@ export function activate(): () => void {
},
]),
installMissingServerPrompts(),
installServerWarningPrompts(),
registerJdkActions(),
installJdkPrompts(),
];
+28
Переглянути файл
@@ -56,3 +56,31 @@ export function installMissingServerPrompts(): () => void {
void promptInstall(e.name).catch(() => {});
});
}
/** A system server that lacks a capability can be replaced by the managed tool. */
export function installServerWarningPrompts(): () => void {
const prompted = new Set<string>();
return onIdeEvent("languageServerWarning", (e) => {
const key = `lsp-warning-${e.name}-${e.installTool}`;
if (prompted.has(key)) return;
prompted.add(key);
void (async () => {
if (!useTools.getState().loaded) await refreshTools();
const tool = toolByName(e.installTool);
notifications().notify({
group: "lsp",
level: "warning",
title: e.name,
body: tx(e.message),
key,
sticky: true,
actions: [
...(tool?.canInstall
? [{ label: "Install", run: () => void installTool(e.installTool, { notificationKey: key }) }]
: []),
{ label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) },
],
});
})();
});
}
+58 -34
Переглянути файл
@@ -8,13 +8,17 @@ import { keymap } from "@codemirror/view";
import { unifiedMergeView } from "@codemirror/merge";
import type { MergeSides } from "@/bindings";
import { embeddedEditorExtensions } from "@/editor/api";
import { setContext } from "@/platform/context";
import { tx, useTxTemplate } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { Button, IconButton, showContextMenu, Spinner, T } from "@/ui";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { blameSlot, toggleAnnotations } from "./blame";
import { languageFor, viewerBase } from "./cm";
import { confirmDialog } from "./dialogs";
import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator";
import { syncMergeScroll } from "./mergeScroll";
import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts";
import { baseName } from "./model";
import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops";
@@ -220,28 +224,10 @@ export function MergeEditor({ input }: { input: EditorInput }) {
setLeft(result.state.field(conflictField).conflicts.length);
oursView.current = ours;
theirsView.current = theirs;
// Scroll the three panes together (by scroll ratio), like IDEA's merge view.
const views = [ours, result, theirs];
let syncing = false;
const onScroll = (from: EditorView) => () => {
if (syncing) return;
syncing = true;
const s = from.scrollDOM;
const ratio = s.scrollTop / Math.max(1, s.scrollHeight - s.clientHeight);
for (const other of views) {
if (other === from) continue;
const o = other.scrollDOM;
o.scrollTop = ratio * Math.max(0, o.scrollHeight - o.clientHeight);
}
syncing = false;
};
const offs = views.map((v) => {
const h = onScroll(v);
v.scrollDOM.addEventListener("scroll", h, { passive: true });
return () => v.scrollDOM.removeEventListener("scroll", h);
});
// Scroll the three panes together, lined up through their changes.
const stopSync = syncMergeScroll({ ours, result, theirs });
return () => {
offs.forEach((off) => off());
stopSync();
ours.destroy();
theirs.destroy();
result.destroy();
@@ -313,17 +299,31 @@ export function MergeEditor({ input }: { input: EditorInput }) {
}
};
// IDEA's merge view: F7 / Shift+F7 jump between conflicts; each side pane can
// annotate its version with Git Blame from the context menu.
// Each side pane blames on the branch it belongs to, so lines that only exist on
// the other branch show their real history instead of "Not Committed Yet".
// Next / Previous Difference (F7 / Shift+F7 by default) jump between conflicts through
// the keymap, as in the diff viewer.
// `goto` reads only refs, so the first render's one stays valid.
const [nav] = useState<DiffNavigator>(() => ({
next: () => goto(1),
prev: () => goto(-1),
jumpToSource: () => void editorService().openFile(path, { focus: true }),
applyChange: () => false,
file: () => {},
showSettings: () => {},
}));
useEffect(
() => () => {
if (focusedDiffNavigator() === nav) {
setFocusedDiffNavigator(null);
setContext("diffEditorFocus", false);
}
},
[nav],
);
// Each side pane can annotate its version with Git Blame from the context menu, on
// the branch it belongs to, so lines that only exist on the other branch show their
// real history instead of "Not Committed Yet".
const otherRev = state?.sides.commit ?? "HEAD";
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "F7") {
e.preventDefault();
goto(e.shiftKey ? -1 : 1);
}
};
const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string, revision?: string) => {
const view = viewRef.current;
@@ -358,10 +358,34 @@ export function MergeEditor({ input }: { input: EditorInput }) {
);
return (
<div className={styles.root} onKeyDown={onKeyDown}>
<div
className={styles.root}
onFocusCapture={() => {
setFocusedDiffNavigator(nav);
setContext("diffEditorFocus", true);
}}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav) {
setFocusedDiffNavigator(null);
setContext("diffEditorFocus", false);
}
}}
>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Merge")}>
<IconButton icon="arrow-up" title="Previous Conflict" size="small" onClick={() => goto(-1)} />
<IconButton icon="arrow-down" title="Next Conflict" size="small" onClick={() => goto(1)} />
<IconButton
icon="arrow-up"
title="Previous Conflict"
shortcut={shortcutLabel("diff.prevDifference")}
size="small"
onClick={() => goto(-1)}
/>
<IconButton
icon="arrow-down"
title="Next Conflict"
shortcut={shortcutLabel("diff.nextDifference")}
size="small"
onClick={() => goto(1)}
/>
<span className={styles.sep} />
<Button size="small" onClick={() => acceptAll("ours")}>
<T>Accept All Yours</T>
+255
Переглянути файл
@@ -0,0 +1,255 @@
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView, activateHover, closeHoverTooltips, hasHoverTooltips } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BlameLine, Settings } from "@/bindings";
import { HEAVY_LIMIT, _emitEditorEvent } from "@/editor/api";
import { useProject } from "@/platform/project";
import { useI18n } from "@/platform/i18n";
import { formatInlineBlame, inlineBlame, relativeBlameDate } from "../inlineBlame";
import { useVcs } from "../store";
const mocks = vi.hoisted(() => ({ blame: vi.fn(), file: vi.fn(), log: vi.fn(), action: vi.fn() }));
vi.mock("@/platform/actions", async (original) => ({
...(await original<typeof import("@/platform/actions")>()),
executeAction: mocks.action,
}));
vi.mock("@/platform/ipc", async (original) => {
const actual = await original<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, vcsBlame: mocks.blame, vcsFileAt: mocks.file, vcsLog: mocks.log },
};
});
const now = Date.UTC(2026, 10, 2, 12);
const alice: BlameLine = {
commit: "a".repeat(40),
author: "Alice Smith",
timeMs: now - 86400_000,
summary: "First line",
};
const bob: BlameLine = { commit: "b".repeat(40), author: "Bob Jones", timeMs: now - 3600_000, summary: "Second line" };
const uncommitted: BlameLine = { commit: "0".repeat(40), author: "Not Committed Yet", timeMs: now, summary: "" };
const ctx = { doc: 1, path: "/repo/file.ts", language: "typescript", uiMode: null, readOnly: false };
let view: EditorView | undefined;
function settings(enabled: boolean) {
useProject.setState({ settings: { vcs: { git: { inlineBlame: enabled } } } as Settings });
}
function editor(doc = "one\ntwo\nthree", context = ctx) {
view = new EditorView({
parent: document.body,
state: EditorState.create({ doc, extensions: [inlineBlame(context)] }),
});
return view;
}
const hint = () => view?.dom.querySelector<HTMLElement>(".cm-vcs-inline-blame");
const tick = () => vi.advanceTimersByTimeAsync(300);
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
vi.clearAllMocks();
useI18n.setState({ locale: "en", texts: {} });
settings(true);
useVcs.setState({ available: true, roots: ["/repo"], statusLoaded: true, byPath: new Map(), version: 0 });
mocks.file.mockResolvedValue({ status: "ok", data: "one\ntwo\nthree" });
mocks.blame.mockResolvedValue({ status: "ok", data: [alice, bob, uncommitted] });
});
afterEach(() => {
view?.destroy();
view = undefined;
useProject.setState({ settings: null });
vi.useRealTimers();
});
describe("inline blame formatting", () => {
it("formats authors, relative dates, summaries and missing dates", () => {
expect(formatInlineBlame(alice, now)).toBe("Alice Smith, 1 day ago · First line");
expect(formatInlineBlame({ ...bob, timeMs: null, summary: "Long\nmessage" }, now)).toBe("Bob Jones · Long message");
expect(formatInlineBlame(uncommitted, now)).toBe("You, now · Uncommitted changes");
expect(relativeBlameDate(now + 1000, now)).toBe("now");
expect(relativeBlameDate(now - 120_000, now)).toBe("2 minutes ago");
expect(relativeBlameDate(now - 400 * 86400_000, now)).toBe("1 year ago");
});
});
describe("current line decoration", () => {
it("debounces caret moves and reuses blame for the same document version", async () => {
const v = editor();
await vi.advanceTimersByTimeAsync(299);
expect(mocks.blame).not.toHaveBeenCalled();
v.dispatch({ selection: { anchor: 4 } });
await vi.advanceTimersByTimeAsync(299);
expect(mocks.blame).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(hint()?.textContent).toContain("Bob Jones");
v.dispatch({ selection: { anchor: 0 } });
expect(hint()).toBeNull();
await tick();
expect(hint()?.textContent).toContain("Alice Smith");
expect(mocks.blame).toHaveBeenCalledTimes(1);
expect(mocks.file).toHaveBeenCalledTimes(1);
expect(hint()?.contentEditable).toBe("false");
hint()?.click();
expect(mocks.action).toHaveBeenCalledWith("vcs.showCommit", alice.commit);
});
it("uses only the primary selection and hides multiline selections", async () => {
const v = editor();
v.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(4)], 1) });
await tick();
expect(hint()?.textContent).toContain("Bob Jones");
v.dispatch({ selection: { anchor: 0, head: 5 } });
await tick();
expect(hint()).toBeNull();
v.dispatch({ selection: { anchor: 4, head: 6 } });
await tick();
expect(hint()?.textContent).toContain("Bob Jones");
});
it("loads and caches the full commit message on hover and closes it on caret movement", async () => {
const message = "First line\n\nThe complete commit body.";
mocks.log.mockResolvedValue({ status: "ok", data: [{ message }] });
const v = editor();
await tick();
activateHover(v, 3, 1);
await vi.advanceTimersByTimeAsync(0);
expect(hasHoverTooltips(v.state)).toBe(true);
const tooltip = v.dom.querySelector(".cm-vcs-inline-blame-tooltip");
expect(tooltip?.textContent).toContain(message);
expect(tooltip?.textContent).toContain(alice.author);
expect(tooltip?.textContent).toContain(alice.commit);
expect(tooltip?.textContent).toContain(new Date(alice.timeMs!).toLocaleString());
v.dispatch({ effects: closeHoverTooltips });
activateHover(v, 3, 1);
await vi.advanceTimersByTimeAsync(0);
expect(mocks.log).toHaveBeenCalledTimes(1);
v.dispatch({ selection: { anchor: 4 } });
expect(hasHoverTooltips(v.state)).toBe(false);
});
it("closes an open tooltip when the setting is disabled", async () => {
mocks.log.mockResolvedValue({ status: "ok", data: [{ message: "Full message" }] });
const v = editor();
await tick();
activateHover(v, 3, 1);
await vi.advanceTimersByTimeAsync(0);
expect(hasHoverTooltips(v.state)).toBe(true);
settings(false);
expect(hasHoverTooltips(v.state)).toBe(false);
});
it("refetches edits, saves and VCS refreshes", async () => {
const v = editor();
await tick();
mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted, bob, uncommitted] });
v.dispatch({ changes: { from: 0, insert: "x" } });
expect(hint()).toBeNull();
await tick();
expect(mocks.blame).toHaveBeenLastCalledWith(ctx.path, "xone\ntwo\nthree", null);
expect(hint()?.textContent).toBe("You, now · Uncommitted changes");
_emitEditorEvent({ type: "docSaved", doc: 1, path: ctx.path });
await tick();
useVcs.setState({ version: 1 });
await tick();
expect(mocks.blame).toHaveBeenCalledTimes(4);
});
it("discards a response from an older document version", async () => {
let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void;
mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done)));
const v = editor();
await tick();
v.dispatch({ changes: { from: 0, insert: "x" } });
resolve({ status: "ok", data: [alice] });
await vi.advanceTimersByTimeAsync(0);
expect(hint()).toBeNull();
mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted] });
await tick();
expect(hint()?.textContent).toBe("You, now · Uncommitted changes");
});
it("coalesces an in-flight blame when the caret moves", async () => {
let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void;
mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done)));
const v = editor();
await tick();
v.dispatch({ selection: { anchor: 4 } });
await tick();
resolve({ status: "ok", data: [alice, bob] });
await vi.advanceTimersByTimeAsync(0);
expect(hint()?.textContent).toContain("Bob Jones");
expect(mocks.blame).toHaveBeenCalledTimes(1);
});
it("responds to the setting immediately", async () => {
settings(false);
editor();
await tick();
expect(mocks.blame).not.toHaveBeenCalled();
settings(true);
await tick();
expect(hint()).not.toBeNull();
settings(false);
expect(hint()).toBeNull();
await tick();
expect(mocks.blame).toHaveBeenCalledTimes(1);
});
it("ignores a pending response after disabling", async () => {
let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void;
mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done)));
editor();
await tick();
settings(false);
resolve({ status: "ok", data: [alice] });
await vi.advanceTimersByTimeAsync(0);
expect(hint()).toBeNull();
await tick();
expect(hint()).toBeNull();
});
it.each(["untracked", "ignored"] as const)("skips %s paths", async (unstaged) => {
useVcs.setState({
byPath: new Map([[ctx.path, { path: ctx.path, relPath: "file.ts", unstaged, staged: null, renamedFrom: null }]]),
});
editor();
await tick();
expect(mocks.file).not.toHaveBeenCalled();
expect(hint()).toBeNull();
});
it("skips ignored paths missing from status and HEAD", async () => {
mocks.file.mockResolvedValue({ status: "ok", data: null });
editor();
await tick();
expect(mocks.blame).not.toHaveBeenCalled();
expect(hint()).toBeNull();
});
it("skips paths outside repositories", async () => {
editor("one", { ...ctx, path: "/elsewhere/file.ts" });
await tick();
expect(mocks.file).not.toHaveBeenCalled();
});
it("skips large, virtual and read-only documents", async () => {
editor("x".repeat(HEAVY_LIMIT + 1));
await tick();
expect(mocks.file).not.toHaveBeenCalled();
expect(inlineBlame({ ...ctx, virtual: true })).toEqual([]);
expect(inlineBlame({ ...ctx, readOnly: true })).toEqual([]);
});
it("cancels pending work when the view is destroyed", async () => {
editor().destroy();
view = undefined;
await tick();
expect(mocks.file).not.toHaveBeenCalled();
});
});
+28
Переглянути файл
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { ScrollEchoes } from "../mergeScroll";
describe("ScrollEchoes", () => {
it("recognizes the scroll event a move causes, once", () => {
const echoes = new ScrollEchoes<string>();
echoes.mark("result", 120);
// The browser may round the position it scrolled to.
expect(echoes.take("result", 120.5)).toBe(true);
// The next event of that pane is the user's.
expect(echoes.take("result", 120.5)).toBe(false);
});
it("treats a pane the user scrolled past the move as the user's", () => {
const echoes = new ScrollEchoes<string>();
echoes.mark("theirs", 120);
expect(echoes.take("theirs", 180)).toBe(false);
expect(echoes.take("ours", 0)).toBe(false);
});
it("keeps moves of each pane apart", () => {
const echoes = new ScrollEchoes<string>();
echoes.mark("ours", 10);
echoes.mark("theirs", 40);
expect(echoes.take("theirs", 40)).toBe(true);
expect(echoes.take("ours", 10)).toBe(true);
});
});
+2 -2
Переглянути файл
@@ -23,7 +23,7 @@ export interface Band {
}
/** Pixel [top, bottom] of a chunk's range in `view`; an empty range is the boundary it sits on. */
function extent(view: EditorView, from: number, to: number): [number, number] {
export function extent(view: EditorView, from: number, to: number): [number, number] {
const len = view.state.doc.length;
if (from > len) {
const end = view.lineBlockAt(len).bottom;
@@ -345,6 +345,6 @@ export class UnalignedPanes {
}
/** Offset of the document's top within the pane's scrolled content. */
function docOffset(view: EditorView): number {
export function docOffset(view: EditorView): number {
return view.documentTop - view.scrollDOM.getBoundingClientRect().top + view.scrollDOM.scrollTop;
}
+2 -1
Переглянути файл
@@ -23,6 +23,7 @@ import { registerHistoryActions, trackOperationContext } from "./historyActions"
import { registerLogPaneActions } from "./logPaneActions";
import { controlLabel, controlOperation, inProgress } from "./operations";
import { annotationsShown, blameSlot } from "./blame";
import { inlineBlame } from "./inlineBlame";
import { BranchWidget, useBranchesPopup } from "./BranchWidget";
import { showConflictsDialog } from "./conflictsUi";
import { registerCompareActions } from "./compare";
@@ -212,7 +213,7 @@ export function activate(): () => void {
registerEditorContribution({
id: "vcs.changeMarkers",
order: 60,
extensions: (ctx) => [changeMarkers(ctx), blameSlot()],
extensions: (ctx) => [changeMarkers(ctx), blameSlot(), inlineBlame(ctx)],
}),
registerMenuGroup({
id: "vcs.gutter",
+281
Переглянути файл
@@ -0,0 +1,281 @@
import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
hoverTooltip,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import type { BlameLine } from "@/bindings";
import { HEAVY_LIMIT, onEditorEvent, type EditorDocContext } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { tx, useI18n } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { isUncommitted } from "./blame";
import { repoOf, statusOf, useVcs } from "./store";
export function relativeBlameDate(timeMs: number | null, now = Date.now()): string {
if (timeMs === null) return "";
const seconds = Math.max(0, (now - timeMs) / 1000);
const units = [
["year", 365 * 86400],
["month", 30 * 86400],
["day", 86400],
["hour", 3600],
["minute", 60],
] as const;
const unit = units.find(([, size]) => seconds >= size);
return unit
? new Intl.RelativeTimeFormat(useI18n.getState().locale, { numeric: "always" }).format(
-Math.floor(seconds / unit[1]),
unit[0],
)
: tx("now");
}
export function formatInlineBlame(line: BlameLine, now = Date.now()): string {
if (isUncommitted(line)) return tx("You, now · Uncommitted changes");
const date = relativeBlameDate(line.timeMs, now);
return `${line.author}${date ? `, ${date}` : ""} · ${line.summary.replace(/\s+/g, " ")}`;
}
/** Only the current document version is retained, including its in-flight request. */
class BlameCache {
private doc: Text | undefined;
private pending: Promise<BlameLine[]> | undefined;
clear() {
this.doc = undefined;
this.pending = undefined;
}
get(doc: Text, load: () => Promise<BlameLine[]>): Promise<BlameLine[]> {
if (doc !== this.doc || !this.pending) {
this.doc = doc;
this.pending = load();
}
return this.pending;
}
}
class BlameWidget extends WidgetType {
constructor(readonly line: BlameLine) {
super();
}
eq(other: BlameWidget) {
return formatInlineBlame(other.line) === formatInlineBlame(this.line) && other.line.commit === this.line.commit;
}
toDOM() {
const el = document.createElement("span");
el.className = "cm-vcs-inline-blame";
el.textContent = formatInlineBlame(this.line);
el.contentEditable = "false";
if (!isUncommitted(this.line)) {
el.setAttribute("role", "button");
el.tabIndex = 0;
const open = () => void executeAction("vcs.showCommit", this.line.commit);
el.addEventListener("mousedown", (e) => e.preventDefault());
el.addEventListener("click", open);
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
});
}
return el;
}
}
const setInlineBlame = StateEffect.define<DecorationSet>();
const inlineBlameField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
if (tr.docChanged || tr.selection) return Decoration.none;
for (const effect of tr.effects) if (effect.is(setInlineBlame)) return effect.value;
return value;
},
provide: (field) => EditorView.decorations.from(field),
});
export function inlineBlame(ctx: EditorDocContext): Extension {
if (ctx.virtual || ctx.readOnly) return [];
const plugin = ViewPlugin.fromClass(
class {
private timer: ReturnType<typeof setTimeout> | undefined;
private readonly cache = new BlameCache();
private readonly messages = new Map<string, Promise<string>>();
private readonly off: (() => void)[];
private generation = 0;
private destroyed = false;
line: BlameLine | undefined;
constructor(readonly view: EditorView) {
this.off = [
useVcs.subscribe((s, prev) => {
if (s.version !== prev.version) this.invalidate();
}),
onEditorEvent((e) => {
if (e.type === "docSaved" && e.path === ctx.path) this.invalidate();
}),
onWorkflowSettingsChange(
(s) => s.vcs.git.inlineBlame,
() => this.invalidate(),
),
useI18n.subscribe(() => this.invalidate()),
];
this.schedule();
}
private enabled() {
const vcs = useVcs.getState();
const status = statusOf(ctx.path);
return (
workflowSettings().vcs.git.inlineBlame &&
isTauri() &&
vcs.available &&
vcs.statusLoaded &&
!!repoOf(ctx.path) &&
status?.unstaged !== "untracked" &&
status?.unstaged !== "ignored" &&
this.view.state.doc.length <= HEAVY_LIMIT
);
}
private clear() {
this.line = undefined;
if (this.view.state.field(inlineBlameField).size)
this.view.dispatch({ effects: setInlineBlame.of(Decoration.none) });
}
private invalidate() {
this.cache.clear();
this.clear();
this.schedule();
}
update(update: ViewUpdate) {
if (update.docChanged) this.cache.clear();
if (update.docChanged || update.selectionSet) {
this.line = undefined;
this.schedule();
}
}
private schedule() {
clearTimeout(this.timer);
++this.generation;
this.timer = setTimeout(() => void this.refresh(), 300);
}
private async refresh() {
if (this.destroyed || !this.enabled()) return;
const { doc, selection } = this.view.state;
const caret = doc.lineAt(selection.main.head);
if (doc.lineAt(selection.main.from).number !== doc.lineAt(selection.main.to).number) return;
const generation = this.generation;
try {
const lines = await this.cache.get(doc, async () => {
// Blame also returns synthetic entries for paths absent from HEAD (ignored / new files).
if ((await call(commands.vcsFileAt(ctx.path, "HEAD"))) === null) return [];
return call(commands.vcsBlame(ctx.path, doc.toString(), null));
});
if (this.destroyed || generation !== this.generation || doc !== this.view.state.doc || !this.enabled())
return;
this.line = lines[caret.number - 1];
if (this.line) {
const widget = Decoration.widget({ widget: new BlameWidget(this.line), side: 1 }).range(caret.to);
this.view.dispatch({ effects: setInlineBlame.of(Decoration.set([widget])) });
}
} catch {
// Repository removed, unborn HEAD, inaccessible file, etc.: leave the line undecorated.
}
}
message(line: BlameLine): Promise<string> {
if (isUncommitted(line)) return Promise.resolve(tx("You, now · Uncommitted changes"));
let message = this.messages.get(line.commit);
if (!message) {
message = call(
commands.vcsLog(repoOf(ctx.path), {
branch: line.commit,
path: null,
author: null,
text: null,
skip: 0,
limit: 1,
}),
).then(
(commits) => commits[0]?.message ?? line.summary,
() => line.summary,
);
if (this.messages.size >= 100) this.messages.delete(this.messages.keys().next().value!);
this.messages.set(line.commit, message);
}
return message;
}
destroy() {
this.destroyed = true;
clearTimeout(this.timer);
this.cache.clear();
this.off.forEach((off) => off());
}
},
);
return [
inlineBlameField,
plugin,
hoverTooltip(
async (view, pos, side) => {
const instance = view.plugin(plugin);
const line = instance?.line;
const end = view.state.doc.lineAt(view.state.selection.main.head).to;
if (!instance || !line || pos !== end || side < 0) return null;
const message = await instance.message(line);
if (view.plugin(plugin)?.line !== line) return null;
return {
pos: end,
above: true,
create() {
const dom = document.createElement("div");
dom.className = "cm-vcs-inline-blame-tooltip";
dom.textContent = isUncommitted(line)
? message
: `${line.author}\n${line.timeMs === null ? "" : new Date(line.timeMs).toLocaleString()}\n${line.commit}\n\n${message}`;
return { dom };
},
};
},
{
hideOnChange: true,
hideOn: (tr) => tr.effects.some((effect) => effect.is(setInlineBlame)),
},
),
EditorView.baseTheme({
".cm-vcs-inline-blame": {
color: "var(--fg-inlay)",
marginLeft: "3ch",
display: "inline-block",
maxWidth: "min(60ch, 50vw)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
verticalAlign: "bottom",
userSelect: "none",
},
'.cm-vcs-inline-blame[role="button"]': { cursor: "pointer" },
".cm-vcs-inline-blame-tooltip": {
padding: "8px 12px",
maxWidth: "min(70ch, 80vw)",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
},
}),
];
}
+98
Переглянути файл
@@ -0,0 +1,98 @@
/**
* Synchronized scrolling of the merge editor's three panes. Each side pane lines up with
* the result through the changes between them (the diff viewer's SyncScrollSupport
* mapping), and one side follows the other through the result.
*/
import { Chunk } from "@codemirror/merge";
import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { bandAt, docOffset, extent, transferY } from "./diffScroll";
export type MergePane = "ours" | "result" | "theirs";
const PANES: readonly MergePane[] = ["ours", "result", "theirs"];
/**
* Scroll positions a pane was moved to by another one. A pane's scroll event arrives a
* frame after the move, so it must be recognized as that move's echo: following it would
* write back to the pane the user is scrolling and cancel the scroll in progress.
*/
export class ScrollEchoes<K> {
private readonly moved = new Map<K, number>();
/** `pane` was moved to `top`. */
mark(pane: K, top: number) {
this.moved.set(pane, top);
}
/** Whether a scroll event of `pane` at `top` is the echo of the last move (consumed either way). */
take(pane: K, top: number): boolean {
const moved = this.moved.get(pane);
this.moved.delete(pane);
return moved !== undefined && Math.abs(top - moved) <= 1;
}
}
/** Starts scrolling `views` together; returns the function that stops it. */
export function syncMergeScroll(views: Record<MergePane, EditorView>): () => void {
const built = new Map<MergePane, { a: Text; b: Text; chunks: readonly Chunk[] }>();
const chunks = (side: MergePane) => {
const a = views[side].state.doc;
const b = views.result.state.doc;
const hit = built.get(side);
if (hit && hit.a === a && hit.b === b) return hit.chunks;
const fresh = Chunk.build(a, b);
built.set(side, { a, b, chunks: fresh });
return fresh;
};
/** Document y of `side` → the result (`toResult`), or back. */
const transfer = (side: MergePane, y: number, toResult: boolean) => {
const a = views[side];
const b = views.result;
const from = toResult ? "a" : "b";
const band = bandAt(
chunks(side),
(c) => extent(a, c.fromA, c.toA),
(c) => extent(b, c.fromB, c.toB),
a.lineBlockAt(a.state.doc.length).bottom,
b.lineBlockAt(b.state.doc.length).bottom,
y,
from,
);
return transferY([band], y, from);
};
const map = (from: MergePane, to: MergePane, y: number) => {
if (to === "result") return transfer(from, y, true);
if (from === "result") return transfer(to, y, false);
return transfer(to, transfer(from, y, true), false);
};
const echoes = new ScrollEchoes<MergePane>();
/** Scrolls the other panes to match `from`, anchored a third down the pane. */
const follow = (from: MergePane) => {
const master = views[from];
const anchor = master.scrollDOM.clientHeight / 3;
const at = master.scrollDOM.scrollTop + anchor - docOffset(master);
for (const to of PANES) {
if (to === from) continue;
const el = views[to].scrollDOM;
const top = Math.max(
0,
Math.min(map(from, to, at) + docOffset(views[to]) - anchor, el.scrollHeight - el.clientHeight),
);
if (Math.abs(el.scrollTop - top) < 1) continue;
const before = el.scrollTop;
el.scrollTop = top;
if (el.scrollTop !== before) echoes.mark(to, el.scrollTop);
}
};
const offs = PANES.map((pane) => {
const el = views[pane].scrollDOM;
const onScroll = () => {
if (!echoes.take(pane, el.scrollTop)) follow(pane);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
});
return () => offs.forEach((off) => off());
}
+1
Переглянути файл
@@ -77,6 +77,7 @@ export const CLIENT_SETTINGS_KEYS = [
"run.confirmTerminate",
"run.showOnStart",
"vcs.showDirtyDirectories",
"vcs.git.inlineBlame",
"vcs.git.showPushDialog",
"vcs.git.pushDialogOnlyProtected",
"vcs.git.combineStashAndShelf",
+12 -1
Переглянути файл
@@ -71,10 +71,21 @@ export function parseJavaArgs(args: readonly string[]): JavaCommandLine {
return { vmArgs, mainClass: null, programArgs: [], classpath, explicitClasspath };
}
/**
* VM options a launch adds for the console, as IDEA's Spring Boot configurations do: the
* console renders ANSI colours, but Spring only detects a terminal, and the process runs on pipes.
* Kept out when the configuration sets the property itself.
*/
export function consoleVmArgs(config: Pick<RunConfig, "kind" | "args">): string[] {
if (config.kind !== "spring-boot") return [];
const own = parseJavaArgs(config.args).vmArgs.some((a) => a.startsWith("-Dspring.output.ansi.enabled="));
return own ? [] : ["-Dspring.output.ansi.enabled=always"];
}
/** The configuration with its placeholders and launcher resolved. */
export function resolveJavaCommand(config: RunConfig, info: JavaLaunchInfo): RunConfig {
const cp = info.classPaths.join(info.pathListSeparator);
const args: string[] = [];
const args: string[] = [...consoleVmArgs(config)];
if (info.modulePaths.length && !config.args.some((a) => a === "-p" || a === "--module-path"))
args.push("--module-path", info.modulePaths.join(info.pathListSeparator));
args.push(...config.args.map((a) => (a === CLASSPATH || a === TEST_CLASSPATH ? cp : a)));
+14 -7
Переглянути файл
@@ -1,4 +1,6 @@
{
"The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "语言服务器 JDK({path})必须是包含 jdk.compiler、jdk.jdi 和 jdk.unsupported 的完整 JDK,才能支持 Java 语言功能和调试。jdtls 需要 JDK {version} 或更高版本。请下载 JDK {version},在 {setting} 中选择完整 JDK,或设为自动。",
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。",
"Newer dependency version available": "有更新的依赖版本可用",
"Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。",
"Update to {latest}": "更新至 {latest}",
@@ -1613,16 +1615,18 @@
"Install Missing Tool": "安装缺失的工具",
"Prettier formatting failed": "Prettier 格式化失败",
"Prettier formatting timed out": "Prettier 格式化超时",
"Prettier does not support selection formatting for this language; formatted the whole file": "Prettier 不支持此语言的选区格式化,已格式化整个文件",
"oxfmt formatting failed": "oxfmt 格式化失败",
"oxfmt formatting timed out": "oxfmt 格式化超时",
"{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件",
"Formatting timed out": "格式化超时",
"Prettier: Node.js is required": "Prettier:需要 Node.js",
"{name}: Node.js is required": "{name}:需要 Node.js",
"Formatter": "格式化工具",
"JSON with Comments": "带注释的 JSON",
"Unknown external formatter: {tool}": "未知的外部格式化工具:{tool}",
"Prettier: package has no CLI entry point": "Prettier:软件包未提供命令行入口",
"Prettier: CLI does not exist: {path}": "Prettier:命令行程序不存在:{path}",
"Prettier: file has no directory": "Prettier:文件路径不含目录",
"Prettier: {error}": "Prettier:{error}",
"{name}: package has no CLI entry point": "{name}:软件包未提供命令行入口",
"{name}: CLI does not exist: {path}": "{name}:命令行程序不存在:{path}",
"{name}: file has no directory": "{name}:文件路径不含目录",
"{name}: {error}": "{name}:{error}",
"Reformat File": "重新格式化文件",
"Reformat code": "重新格式化代码",
"Reformat code on save": "保存时重新格式化代码",
@@ -5494,5 +5498,8 @@
"Cannot paste image": "无法粘贴图片",
"Remote server does not support image paste.": "远程服务器不支持粘贴图片。",
"Clipboard image reading is unavailable.": "无法读取剪贴板图片。",
"Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。"
"Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。",
"Show inline blame for the current line": "为当前行显示内联 Git Blame",
"You, now · Uncommitted changes": "你,刚刚 · 未提交的更改",
"now": "刚刚"
}
+13 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildPresentation, moduleFolderIcon } from "./projectPresentation";
import { buildPresentation, isModuleFolder, moduleFolderIcon } from "./projectPresentation";
import type { BuildModel } from "./ipc";
const model = (partial: unknown) => ({ maven: null, gradle: null, ...(partial as object) }) as BuildModel;
@@ -13,6 +13,18 @@ describe("build presentation context", () => {
expect(moduleFolderIcon("app", false, modules)).toBe(`${tool}-module`);
expect(moduleFolderIcon("plain", false, modules)).toBeNull();
});
it("marks nested module folders, not the project root or plain folders", () => {
const modules = new Map<string, "maven" | "gradle">([
["", "maven"],
["core", "maven"],
["services/api", "gradle"],
]);
expect(isModuleFolder("core", false, modules)).toBe(true);
expect(isModuleFolder("services/api", false, modules)).toBe(true);
expect(isModuleFolder("", true, modules)).toBe(false);
expect(isModuleFolder("services", false, modules)).toBe(false);
expect(isModuleFolder("core/src", false, modules)).toBe(false);
});
it("includes Maven aggregators and modules without source roots", () => {
const context = buildPresentation(
model({
+5
Переглянути файл
@@ -24,6 +24,11 @@ export function buildPresentation(model: BuildModel): BuildPresentation {
return { modules, spring };
}
/** A Maven / Gradle module folder below the project root: IDEA shows its name in bold. */
export function isModuleFolder(rel: string, root: boolean, modules: BuildPresentation["modules"]): boolean {
return !root && rel !== "" && modules.has(rel);
}
export function moduleFolderIcon(rel: string, root: boolean, modules: BuildPresentation["modules"]): string | null {
const tool = modules.get(rel);
return tool ? `${tool}-${root ? "project" : "module"}` : null;
+6
Переглянути файл
@@ -3,6 +3,12 @@ import { splitCommandLine } from "./commandLine";
import { DEFAULT_WORKFLOW, validateWorkflow } from "./workflowSettings";
describe("validateWorkflow", () => {
it("defaults inline blame off and accepts only boolean values", () => {
expect(DEFAULT_WORKFLOW.vcs.git.inlineBlame).toBe(false);
expect(validateWorkflow({ vcs: { git: { inlineBlame: true } } }).vcs.git.inlineBlame).toBe(true);
expect(validateWorkflow({ vcs: { git: { inlineBlame: "true" } } }).vcs.git.inlineBlame).toBe(false);
});
it("falls back to defaults for missing or malformed values", () => {
expect(validateWorkflow(undefined)).toEqual(DEFAULT_WORKFLOW);
const v = validateWorkflow({
+3
Переглянути файл
@@ -18,6 +18,7 @@ export interface IssueLinkValid {
}
export interface GitSettingsValid {
inlineBlame: boolean;
executable: string;
updateMethod: UpdateMethod;
autoUpdateOnPushRejected: boolean;
@@ -183,6 +184,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
limitHistory: true,
historyLimit: 1000,
git: {
inlineBlame: false,
executable: "",
updateMethod: "merge",
autoUpdateOnPushRejected: false,
@@ -309,6 +311,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid {
limitHistory: bool(v.limitHistory, d.limitHistory),
historyLimit: num(v.historyLimit, d.historyLimit, L.historyLimit, true),
git: {
inlineBlame: bool(g.inlineBlame, d.git.inlineBlame),
executable: text(g.executable, d.git.executable).trim(),
updateMethod: oneOf(g.updateMethod, ["merge", "rebase"] as const, d.git.updateMethod),
autoUpdateOnPushRejected: bool(g.autoUpdateOnPushRejected, d.git.autoUpdateOnPushRejected),
+3 -1
Переглянути файл
@@ -130,6 +130,7 @@ const ICONS = {
"file-gdscript": "openidea/fileTypes/gdscript",
"file-godot": "openidea/fileTypes/godot",
"file-gdshader": "openidea/fileTypes/gdshader",
"file-asciidoc": "openidea/fileTypes/asciidoc",
"file-d": "openidea/fileTypes/d",
"file-dart": "openidea/fileTypes/dart",
"file-elixir": "openidea/fileTypes/elixir",
@@ -826,7 +827,7 @@ const EXT_ICONS = table({
"file-rst": "rst",
"file-yaml": "yaml yml",
"file-toml": "toml",
"file-text": "txt text adoc",
"file-text": "txt text",
"file-log": "log",
"file-csv": "csv tsv",
"file-html": "html htm shtml html.tmpl",
@@ -879,6 +880,7 @@ const EXT_ICONS = table({
"file-gdscript": "gd",
"file-godot": "tscn tres godot import",
"file-gdshader": "gdshader gdshaderinc",
"file-asciidoc": "adoc asciidoc",
"file-d": "d di",
"file-dart": "dart",
"file-elixir": "ex exs",
+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><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="#C94F6D"/><path d="M4.5 11.5L8 4L11.5 11.5" stroke="#2B2D30" stroke-linecap="round"/><path d="M5.9 8.5H10.1" stroke="#2B2D30"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 399 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><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="#E0708A"/><path d="M4.5 11.5L8 4L11.5 11.5" stroke="#2B2D30" stroke-linecap="round"/><path d="M5.9 8.5H10.1" stroke="#2B2D30"/></g>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 399 B