mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 02:39:49 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575a0f91c2 | ||
|
|
fc11eaf678 | ||
|
|
8ab0cba184 | ||
|
|
4015a5b71f | ||
|
|
88619505c3 | ||
|
|
0d0de01831 | ||
|
|
9ce3c4cd7e | ||
|
|
74062497bd | ||
|
|
8d7ce81249 | ||
|
|
b3abc8edd1 | ||
|
|
245fe3cdf4 | ||
|
|
b5dbd3c020 | ||
|
|
4034853f91 | ||
|
|
93328d7895 | ||
|
|
b9d3ce7723 | ||
|
|
be7e2ff710 | ||
|
|
210f9a78ac |
No files matched your search
Generated
+4
@@ -4241,6 +4241,7 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4250,6 +4251,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4683,6 +4685,7 @@ dependencies = [
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -6207,6 +6210,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -285,6 +285,14 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec<String> {
|
||||
e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Child modules of a project or profile: `<modules>` (model 4.0.0) plus `<subprojects>`,
|
||||
/// which Maven 4 (model 4.1.0) uses instead.
|
||||
fn module_names(e: &Element) -> Vec<String> {
|
||||
let mut names = texts(e, "modules", "module");
|
||||
names.extend(texts(e, "subprojects", "subproject"));
|
||||
names
|
||||
}
|
||||
|
||||
fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPlugin>) {
|
||||
plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin));
|
||||
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
|
||||
@@ -308,7 +316,7 @@ fn read_raw(project: &Element) -> RawPom {
|
||||
name: project.text_of("name"),
|
||||
parent,
|
||||
properties: read_properties(project.child("properties")),
|
||||
modules: texts(project, "modules", "module"),
|
||||
modules: module_names(project),
|
||||
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
|
||||
managed: project
|
||||
.list("dependencyManagement/dependencies", "dependency")
|
||||
@@ -329,7 +337,7 @@ fn read_raw(project: &Element) -> RawPom {
|
||||
let mut profile = RawProfile {
|
||||
id: p.text_of("id").unwrap_or_else(|| "default".into()),
|
||||
active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"),
|
||||
modules: texts(p, "modules", "module"),
|
||||
modules: module_names(p),
|
||||
properties: read_properties(p.child("properties")),
|
||||
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
|
||||
..RawProfile::default()
|
||||
@@ -1276,6 +1284,26 @@ mod tests {
|
||||
</profiles>
|
||||
</project>"#;
|
||||
|
||||
#[test]
|
||||
fn maven_4_subprojects_are_modules() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project xmlns="http://maven.apache.org/POM/4.1.0"><modelVersion>4.1.0</modelVersion>
|
||||
<groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
|
||||
<subprojects><subproject>core</subproject></subprojects>
|
||||
<profiles><profile><id>extra</id><subprojects><subproject>extra</subproject></subprojects></profile></profiles>
|
||||
</project>"#,
|
||||
);
|
||||
write(root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(root, "extra/pom.xml", "<project><artifactId>extra</artifactId></project>");
|
||||
let model = load_with_repo(root, None);
|
||||
assert_eq!(model.projects[0].modules, ["core/pom.xml"]);
|
||||
assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_module_parent_properties_and_managed_versions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -19,4 +19,4 @@ tokio-util.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
@@ -13,7 +13,10 @@
|
||||
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
|
||||
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
|
||||
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
|
||||
//! how a build reads sources that declare no encoding.
|
||||
//! how a build reads sources that declare no encoding. The application JVMs a build
|
||||
//! forks get `file.encoding=UTF-8` for a UTF-8 console instead, through the build's own
|
||||
//! options: `-Dspring-boot.run.jvmArguments` for Maven `spring-boot:run`, an init script
|
||||
//! ([`GRADLE_FILE_ENCODING_SCRIPT`]) for Gradle `JavaExec` tasks (`bootRun`, `run`).
|
||||
//! - Python: `PYTHONIOENCODING`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
@@ -75,16 +78,34 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
const SPRING_BOOT_JVM_ARGS: &str = "-Dspring-boot.run.jvmArguments=";
|
||||
|
||||
/// Build tools and launcher scripts that run on (and may fork) JVMs.
|
||||
const JVM_TOOLS: &[&str] =
|
||||
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
|
||||
const JVM_TOOLS: &[&str] = &["sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
|
||||
const MAVEN: &[&str] = &["mvn", "mvnw", "mvnd"];
|
||||
const GRADLE: &[&str] = &["gradle", "gradlew"];
|
||||
|
||||
/// Gradle init script giving the `JavaExec` tasks (`bootRun`, `run`, custom ones) a UTF-8
|
||||
/// default charset before the build configures them, so the build can override it.
|
||||
/// Gradle's own JVM and the compiler's source encoding are untouched.
|
||||
pub const GRADLE_FILE_ENCODING_SCRIPT: &str = "\
|
||||
// Written by OpenIDEA: a UTF-8 default charset for applications run by Gradle, as the Run console decodes UTF-8.
|
||||
allprojects {
|
||||
tasks.withType(JavaExec).configureEach {
|
||||
defaultCharacterEncoding = 'UTF-8'
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
/// What a launch needs: `-D` options to put first on a `java` command line,
|
||||
/// environment variables to set, and the notice line its JVMs print because of them.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncodingFix {
|
||||
pub java_args: Vec<String>,
|
||||
/// The build tool's arguments, rewritten to pass options to the JVM it forks.
|
||||
pub args: Option<Vec<String>>,
|
||||
/// Gradle: pass [`GRADLE_FILE_ENCODING_SCRIPT`] with `--init-script`.
|
||||
pub gradle_init_script: bool,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
|
||||
pub notice: Option<String>,
|
||||
@@ -116,6 +137,8 @@ fn options(properties: &[&str], encoding: &str) -> Vec<String> {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Java,
|
||||
Maven,
|
||||
Gradle,
|
||||
JvmTool,
|
||||
Python,
|
||||
Other,
|
||||
@@ -125,6 +148,10 @@ fn kind(program: &str) -> Kind {
|
||||
let name = program_name(program);
|
||||
if name == "java" || name == "javaw" {
|
||||
Kind::Java
|
||||
} else if MAVEN.contains(&name.as_str()) {
|
||||
Kind::Maven
|
||||
} else if GRADLE.contains(&name.as_str()) {
|
||||
Kind::Gradle
|
||||
} else if JVM_TOOLS.contains(&name.as_str()) {
|
||||
Kind::JvmTool
|
||||
} else if is_python(&name) {
|
||||
@@ -193,7 +220,17 @@ fn fix_kind(
|
||||
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
|
||||
}
|
||||
}
|
||||
(Kind::Java | Kind::JvmTool, _) => {
|
||||
(Kind::Java | Kind::Maven | Kind::Gradle | Kind::JvmTool, _) => {
|
||||
if let Some(args) = args
|
||||
&& is_utf8(encoding)
|
||||
&& !sets(&tool_options, &[FILE_ENCODING])
|
||||
{
|
||||
match kind {
|
||||
Kind::Maven => out.args = spring_boot_file_encoding(args),
|
||||
Kind::Gradle => out.gradle_init_script = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !sets(&tool_options, STD_PROPERTIES) {
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
let value = match tool_options.trim() {
|
||||
@@ -214,14 +251,42 @@ fn fix_kind(
|
||||
out
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
|
||||
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
|
||||
fn spring_boot_file_encoding(args: &[String]) -> Option<Vec<String>> {
|
||||
if !args.iter().any(|a| a == "spring-boot:run" || (a.contains(":spring-boot-maven-plugin:") && a.ends_with(":run")))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
let option = format!("-D{FILE_ENCODING}=UTF-8");
|
||||
let mut out = args.to_vec();
|
||||
match out.iter_mut().rfind(|a| a.starts_with(SPRING_BOOT_JVM_ARGS)) {
|
||||
Some(own) if sets(own, &[FILE_ENCODING]) => return None,
|
||||
Some(own) => {
|
||||
if own.len() > SPRING_BOOT_JVM_ARGS.len() {
|
||||
own.push(' ');
|
||||
}
|
||||
own.push_str(&option);
|
||||
}
|
||||
None => out.push(format!("{SPRING_BOOT_JVM_ARGS}{option}")),
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
|
||||
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
|
||||
pub fn java_debug_vm_args(
|
||||
vm_args: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: Option<&str>,
|
||||
windows: bool,
|
||||
) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
|
||||
if added.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let added = added.join(" ");
|
||||
Some(match vm_args.trim() {
|
||||
"" => added,
|
||||
own => format!("{own} {added}"),
|
||||
@@ -427,6 +492,64 @@ mod tests {
|
||||
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_boot_run_forks_get_a_utf8_default_charset() {
|
||||
let boot = |args: &[&str], env: &[(&str, &str)]| run("mvn.cmd", args, env, "UTF-8", true).args;
|
||||
let utf8 = "-Dspring-boot.run.jvmArguments=-Dfile.encoding=UTF-8";
|
||||
assert_eq!(boot(&["spring-boot:run"], &[]).unwrap(), ["spring-boot:run", utf8]);
|
||||
let plugin = "org.springframework.boot:spring-boot-maven-plugin:3.2.0:run";
|
||||
assert_eq!(boot(&["-pl", "app", plugin], &[]).unwrap(), ["-pl", "app", plugin, utf8]);
|
||||
// The user's VM options are kept; their own `file.encoding` wins.
|
||||
assert_eq!(
|
||||
boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\""], &[]).unwrap(),
|
||||
["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\" -Dfile.encoding=UTF-8"]
|
||||
);
|
||||
assert_eq!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments="], &[]).unwrap()[1], utf8);
|
||||
assert!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Dfile.encoding=GBK"], &[]).is_none());
|
||||
assert!(boot(&["spring-boot:run"], &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")]).is_none());
|
||||
// Other goals and tools, other consoles, and other platforms are left alone.
|
||||
assert!(boot(&["package"], &[]).is_none());
|
||||
assert!(run("mvn", &["spring-boot:run"], &[], "GBK", true).args.is_none());
|
||||
assert!(run("mvn", &["spring-boot:run"], &[], "UTF-8", false).args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forked_application_jvms_get_a_utf8_default_charset() {
|
||||
// Reported: a Spring Boot app run by the build on Chinese Windows (JDK 8–17, GBK default).
|
||||
for (tool, args) in [
|
||||
("C:\\p\\gradlew.bat", &["bootRun"][..]),
|
||||
("gradle", &[":app:run"]),
|
||||
("mvnw.cmd", &["spring-boot:run"]),
|
||||
("mvnd", &["org.springframework.boot:spring-boot-maven-plugin:2.7.18:run"]),
|
||||
] {
|
||||
let f = run(tool, args, &[], "UTF-8", true);
|
||||
let gradle = tool.contains("gradle");
|
||||
assert_eq!(f.gradle_init_script, gradle, "{tool}");
|
||||
assert_eq!(f.args.is_some(), !gradle, "{tool}");
|
||||
// The build tool's own JVM keeps its default charset.
|
||||
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "{tool}");
|
||||
// The user's choice, a GBK console and other platforms are left alone.
|
||||
let own = run(tool, args, &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")], "UTF-8", true);
|
||||
assert!(!own.gradle_init_script && own.args.is_none(), "{tool}");
|
||||
let gbk = run(tool, args, &[], "GBK", true);
|
||||
assert!(!gbk.gradle_init_script && gbk.args.is_none(), "{tool}");
|
||||
assert_eq!(run(tool, args, &[], "UTF-8", false), EncodingFix::default(), "{tool}");
|
||||
}
|
||||
// Shell command lines can't take arguments; other JVM tools fork no application.
|
||||
assert!(!fix_command_line("gradlew bootRun", &BTreeMap::new(), |_| None, "UTF-8", true).gradle_init_script);
|
||||
let sbt = run("sbt", &["run"], &[], "UTF-8", true);
|
||||
assert!(!sbt.gradle_init_script && sbt.args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_script_respects_the_builds_own_file_encoding() {
|
||||
let s = GRADLE_FILE_ENCODING_SCRIPT;
|
||||
assert!(s.contains("tasks.withType(JavaExec).configureEach"), "{s}");
|
||||
assert!(s.contains("defaultCharacterEncoding = 'UTF-8'"), "{s}");
|
||||
assert!(!s.contains("doFirst"), "the build must be able to override the default: {s}");
|
||||
assert!(!s.contains("withType(Test)") && !s.contains("JavaCompile"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_lines_get_variables_from_their_first_word() {
|
||||
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
|
||||
@@ -446,12 +569,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_write_what_java_debug_decodes() {
|
||||
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
|
||||
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
|
||||
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
|
||||
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
|
||||
let debug =
|
||||
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
|
||||
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
|
||||
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
|
||||
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
|
||||
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
|
||||
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
|
||||
assert_eq!(debug("-Xmx1g", encoding, false), None);
|
||||
}
|
||||
for windows in [true, false] {
|
||||
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_preserves_user_options_independently() {
|
||||
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
|
||||
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
|
||||
for property in STD_PROPERTIES {
|
||||
let args = format!("-D{property}=GBK");
|
||||
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
|
||||
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
|
||||
}
|
||||
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
|
||||
for (own, expected) in [
|
||||
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
|
||||
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
|
||||
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
|
||||
] {
|
||||
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
|
||||
assert_eq!(
|
||||
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
|
||||
expected
|
||||
);
|
||||
}
|
||||
// A launch environment value overrides the inherited one, as in Run.
|
||||
assert_eq!(
|
||||
java_debug_vm_args(
|
||||
"",
|
||||
&vars(&[(key, "")]),
|
||||
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
|
||||
None,
|
||||
true
|
||||
),
|
||||
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn filtered(chunks: &[&str]) -> String {
|
||||
|
||||
+133
-38
@@ -245,10 +245,7 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
|
||||
#[cfg(windows)]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
|
||||
RegOpenKeyExW,
|
||||
};
|
||||
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
@@ -259,45 +256,53 @@ pub fn user_path() -> Option<OsString> {
|
||||
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let read = || {
|
||||
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
|
||||
let mut kind = 0u32;
|
||||
let mut len = 0u32;
|
||||
let name = wide("Path");
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
std::ptr::null_mut(),
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
buf.as_mut_ptr() as *mut _,
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf16_lossy(&buf);
|
||||
Some(text.trim_end_matches('\0').to_owned())
|
||||
};
|
||||
let value = read();
|
||||
let value = read_registry_string(key, &wide("Path"));
|
||||
RegCloseKey(key);
|
||||
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded
|
||||
/// against the current environment. `None` when the value is missing or unreadable.
|
||||
///
|
||||
/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value
|
||||
/// that grows when expanded is re-read at the size the failed call reports. And the
|
||||
/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it —
|
||||
/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)`
|
||||
/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at
|
||||
/// the first NUL, not just stripped of trailing ones.
|
||||
#[cfg(windows)]
|
||||
fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option<String> {
|
||||
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
|
||||
use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW};
|
||||
|
||||
const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
|
||||
let mut kind = 0u32;
|
||||
let mut len = 0u32;
|
||||
// SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`.
|
||||
let status =
|
||||
unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) };
|
||||
if status != ERROR_SUCCESS || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let buf = loop {
|
||||
let mut buf = vec![0u16; len as usize / 2 + 1];
|
||||
// SAFETY: as above; the buffer holds at least the `len` bytes the call may write.
|
||||
let status = unsafe {
|
||||
RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len)
|
||||
};
|
||||
if status == ERROR_SUCCESS {
|
||||
break buf;
|
||||
}
|
||||
if status != ERROR_MORE_DATA || len == 0 {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let text = String::from_utf16_lossy(&buf);
|
||||
let end = text.find('\0').unwrap_or(text.len());
|
||||
Some(text[..end].to_owned())
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
|
||||
#[cfg(not(windows))]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
@@ -334,6 +339,96 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn registry_strings_expand_and_stay_terminator_free() {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
|
||||
RegDeleteTreeW, RegSetValueExW,
|
||||
};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
struct TestState {
|
||||
key: HKEY,
|
||||
name: String,
|
||||
variable: String,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
impl Drop for TestState {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
|
||||
unsafe {
|
||||
RegCloseKey(self.key);
|
||||
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(&self.variable, value),
|
||||
None => std::env::remove_var(&self.variable),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
|
||||
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
|
||||
let long = "x".repeat(600);
|
||||
let mut key: HKEY = std::ptr::null_mut();
|
||||
let opened = unsafe {
|
||||
RegCreateKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
wide(&name).as_ptr(),
|
||||
0,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
KEY_READ | KEY_WRITE,
|
||||
std::ptr::null(),
|
||||
&mut key,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
|
||||
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
|
||||
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
|
||||
unsafe { std::env::set_var(&variable, &long) };
|
||||
let set = |value: &str, kind: u32, data: &[u16]| {
|
||||
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
RegSetValueExW(
|
||||
key,
|
||||
wide(value).as_ptr(),
|
||||
0,
|
||||
kind,
|
||||
data.as_ptr() as *const _,
|
||||
(data.len() * 2) as u32,
|
||||
)
|
||||
},
|
||||
ERROR_SUCCESS
|
||||
);
|
||||
};
|
||||
let read = |value: &str| read_registry_string(key, &wide(value));
|
||||
set("Plain", REG_SZ, &wide("plain"));
|
||||
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
|
||||
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
|
||||
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
|
||||
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
|
||||
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
|
||||
set("Empty", REG_SZ, &wide(""));
|
||||
let plain = read("Plain");
|
||||
let expand = read("Expand");
|
||||
let expand_long = read("ExpandLong");
|
||||
assert_eq!(plain.as_deref(), Some("plain"));
|
||||
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
|
||||
// Longer than its unexpanded form: needs the re-read at the expanded size.
|
||||
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
|
||||
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
|
||||
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
|
||||
assert_eq!(read("Empty").as_deref(), Some(""));
|
||||
assert_eq!(read("Missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
|
||||
|
||||
@@ -58,6 +58,43 @@ pub struct Settings {
|
||||
pub deployment: DeploymentSettings,
|
||||
/// Build, Execution, Deployment › Run Targets.
|
||||
pub run_targets: Vec<RunTarget>,
|
||||
pub cmake: CMakeSettings,
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CMakeSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub profiles: Option<Vec<CMakeProfile>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CMakeProfile {
|
||||
pub name: String,
|
||||
pub build_type: String,
|
||||
pub generator: String,
|
||||
pub cmake_args: Vec<String>,
|
||||
/// Empty uses cmake-build-<lowercase build type>.
|
||||
pub build_dir: String,
|
||||
pub build_args: Vec<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for CMakeProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Debug".into(),
|
||||
build_type: "Debug".into(),
|
||||
generator: String::new(),
|
||||
cmake_args: Vec::new(),
|
||||
build_dir: String::new(),
|
||||
build_args: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -154,6 +191,7 @@ impl Default for Settings {
|
||||
ssh: SshSettings::default(),
|
||||
deployment: DeploymentSettings::default(),
|
||||
run_targets: Vec::new(),
|
||||
cmake: CMakeSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,6 +803,24 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_argument_arrays_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.cmake.profiles.is_none());
|
||||
assert!(CMakeProfile::default().cmake_args.is_empty());
|
||||
assert!(CMakeProfile::default().build_args.is_empty());
|
||||
let profile = CMakeProfile {
|
||||
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
|
||||
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
|
||||
..Default::default()
|
||||
};
|
||||
settings.cmake.profiles = Some(vec![profile.clone()]);
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
|
||||
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
|
||||
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_font_fallback_defaults_empty_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
|
||||
|
||||
@@ -51,4 +51,11 @@ uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1.1", features = ["fs", "process"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
@@ -55,6 +55,7 @@ async fn run(base: PathBuf) -> i32 {
|
||||
let sink_peer = peer.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir,
|
||||
local_data_dir: data_dir.clone(),
|
||||
data_dir,
|
||||
languages,
|
||||
syntax: Arc::default(),
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Storage policy and best-effort migration of rebuildable desktop data.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Unknown entries and recovery/user content deliberately stay in roaming data.
|
||||
pub fn is_local(entry: &Path) -> bool {
|
||||
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
|
||||
.iter()
|
||||
.any(|root| entry.starts_with(root))
|
||||
|| entry.starts_with("agents/tools")
|
||||
|| entry == Path::new("agents/registry.json")
|
||||
|| (entry.components().count() == 1
|
||||
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
|
||||
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
|
||||
}
|
||||
|
||||
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
|
||||
if is_local(entry) { local.join(entry) } else { data.join(entry) }
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Action {
|
||||
Skip,
|
||||
Rename,
|
||||
}
|
||||
|
||||
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
|
||||
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RenameFailure {
|
||||
Copy,
|
||||
KeepSource,
|
||||
}
|
||||
|
||||
fn rename_failure(error: &io::Error) -> RenameFailure {
|
||||
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
|
||||
}
|
||||
|
||||
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
|
||||
/// the background. Consumers always use Local, even when migration fails.
|
||||
pub fn migrate(data: &Path, local: &Path) {
|
||||
if cfg!(target_os = "windows") && data != local {
|
||||
migrate_entries(data, local);
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_entries(data: &Path, local: &Path) {
|
||||
let mut entries: Vec<PathBuf> = [
|
||||
"language-server-runtimes",
|
||||
"index",
|
||||
"cache",
|
||||
"lsp",
|
||||
"notebook-runtime",
|
||||
"server",
|
||||
"java-usages-1.1.1.jar",
|
||||
"agents/registry.json",
|
||||
]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
// Virtual environments contain absolute interpreter paths. Keep the old copy
|
||||
// intact; these tools are installed afresh in Local when requested. Registry
|
||||
// agents also have absolute commands in user config, so leave legacy installs.
|
||||
match fs::read_dir(data.join("tools")) {
|
||||
Ok(tools) => {
|
||||
for entry in tools {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let name = entry.file_name();
|
||||
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
|
||||
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
|
||||
});
|
||||
if venv {
|
||||
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
|
||||
} else {
|
||||
entries.push(Path::new("tools").join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for entry in entries {
|
||||
let source = data.join(&entry);
|
||||
let target = data_path(data, local, &entry);
|
||||
match try_rename(&source, &target) {
|
||||
Ok(true) => copies.push((source, target)),
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !copies.is_empty() {
|
||||
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
|
||||
for (source, target) in copies {
|
||||
tracing::info!(?source, ?target, "copying legacy data in background");
|
||||
if let Err(error) = copy_then_remove(&source, &target) {
|
||||
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when a cross-volume copy is needed.
|
||||
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
|
||||
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
|
||||
return Ok(false);
|
||||
}
|
||||
if fs::symlink_metadata(source)?.file_type().is_symlink() {
|
||||
return Err(io::Error::other("refusing to migrate a linked data entry"));
|
||||
}
|
||||
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
|
||||
// Relative wrappers work at the old location too, so even a failed rename
|
||||
// leaves the old installation usable and needs no rollback.
|
||||
relocate_shims(source, source)?;
|
||||
match rename_missing(source, target) {
|
||||
Ok(()) => {
|
||||
tracing::info!(?source, ?target, "moved legacy data to Local");
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
|
||||
if target.try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
|
||||
let result = (|| {
|
||||
copy_tree(source, &staging)?;
|
||||
relocate_shims(&staging, source)?;
|
||||
// A tool may have been installed or a cache rebuilt while copying. Never
|
||||
// merge into or overwrite it, and retain the original for recovery.
|
||||
if target.try_exists()? {
|
||||
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
|
||||
return Ok(());
|
||||
}
|
||||
rename_missing(&staging, target)?;
|
||||
remove_entry(source)?;
|
||||
tracing::info!(?source, ?target, "copied legacy data to Local");
|
||||
Ok(())
|
||||
})();
|
||||
if staging.exists() {
|
||||
let _ = remove_entry(&staging);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// std::fs::rename may replace even an empty directory on modern Windows.
|
||||
#[cfg(windows)]
|
||||
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
|
||||
|
||||
// canonicalize supplies extended-length paths, including redirected UNC roots.
|
||||
let source = fs::canonicalize(source)?;
|
||||
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
|
||||
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
|
||||
let wide = |path: &Path| -> io::Result<Vec<u16>> {
|
||||
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
|
||||
if value.contains(&0) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
|
||||
}
|
||||
value.push(0);
|
||||
Ok(value)
|
||||
};
|
||||
let source = wide(&source)?;
|
||||
let target = wide(&target)?;
|
||||
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
|
||||
// neither replacement nor implicit cross-volume copying is permitted.
|
||||
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Filesystem tests run on Unix too; production migration is Windows-only.
|
||||
#[cfg(not(windows))]
|
||||
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
|
||||
if target.try_exists()? {
|
||||
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
|
||||
}
|
||||
fs::rename(source, target)
|
||||
}
|
||||
|
||||
fn remove_entry(path: &Path) -> io::Result<()> {
|
||||
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
|
||||
}
|
||||
|
||||
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
|
||||
let metadata = fs::symlink_metadata(source)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(io::Error::other("refusing to follow a link during data migration"));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
fs::create_dir(target)?;
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
fs::copy(source, target)?;
|
||||
} else {
|
||||
return Err(io::Error::other("unsupported data entry"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
|
||||
/// valid in both locations if the move must be rolled back. npm shims already use
|
||||
/// relative paths; payloads and user configuration are never rewritten.
|
||||
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
|
||||
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
|
||||
return Ok(());
|
||||
}
|
||||
let bin = root.join("bin");
|
||||
if !bin.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(bin)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().is_none_or(|ext| ext != "cmd") {
|
||||
continue;
|
||||
}
|
||||
let text = fs::read_to_string(&path)?;
|
||||
if text.starts_with("@echo off\r\n") {
|
||||
let updated = relative_wrapper(&text, source);
|
||||
if updated != text {
|
||||
fs::write(path, updated)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn relative_wrapper(text: &str, source: &Path) -> String {
|
||||
// PathBuf can contain both separators (notably roots joined with "tools/demo").
|
||||
// Compare normalized paths, but retain every other byte of the managed wrapper.
|
||||
let normalized = text.replace('\\', "/");
|
||||
let prefix = source.to_string_lossy().replace('\\', "/");
|
||||
#[cfg(windows)]
|
||||
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
|
||||
let mut updated = text.to_owned();
|
||||
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
|
||||
for offset in offsets.into_iter().rev() {
|
||||
let end = offset + prefix.len();
|
||||
if normalized.as_bytes().get(end) == Some(&b'/') {
|
||||
updated.replace_range(offset..end, "%~dp0..");
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
|
||||
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
|
||||
assert_eq!(
|
||||
relative_wrapper(text, Path::new("C:/old/tools/demo")),
|
||||
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_policy_is_conservative_and_preserves_unix_layout() {
|
||||
for entry in [
|
||||
"tools/jdks",
|
||||
"language-server-runtimes",
|
||||
"lsp/jdtls",
|
||||
"index/x",
|
||||
"cache/x",
|
||||
"notebook-runtime",
|
||||
"server",
|
||||
"logs",
|
||||
"agents/tools",
|
||||
"agents/registry.json",
|
||||
"java-usages-1.1.1.jar",
|
||||
] {
|
||||
assert!(is_local(Path::new(entry)), "{entry}");
|
||||
assert_eq!(
|
||||
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
|
||||
Path::new("local").join(entry)
|
||||
);
|
||||
assert_eq!(
|
||||
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
|
||||
Path::new("data").join(entry)
|
||||
);
|
||||
}
|
||||
for entry in [
|
||||
"git-credentials.json",
|
||||
"history",
|
||||
"remote",
|
||||
"scratches",
|
||||
"consoles",
|
||||
"plugins",
|
||||
"workspaces",
|
||||
"agents/models.json",
|
||||
"agents/project",
|
||||
"unknown",
|
||||
] {
|
||||
assert!(!is_local(Path::new(entry)), "{entry}");
|
||||
assert_eq!(
|
||||
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
|
||||
Path::new("roaming").join(entry)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
|
||||
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let new = tmp.path().join("new");
|
||||
fs::create_dir_all(old.join("index")).unwrap();
|
||||
migrate(&old, &new);
|
||||
assert!(old.join("index").exists());
|
||||
assert!(!new.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_cover_missing_existing_and_failed_renames() {
|
||||
assert_eq!(action(true, true, false), Action::Skip);
|
||||
assert_eq!(action(false, false, false), Action::Skip);
|
||||
assert_eq!(action(false, true, true), Action::Skip);
|
||||
assert_eq!(action(false, true, false), Action::Rename);
|
||||
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
|
||||
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
|
||||
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_volume_moves_and_second_start_does_not_overwrite() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let new = tmp.path().join("new");
|
||||
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
|
||||
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
|
||||
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
|
||||
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
|
||||
fs::write(old.join("git-credentials.json"), "secret").unwrap();
|
||||
fs::create_dir_all(old.join("index")).unwrap();
|
||||
migrate_entries(&old, &new);
|
||||
assert!(!old.join("tools/demo").exists());
|
||||
assert!(old.join("tools/ty/venv").exists());
|
||||
assert!(old.join("git-credentials.json").exists());
|
||||
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
|
||||
fs::create_dir_all(old.join("index")).unwrap();
|
||||
fs::write(old.join("index/legacy"), "old").unwrap();
|
||||
migrate_entries(&old, &new);
|
||||
assert!(old.join("index/legacy").exists());
|
||||
assert!(!new.join("index/legacy").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn copy_failure_does_not_publish_or_remove_source() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let new = tmp.path().join("new");
|
||||
fs::create_dir(&old).unwrap();
|
||||
fs::write(old.join("payload"), "old").unwrap();
|
||||
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
|
||||
assert!(copy_then_remove(&old, &new).is_err());
|
||||
assert!(old.join("payload").exists());
|
||||
assert!(!new.exists());
|
||||
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let new = tmp.path().join("new");
|
||||
fs::create_dir_all(old.join("nested")).unwrap();
|
||||
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
|
||||
copy_then_remove(&old, &new).unwrap();
|
||||
assert!(!old.exists());
|
||||
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
|
||||
fs::create_dir(&old).unwrap();
|
||||
fs::write(old.join("legacy"), "old").unwrap();
|
||||
copy_then_remove(&old, &new).unwrap();
|
||||
assert!(old.join("legacy").exists());
|
||||
assert!(!new.join("legacy").exists());
|
||||
let old_file = tmp.path().join("registry-old.json");
|
||||
let new_file = tmp.path().join("registry-new.json");
|
||||
fs::write(&old_file, "cached registry").unwrap();
|
||||
copy_then_remove(&old_file, &new_file).unwrap();
|
||||
assert!(!old_file.exists());
|
||||
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_refuses_existing_files_and_empty_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for directory in [false, true] {
|
||||
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
|
||||
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
|
||||
if directory {
|
||||
fs::create_dir(&old).unwrap();
|
||||
fs::create_dir(&new).unwrap();
|
||||
fs::write(old.join("payload"), "old").unwrap();
|
||||
} else {
|
||||
fs::write(&old, "old").unwrap();
|
||||
fs::write(&new, "new").unwrap();
|
||||
}
|
||||
assert!(rename_missing(&old, &new).is_err());
|
||||
assert!(old.exists() && new.exists());
|
||||
if directory {
|
||||
assert!(!new.join("payload").exists());
|
||||
} else {
|
||||
assert_eq!(fs::read_to_string(new).unwrap(), "new");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapper_failure_keeps_the_old_installation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old/tools/demo");
|
||||
let new = tmp.path().join("new/tools/demo");
|
||||
fs::create_dir_all(old.join("bin")).unwrap();
|
||||
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
|
||||
assert!(try_rename(&old, &new).is_err());
|
||||
assert!(old.join("bin/demo.cmd").exists());
|
||||
assert!(!new.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_keep_source_without_publishing_partial_data() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let blocker = tmp.path().join("blocker");
|
||||
fs::create_dir(&old).unwrap();
|
||||
fs::write(old.join("payload"), "old").unwrap();
|
||||
fs::write(&blocker, "file").unwrap();
|
||||
assert!(try_rename(&old, &blocker.join("target")).is_err());
|
||||
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
|
||||
assert!(old.join("payload").exists());
|
||||
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
|
||||
assert!(!tmp.path().join("new").exists());
|
||||
assert!(
|
||||
!fs::read_dir(tmp.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ pub async fn format(
|
||||
}
|
||||
let config = config.unwrap().clone();
|
||||
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let tools = Tools::new(&host.local_data_dir);
|
||||
let whole_selection = range.is_some() && !config.range;
|
||||
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
|
||||
let text = ide_text::Document::new(&snap.text);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod data_dirs;
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
@@ -33,7 +34,10 @@ pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
|
||||
|
||||
pub struct HostConfig {
|
||||
pub config_dir: PathBuf,
|
||||
/// Persistent user state, including credentials and recovery history.
|
||||
pub data_dir: PathBuf,
|
||||
/// Machine-local tools and rebuildable caches.
|
||||
pub local_data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
/// Grammars, possibly still being built on another thread.
|
||||
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
@@ -49,10 +53,14 @@ pub struct HostConfig {
|
||||
pub struct Host {
|
||||
pub trust: trust::TrustStore,
|
||||
pub config_dir: PathBuf,
|
||||
/// Persistent user state, including credentials and recovery history.
|
||||
pub data_dir: PathBuf,
|
||||
/// Machine-local tools and rebuildable caches.
|
||||
pub local_data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub global_settings: SettingsStore,
|
||||
pub dependency_versions: Arc<ide_workspace::dependencies::VersionCache>,
|
||||
pub terminals: TerminalManager,
|
||||
pub processes: ProcessManager,
|
||||
/// Debug sessions (adapters run on this machine, next to the project).
|
||||
@@ -76,8 +84,17 @@ pub struct Host {
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
|
||||
config;
|
||||
let HostConfig {
|
||||
config_dir,
|
||||
data_dir,
|
||||
local_data_dir,
|
||||
languages,
|
||||
syntax,
|
||||
sink_for,
|
||||
owner,
|
||||
runtime,
|
||||
project_environment,
|
||||
} = config;
|
||||
let global_settings = SettingsStore::load(&config_dir, None);
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
@@ -86,8 +103,11 @@ impl Host {
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, data_dir.clone()),
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
global_settings,
|
||||
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
|
||||
data_dir.join("cache/dependency-versions"),
|
||||
)),
|
||||
// Sessions, runs and terminals report to the window that started them (`window_sink`).
|
||||
terminals: TerminalManager::new(),
|
||||
processes: ProcessManager::new(),
|
||||
@@ -96,6 +116,7 @@ impl Host {
|
||||
syntax,
|
||||
config_dir,
|
||||
data_dir,
|
||||
local_data_dir,
|
||||
sink_for,
|
||||
owner,
|
||||
runtime,
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
|
||||
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
|
||||
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
|
||||
RegistryPaths::new(&host.config_dir, &host.data_dir)
|
||||
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
|
||||
}
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
|
||||
@@ -133,22 +133,29 @@ pub async fn build_sync(
|
||||
host.end_request(request);
|
||||
let model = result??;
|
||||
if tool == BuildTool::Maven
|
||||
&& let Some(maven) = &model.maven
|
||||
&& model.maven.is_some()
|
||||
&& let Some(bridge) = &project.kotlin_bridge
|
||||
{
|
||||
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
|
||||
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
|
||||
// to update the project configurations re-resolves them (#436).
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.configure_maven(offline, bridge_profiles);
|
||||
}
|
||||
let lsp = project.lsp.clone();
|
||||
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
});
|
||||
bridge.configure_maven(offline, bridge_profiles);
|
||||
}
|
||||
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
|
||||
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
|
||||
// to update the project configurations re-resolves them (#436). The same
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let build_files: Vec<String> = match tool {
|
||||
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
|
||||
BuildTool::Gradle => {
|
||||
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
|
||||
}
|
||||
};
|
||||
let lsp = project.lsp.clone();
|
||||
let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
});
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
.await?;
|
||||
}
|
||||
if config.adapter == ide_dap::kotlin::ADAPTER {
|
||||
let data_dir = host.data_dir.clone();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let runtime_settings = settings.clone();
|
||||
let runtime = host
|
||||
@@ -96,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
|
||||
};
|
||||
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let encoding = extra.get("encoding").and_then(|v| v.as_str());
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
|
||||
vm_args,
|
||||
&config.env,
|
||||
|key| std::env::var(key).ok(),
|
||||
encoding,
|
||||
windows,
|
||||
) {
|
||||
extra.insert("vmArgs".into(), vm_args.into());
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
}
|
||||
@@ -452,7 +458,7 @@ mod tests {
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -468,7 +474,7 @@ mod tests {
|
||||
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
|
||||
assert_eq!(
|
||||
vm_args(&fixed),
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
|
||||
@@ -481,4 +487,13 @@ mod tests {
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_respect_environment_encoding() {
|
||||
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
|
||||
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
|
||||
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Repository version lookups for manifest inspections and completion.
|
||||
use crate::{Host, rpc::Dispatcher};
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
|
||||
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
|
||||
|
||||
struct CratesIo;
|
||||
impl DependencyVersionSource for CratesIo {
|
||||
fn id(&self) -> &'static str {
|
||||
"crates-io"
|
||||
}
|
||||
|
||||
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?;
|
||||
let search_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited(
|
||||
&format!("https://index.crates.io/{path}"),
|
||||
Some(16 * 1024 * 1024),
|
||||
cancel,
|
||||
)?;
|
||||
cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `offset` limits a completion request to its version string; inspections omit it.
|
||||
pub async fn dependency_versions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
offset: Option<u32>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<DependencyVersions>> {
|
||||
let project = host.project(window)?;
|
||||
let cache = host.dependency_versions.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = host
|
||||
.blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for dependency in cargo::dependencies(&snap.text) {
|
||||
if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) {
|
||||
continue;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?;
|
||||
let update = cargo::update(&dependency.requirement, &versions);
|
||||
out.push(DependencyVersions { dependency, versions, update });
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("dependency_versions", |c| async move {
|
||||
dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::HostConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Source;
|
||||
impl DependencyVersionSource for Source {
|
||||
fn id(&self) -> &'static str {
|
||||
"crates-io"
|
||||
}
|
||||
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
Ok(vec!["1.10.0".into(), "0.2.1".into()])
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn host_uses_synced_document_cache_and_cursor_filter_without_network() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n";
|
||||
let path = root.join("Cargo.toml");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
let info = project.documents.open(&path).unwrap();
|
||||
for package in ["tokio", "serde"] {
|
||||
host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap();
|
||||
}
|
||||
let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10");
|
||||
assert!(all[1].update.is_none());
|
||||
let offset = all[1].dependency.from;
|
||||
let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap();
|
||||
assert_eq!(at_cursor.len(), 1);
|
||||
assert_eq!(at_cursor[0].dependency.package, "serde");
|
||||
assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty());
|
||||
host.cancel_request(RequestId(413));
|
||||
assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err());
|
||||
let other = root.join("other.toml");
|
||||
std::fs::write(&other, text).unwrap();
|
||||
let info = project.documents.open(&other).unwrap();
|
||||
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ impl Fixture {
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
|
||||
@@ -61,7 +61,7 @@ async fn resolve_launch(
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.data_dir.clone();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _external_build = external_build;
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod build;
|
||||
pub mod bytes;
|
||||
pub mod coverage;
|
||||
pub mod debug;
|
||||
pub mod dependencies;
|
||||
pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod env;
|
||||
@@ -69,6 +70,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
analysis::register(d);
|
||||
syntax::register(d);
|
||||
spell::register(d);
|
||||
dependencies::register(d);
|
||||
history::register(d);
|
||||
coverage::register(d);
|
||||
build::register(d);
|
||||
|
||||
@@ -50,10 +50,11 @@ fn detected_and_saved(
|
||||
suppressed: &[String],
|
||||
) -> Vec<RunConfig> {
|
||||
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
|
||||
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
|
||||
.into_iter()
|
||||
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
|
||||
.collect();
|
||||
let mut all: Vec<RunConfig> =
|
||||
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
|
||||
.into_iter()
|
||||
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
|
||||
.collect();
|
||||
all.extend(saved.iter().cloned());
|
||||
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
|
||||
|
||||
@@ -5,6 +5,23 @@ use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_run::RunConfig;
|
||||
|
||||
pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult<RunConfig> {
|
||||
host.require_trust(window)?;
|
||||
if !matches!(config.kind.as_str(), "dotnet" | "dotnet-test")
|
||||
|| config.extras.target.as_ref().is_some_and(|s| !s.is_empty())
|
||||
{
|
||||
return Err(IdeError::invalid(".NET Project requires the local machine"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
let mut config = host.blocking(move || ide_run::dotnet::resolve(&project.root, config)).await?;
|
||||
if debug {
|
||||
let root = host.project(window)?.root.clone();
|
||||
let target = ide_run::dotnet::target_path(&root, &config).await?;
|
||||
config.extras.params.insert("targetPath".into(), target);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -206,6 +223,9 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_dotnet_resolve", |c| async move {
|
||||
run_dotnet_resolve(&c.host, &c.window, c.arg("config")?, c.arg("debug")?).await
|
||||
});
|
||||
d.add("run_application_build", |c| async move {
|
||||
run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await
|
||||
});
|
||||
|
||||
@@ -3,6 +3,21 @@ use crate::Host;
|
||||
use ide_core::{IdeResult, TerminalId};
|
||||
use ide_terminal::{TerminalInfo, TerminalOptions};
|
||||
|
||||
mod images;
|
||||
|
||||
const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
|
||||
|
||||
pub async fn store_image(host: &Host, id: TerminalId, data: Vec<u8>, mime: String) -> IdeResult<String> {
|
||||
if data.len() > MAX_IMAGE_BYTES {
|
||||
return Err(ide_core::IdeError::invalid("image is too large to paste (limit 20 MB)"));
|
||||
}
|
||||
if !host.terminals.contains(id) {
|
||||
return Err(ide_core::IdeError::invalid("terminal session not found"));
|
||||
}
|
||||
let data_dir = host.data_dir.clone();
|
||||
host.blocking(move || images::store(&data_dir, &data, &mime)).await
|
||||
}
|
||||
|
||||
pub fn spawn(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -47,3 +62,34 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> {
|
||||
pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult<bool> {
|
||||
host.terminals.is_busy(id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::HostConfig;
|
||||
use ide_core::{NullSink, error::ErrorKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_size_limit_is_checked_before_storage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: root.path().join("config"),
|
||||
data_dir: root.path().join("data"),
|
||||
local_data_dir: root.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let id = TerminalId::next();
|
||||
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(error.message, "image is too large to paste (limit 20 MB)");
|
||||
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err();
|
||||
assert_eq!(error.message, "terminal session not found");
|
||||
assert!(!host.data_dir.join("terminal-images").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
use super::*;
|
||||
use rustix::fs::{self, AtFlags, Dir, Mode, OFlags};
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const DIRECTORY: &str = "terminal-images";
|
||||
|
||||
fn extension(mime: &str) -> IdeResult<&str> {
|
||||
match mime {
|
||||
"image/png" => Ok("png"),
|
||||
"image/jpeg" => Ok("jpg"),
|
||||
"image/gif" => Ok("gif"),
|
||||
"image/webp" => Ok("webp"),
|
||||
"image/bmp" => Ok("bmp"),
|
||||
"image/svg+xml" => Ok("svg"),
|
||||
"image/tiff" => Ok("tiff"),
|
||||
"image/x-icon" => Ok("ico"),
|
||||
_ => Err(IdeError::invalid("unsupported image type")),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_directory(data_dir: &Path) -> io::Result<File> {
|
||||
if !data_dir.is_absolute() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
|
||||
}
|
||||
// The configured data directory and its ancestors are the host's trust boundary.
|
||||
let parent =
|
||||
fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?;
|
||||
match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) {
|
||||
Ok(()) | Err(rustix::io::Errno::EXIST) => (),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let dir: File = fs::openat(
|
||||
&parent,
|
||||
DIRECTORY,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?
|
||||
.into();
|
||||
let meta = dir.metadata()?;
|
||||
if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"terminal image directory is not private or owned by the current user",
|
||||
));
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn owned_name(name: &str) -> bool {
|
||||
let name = name.strip_prefix(".partial-").unwrap_or(name);
|
||||
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
|
||||
id.len() == 32
|
||||
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
||||
&& matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico")
|
||||
}
|
||||
|
||||
fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> {
|
||||
for entry in Dir::read_from(dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
if !name.to_str().is_ok_and(owned_name) {
|
||||
continue;
|
||||
}
|
||||
// Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO.
|
||||
let fd = match fs::openat(
|
||||
dir,
|
||||
name,
|
||||
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
) {
|
||||
Ok(fd) => fd,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let file = File::from(fd);
|
||||
let meta = file.metadata()?;
|
||||
if meta.is_file()
|
||||
&& meta.uid() == rustix::process::geteuid().as_raw()
|
||||
&& meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| now.duration_since(time).ok())
|
||||
.is_some_and(|age| age >= LIFETIME)
|
||||
{
|
||||
match fs::unlinkat(dir, name, AtFlags::empty()) {
|
||||
Ok(()) | Err(rustix::io::Errno::NOENT) => (),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_with(
|
||||
data_dir: &Path,
|
||||
data: &[u8],
|
||||
mime: &str,
|
||||
now: SystemTime,
|
||||
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
|
||||
) -> IdeResult<String> {
|
||||
let ext = extension(mime)?;
|
||||
let dir = open_directory(data_dir)?;
|
||||
cleanup(&dir, now)?;
|
||||
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
|
||||
let temporary = format!(".partial-{name}");
|
||||
let fd = fs::openat(
|
||||
&dir,
|
||||
temporary.as_str(),
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::from_bits_truncate(0o600),
|
||||
)
|
||||
.map_err(io::Error::from)?;
|
||||
let mut file = File::from(fd);
|
||||
if let Err(error) = write(&mut file, data) {
|
||||
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) {
|
||||
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
|
||||
return Err(io::Error::from(error).into());
|
||||
}
|
||||
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
|
||||
Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
|
||||
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
|
||||
#[test]
|
||||
fn stores_bytes_with_private_modes_and_unpredictable_names() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let first = store(root.path(), b"image bytes", "image/png").unwrap();
|
||||
let second = store(root.path(), b"other", "image/jpeg").unwrap();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(fs::read(&first).unwrap(), b"image bytes");
|
||||
assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_only_removes_owned_regular_files_at_boundary() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = open_directory(root.path()).unwrap();
|
||||
let path = root.path().join(DIRECTORY);
|
||||
let now = SystemTime::now();
|
||||
let names = [
|
||||
"image-00000000000000000000000000000001.png",
|
||||
"image-00000000000000000000000000000002.png",
|
||||
"image-00000000000000000000000000000003.png",
|
||||
"unrelated.png",
|
||||
];
|
||||
for (name, time) in names.iter().zip([
|
||||
now - LIFETIME,
|
||||
now - LIFETIME + Duration::from_secs(1),
|
||||
now + Duration::from_secs(1),
|
||||
now - LIFETIME,
|
||||
]) {
|
||||
let file = File::create(path.join(name)).unwrap();
|
||||
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
|
||||
}
|
||||
let link = path.join("image-00000000000000000000000000000004.png");
|
||||
symlink(path.join(names[0]), &link).unwrap();
|
||||
cleanup(&dir, now).unwrap();
|
||||
assert!(!path.join(names[0]).exists());
|
||||
for name in &names[1..] {
|
||||
assert!(path.join(name).exists());
|
||||
}
|
||||
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_and_insecure_directory() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let target = root.path().join(DIRECTORY);
|
||||
symlink(outside.path(), &target).unwrap();
|
||||
assert!(store(root.path(), b"x", "image/png").is_err());
|
||||
fs::remove_file(&target).unwrap();
|
||||
fs::create_dir(&target).unwrap();
|
||||
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(store(root.path(), b"x", "image/png").is_err());
|
||||
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_write_removes_partial_file() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| {
|
||||
file.write_all(bytes)?;
|
||||
Err(io::Error::other("simulated failure"))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) use unix::store;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult<String> {
|
||||
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use std::path::PathBuf;
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
host.require_trust(window)?;
|
||||
let data = host.data_dir.clone();
|
||||
let data = host.local_data_dir.clone();
|
||||
let (settings, root) = match host.project(window) {
|
||||
Ok(project) => (project.settings.effective(), project.root.clone()),
|
||||
Err(_) => (host.global_settings.effective(), Default::default()),
|
||||
@@ -104,7 +104,7 @@ pub async fn jdk_download_releases(
|
||||
vendor: String,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JdkReleases> {
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let tools = Tools::new(&host.local_data_dir);
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
@@ -113,7 +113,7 @@ pub async fn jdk_download_releases(
|
||||
|
||||
/// Where Download JDK puts JDKs unless told otherwise.
|
||||
pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::IpcPath> {
|
||||
Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into())
|
||||
Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into())
|
||||
}
|
||||
|
||||
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
|
||||
@@ -130,7 +130,7 @@ pub async fn jdk_download(
|
||||
request: RequestId,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let tools = Tools::new(&host.local_data_dir);
|
||||
let location = location.filter(|p| !p.as_os_str().is_empty());
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
|
||||
@@ -112,7 +112,7 @@ impl Host {
|
||||
let project_key = project_key(&root);
|
||||
let index = ProjectIndex::open(
|
||||
&root,
|
||||
&self.data_dir.join("index").join(&project_key),
|
||||
&self.local_data_dir.join("index").join(&project_key),
|
||||
index_roots(&root, extra_roots.clone()),
|
||||
excludes.clone(),
|
||||
languages.clone(),
|
||||
@@ -127,7 +127,7 @@ impl Host {
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let data_dir = self.local_data_dir.clone();
|
||||
let config_dir = self.config_dir.clone();
|
||||
let sink = sink.clone();
|
||||
let formatter_problem = std::sync::Mutex::new(None);
|
||||
@@ -214,7 +214,7 @@ impl Host {
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let data_dir = self.local_data_dir.clone();
|
||||
let sink = sink.clone();
|
||||
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
|
||||
let effective = settings.effective();
|
||||
@@ -255,8 +255,8 @@ impl Host {
|
||||
|
||||
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
|
||||
root.clone(),
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
&self.data_dir,
|
||||
self.local_data_dir.join("cache").join(&project_key).join("build"),
|
||||
&self.local_data_dir,
|
||||
settings.clone(),
|
||||
lsp.clone(),
|
||||
sink.clone(),
|
||||
@@ -385,7 +385,7 @@ impl Host {
|
||||
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
@@ -404,9 +404,9 @@ impl Host {
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots,
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
cache_dir: self.local_data_dir.join("cache").join(&project_key),
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
self.local_data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
db,
|
||||
http_history,
|
||||
@@ -509,7 +509,7 @@ impl Host {
|
||||
let project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
let project = Arc::new(Project {
|
||||
@@ -526,9 +526,9 @@ impl Host {
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots: Arc::default(),
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
cache_dir: self.local_data_dir.join("cache").join(&project_key),
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
self.local_data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
db,
|
||||
http_history,
|
||||
|
||||
@@ -231,6 +231,9 @@ impl Dispatcher {
|
||||
let on_output = c.channel("onOutput")?;
|
||||
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
|
||||
})
|
||||
.add("term_store_image", |c| async move {
|
||||
ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await
|
||||
})
|
||||
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
|
||||
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
|
||||
|
||||
@@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "index_status"
|
||||
| "jvm_file_presentations"
|
||||
| "deploy_coverage"
|
||||
| "deploy_test_connection"
|
||||
| "find_files"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
@@ -243,6 +244,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_bootstrap_list"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let data = base.join("roaming");
|
||||
let local = base.join("local");
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::write(root.join("notes.txt"), "hello\n").unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: data.clone(),
|
||||
local_data_dir: local.clone(),
|
||||
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,
|
||||
});
|
||||
assert_eq!(
|
||||
std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()),
|
||||
local.join("tools/jdks")
|
||||
);
|
||||
let light = host.open_light("main", &root.join("notes.txt")).unwrap();
|
||||
assert!(light.cache_dir.starts_with(&local));
|
||||
assert!(data.join("history").exists());
|
||||
assert!(!local.join("history").exists());
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(project.cache_dir.starts_with(&local));
|
||||
assert!(local.join("index").exists());
|
||||
assert!(!data.join("index").exists());
|
||||
assert!(!data.join("tools").exists());
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
|
||||
@@ -20,6 +20,7 @@ fn host(base: &Path) -> Host {
|
||||
Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
|
||||
@@ -10,6 +10,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
|
||||
@@ -79,6 +79,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| sink_events.clone() as SharedSink),
|
||||
|
||||
@@ -56,6 +56,7 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
|
||||
@@ -261,6 +262,7 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
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(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))),
|
||||
@@ -328,6 +330,7 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
|
||||
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(ide_core::NullSink)),
|
||||
|
||||
@@ -6,6 +6,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
|
||||
@@ -24,6 +24,7 @@ fn terminal_activation_uses_the_resolved_default_shell() {
|
||||
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)),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::{NullSink, TerminalId};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_terminal::TerminalOptions;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn storage_requires_a_registered_terminal() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let data_dir = root.path().join("data");
|
||||
std::fs::create_dir(&data_dir).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: root.path().join("config"),
|
||||
data_dir: data_dir.clone(),
|
||||
local_data_dir: data_dir.clone(),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err());
|
||||
assert!(!data_dir.join("terminal-images").exists());
|
||||
let terminal = host
|
||||
.terminals
|
||||
.spawn(
|
||||
TerminalOptions {
|
||||
cwd: root.path().into(),
|
||||
shell: Some("/bin/sh".into()),
|
||||
args: vec![],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shell_integration: false,
|
||||
},
|
||||
Arc::new(NullSink),
|
||||
Box::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap();
|
||||
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
|
||||
host.terminals.kill(terminal.id).unwrap();
|
||||
assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err());
|
||||
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ fn host(base: &Path, environment: bool) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
@@ -55,6 +56,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"db_console_path",
|
||||
"db_export_file",
|
||||
"vcs_write_patch",
|
||||
"remote_bootstrap_list",
|
||||
"deploy_test_connection",
|
||||
] {
|
||||
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
for window in ["main", "welcome"] {
|
||||
@@ -74,6 +77,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"vcs_git_version",
|
||||
"docker_compose_op",
|
||||
"db_execute",
|
||||
"remote_delete",
|
||||
"remote_mkdir",
|
||||
"remote_rename",
|
||||
"remote_import_local",
|
||||
"remote_bootstrap_exec",
|
||||
"deploy_upload",
|
||||
"deploy_download",
|
||||
"deploy_delete_remote",
|
||||
"deploy_sync",
|
||||
"unknown_command",
|
||||
] {
|
||||
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
@@ -108,6 +120,55 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
|
||||
use ide_core::remote_settings::{SshAuth, SshConfiguration};
|
||||
use ide_ssh::{FsConnection, SshFiles, SshManager};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let server_root = tmp.path().join("server");
|
||||
std::fs::create_dir_all(server_root.join("projects/demo")).unwrap();
|
||||
std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap();
|
||||
let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await;
|
||||
let ssh = Arc::new(SshManager::with_files(
|
||||
tmp.path().join("secrets.json"),
|
||||
SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None },
|
||||
));
|
||||
ssh.set_configs(vec![SshConfiguration {
|
||||
id: "selected-host".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(port),
|
||||
user: "dev".into(),
|
||||
auth: SshAuth::Password,
|
||||
..Default::default()
|
||||
}]);
|
||||
ssh.set_secret("selected-host", Some("secret".into()), false);
|
||||
assert!(ssh.session("selected-host").await.is_err());
|
||||
ssh.trust_host("selected-host").await.unwrap();
|
||||
|
||||
let mut host = host(&tmp.path().join("app"), false);
|
||||
let state = Arc::get_mut(&mut host).unwrap();
|
||||
state.ssh = ssh;
|
||||
state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache")));
|
||||
assert!(host.project("welcome").is_err());
|
||||
host.authorize_command("welcome", "remote_bootstrap_list").unwrap();
|
||||
let connection = FsConnection::Sftp { ssh: "selected-host".into() };
|
||||
let entries = host.remote.list(&connection, "/projects").await.unwrap();
|
||||
assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir));
|
||||
assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir));
|
||||
assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty());
|
||||
host.authorize_command("welcome", "deploy_test_connection").unwrap();
|
||||
host.remote.test_connection(&connection, "/projects").await.unwrap();
|
||||
let local = tmp.path().join("local-deployment");
|
||||
std::fs::create_dir(&local).unwrap();
|
||||
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
|
||||
assert!(host.project("welcome").is_err());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -12,6 +12,7 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
|
||||
@@ -31,6 +31,7 @@ async fn patches_read_and_write_the_requested_repository() {
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
|
||||
@@ -22,6 +22,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! Detection never fails: unreadable or malformed files simply contribute nothing.
|
||||
|
||||
pub(crate) mod cmake;
|
||||
mod dotnet;
|
||||
|
||||
use crate::RunConfig;
|
||||
use serde_json::Value as Json;
|
||||
@@ -16,6 +17,14 @@ pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
|
||||
}
|
||||
|
||||
pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
|
||||
detect_with_profiles(root, excluded_poms, None)
|
||||
}
|
||||
|
||||
pub(crate) fn detect_with_profiles(
|
||||
root: &Path,
|
||||
excluded_poms: &[PathBuf],
|
||||
profiles: Option<&[ide_core::settings::CMakeProfile]>,
|
||||
) -> Vec<RunConfig> {
|
||||
let mut out = Vec::new();
|
||||
detect_node(root, &mut out);
|
||||
detect_deno(root, &mut out);
|
||||
@@ -24,7 +33,8 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<Ru
|
||||
detect_maven(root, excluded_poms, &mut out);
|
||||
detect_go(root, &mut out);
|
||||
detect_python(root, &mut out);
|
||||
cmake::detect(root, &mut out);
|
||||
cmake::detect(root, profiles, &mut out);
|
||||
dotnet::detect(root, &mut out);
|
||||
detect_make(root, &mut out);
|
||||
detect_just(root, &mut out);
|
||||
detect_godot(root, &mut out);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::{ConfigExt, config};
|
||||
use crate::{BeforeLaunchTask, RunConfig};
|
||||
use ide_core::settings::CMakeProfile;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -19,10 +20,16 @@ fn before(config: &mut RunConfig, id: &str) {
|
||||
config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() });
|
||||
}
|
||||
|
||||
pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec<RunConfig>) {
|
||||
if !root.join("CMakeLists.txt").is_file() {
|
||||
return;
|
||||
}
|
||||
if let Some(profiles) = profiles {
|
||||
for (index, profile) in profiles.iter().enumerate() {
|
||||
detect_profile(root, profile, index, profiles.len() > 1, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut builds = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(root) {
|
||||
builds.extend(entries.flatten().filter_map(|e| {
|
||||
@@ -79,11 +86,69 @@ pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
);
|
||||
before(&mut test, &build_id);
|
||||
out.push(test);
|
||||
targets(root, &build, &dir, &prefix, out);
|
||||
targets(root, &build, &dir, &prefix, None, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<RunConfig>) {
|
||||
fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec<RunConfig>) {
|
||||
let dir = if profile.build_dir.trim().is_empty() {
|
||||
format!("cmake-build-{}", profile.build_type.to_lowercase())
|
||||
} else {
|
||||
profile.build_dir.clone()
|
||||
};
|
||||
let build = root.join(&dir);
|
||||
let prefix = format!("cmake:profile:{index}");
|
||||
let configure_id = format!("{prefix}:configure");
|
||||
let build_id = format!("{prefix}:build");
|
||||
let start = out.len();
|
||||
let mut configure =
|
||||
config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]);
|
||||
if !profile.generator.trim().is_empty() {
|
||||
configure.args.extend(["-G".into(), profile.generator.clone()]);
|
||||
}
|
||||
configure.args.push(format!("-DCMAKE_BUILD_TYPE={}", profile.build_type));
|
||||
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
|
||||
configure.args.extend_from_slice(&profile.cmake_args);
|
||||
configure.extras.params.insert("buildDir".into(), dir.clone());
|
||||
out.push(configure);
|
||||
let mut all = config(
|
||||
&build_id,
|
||||
format!("CMake Build ({dir})"),
|
||||
"cmake",
|
||||
"cmake",
|
||||
&["--build", &dir, "--config", &profile.build_type],
|
||||
);
|
||||
all.args.extend_from_slice(&profile.build_args);
|
||||
if !build.join("CMakeCache.txt").is_file() {
|
||||
before(&mut all, &configure_id);
|
||||
}
|
||||
out.push(all);
|
||||
let mut test = config(
|
||||
format!("{prefix}:test"),
|
||||
format!("CTest ({dir})"),
|
||||
"cmake",
|
||||
"ctest",
|
||||
&["--test-dir", &dir, "-C", &profile.build_type, "--output-on-failure"],
|
||||
);
|
||||
before(&mut test, &build_id);
|
||||
out.push(test);
|
||||
targets(root, &build, &dir, &prefix, Some(profile), out);
|
||||
for config in &mut out[start..] {
|
||||
config.env = profile.env.clone();
|
||||
if multiple {
|
||||
config.name = format!("{} [{}]", config.name, profile.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn targets(
|
||||
root: &Path,
|
||||
build: &Path,
|
||||
dir: &str,
|
||||
prefix: &str,
|
||||
profile: Option<&CMakeProfile>,
|
||||
out: &mut Vec<RunConfig>,
|
||||
) {
|
||||
let reply = build.join(".cmake/api/v1/reply");
|
||||
let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| {
|
||||
entries
|
||||
@@ -113,6 +178,9 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<Run
|
||||
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
|
||||
for configuration in configurations {
|
||||
let name = configuration["name"].as_str().unwrap_or("");
|
||||
if profile.is_some_and(|p| !name.is_empty() && name != p.build_type) {
|
||||
continue;
|
||||
}
|
||||
let Some(targets) = configuration["targets"].as_array() else { continue };
|
||||
for target in targets {
|
||||
let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue };
|
||||
@@ -130,8 +198,17 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<Run
|
||||
if !name.is_empty() {
|
||||
args.extend(["--config", name]);
|
||||
}
|
||||
let build_config =
|
||||
let mut build_config =
|
||||
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
|
||||
if let Some(profile) = profile {
|
||||
if name.is_empty() {
|
||||
build_config.args.extend(["--config".into(), profile.build_type.clone()]);
|
||||
}
|
||||
build_config.args.extend_from_slice(&profile.build_args);
|
||||
if !build.join("CMakeCache.txt").is_file() {
|
||||
before(&mut build_config, &format!("{prefix}:configure"));
|
||||
}
|
||||
}
|
||||
out.push(build_config);
|
||||
let binary = build.join(artifact);
|
||||
let mut run =
|
||||
@@ -165,9 +242,99 @@ pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
|
||||
&& config.args.first().is_some_and(|a| a == "-S")
|
||||
&& let Some(dir) = config.extras.params.get("buildDir")
|
||||
{
|
||||
let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea");
|
||||
let build = cwd.join(dir);
|
||||
let generator = config.args.windows(2).find(|a| a[0] == "-G").map(|a| a[1].as_str());
|
||||
if let Some(generator) = generator
|
||||
&& let Ok(cache) = fs::read_to_string(build.join("CMakeCache.txt"))
|
||||
&& let Some(cached) = cache.lines().find_map(|l| l.strip_prefix("CMAKE_GENERATOR:INTERNAL="))
|
||||
&& cached != generator
|
||||
{
|
||||
fs::remove_file(build.join("CMakeCache.txt"))?;
|
||||
let files = build.join("CMakeFiles");
|
||||
if files.is_dir() {
|
||||
fs::remove_dir_all(files)?;
|
||||
}
|
||||
}
|
||||
let query = build.join(".cmake/api/v1/query/client-openidea");
|
||||
fs::create_dir_all(&query)?;
|
||||
fs::write(query.join("codemodel-v2"), "")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn profiles_select_file_api_configuration_and_apply_target_options() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
|
||||
let reply = root.path().join("output/.cmake/api/v1/reply");
|
||||
fs::create_dir_all(&reply).unwrap();
|
||||
let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap();
|
||||
write(
|
||||
"index-1.json",
|
||||
serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}),
|
||||
);
|
||||
write(
|
||||
"model.json",
|
||||
serde_json::json!({"paths":{"source":root.path()},"configurations":[
|
||||
{"name":"Debug","targets":[{"jsonFile":"debug.json"}]},
|
||||
{"name":"Release","targets":[{"jsonFile":"release.json"}]}
|
||||
]}),
|
||||
);
|
||||
for name in ["debug", "release"] {
|
||||
write(
|
||||
&format!("{name}.json"),
|
||||
serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}),
|
||||
);
|
||||
}
|
||||
let profile = CMakeProfile {
|
||||
name: "Production".into(),
|
||||
build_type: "Release".into(),
|
||||
build_dir: "output".into(),
|
||||
build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(),
|
||||
env: [("FOO".into(), "bar".into())].into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut configs = Vec::new();
|
||||
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs);
|
||||
let run = configs.iter().find(|c| c.kind == "native").unwrap();
|
||||
assert!(run.command.ends_with("release/app"));
|
||||
assert_eq!(run.env, profile.env);
|
||||
assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1);
|
||||
let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap();
|
||||
assert_eq!(
|
||||
build.args,
|
||||
["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"]
|
||||
);
|
||||
assert_eq!(build.env, profile.env);
|
||||
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
|
||||
fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap();
|
||||
let mut configured = Vec::new();
|
||||
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured);
|
||||
for id in ["cmake:profile:0:build", build.id.as_str()] {
|
||||
let build = configured.iter().find(|c| c.id == id).unwrap();
|
||||
assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_change_clears_only_cmake_state_at_launch() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let build = root.path().join("cmake-build-debug");
|
||||
fs::create_dir_all(build.join("CMakeFiles")).unwrap();
|
||||
fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
|
||||
fs::write(build.join("keep.txt"), "artifact").unwrap();
|
||||
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
|
||||
let mut configs = Vec::new();
|
||||
detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs);
|
||||
assert!(build.join("CMakeCache.txt").exists());
|
||||
prepare(&configs[0], root.path()).unwrap();
|
||||
assert!(!build.join("CMakeCache.txt").exists());
|
||||
assert!(!build.join("CMakeFiles").exists());
|
||||
assert!(build.join("keep.txt").exists());
|
||||
assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Runnable .NET projects and launch profiles, without a solution model.
|
||||
|
||||
use crate::{
|
||||
RunConfig,
|
||||
dotnet::{parse_project, profiles, solution_projects},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub(crate) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
let mut dirs = vec![root.to_path_buf()];
|
||||
let mut projects = BTreeSet::new();
|
||||
while let Some(dir) = dirs.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
if !name.starts_with('.')
|
||||
&& !matches!(
|
||||
name.as_str(),
|
||||
"bin" | "obj" | "node_modules" | "target" | "build" | "dist" | "vendor" | "packages"
|
||||
)
|
||||
{
|
||||
dirs.push(path);
|
||||
}
|
||||
} else if entry.file_type().is_ok_and(|t| t.is_file()) {
|
||||
match path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase().as_str() {
|
||||
"csproj" => {
|
||||
projects.insert(path);
|
||||
}
|
||||
"sln" | "slnx" => {
|
||||
if let Ok(src) = fs::read_to_string(&path) {
|
||||
for project in solution_projects(
|
||||
&src,
|
||||
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("slnx")),
|
||||
) {
|
||||
projects.insert(dir.join(project));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.into());
|
||||
let projects: BTreeSet<_> = projects.into_iter().filter_map(|p| dunce::canonicalize(p).ok()).collect();
|
||||
for path in projects {
|
||||
let Some(project) = fs::read_to_string(&path).ok().and_then(|s| parse_project(&s)) else { continue };
|
||||
let rel = path.strip_prefix(&root).unwrap_or(&path).to_string_lossy().replace('\\', "/");
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
let profiles = profiles(&path);
|
||||
let mut params = BTreeMap::from([
|
||||
("project".into(), rel.clone()),
|
||||
("framework".into(), project.frameworks.first().cloned().unwrap_or_default()),
|
||||
("frameworks".into(), project.frameworks.join(";")),
|
||||
("profiles".into(), serde_json::to_string(&profiles).unwrap_or_default()),
|
||||
("launchProfile".into(), String::new()),
|
||||
("programArgs".into(), String::new()),
|
||||
]);
|
||||
if project.executable && !project.test {
|
||||
out.push(detected(&rel, &name, "", params.clone(), false));
|
||||
for profile in profiles.keys() {
|
||||
params.insert("launchProfile".into(), profile.clone());
|
||||
out.push(detected(&rel, &name, profile, params.clone(), false));
|
||||
}
|
||||
}
|
||||
if project.test {
|
||||
params.insert("launchProfile".into(), String::new());
|
||||
out.push(detected(&rel, &name, "", params, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detected(project: &str, name: &str, profile: &str, params: BTreeMap<String, String>, test: bool) -> RunConfig {
|
||||
let op = if test { "test" } else { "run" };
|
||||
let mut config = super::config(
|
||||
format!("dotnet:{project}:{op}:{profile}"),
|
||||
if test {
|
||||
format!("dotnet test ({name})")
|
||||
} else if profile.is_empty() {
|
||||
format!("{name} (.NET Project)")
|
||||
} else {
|
||||
format!("{name}: {profile}")
|
||||
},
|
||||
if test { "dotnet-test" } else { "dotnet" },
|
||||
"dotnet",
|
||||
&[],
|
||||
);
|
||||
config.args = command_args(¶ms, test);
|
||||
config.is_test = test;
|
||||
config.debug_adapter = (!test).then(|| "netcoredbg".into());
|
||||
config.extras.params = params;
|
||||
if !test {
|
||||
config.extras.before_launch.push(crate::BeforeLaunchTask { kind: "dotnetBuild".into(), target: String::new() });
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
fn command_args(params: &BTreeMap<String, String>, test: bool) -> Vec<String> {
|
||||
let mut args = if test {
|
||||
vec!["test".into(), params.get("project").cloned().unwrap_or_default()]
|
||||
} else {
|
||||
vec!["run".into(), "--project".into(), params.get("project").cloned().unwrap_or_default()]
|
||||
};
|
||||
if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) {
|
||||
args.extend(["--framework".into(), framework.clone()]);
|
||||
}
|
||||
if !test {
|
||||
if let Some(profile) = params.get("launchProfile").filter(|s| !s.is_empty()) {
|
||||
args.extend(["--launch-profile".into(), profile.clone()]);
|
||||
} else {
|
||||
args.push("--no-launch-profile".into());
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Lightweight .NET project discovery; MSBuild remains the authority for build outputs.
|
||||
|
||||
use crate::RunConfig;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use quick_xml::{Reader, XmlVersion, events::Event};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Project {
|
||||
pub executable: bool,
|
||||
pub frameworks: Vec<String>,
|
||||
pub test: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Element {
|
||||
name: String,
|
||||
attrs: BTreeMap<String, String>,
|
||||
text: String,
|
||||
conditional: bool,
|
||||
}
|
||||
|
||||
fn elements(xml: &str) -> Option<Vec<Element>> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut out: Vec<Element> = Vec::new();
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
loop {
|
||||
let event = reader.read_event().ok()?;
|
||||
let empty = matches!(&event, Event::Empty(_));
|
||||
match event {
|
||||
Event::Start(tag) | Event::Empty(tag) => {
|
||||
let attrs: BTreeMap<_, _> = tag
|
||||
.attributes()
|
||||
.map(|a| {
|
||||
let a = a.ok()?;
|
||||
Some((
|
||||
a.key.as_ref().to_owned(),
|
||||
a.normalized_value(XmlVersion::Implicit1_0).ok()?.into_owned(),
|
||||
))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let conditional = attrs.contains_key("Condition") || stack.last().is_some_and(|i| out[*i].conditional);
|
||||
out.push(Element {
|
||||
name: tag.local_name().as_ref().to_owned(),
|
||||
attrs,
|
||||
conditional,
|
||||
..Default::default()
|
||||
});
|
||||
// Empty elements have no corresponding End event.
|
||||
if !empty {
|
||||
stack.push(out.len() - 1);
|
||||
}
|
||||
}
|
||||
Event::End(_) => {
|
||||
stack.pop()?;
|
||||
}
|
||||
Event::Text(text) => {
|
||||
if let Some(i) = stack.last() {
|
||||
out[*i].text.push_str(&text.xml_content(XmlVersion::Implicit1_0));
|
||||
}
|
||||
}
|
||||
Event::CData(text) => {
|
||||
if let Some(i) = stack.last() {
|
||||
out[*i].text.push_str(text.as_ref());
|
||||
}
|
||||
}
|
||||
Event::GeneralRef(reference) => {
|
||||
let name = reference.as_ref();
|
||||
let escaped = format!("&{name};");
|
||||
if let Some(i) = stack.last() {
|
||||
out[*i].text.push_str(&quick_xml::escape::unescape(&escaped).ok()?);
|
||||
}
|
||||
}
|
||||
Event::Eof => return stack.is_empty().then_some(out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_project(xml: &str) -> Option<Project> {
|
||||
let tags = elements(xml)?;
|
||||
if tags.first()?.name != "Project" {
|
||||
return None;
|
||||
}
|
||||
let property = |name: &str| tags.iter().rev().find(|t| t.name == name && !t.conditional).map(|t| t.text.trim());
|
||||
let web = tags.iter().any(|t| {
|
||||
t.attrs
|
||||
.get("Sdk")
|
||||
.or_else(|| (t.name == "Sdk").then(|| t.attrs.get("Name")).flatten())
|
||||
.is_some_and(|s| s.split(';').any(|sdk| sdk.split('/').next() == Some("Microsoft.NET.Sdk.Web")))
|
||||
});
|
||||
let executable = match property("OutputType") {
|
||||
Some(output) => output.eq_ignore_ascii_case("exe") || output.eq_ignore_ascii_case("winexe"),
|
||||
None => web,
|
||||
};
|
||||
let frameworks = property("TargetFramework")
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| property("TargetFrameworks"))
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let test = tags.iter().any(|t| {
|
||||
t.name == "PackageReference"
|
||||
&& t.attrs
|
||||
.get("Include")
|
||||
.or_else(|| t.attrs.get("Update"))
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("Microsoft.NET.Test.Sdk"))
|
||||
});
|
||||
Some(Project { executable, frameworks, test })
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Profile {
|
||||
pub command_name: String,
|
||||
pub command_line_args: String,
|
||||
pub working_directory: String,
|
||||
pub application_url: String,
|
||||
pub environment_variables: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn parse_profiles(json: &str) -> BTreeMap<String, Profile> {
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(&ide_core::jsonc::strip(json)) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
json["profiles"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(name, value)| {
|
||||
let profile: Profile = serde_json::from_value(value.clone()).ok()?;
|
||||
(profile.command_name == "Project").then(|| (name.clone(), profile))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn solution_projects(src: &str, xml: bool) -> Vec<PathBuf> {
|
||||
let paths: Vec<String> = if xml {
|
||||
elements(src)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|e| e.name == "Project")
|
||||
.filter_map(|mut e| e.attrs.remove("Path"))
|
||||
.collect()
|
||||
} else {
|
||||
src.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if !line.starts_with("Project(") {
|
||||
return None;
|
||||
}
|
||||
Some(line.split_once('=')?.1.split('"').nth(3)?.to_owned())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
paths
|
||||
.into_iter()
|
||||
.filter(|p| p.to_ascii_lowercase().ends_with(".csproj"))
|
||||
.map(|p| PathBuf::from(p.replace('\\', "/")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn profiles(project: &Path) -> BTreeMap<String, Profile> {
|
||||
project
|
||||
.parent()
|
||||
.and_then(|p| fs::read_to_string(p.join("Properties/launchSettings.json")).ok())
|
||||
.map(|s| parse_profiles(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolves the selected profile afresh so changing project/profile never reuses stale values.
|
||||
pub fn resolve(root: &Path, mut config: RunConfig) -> IdeResult<RunConfig> {
|
||||
let params = &config.extras.params;
|
||||
let project = params
|
||||
.get("project")
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| IdeError::invalid("Select a .NET project"))?;
|
||||
let project = dunce::canonicalize(root.join(project))?;
|
||||
let dir = project.parent().ok_or_else(|| IdeError::invalid("Invalid .NET project path"))?;
|
||||
let profile_name = params.get("launchProfile").cloned().unwrap_or_default();
|
||||
let profile = if profile_name.is_empty() {
|
||||
Profile::default()
|
||||
} else {
|
||||
profiles(&project).remove(&profile_name).ok_or_else(|| {
|
||||
IdeError::invalid(format!(".NET launch profile '{profile_name}' is missing or is not a Project profile"))
|
||||
})?
|
||||
};
|
||||
let mut env = profile.environment_variables;
|
||||
if !profile.application_url.is_empty() {
|
||||
env.insert("ASPNETCORE_URLS".into(), profile.application_url);
|
||||
}
|
||||
if !profile_name.is_empty() {
|
||||
env.insert("DOTNET_LAUNCH_PROFILE".into(), profile_name);
|
||||
}
|
||||
env.extend(config.env);
|
||||
config.env = env;
|
||||
config.cwd = Some(
|
||||
config
|
||||
.cwd
|
||||
.map(|p| root.join(&*p))
|
||||
.unwrap_or_else(|| {
|
||||
if profile.working_directory.is_empty() { dir.into() } else { dir.join(&profile.working_directory) }
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
config.extras.params.insert("project".into(), project.to_string_lossy().into_owned());
|
||||
config.extras.params.insert("profileArgs".into(), profile.command_line_args);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Queries the evaluated output path, including AssemblyName, OutputPath and imported properties.
|
||||
pub async fn target_path(root: &Path, config: &RunConfig) -> IdeResult<String> {
|
||||
let params = &config.extras.params;
|
||||
let project = params.get("project").ok_or_else(|| IdeError::invalid("Select a .NET project"))?;
|
||||
let mut args = vec![
|
||||
"msbuild".to_owned(),
|
||||
root.join(project).to_string_lossy().into_owned(),
|
||||
"-nologo".into(),
|
||||
"-getProperty:TargetPath".into(),
|
||||
"-p:Configuration=Debug".into(),
|
||||
];
|
||||
if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) {
|
||||
args.push(format!("-p:TargetFramework={framework}"));
|
||||
}
|
||||
let mut command = std::process::Command::new(&config.command);
|
||||
ide_core::process::host_env(&mut command);
|
||||
command.args(args).current_dir(root);
|
||||
if !config.extras.include_system_env {
|
||||
command.env_clear();
|
||||
} else {
|
||||
command.envs(ide_core::process::shell_env());
|
||||
}
|
||||
command.envs(crate::resolved_env(root, config)?);
|
||||
let mut command = tokio::process::Command::from(command);
|
||||
command.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable(".NET output resolution timed out"))??;
|
||||
if !output.status.success() {
|
||||
return Err(IdeError::invalid(format!(
|
||||
".NET output resolution failed: {}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
let target = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||
if target.is_empty() || !Path::new(&target).is_file() {
|
||||
return Err(IdeError::invalid(".NET build output is missing; run dotnet build before debugging"));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
@@ -41,17 +41,22 @@ pub fn default_dir() -> PathBuf {
|
||||
/// Writes the script for `jvm_args` under `dir` (named by its content, so launches share
|
||||
/// it and nothing needs deleting) and returns its path.
|
||||
pub fn write_init_script(dir: &Path, jvm_args: &[String]) -> std::io::Result<PathBuf> {
|
||||
write_script(dir, "jvm-args", &init_script(jvm_args))
|
||||
}
|
||||
|
||||
/// Writes the init script `text` under `dir` as `<prefix>-<hash>.init.gradle` (written
|
||||
/// once per content, so launches share it) and returns its path.
|
||||
pub fn write_script(dir: &Path, prefix: &str, text: &str) -> std::io::Result<PathBuf> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let text = init_script(jvm_args);
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut h);
|
||||
let file = dir.join(format!("jvm-args-{:016x}.init.gradle", h.finish()));
|
||||
let file = dir.join(format!("{prefix}-{:016x}.init.gradle", h.finish()));
|
||||
if std::fs::read_to_string(&file).is_ok_and(|t| t == text) {
|
||||
return Ok(file);
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = file.with_extension(format!("{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, &text)?;
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, &file).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})?;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod config;
|
||||
mod detect;
|
||||
pub mod dotnet;
|
||||
pub mod envfile;
|
||||
pub mod go;
|
||||
pub mod gradle_jvm;
|
||||
@@ -201,6 +202,15 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig
|
||||
detect::detect_excluding(root, excluded_poms)
|
||||
}
|
||||
|
||||
/// Detects configurations with explicit CMake profiles, or legacy discovery when absent.
|
||||
pub fn detect_with_profiles(
|
||||
root: &Path,
|
||||
excluded_poms: &[PathBuf],
|
||||
profiles: Option<&[ide_core::settings::CMakeProfile]>,
|
||||
) -> Vec<RunConfig> {
|
||||
detect::detect_with_profiles(root, excluded_poms, profiles)
|
||||
}
|
||||
|
||||
/// Reads `<root>/.openidea/run.toml`; a missing file yields no configs.
|
||||
pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
config::load(root)
|
||||
|
||||
@@ -195,8 +195,21 @@ fn match_console_encoding(
|
||||
output_encoding::fix(&program, &inv.args, &env.vars, inherited, encoding, cfg!(windows))
|
||||
};
|
||||
env.vars.extend(fix.env);
|
||||
if !fix.java_args.is_empty() && !inv.raw_args {
|
||||
inv.args.splice(0..0, fix.java_args);
|
||||
let mut prepend = fix.java_args;
|
||||
if fix.gradle_init_script {
|
||||
match crate::gradle_jvm::write_script(
|
||||
&crate::gradle_jvm::default_dir(),
|
||||
"file-encoding",
|
||||
output_encoding::GRADLE_FILE_ENCODING_SCRIPT,
|
||||
) {
|
||||
Ok(script) => prepend.extend(["--init-script".to_owned(), script.to_string_lossy().into_owned()]),
|
||||
Err(e) => tracing::warn!("cannot write the Gradle file.encoding init script: {e}"),
|
||||
}
|
||||
}
|
||||
let changed = fix.args.is_some() || !prepend.is_empty();
|
||||
if changed && !inv.raw_args {
|
||||
inv.args = fix.args.unwrap_or_else(|| std::mem::take(&mut inv.args));
|
||||
inv.args.splice(0..0, prepend);
|
||||
let mut display = quote(config.command.trim());
|
||||
for a in &inv.args {
|
||||
display.push(' ');
|
||||
|
||||
@@ -652,3 +652,72 @@ fn go_default_build_output_is_ignored_by_version_control() {
|
||||
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
|
||||
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_profiles_control_commands_names_and_environment() {
|
||||
use ide_core::settings::CMakeProfile;
|
||||
let dir = project(&[("CMakeLists.txt", "project(app)")]);
|
||||
for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] {
|
||||
let profile = CMakeProfile {
|
||||
name: "Production".into(),
|
||||
build_type: "Release".into(),
|
||||
generator: generator.into(),
|
||||
cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(),
|
||||
build_dir: "output dir".into(),
|
||||
build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(),
|
||||
env: [("MY_ENV".into(), "yes".into())].into(),
|
||||
};
|
||||
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile)));
|
||||
let configure = find(&configs, "cmake:profile:0:configure");
|
||||
let mut expected = vec!["-S", ".", "-B", "output dir"];
|
||||
if !generator.is_empty() {
|
||||
expected.extend(["-G", generator]);
|
||||
}
|
||||
expected.extend([
|
||||
"-DCMAKE_BUILD_TYPE=Release",
|
||||
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
|
||||
"-DFEATURE=ON",
|
||||
"-DMESSAGE=two words",
|
||||
"-DQUOTE=\"hello world\"",
|
||||
"",
|
||||
]);
|
||||
assert_eq!(configure.args, expected);
|
||||
assert_eq!(configure.name, "CMake Configure (output dir)");
|
||||
let build = find(&configs, "cmake:profile:0:build");
|
||||
assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]);
|
||||
assert_eq!(build.extras.before_launch[0].target, configure.id);
|
||||
assert!(configs.iter().all(|c| c.env == profile.env));
|
||||
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile]));
|
||||
assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]"));
|
||||
assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]"));
|
||||
assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug");
|
||||
}
|
||||
assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path()));
|
||||
assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_build_configures_only_without_a_cache() {
|
||||
use ide_core::settings::CMakeProfile;
|
||||
let root = project(&[("CMakeLists.txt", "project(app)")]);
|
||||
for dir in ["", "custom output"] {
|
||||
let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() };
|
||||
let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir });
|
||||
for cached in [false, true] {
|
||||
if cached {
|
||||
fs::create_dir_all(&build_dir).unwrap();
|
||||
// Generator changes are handled by an explicit Configure, not every Build.
|
||||
fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
|
||||
}
|
||||
let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile)));
|
||||
let build = find(&configs, "cmake:profile:0:build");
|
||||
if cached {
|
||||
assert!(build.extras.before_launch.is_empty());
|
||||
} else {
|
||||
assert_eq!(build.extras.before_launch.len(), 1);
|
||||
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
|
||||
}
|
||||
assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use ide_run::{
|
||||
detect,
|
||||
dotnet::{parse_profiles, parse_project, resolve, solution_projects},
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const CONSOLE: &str = r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFrameworks>net10.0; net9.0</TargetFrameworks></PropertyGroup></Project>"#;
|
||||
const PROFILES: &str = r#"{
|
||||
// Only Project profiles can be run with dotnet.
|
||||
"profiles": {
|
||||
"http": {"commandName":"Project", "applicationUrl":"http://localhost:5165", "commandLineArgs":"--message \"hello world\"", "workingDirectory":"work", "environmentVariables":{"DEMO":"profile"}},
|
||||
"https": {"commandName":"Project", "environmentVariables":{"DEMO":"https"}},
|
||||
"IIS Express": {"commandName":"IISExpress"},
|
||||
"external": {"commandName":"Executable"},
|
||||
"broken": {"commandName":"Project", "environmentVariables":7}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fn write(root: &std::path::Path, file: &str, src: &str) {
|
||||
let path = root.join(file);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, src).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_properties_and_sdk_defaults() {
|
||||
let p = parse_project(CONSOLE).unwrap();
|
||||
assert!(p.executable && !p.test);
|
||||
assert_eq!(p.frameworks, ["net10.0", "net9.0"]);
|
||||
for (sdk, output, executable) in [
|
||||
("Microsoft.NET.Sdk", "", false),
|
||||
("Microsoft.NET.Sdk", "<OutputType>WinExe</OutputType>", true),
|
||||
("Microsoft.NET.Sdk.Web", "", true),
|
||||
("Microsoft.NET.Sdk.Web", "<OutputType>Library</OutputType>", false),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<Project Sdk="{sdk}"><PropertyGroup>{output}<TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>"#
|
||||
);
|
||||
let p = parse_project(&xml).unwrap();
|
||||
assert_eq!(p.executable, executable, "{xml}");
|
||||
assert_eq!(p.frameworks, ["net10.0"]);
|
||||
}
|
||||
let p = parse_project(r#"<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"><Sdk Name="Microsoft.NET.Sdk.Web"/><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><PropertyGroup Condition="'$(Configuration)'=='Release'"><OutputType>Library</OutputType></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0" /></ItemGroup></Project>"#).unwrap();
|
||||
assert!(p.executable && p.test);
|
||||
assert!(parse_project("<Project><PropertyGroup>").is_none());
|
||||
assert!(parse_project("garbage").is_none());
|
||||
assert!(
|
||||
parse_project(
|
||||
r#"<Project><ItemGroup><PackageReference Update="microsoft.net.test.sdk" /></ItemGroup></Project>"#
|
||||
)
|
||||
.unwrap()
|
||||
.test
|
||||
);
|
||||
assert!(
|
||||
!parse_project(r#"<Project><!-- <PackageReference Include="Microsoft.NET.Test.Sdk"/> --></Project>"#)
|
||||
.unwrap()
|
||||
.test
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_filter_and_preserve_launch_properties() {
|
||||
let p = parse_profiles(PROFILES);
|
||||
assert_eq!(p.keys().map(String::as_str).collect::<Vec<_>>(), ["http", "https"]);
|
||||
assert_eq!(p["http"].application_url, "http://localhost:5165");
|
||||
assert_eq!(p["http"].environment_variables["DEMO"], "profile");
|
||||
assert_eq!(p["http"].command_line_args, "--message \"hello world\"");
|
||||
assert_eq!(p["http"].working_directory, "work");
|
||||
assert!(parse_profiles("invalid").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solution_paths_support_windows_separators_folders_and_entities() {
|
||||
let sln = "Project(\"{guid}\") = \"App\", \"src\\App\\App.csproj\", \"{guid}\"\nEndProject\nProject(\"{folder}\") = \"Folder\", \"Folder\", \"{guid}\"\nProject(\"{guid}\") = \"External\", \"..\\other\\Other.csproj\", \"{guid}\"";
|
||||
assert_eq!(
|
||||
solution_projects(sln, false),
|
||||
[PathBuf::from("src/App/App.csproj"), PathBuf::from("../other/Other.csproj")]
|
||||
);
|
||||
let slnx = r#"<Solution><Folder Name="/src/"><Project Path="src\A&B\App.csproj" /></Folder><Project Path="other.vbproj" /></Solution>"#;
|
||||
assert_eq!(solution_projects(slnx, true), [PathBuf::from("src/A&B/App.csproj")]);
|
||||
assert!(solution_projects("<Solution><Project", true).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection_deduplicates_projects_and_generates_commands() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
write(root, "src/App/App.csproj", CONSOLE);
|
||||
write(root, "src/App/Properties/launchSettings.json", PROFILES);
|
||||
write(
|
||||
root,
|
||||
"demo.slnx",
|
||||
r#"<Solution><Project Path="src/App/App.csproj"/><Project Path="../external/Missing.csproj"/></Solution>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"src/Test/Test.csproj",
|
||||
r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk"/></ItemGroup></Project>"#,
|
||||
);
|
||||
write(root, "bin/Ignored.csproj", CONSOLE);
|
||||
write(root, "obj/Ignored.csproj", CONSOLE);
|
||||
write(root, "node_modules/Ignored.csproj", CONSOLE);
|
||||
write(root, "Lib.csproj", "<Project Sdk=\"Microsoft.NET.Sdk\" />");
|
||||
let configs = detect(root);
|
||||
assert_eq!(configs.len(), 4);
|
||||
let app = configs.iter().find(|c| c.extras.params.get("launchProfile").is_some_and(|p| p == "http")).unwrap();
|
||||
assert_eq!(
|
||||
app.args,
|
||||
["run", "--project", "src/App/App.csproj", "--framework", "net10.0", "--launch-profile", "http"]
|
||||
);
|
||||
assert_eq!(app.debug_adapter.as_deref(), Some("netcoredbg"));
|
||||
assert_eq!(app.extras.before_launch[0].kind, "dotnetBuild");
|
||||
let test = configs.iter().find(|c| c.is_test).unwrap();
|
||||
assert_eq!(test.kind, "dotnet-test");
|
||||
assert_eq!(test.args, ["test", "src/Test/Test.csproj", "--framework", "net10.0"]);
|
||||
assert!(test.debug_adapter.is_none());
|
||||
let mut edited = app.clone();
|
||||
edited.env.insert("DEMO".into(), "override".into());
|
||||
let resolved = resolve(root, edited.clone()).unwrap();
|
||||
assert_eq!(resolved.env["DEMO"], "override");
|
||||
assert_eq!(resolved.env["ASPNETCORE_URLS"], "http://localhost:5165");
|
||||
assert!(resolved.cwd.unwrap().ends_with("src/App/work"));
|
||||
edited.extras.params.insert("launchProfile".into(), "https".into());
|
||||
let resolved = resolve(root, edited).unwrap();
|
||||
assert!(!resolved.env.contains_key("ASPNETCORE_URLS"));
|
||||
assert!(resolved.cwd.unwrap().ends_with("src/App"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solution_references_can_reach_projects_outside_the_open_directory() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
write(temp.path(), "external/App.csproj", CONSOLE);
|
||||
write(temp.path(), "open/demo.sln", "Project(\"{guid}\") = \"App\", \"..\\external\\App.csproj\", \"{guid}\"");
|
||||
let configs = detect(&temp.path().join("open"));
|
||||
assert_eq!(configs.len(), 1);
|
||||
assert!(configs[0].args[2].ends_with("external/App.csproj"));
|
||||
}
|
||||
@@ -178,6 +178,10 @@ impl TerminalManager {
|
||||
let Some(pid) = session.info.pid else { return Ok(false) };
|
||||
Ok(foreground_busy(&session, pid))
|
||||
}
|
||||
/// Whether a terminal session is still registered.
|
||||
pub fn contains(&self, id: TerminalId) -> bool {
|
||||
self.sessions.lock().contains_key(&id)
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<TerminalInfo> {
|
||||
let mut out: Vec<TerminalInfo> = self.sessions.lock().values().map(|s| s.info.clone()).collect();
|
||||
|
||||
@@ -15,6 +15,7 @@ serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
semver = "1.0.28"
|
||||
parking_lot.workspace = true
|
||||
blake3.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -23,16 +23,26 @@ pub struct InvalidateCachesOptions {
|
||||
const MARKER: &str = "invalidate-caches.json";
|
||||
|
||||
/// Data-directory folders removed for `options`.
|
||||
pub fn cache_dirs(data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![data_dir.join("index")];
|
||||
pub fn cache_dirs(data_dir: &Path, local_data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![local_data_dir.join("index")];
|
||||
if options.local_history {
|
||||
dirs.push(data_dir.join("history"));
|
||||
}
|
||||
if options.language_servers {
|
||||
dirs.push(data_dir.join("lsp"));
|
||||
dirs.push(local_data_dir.join("lsp"));
|
||||
}
|
||||
if options.build_caches {
|
||||
dirs.push(data_dir.join("cache"));
|
||||
dirs.push(local_data_dir.join("cache"));
|
||||
}
|
||||
// Also invalidate pre-migration caches so a later start cannot revive them.
|
||||
if data_dir != local_data_dir {
|
||||
dirs.push(data_dir.join("index"));
|
||||
if options.language_servers {
|
||||
dirs.push(data_dir.join("lsp"));
|
||||
}
|
||||
if options.build_caches {
|
||||
dirs.push(data_dir.join("cache"));
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
@@ -57,11 +67,11 @@ pub fn cancel(data_dir: &Path) {
|
||||
|
||||
/// Carries out a scheduled invalidation (call at start-up, before projects
|
||||
/// open). Returns the folders removed.
|
||||
pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
|
||||
pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec<PathBuf> {
|
||||
let Some(options) = pending(data_dir) else { return Vec::new() };
|
||||
cancel(data_dir);
|
||||
let mut removed = Vec::new();
|
||||
for dir in cache_dirs(data_dir, &options) {
|
||||
for dir in cache_dirs(data_dir, local_data_dir, &options) {
|
||||
match fs::remove_dir_all(&dir) {
|
||||
Ok(()) => removed.push(dir),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
@@ -75,6 +85,34 @@ pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_roots_invalidate_local_caches_and_roaming_history() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let data = tmp.path().join("roaming");
|
||||
let local = tmp.path().join("local");
|
||||
let options = InvalidateCachesOptions { local_history: true, language_servers: true, build_caches: true };
|
||||
assert_eq!(
|
||||
cache_dirs(&data, &local, &options),
|
||||
vec![
|
||||
local.join("index"),
|
||||
data.join("history"),
|
||||
local.join("lsp"),
|
||||
local.join("cache"),
|
||||
data.join("index"),
|
||||
data.join("lsp"),
|
||||
data.join("cache")
|
||||
]
|
||||
);
|
||||
for path in cache_dirs(&data, &local, &options) {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
}
|
||||
fs::create_dir_all(local.join("tools")).unwrap();
|
||||
schedule(&data, &options).unwrap();
|
||||
assert_eq!(apply_pending(&data, &local).len(), 7);
|
||||
assert!(local.join("tools").exists());
|
||||
assert_eq!(pending(&data), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduled_invalidation_runs_once() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -82,16 +120,16 @@ mod tests {
|
||||
for d in ["index/p1", "history/p1", "lsp/jdtls/x", "cache/p1/build", "tools/jdk"] {
|
||||
fs::create_dir_all(data.join(d)).unwrap();
|
||||
}
|
||||
assert!(apply_pending(data).is_empty());
|
||||
assert!(apply_pending(data, data).is_empty());
|
||||
|
||||
let opts = InvalidateCachesOptions { local_history: true, language_servers: false, build_caches: true };
|
||||
schedule(data, &opts).unwrap();
|
||||
assert_eq!(pending(data), Some(opts));
|
||||
let removed = apply_pending(data);
|
||||
let removed = apply_pending(data, data);
|
||||
assert_eq!(removed, vec![data.join("index"), data.join("history"), data.join("cache")]);
|
||||
assert!(data.join("lsp/jdtls/x").exists() && data.join("tools/jdk").exists());
|
||||
assert_eq!(pending(data), None);
|
||||
assert!(apply_pending(data).is_empty());
|
||||
assert!(apply_pending(data, data).is_empty());
|
||||
|
||||
schedule(data, &InvalidateCachesOptions::default()).unwrap();
|
||||
cancel(data);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::DependencyVersionSource;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const TTL: Duration = Duration::from_secs(3600);
|
||||
const FAILURE_TTL: Duration = Duration::from_secs(30);
|
||||
const MAX_DISK_BYTES: u64 = 2 * 1024 * 1024;
|
||||
type Slot = Arc<Mutex<Option<Cached>>>;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Cached {
|
||||
fetched: u64,
|
||||
versions: Vec<String>,
|
||||
#[serde(skip)]
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
/// Same-package lookups share one lock and fetch. Different packages don't block
|
||||
/// each other; waiting callers can cancel independently of the active fetch.
|
||||
pub struct VersionCache {
|
||||
directory: PathBuf,
|
||||
entries: Mutex<HashMap<String, Slot>>,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl VersionCache {
|
||||
pub fn new(directory: PathBuf) -> Self {
|
||||
Self { directory, entries: Mutex::default(), ttl: TTL }
|
||||
}
|
||||
|
||||
pub fn versions(
|
||||
&self,
|
||||
source: &dyn DependencyVersionSource,
|
||||
package: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b));
|
||||
if !safe(source.id()) || !safe(package) || package.len() > 64 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let key = format!("{}-{}", source.id(), package.to_ascii_lowercase());
|
||||
let slot = self.entries.lock().entry(key.clone()).or_default().clone();
|
||||
let mut cached = loop {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if let Some(guard) = slot.try_lock_for(Duration::from_millis(50)) {
|
||||
break guard;
|
||||
}
|
||||
};
|
||||
let checked_at = now();
|
||||
let fresh = |c: &Cached| {
|
||||
c.fetched <= checked_at && checked_at - c.fetched < if c.failed { FAILURE_TTL } else { self.ttl }.as_secs()
|
||||
};
|
||||
if let Some(entry) = cached.as_ref().filter(|c| fresh(c)) {
|
||||
return Ok(entry.versions.clone());
|
||||
}
|
||||
let path = self.directory.join(format!("{key}.json"));
|
||||
let disk = std::fs::metadata(&path)
|
||||
.ok()
|
||||
.filter(|m| m.len() <= MAX_DISK_BYTES)
|
||||
.and_then(|_| std::fs::read(&path).ok())
|
||||
.and_then(|bytes| serde_json::from_slice::<Cached>(&bytes).ok())
|
||||
.filter(fresh);
|
||||
if let Some(entry) = disk {
|
||||
let versions = entry.versions.clone();
|
||||
*cached = Some(entry);
|
||||
return Ok(versions);
|
||||
}
|
||||
let result = source.fetch(package, cancel);
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let failed = result.is_err();
|
||||
let versions = result.unwrap_or_default();
|
||||
let entry = Cached { fetched: now(), versions: versions.clone(), failed };
|
||||
if !failed
|
||||
&& let Ok(bytes) = serde_json::to_vec(&entry)
|
||||
&& std::fs::create_dir_all(&self.directory).is_ok()
|
||||
{
|
||||
let temporary = path.with_extension("tmp");
|
||||
if std::fs::write(&temporary, bytes).is_ok() {
|
||||
let _ = std::fs::rename(&temporary, &path);
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
}
|
||||
}
|
||||
*cached = Some(entry);
|
||||
Ok(versions)
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct Source {
|
||||
calls: AtomicUsize,
|
||||
fail: bool,
|
||||
}
|
||||
impl DependencyVersionSource for Source {
|
||||
fn id(&self) -> &'static str {
|
||||
"test"
|
||||
}
|
||||
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
if self.fail { Err(IdeError::unavailable("offline")) } else { Ok(vec!["2.0.0".into()]) }
|
||||
}
|
||||
}
|
||||
fn source(fail: bool) -> Source {
|
||||
Source { calls: AtomicUsize::new(0), fail }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_disk_ttl_corruption_and_failed_cache_writes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let s = source(false);
|
||||
let cancel = CancellationToken::new();
|
||||
let cache = VersionCache::new(dir.path().into());
|
||||
for _ in 0..2 {
|
||||
assert_eq!(cache.versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]);
|
||||
}
|
||||
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
|
||||
VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap();
|
||||
assert_eq!(s.calls.load(Ordering::SeqCst), 1, "disk hit after service restart");
|
||||
let mut expired = VersionCache::new(dir.path().into());
|
||||
expired.ttl = Duration::ZERO;
|
||||
expired.versions(&s, "foo", &cancel).unwrap();
|
||||
assert_eq!(s.calls.load(Ordering::SeqCst), 2);
|
||||
std::fs::write(dir.path().join("test-foo.json"), b"corrupt").unwrap();
|
||||
VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap();
|
||||
assert_eq!(s.calls.load(Ordering::SeqCst), 3);
|
||||
let file = dir.path().join("not-a-directory");
|
||||
std::fs::write(&file, "x").unwrap();
|
||||
assert_eq!(VersionCache::new(file).versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_fetches_coalesce_and_failures_are_silent_and_short_cached() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = VersionCache::new(dir.path().into());
|
||||
let s = source(false);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..8 {
|
||||
scope.spawn(|| assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]));
|
||||
}
|
||||
});
|
||||
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
|
||||
let s = source(true);
|
||||
for _ in 0..2 {
|
||||
assert!(cache.versions(&s, "offline", &CancellationToken::new()).unwrap().is_empty());
|
||||
}
|
||||
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
|
||||
assert!(!dir.path().join("test-offline.json").exists(), "failures aren't persisted");
|
||||
let slot = cache.entries.lock().get("test-offline").unwrap().clone();
|
||||
slot.lock().as_mut().unwrap().fetched -= 31;
|
||||
cache.versions(&s, "offline", &CancellationToken::new()).unwrap();
|
||||
assert_eq!(s.calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_and_invalid_cache_keys_do_not_fetch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = VersionCache::new(dir.path().into());
|
||||
let s = source(false);
|
||||
let token = CancellationToken::new();
|
||||
token.cancel();
|
||||
assert!(cache.versions(&s, "foo", &token).is_err());
|
||||
assert!(cache.versions(&s, "../foo", &CancellationToken::new()).unwrap().is_empty());
|
||||
assert_eq!(s.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_waiter_does_not_cancel_the_shared_fetch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = VersionCache::new(dir.path().into());
|
||||
let s = source(false);
|
||||
let slot = Arc::new(Mutex::new(None));
|
||||
cache.entries.lock().insert("test-foo".into(), slot.clone());
|
||||
let guard = slot.lock();
|
||||
let token = CancellationToken::new();
|
||||
std::thread::scope(|scope| {
|
||||
let waiter = scope.spawn(|| cache.versions(&s, "foo", &token));
|
||||
token.cancel();
|
||||
assert!(waiter.join().unwrap().is_err());
|
||||
});
|
||||
drop(guard);
|
||||
assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use super::{DependencyUpdate, ManifestDependency};
|
||||
use semver::{Op, Version, VersionReq};
|
||||
use serde::Deserialize;
|
||||
use toml_edit::{Document, Item, TableLike};
|
||||
|
||||
/// Cargo's sparse-index path, including the special cases for short names.
|
||||
pub fn index_path(name: &str) -> Option<String> {
|
||||
if name.is_empty()
|
||||
|| name.len() > 64
|
||||
|| !name.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_'))
|
||||
|| !name.as_bytes()[0].is_ascii_alphabetic()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let name = name.to_ascii_lowercase();
|
||||
Some(match name.len() {
|
||||
1 => format!("1/{name}"),
|
||||
2 => format!("2/{name}"),
|
||||
3 => format!("3/{}/{name}", &name[..1]),
|
||||
_ => format!("{}/{}/{name}", &name[..2], &name[2..4]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_index(text: &str) -> Option<Vec<String>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Entry {
|
||||
vers: String,
|
||||
#[serde(default)]
|
||||
yanked: bool,
|
||||
}
|
||||
let mut versions = Vec::new();
|
||||
for line in text.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let entry: Entry = serde_json::from_str(line).ok()?;
|
||||
if !entry.yanked {
|
||||
versions.push(Version::parse(&entry.vers).ok()?);
|
||||
}
|
||||
}
|
||||
versions.sort_unstable_by(|a, b| b.cmp(a));
|
||||
versions.dedup();
|
||||
Some(versions.into_iter().map(|v| v.to_string()).collect())
|
||||
}
|
||||
|
||||
/// A complete TOML parser supplies spans, so strings/comments that resemble tables
|
||||
/// never become dependencies. Broken documents are silent until they parse again.
|
||||
pub fn dependencies(text: &str) -> Vec<ManifestDependency> {
|
||||
let Ok(doc) = text.parse::<Document<String>>() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
sections(doc.as_table(), text, &mut out);
|
||||
if let Some(workspace) = doc.get("workspace").and_then(Item::as_table_like)
|
||||
&& let Some(deps) = workspace.get("dependencies").and_then(Item::as_table_like)
|
||||
{
|
||||
collect(deps, text, &mut out);
|
||||
}
|
||||
if let Some(targets) = doc.get("target").and_then(Item::as_table_like) {
|
||||
for (_, target) in targets.iter() {
|
||||
if let Some(target) = target.as_table_like() {
|
||||
sections(target, text, &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable_by_key(|d| d.from);
|
||||
out
|
||||
}
|
||||
|
||||
fn sections(table: &dyn TableLike, text: &str, out: &mut Vec<ManifestDependency>) {
|
||||
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
|
||||
if let Some(deps) = table.get(section).and_then(Item::as_table_like) {
|
||||
collect(deps, text, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect(table: &dyn TableLike, text: &str, out: &mut Vec<ManifestDependency>) {
|
||||
for (name, item) in table.iter() {
|
||||
let (package, value) = if let Some(dep) = item.as_table_like() {
|
||||
if ["path", "git", "registry", "registry-index"].iter().any(|k| dep.contains_key(k))
|
||||
|| dep.get("workspace").and_then(Item::as_bool) == Some(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(version) = dep.get("version") else { continue };
|
||||
(dep.get("package").and_then(Item::as_str).unwrap_or(name), version)
|
||||
} else {
|
||||
(name, item)
|
||||
};
|
||||
if index_path(package).is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(requirement) = value.as_str() else { continue };
|
||||
let Some(span) = value.span() else { continue };
|
||||
let Some(raw) = text.get(span.clone()) else { continue };
|
||||
// Spans cover the encoded TOML string. Replace its entire contents,
|
||||
// including escapes, while retaining the original quote delimiters.
|
||||
let delimiter = if raw.starts_with("\"\"\"") || raw.starts_with("'''") { 3 } else { 1 };
|
||||
out.push(ManifestDependency {
|
||||
package: package.to_owned(),
|
||||
requirement: requirement.to_owned(),
|
||||
from: text[..span.start + delimiter].encode_utf16().count() as u32,
|
||||
to: text[..span.end - delimiter].encode_utf16().count() as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(requirement: &str, versions: &[String]) -> Option<DependencyUpdate> {
|
||||
let req = VersionReq::parse(requirement).ok()?;
|
||||
let prerelease = req.comparators.iter().any(|c| !c.pre.is_empty());
|
||||
let latest =
|
||||
versions.iter().filter_map(|v| Version::parse(v).ok()).filter(|v| prerelease || v.pre.is_empty()).max()?;
|
||||
if req.matches(&latest) {
|
||||
return None;
|
||||
}
|
||||
// An unavailable future version isn't an invitation to downgrade.
|
||||
if req.comparators.iter().any(|c| {
|
||||
(c.op == Op::Greater
|
||||
&& c.patch.is_none()
|
||||
&& (latest.major < c.major
|
||||
|| (latest.major == c.major && c.minor.is_none_or(|minor| latest.minor <= minor))))
|
||||
|| !matches!(c.op, Op::Less | Op::LessEq)
|
||||
&& latest
|
||||
<= Version {
|
||||
major: c.major,
|
||||
minor: c.minor.unwrap_or(0),
|
||||
patch: c.patch.unwrap_or(0),
|
||||
pre: c.pre.clone(),
|
||||
build: Default::default(),
|
||||
}
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let trimmed = requirement.trim();
|
||||
let digits = trimmed.trim_start_matches(['^', '~', '=']).trim_start();
|
||||
let simple = digits.split('.').all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()));
|
||||
let mut replacement = latest.to_string();
|
||||
if simple && latest.pre.is_empty() {
|
||||
let precision = digits.split('.').count();
|
||||
let version = match precision {
|
||||
1 => latest.major.to_string(),
|
||||
2 => format!("{}.{}", latest.major, latest.minor),
|
||||
_ => latest.to_string(),
|
||||
};
|
||||
let candidate = format!("{}{version}", &trimmed[..trimmed.len() - digits.len()]);
|
||||
if VersionReq::parse(&candidate).is_ok_and(|r| r.matches(&latest)) {
|
||||
replacement = candidate;
|
||||
}
|
||||
}
|
||||
Some(DependencyUpdate { latest: latest.to_string(), replacement })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,143 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sparse_paths_and_input_validation() {
|
||||
for (name, path) in
|
||||
[("A", "1/a"), ("ab", "2/ab"), ("AbC", "3/a/abc"), ("serde", "se/rd/serde"), ("my_crate", "my/_c/my_crate")]
|
||||
{
|
||||
assert_eq!(index_path(name).as_deref(), Some(path));
|
||||
}
|
||||
for name in ["", "../serde", "a/b", "sé", "-foo", "a?b", "1abc"] {
|
||||
assert!(index_path(name).is_none(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_index_filters_yanked_sorts_semver_and_keeps_prereleases_for_completion() {
|
||||
let text = r#"{"vers":"1.9.0","yanked":false}
|
||||
{"vers":"2.0.0-beta.1","yanked":false}
|
||||
{"vers":"9.0.0","yanked":true}
|
||||
{"vers":"1.10.0","yanked":false}
|
||||
{"vers":"1.10.0"}"#;
|
||||
assert_eq!(parse_index(text).unwrap(), ["2.0.0-beta.1", "1.10.0", "1.9.0"]);
|
||||
assert!(parse_index("{broken").is_none());
|
||||
assert!(parse_index(r#"{"vers":"bogus"}"#).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_requirements_updates_and_precision() {
|
||||
for (req, latest, expected) in [
|
||||
("1", "1.9.0", None),
|
||||
("1.0.100", "1.0.228", None),
|
||||
("^1.2", "1.9.0", None),
|
||||
("0.7", "0.7.9", None),
|
||||
("0.0.1", "0.0.2", Some("0.0.2")),
|
||||
("1", "2.3.4", Some("2")),
|
||||
("1.2", "2.3.4", Some("2.3")),
|
||||
("1.2.3", "2.3.0", Some("2.3.0")),
|
||||
("1.2.3", "1.3.0", None),
|
||||
("0.2", "1.3.0", Some("1.3")),
|
||||
("=1.2.3", "1.3.0", Some("=1.3.0")),
|
||||
("~1.2", "1.3.0", Some("~1.3")),
|
||||
("^0.2", "0.3.1", Some("^0.3")),
|
||||
("1.*", "1.9.0", None),
|
||||
("*", "2.3.4", None),
|
||||
(">=1, <2", "2.3.4", Some("2.3.4")),
|
||||
(">=1, <2", "2.0.0", Some("2.0.0")),
|
||||
("<1", "1.0.0", Some("1.0.0")),
|
||||
(">=1, <3", "2.3.4", None),
|
||||
(">=9", "2.3.4", None),
|
||||
(">1", "1.9.0", None),
|
||||
(">1.2", "1.2.9", None),
|
||||
(">1.2.3", "1.2.3", None),
|
||||
("=9.1.2", "2.3.4", None),
|
||||
("bad", "2.3.4", None),
|
||||
("1.0", "2.0.0-beta.1", None),
|
||||
("1.0.0-beta.1", "1.0.0-beta.2", None),
|
||||
("=1.0.0-beta.1", "1.0.0-beta.2", Some("1.0.0-beta.2")),
|
||||
("1.0.0-beta.1", "2.0.0-beta.1", Some("2.0.0-beta.1")),
|
||||
] {
|
||||
let actual = update(req, &[latest.to_owned()]);
|
||||
assert_eq!(actual.as_ref().map(|u| u.replacement.as_str()), expected, "{req} -> {latest}");
|
||||
if let Some(u) = actual {
|
||||
assert!(VersionReq::parse(&u.replacement).unwrap().matches(&Version::parse(latest).unwrap()));
|
||||
}
|
||||
}
|
||||
let versions = vec!["3.0.0-beta.1".into(), "2.0.0".into(), "1.0.0".into()];
|
||||
assert_eq!(update("1", &versions).unwrap().latest, "2.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locates_all_cargo_sections_and_only_replaces_version_contents() {
|
||||
let text = r#"# 中文 😀
|
||||
[package]
|
||||
name = "fixture"
|
||||
version = "1.0.0"
|
||||
[dependencies]
|
||||
serde = "1.0.100" # stays
|
||||
rand = '0.7'
|
||||
tokio = { version = "0.2", features = ["full"] }
|
||||
renamed = { package = "real-crate", version = "1.2" }
|
||||
local = { path = "../local", version = "1" }
|
||||
git = { git = "https://example.com/repo", version = "1" }
|
||||
inherited = { workspace = true }
|
||||
other = { registry = "private", version = "1" }
|
||||
[dev-dependencies]
|
||||
check = "1"
|
||||
[build-dependencies.build]
|
||||
version = "1.2.3"
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
native = "0.1"
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
win = { version = "1" }
|
||||
[target.'cfg(unix)'.build-dependencies]
|
||||
build-native = "2"
|
||||
[workspace.dependencies]
|
||||
shared = { version = "1", features = ["std"] }
|
||||
[package.metadata.dependencies]
|
||||
not-a-dependency = "1"
|
||||
"#;
|
||||
let deps = dependencies(text);
|
||||
assert_eq!(
|
||||
deps.iter().map(|d| d.package.as_str()).collect::<Vec<_>>(),
|
||||
["serde", "rand", "tokio", "real-crate", "check", "build", "native", "win", "build-native", "shared"]
|
||||
);
|
||||
let utf16: Vec<_> = text.encode_utf16().collect();
|
||||
for dep in &deps {
|
||||
assert_eq!(String::from_utf16(&utf16[dep.from as usize..dep.to as usize]).unwrap(), dep.requirement);
|
||||
}
|
||||
let tokio = &deps[2];
|
||||
let mut edited = utf16.clone();
|
||||
edited.splice(tokio.from as usize..tokio.to as usize, "1.3".encode_utf16());
|
||||
assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("version = \"0.2\"", "version = \"1.3\""));
|
||||
let rand = &deps[1];
|
||||
let mut edited = utf16;
|
||||
edited.splice(rand.from as usize..rand.to as usize, "0.9".encode_utf16());
|
||||
assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("'0.7'", "'0.9'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_toml_and_decoy_tables_are_silent() {
|
||||
assert!(dependencies("[dependencies]\na = \"").is_empty());
|
||||
assert!(dependencies("[package]\ndescription = '''\n[dependencies]\na = \"1\"\n'''\n").is_empty());
|
||||
assert!(dependencies("[dependencies]\na = { version = 1 }\nb = { workspace = true, version = \"1\" }\nc = { version = \"1\", registry-index = \"x\" }").is_empty());
|
||||
let deps = dependencies("[dependencies]\na = \"\"\n");
|
||||
assert_eq!(deps.len(), 1, "empty version strings still offer completion");
|
||||
assert_eq!(deps[0].from, deps[0].to);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_encoded_and_multiline_strings_without_damaging_quotes() {
|
||||
for raw in [r#""0.\u0032""#, "\"\"\"\n0.2\"\"\"", "'''0.2'''"] {
|
||||
let text = format!("[dependencies]\ntokio = {{ version = {raw}, features = [\"full\"] }}\n");
|
||||
let dep = dependencies(&text).pop().unwrap();
|
||||
assert_eq!(dep.requirement, "0.2");
|
||||
let mut encoded: Vec<_> = text.encode_utf16().collect();
|
||||
encoded.splice(dep.from as usize..dep.to as usize, "1.10".encode_utf16());
|
||||
let edited = String::from_utf16(&encoded).unwrap();
|
||||
assert!(edited.contains("features = [\"full\"]"));
|
||||
assert_eq!(dependencies(&edited)[0].requirement, "1.10");
|
||||
assert_eq!(edited.matches('"').count(), text.matches('"').count());
|
||||
assert_eq!(edited.matches('\'').count(), text.matches('\'').count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Manifest dependencies and repository versions, independent of editor and transport.
|
||||
mod cache;
|
||||
pub mod cargo;
|
||||
|
||||
pub use cache::VersionCache;
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// A repository adapter; Maven/npm can use the same cache and editor protocol.
|
||||
pub trait DependencyVersionSource: Send + Sync {
|
||||
/// A filesystem-safe, stable repository identifier.
|
||||
fn id(&self) -> &'static str;
|
||||
/// Non-yanked versions in descending semantic order.
|
||||
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyUpdate {
|
||||
pub latest: String,
|
||||
pub replacement: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManifestDependency {
|
||||
pub package: String,
|
||||
pub requirement: String,
|
||||
/// UTF-16 offsets of the string's contents (quotes excluded).
|
||||
pub from: u32,
|
||||
pub to: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyVersions {
|
||||
pub dependency: ManifestDependency,
|
||||
pub versions: Vec<String>,
|
||||
pub update: Option<DependencyUpdate>,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The project model that ties documents, settings and history together.
|
||||
|
||||
pub mod caches;
|
||||
pub mod dependencies;
|
||||
pub mod devcontainer;
|
||||
pub mod documents;
|
||||
pub mod editorconfig;
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Platform adapters supply the system's tab preference and active project state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TabbingPreference {
|
||||
Always,
|
||||
InFullScreen,
|
||||
Manually,
|
||||
}
|
||||
|
||||
pub fn should_group_new_project(
|
||||
preference: TabbingPreference,
|
||||
fullscreen: bool,
|
||||
active_project: bool,
|
||||
light_edit: bool,
|
||||
) -> bool {
|
||||
active_project
|
||||
&& !light_edit
|
||||
&& match preference {
|
||||
TabbingPreference::Always => true,
|
||||
TabbingPreference::InFullScreen => fullscreen,
|
||||
TabbingPreference::Manually => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WindowGroup {
|
||||
pub members: Vec<String>,
|
||||
@@ -39,6 +62,13 @@ impl WindowGroups {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a window to the anchor's group without merging unrelated groups.
|
||||
pub fn join(&mut self, anchor: &str, member: &str) -> IdeResult<()> {
|
||||
let mut members = self.group(anchor).map(|g| g.members.clone()).unwrap_or_else(|| vec![anchor.to_owned()]);
|
||||
members.push(member.to_owned());
|
||||
self.merge(members, anchor)
|
||||
}
|
||||
|
||||
pub fn select(&mut self, caller: &str, target: &str) -> IdeResult<WindowGroup> {
|
||||
let group = self
|
||||
.groups
|
||||
@@ -94,6 +124,45 @@ mod tests {
|
||||
values.iter().map(|s| (*s).into()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_project_grouping_follows_system_preference_and_excludes_light_edit() {
|
||||
for preference in [TabbingPreference::Always, TabbingPreference::InFullScreen, TabbingPreference::Manually] {
|
||||
for fullscreen in [false, true] {
|
||||
for active_project in [false, true] {
|
||||
for light_edit in [false, true] {
|
||||
let expected = matches!(
|
||||
(preference, fullscreen, active_project, light_edit),
|
||||
(TabbingPreference::Always, _, true, false)
|
||||
| (TabbingPreference::InFullScreen, true, true, false)
|
||||
);
|
||||
assert_eq!(
|
||||
should_group_new_project(preference, fullscreen, active_project, light_edit),
|
||||
expected,
|
||||
"{preference:?}, fullscreen={fullscreen}, active={active_project}, light={light_edit}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_new_windows_preserves_the_active_group_and_other_groups() {
|
||||
let mut groups = WindowGroups::default();
|
||||
groups.merge(members(&["other-a", "other-b"]), "other-b").unwrap();
|
||||
groups.join("a", "b").unwrap();
|
||||
assert_eq!(groups.group("a").unwrap().members, members(&["a", "b"]));
|
||||
let before = groups.select("a", "b").unwrap();
|
||||
assert_eq!(before.selected, "a");
|
||||
groups.join("b", "c").unwrap();
|
||||
let before = groups.select("b", "c").unwrap();
|
||||
assert_eq!(before.selected, "b");
|
||||
assert_eq!(groups.group("c").unwrap().members, members(&["a", "b", "c"]));
|
||||
assert_eq!(groups.group("c").unwrap().selected, "c");
|
||||
assert_eq!(groups.group("other-a").unwrap().members, members(&["other-a", "other-b"]));
|
||||
assert_eq!(groups.group("other-a").unwrap().selected, "other-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_and_detaching_preserve_the_other_sessions() {
|
||||
let mut groups = WindowGroups::default();
|
||||
|
||||
@@ -22,7 +22,7 @@ install_dir = { windows = "%LOCALAPPDATA%/OpenIDEA", macos = "/Applications/Open
|
||||
logs = { windows = "%LOCALAPPDATA%/ai.l0ng.openidea/logs/openidea*.log", macos = "~/Library/Logs/ai.l0ng.openidea/openidea*.log", linux = "~/.local/share/ai.l0ng.openidea/logs/openidea*.log" }
|
||||
# The index and build caches. jdtls keeps its own workspace under lsp/jdtls,
|
||||
# next to the jdtls install, so a cold Java run still reuses jdtls's build state.
|
||||
cache_dirs = { windows = ["%APPDATA%/ai.l0ng.openidea/index", "%APPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
|
||||
cache_dirs = { windows = ["%LOCALAPPDATA%/ai.l0ng.openidea/index", "%LOCALAPPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
|
||||
|
||||
[apps.openidea.markers]
|
||||
project_opened = 'opened .* in window'
|
||||
|
||||
@@ -78,3 +78,11 @@ objc2-core-foundation = { version = "0.3", default-features = false, features =
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
|
||||
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
|
||||
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Typed IPC adapters for manifest dependency versions.
|
||||
use crate::state::AppState;
|
||||
use ide_core::{DocId, IdeResult, RequestId};
|
||||
use ide_workspace::dependencies::DependencyVersions;
|
||||
use tauri::{State, Window};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn dependency_versions(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
doc: DocId,
|
||||
offset: Option<u32>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<DependencyVersions>> {
|
||||
ide_host::ops::dependencies::dependency_versions(&state.host, window.label(), doc, offset, request).await
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod clipboard;
|
||||
pub mod coverage;
|
||||
pub mod db;
|
||||
pub mod debug;
|
||||
pub mod dependencies;
|
||||
pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod docker;
|
||||
@@ -190,6 +191,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
nav::fs_resolve_reference,
|
||||
nav::path_completions,
|
||||
nav::web_unresolved_references,
|
||||
dependencies::dependency_versions,
|
||||
spell::spell_check,
|
||||
spell::spell_suggest,
|
||||
search::search_start,
|
||||
@@ -296,6 +298,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
vcs::vcs_changelists_save,
|
||||
vcs::vcs_write_patch,
|
||||
term::term_spawn,
|
||||
term::term_store_image,
|
||||
term::term_write,
|
||||
term::term_resize,
|
||||
term::term_kill,
|
||||
@@ -303,6 +306,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
term::term_default_shell,
|
||||
term::term_available_shells,
|
||||
run::run_configs,
|
||||
run::run_dotnet_resolve,
|
||||
run::run_save_configs,
|
||||
run::run_templates,
|
||||
run::run_save_templates,
|
||||
|
||||
@@ -8,6 +8,17 @@ use ide_run::{OutputChunk, RunConfig, RunInfo};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{State, Window};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn run_dotnet_resolve(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
config: RunConfig,
|
||||
debug: bool,
|
||||
) -> IdeResult<RunConfig> {
|
||||
ide_host::ops::run::run_dotnet_resolve(&state.host, window.label(), config, debug).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn run_application_build(
|
||||
|
||||
@@ -24,6 +24,17 @@ pub async fn term_spawn(
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn term_store_image(
|
||||
state: State<'_, AppState>,
|
||||
id: TerminalId,
|
||||
data: Vec<u8>,
|
||||
mime: String,
|
||||
) -> IdeResult<String> {
|
||||
ide_host::ops::term::store_image(&state.host, id, data, mime).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn term_write(state: State<'_, AppState>, id: TerminalId, data: String) -> IdeResult<()> {
|
||||
|
||||
+12
-2
@@ -11,6 +11,8 @@ mod events;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_window_state;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
mod menu;
|
||||
mod remote_dev;
|
||||
|
||||
@@ -19,6 +21,8 @@ mod theme_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod titlebar;
|
||||
mod window_groups;
|
||||
#[cfg(any(windows, test))]
|
||||
mod window_work_area;
|
||||
mod windows;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
@@ -53,7 +57,7 @@ pub fn run() {
|
||||
|
||||
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
|
||||
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
|
||||
if let Some(data) = dirs::data_dir() {
|
||||
if let Some(data) = dirs::data_local_dir() {
|
||||
ide_tools::extend_process_path(&data.join(&context.config().identifier));
|
||||
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier)));
|
||||
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier)));
|
||||
@@ -70,6 +74,9 @@ pub fn run() {
|
||||
// Single instance first: a second launch focuses this one and opens its path argument.
|
||||
// Debug builds have a separate identifier per worktree.
|
||||
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
|
||||
// Install before window-state restores maximized geometry during window creation.
|
||||
#[cfg(windows)]
|
||||
let app = app.plugin(window_work_area::init());
|
||||
let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init());
|
||||
#[cfg(target_os = "linux")]
|
||||
let app = app.plugin(linux_window_state::plugin());
|
||||
@@ -167,7 +174,10 @@ pub fn run() {
|
||||
tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false),
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
#[cfg(target_os = "macos")]
|
||||
titlebar::forget(window);
|
||||
{
|
||||
titlebar::forget(window);
|
||||
macos::forget_fullscreen(window.label());
|
||||
}
|
||||
window_groups::destroyed(window.app_handle(), window.label());
|
||||
if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) {
|
||||
let _ = settings.destroy();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! macOS window tab preferences, applied to application-drawn project tabs.
|
||||
use crate::windows::{Launch, WindowsState};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_workspace::window_groups::{TabbingPreference, should_group_new_project};
|
||||
use objc2::{MainThreadMarker, rc::Retained, runtime::ProtocolObject};
|
||||
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
|
||||
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
|
||||
fn read(mtm: MainThreadMarker) -> TabbingPreference {
|
||||
match NSWindow::userTabbingPreference(mtm) {
|
||||
NSWindowUserTabbingPreference::Always => TabbingPreference::Always,
|
||||
NSWindowUserTabbingPreference::InFullScreen => TabbingPreference::InFullScreen,
|
||||
_ => TabbingPreference::Manually,
|
||||
}
|
||||
}
|
||||
if let Some(mtm) = MainThreadMarker::new() {
|
||||
return Ok(read(mtm));
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
app.run_on_main_thread(move || {
|
||||
let _ = tx.send(read(MainThreadMarker::new().expect("AppKit main thread")));
|
||||
})
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
rx.recv().map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Capture before building the new window, which takes focus itself.
|
||||
pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult<Option<WebviewWindow>> {
|
||||
let windows = app.webview_windows();
|
||||
let active = windows
|
||||
.values()
|
||||
.find(|w| w.is_focused().unwrap_or(false))
|
||||
.cloned()
|
||||
.or_else(|| app.state::<WindowsState>().last_focused().and_then(|label| windows.get(&label).cloned()));
|
||||
let active = active.and_then(|w| {
|
||||
let label = crate::windows::settings_parent(w.label()).unwrap_or(w.label());
|
||||
windows.get(label).cloned()
|
||||
});
|
||||
let active = active.filter(|w| {
|
||||
let host = app.state::<crate::state::AppState>();
|
||||
host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some()
|
||||
});
|
||||
let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false));
|
||||
let light_edit = launch.project.is_none() && launch.remote.is_none();
|
||||
Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit)
|
||||
.then_some(active)
|
||||
.flatten())
|
||||
}
|
||||
|
||||
struct FullscreenObserver(Retained<ProtocolObject<dyn NSObjectProtocol>>);
|
||||
|
||||
impl Drop for FullscreenObserver {
|
||||
fn drop(&mut self) {
|
||||
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.0;
|
||||
unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) };
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FULLSCREEN_TRANSFERS: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = std::cell::RefCell::default();
|
||||
}
|
||||
|
||||
pub fn forget_fullscreen(label: &str) {
|
||||
FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.remove(label));
|
||||
}
|
||||
|
||||
/// AppKit cannot enter another window's fullscreen Space until the old one has exited.
|
||||
pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> {
|
||||
let source = from.label().to_owned();
|
||||
let to = to.clone();
|
||||
from.with_webview(move |webview| unsafe {
|
||||
let native = &*webview.ns_window().cast::<NSWindow>();
|
||||
forget_fullscreen(&source);
|
||||
let label = source.clone();
|
||||
let block = block2::RcBlock::new(move |_| {
|
||||
forget_fullscreen(&label);
|
||||
let app = to.app_handle();
|
||||
let selected = app.state::<WindowsState>().groups.lock().group(to.label()).map(|g| g.selected.clone());
|
||||
if let Some(selected) = selected {
|
||||
// Exiting fullscreen can show the old window again; reapply group visibility.
|
||||
let _ = crate::window_groups::select(app, &selected, &selected);
|
||||
if selected == to.label() {
|
||||
let _ = to.set_fullscreen(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(objc2_app_kit::NSWindowDidExitFullScreenNotification),
|
||||
Some(native),
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.insert(source, FullscreenObserver(observer)));
|
||||
native.toggleFullScreen(None);
|
||||
})
|
||||
}
|
||||
@@ -182,7 +182,7 @@ fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option<std::pa
|
||||
if let Some(p) = std::env::var_os("OPENIDEA_SERVER_BINARY").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
|
||||
return Some(p);
|
||||
}
|
||||
let bundled = state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
|
||||
let bundled = state.local_data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
|
||||
if bundled.is_file() {
|
||||
return Some(bundled);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ async fn download_server(
|
||||
arch: &str,
|
||||
progress: Arc<Progress>,
|
||||
) -> IdeResult<std::path::PathBuf> {
|
||||
let (data, version, os, arch) = (state.data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned());
|
||||
let (data, version, os, arch) = (state.local_data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| match e {
|
||||
ide_tools::InstallEvent::Line { text } => tracing::info!("openidea-server download: {text}"),
|
||||
@@ -259,7 +259,12 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: A
|
||||
`cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \
|
||||
(or set OPENIDEA_SERVER_BINARY)",
|
||||
e.message,
|
||||
state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server").display()
|
||||
state
|
||||
.local_data_dir
|
||||
.join("server")
|
||||
.join(format!("{os}-{arch}"))
|
||||
.join("openidea-server")
|
||||
.display()
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,21 +64,28 @@ impl AppState {
|
||||
}
|
||||
let config_dir = app.path().app_config_dir()?;
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
let local_data_dir = app.path().app_local_data_dir()?;
|
||||
std::fs::create_dir_all(&config_dir)?;
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
if let Err(error) = std::fs::create_dir_all(&local_data_dir) {
|
||||
tracing::warn!(?local_data_dir, %error, "cannot create local data directory");
|
||||
}
|
||||
tracing::info!(?data_dir, ?local_data_dir, "application data directories");
|
||||
// Saved git HTTPS credentials (askpass answers them silently).
|
||||
ide_ssh::git_creds::init(data_dir.join("git-credentials.json"));
|
||||
// File › Invalidate Caches… requested at the last run.
|
||||
let removed = ide_workspace::caches::apply_pending(&data_dir);
|
||||
let removed = ide_workspace::caches::apply_pending(&data_dir, &local_data_dir);
|
||||
if !removed.is_empty() {
|
||||
tracing::info!("invalidated caches: {removed:?}");
|
||||
}
|
||||
ide_host::data_dirs::migrate(&data_dir, &local_data_dir);
|
||||
let global_sink: SharedSink = Arc::new(TauriSink { app: app.clone(), window: None });
|
||||
let languages = Arc::new(LanguageRegistry::load(Some(&config_dir.join("languages")))?);
|
||||
let sink_app = app.clone();
|
||||
let host = Arc::new(Host::new(ide_host::HostConfig {
|
||||
config_dir: config_dir.clone(),
|
||||
data_dir: data_dir.clone(),
|
||||
local_data_dir,
|
||||
languages: languages.clone(),
|
||||
syntax,
|
||||
sink_for: Box::new(move |window| {
|
||||
|
||||
@@ -51,8 +51,13 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
let fullscreen = from.is_fullscreen().map_err(err)?;
|
||||
if fullscreen {
|
||||
from.set_fullscreen(false).map_err(err)?;
|
||||
to.set_fullscreen(true).map_err(err)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos::transfer_fullscreen(from, to).map_err(err)?;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
from.set_fullscreen(false).map_err(err)?;
|
||||
to.set_fullscreen(true).map_err(err)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
@@ -109,6 +114,13 @@ pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> {
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
state.groups.lock().join(anchor, new_window)?;
|
||||
select(app, anchor, new_window)
|
||||
}
|
||||
|
||||
pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
let members = windows::project_window_labels(app)
|
||||
.into_iter()
|
||||
@@ -235,7 +247,7 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
} else if path.is_dir() {
|
||||
let window = match windows::find_project_window(app, path) {
|
||||
Some(w) => w,
|
||||
None => windows::create_window(app, Some(path.clone()))?,
|
||||
None => windows::create_restored_window(app, path.clone())?,
|
||||
};
|
||||
Some(window.label().to_owned())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Native maximize bounds for every Windows frameless window, including state restoration.
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use native::init;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Rect {
|
||||
left: i32,
|
||||
top: i32,
|
||||
right: i32,
|
||||
bottom: i32,
|
||||
}
|
||||
|
||||
/// All coordinates are physical pixels in the selected monitor's coordinate space.
|
||||
/// Auto-hide needs one exposed pixel, but an already reserved edge needs no extra inset.
|
||||
fn bounds(monitor: Rect, work: Rect, auto_hide: [bool; 4], fullscreen: bool) -> Rect {
|
||||
if fullscreen {
|
||||
return monitor;
|
||||
}
|
||||
let [left, top, right, bottom] = auto_hide;
|
||||
Rect {
|
||||
left: work.left + i32::from(left && work.left == monitor.left),
|
||||
top: work.top + i32::from(top && work.top == monitor.top),
|
||||
right: work.right - i32::from(right && work.right == monitor.right),
|
||||
bottom: work.bottom - i32::from(bottom && work.bottom == monitor.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
fn maximize_geometry(monitor: Rect, area: Rect) -> (i32, i32, i32, i32) {
|
||||
(area.left - monitor.left, area.top - monitor.top, area.right - area.left, area.bottom - area.top)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod native {
|
||||
use super::{Rect, bounds, maximize_geometry};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM},
|
||||
Graphics::Gdi::{
|
||||
GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromRect, MonitorFromWindow,
|
||||
},
|
||||
UI::{
|
||||
Shell::{
|
||||
ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_GETAUTOHIDEBAREX, APPBARDATA, DefSubclassProc,
|
||||
RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
|
||||
},
|
||||
WindowsAndMessaging::{
|
||||
GWL_STYLE, GetWindowLongPtrW, IsZoomed, MINMAXINFO, NCCALCSIZE_PARAMS, WM_GETMINMAXINFO, WM_NCCALCSIZE,
|
||||
WM_NCDESTROY, WS_CAPTION,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const SUBCLASS: usize = 0x410;
|
||||
|
||||
pub fn init() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
tauri::plugin::Builder::new("window-work-area")
|
||||
.on_window_ready(|window| {
|
||||
if window.is_decorated().unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let install = || -> tauri::Result<()> {
|
||||
let hwnd = window.hwnd()?.0 as usize;
|
||||
window.run_on_main_thread(move || {
|
||||
// Subclasses must be installed and removed on the owning GUI thread.
|
||||
if unsafe { SetWindowSubclass(hwnd as HWND, Some(window_proc), SUBCLASS, 0) } == 0 {
|
||||
tracing::warn!(
|
||||
"cannot install window work area handler: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Err(error) = install() {
|
||||
tracing::warn!("cannot install window work area handler: {error}");
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
fn rect(rect: RECT) -> Rect {
|
||||
Rect { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
|
||||
}
|
||||
|
||||
fn native_rect(rect: Rect) -> RECT {
|
||||
RECT { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
|
||||
}
|
||||
|
||||
unsafe fn monitor_bounds(monitor: HMONITOR, fullscreen: bool) -> Option<(Rect, Rect)> {
|
||||
let mut info = MONITORINFO { cbSize: size_of::<MONITORINFO>() as u32, ..Default::default() };
|
||||
if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut auto_hide = [false; 4];
|
||||
if !fullscreen {
|
||||
for (index, edge) in [ABE_LEFT, ABE_TOP, ABE_RIGHT, ABE_BOTTOM].into_iter().enumerate() {
|
||||
let mut data = APPBARDATA {
|
||||
cbSize: size_of::<APPBARDATA>() as u32,
|
||||
uEdge: edge,
|
||||
rc: info.rcMonitor,
|
||||
..Default::default()
|
||||
};
|
||||
auto_hide[index] = unsafe { SHAppBarMessage(ABM_GETAUTOHIDEBAREX, &mut data) } != 0;
|
||||
}
|
||||
}
|
||||
let monitor = rect(info.rcMonitor);
|
||||
Some((monitor, bounds(monitor, rect(info.rcWork), auto_hide, fullscreen)))
|
||||
}
|
||||
|
||||
unsafe extern "system" fn window_proc(
|
||||
hwnd: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_id: usize,
|
||||
_data: usize,
|
||||
) -> LRESULT {
|
||||
// Tao keeps WS_CAPTION on frameless windows and removes it for native fullscreen.
|
||||
// Read native styles here: a Tauri state query from its window procedure can deadlock.
|
||||
let fullscreen = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32 & WS_CAPTION == 0;
|
||||
match message {
|
||||
WM_GETMINMAXINFO if !fullscreen => {
|
||||
// Keep Tao's min/max tracking constraints, then set the outer maximize bounds.
|
||||
let result = unsafe { DefSubclassProc(hwnd, message, wparam, lparam) };
|
||||
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
|
||||
if let Some((monitor, area)) = unsafe { monitor_bounds(monitor, false) } {
|
||||
let (x, y, width, height) = maximize_geometry(monitor, area);
|
||||
let info = unsafe { &mut *(lparam as *mut MINMAXINFO) };
|
||||
info.ptMaxPosition.x = x;
|
||||
info.ptMaxPosition.y = y;
|
||||
info.ptMaxSize.x = width;
|
||||
info.ptMaxSize.y = height;
|
||||
}
|
||||
result
|
||||
}
|
||||
WM_NCCALCSIZE if wparam != 0 && (fullscreen || unsafe { IsZoomed(hwnd) } != 0) => {
|
||||
let params = unsafe { &mut *(lparam as *mut NCCALCSIZE_PARAMS) };
|
||||
// Use the proposed rectangle: a move/snap/DPI change can target another monitor.
|
||||
let monitor = unsafe { MonitorFromRect(¶ms.rgrc[0], MONITOR_DEFAULTTONEAREST) };
|
||||
if let Some((_, area)) = unsafe { monitor_bounds(monitor, fullscreen) } {
|
||||
// Replace Tao's client-only clamp and global auto-hide query. Fullscreen
|
||||
// must retain the entire display, even when entered from a maximized window.
|
||||
params.rgrc[0] = native_rect(area);
|
||||
0
|
||||
} else {
|
||||
unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }
|
||||
}
|
||||
}
|
||||
WM_NCDESTROY => {
|
||||
unsafe { RemoveWindowSubclass(hwnd, Some(window_proc), SUBCLASS) };
|
||||
unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }
|
||||
}
|
||||
_ => unsafe { DefSubclassProc(hwnd, message, wparam, lparam) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn r(left: i32, top: i32, right: i32, bottom: i32) -> Rect {
|
||||
Rect { left, top, right, bottom }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taskbars_on_every_edge_and_secondary_displays() {
|
||||
for (monitor, work, expected) in [
|
||||
// Reported case: maximized window must leave the bottom taskbar visible.
|
||||
(r(0, 0, 1920, 1080), r(0, 0, 1920, 1040), (0, 0, 1920, 1040)),
|
||||
(r(0, 0, 1920, 1080), r(48, 0, 1920, 1080), (48, 0, 1872, 1080)),
|
||||
(r(0, 0, 1920, 1080), r(0, 40, 1920, 1080), (0, 40, 1920, 1040)),
|
||||
(r(0, 0, 1920, 1080), r(0, 0, 1872, 1080), (0, 0, 1872, 1080)),
|
||||
// Negative origin, top taskbar, 150% display beside a 100% display.
|
||||
(r(-2560, -1440, 0, 0), r(-2560, -1380, 0, 0), (0, 60, 2560, 1380)),
|
||||
// 250% display: physical bounds must not be scaled with the previous monitor's DPI.
|
||||
(r(1920, 0, 5760, 2160), r(1920, 0, 5760, 2060), (0, 0, 3840, 2060)),
|
||||
] {
|
||||
let area = bounds(monitor, work, [false; 4], false);
|
||||
assert_eq!(area, work);
|
||||
assert_eq!(maximize_geometry(monitor, area), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_hide_leaves_a_physical_pixel_only_on_its_own_monitor_edge() {
|
||||
let monitor = r(-1920, 0, 0, 1080);
|
||||
for (edges, expected) in [
|
||||
([true, false, false, false], r(-1919, 0, 0, 1080)),
|
||||
([false, true, false, false], r(-1920, 1, 0, 1080)),
|
||||
([false, false, true, false], r(-1920, 0, -1, 1080)),
|
||||
([false, false, false, true], r(-1920, 0, 0, 1079)),
|
||||
([false; 4], monitor),
|
||||
([true; 4], r(-1919, 1, -1, 1079)),
|
||||
] {
|
||||
assert_eq!(bounds(monitor, monitor, edges, false), expected);
|
||||
}
|
||||
let reserved = r(-1920, 0, 0, 1040);
|
||||
assert_eq!(bounds(monitor, reserved, [false, false, false, true], false), reserved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_covers_taskbars_including_when_entered_from_maximized() {
|
||||
let monitor = r(1920, -100, 5760, 2060);
|
||||
let work = r(1980, -100, 5760, 1960);
|
||||
assert_eq!(bounds(monitor, work, [true; 4], true), monitor);
|
||||
assert_eq!(bounds(monitor, work, [false; 4], false), work);
|
||||
}
|
||||
}
|
||||
@@ -357,18 +357,35 @@ pub fn create_main_window(app: &AppHandle) -> IdeResult<WebviewWindow> {
|
||||
|
||||
/// Creates a new project window; `project` is opened by its frontend on load.
|
||||
pub fn create_window(app: &AppHandle, project: Option<PathBuf>) -> IdeResult<WebviewWindow> {
|
||||
create_window_with(app, project, Vec::new())
|
||||
create_window_with(app, project, Vec::new(), false)
|
||||
}
|
||||
|
||||
/// [`create_window`] that also opens `files` (without `project`: in LightEdit).
|
||||
fn create_window_with(app: &AppHandle, project: Option<PathBuf>, files: Vec<PathBuf>) -> IdeResult<WebviewWindow> {
|
||||
fn create_window_with(
|
||||
app: &AppHandle,
|
||||
project: Option<PathBuf>,
|
||||
files: Vec<PathBuf>,
|
||||
restoring: bool,
|
||||
) -> IdeResult<WebviewWindow> {
|
||||
let title = project.as_deref().or(files.first().map(PathBuf::as_path)).and_then(Path::file_name);
|
||||
let title = title.map(|n| n.to_string_lossy().into_owned());
|
||||
let files = files.into_iter().map(Into::into).collect();
|
||||
create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() })
|
||||
create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }, restoring)
|
||||
}
|
||||
|
||||
fn create_window_for(app: &AppHandle, title: Option<String>, launch: Launch) -> IdeResult<WebviewWindow> {
|
||||
/// Restoring a saved group preserves its membership and selected tab.
|
||||
pub fn create_restored_window(app: &AppHandle, project: PathBuf) -> IdeResult<WebviewWindow> {
|
||||
create_window_with(app, Some(project), Vec::new(), true)
|
||||
}
|
||||
|
||||
fn create_window_for(
|
||||
app: &AppHandle,
|
||||
title: Option<String>,
|
||||
launch: Launch,
|
||||
_restoring: bool,
|
||||
) -> IdeResult<WebviewWindow> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let tab_target = if _restoring { None } else { crate::macos::new_project_tab_target(app, &launch)? };
|
||||
let windows = app.webview_windows();
|
||||
let label = next_label(windows.keys().map(String::as_str));
|
||||
let mut config = window_config(app)?;
|
||||
@@ -388,6 +405,11 @@ fn create_window_for(app: &AppHandle, title: Option<String>, launch: Launch) ->
|
||||
}
|
||||
app.state::<WindowsState>().set_launch(&label, launch);
|
||||
let window = build_project_window(app, config)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(target) = tab_target {
|
||||
crate::window_groups::join(app, target.label(), window.label())?;
|
||||
return Ok(window);
|
||||
}
|
||||
focus(&window);
|
||||
Ok(window)
|
||||
}
|
||||
@@ -401,7 +423,7 @@ pub fn spawn_window(app: &AppHandle, project: Option<PathBuf>) {
|
||||
fn spawn_window_with(app: &AppHandle, project: Option<PathBuf>, files: Vec<PathBuf>) {
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = create_window_with(&app, project, files) {
|
||||
if let Err(e) = create_window_with(&app, project, files, false) {
|
||||
tracing::warn!("cannot open window: {}", e.message);
|
||||
}
|
||||
});
|
||||
@@ -440,7 +462,7 @@ pub fn open_project_window(
|
||||
if !new_window {
|
||||
return Ok(OpenOutcome::Current);
|
||||
}
|
||||
let window = create_window_with(app, Some(root), files)?;
|
||||
let window = create_window_with(app, Some(root), files, false)?;
|
||||
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
|
||||
}
|
||||
|
||||
@@ -450,7 +472,7 @@ pub fn open_project_window(
|
||||
/// detach an editor tab out of its window.
|
||||
pub fn open_file_in_new_window(app: &AppHandle, project: Option<PathBuf>, file: PathBuf) -> IdeResult<OpenOutcome> {
|
||||
tracing::info!("open_file_in_new_window {} project={:?}", file.display(), project);
|
||||
let window = create_window_with(app, project, vec![file])?;
|
||||
let window = create_window_with(app, project, vec![file], false)?;
|
||||
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
|
||||
}
|
||||
|
||||
@@ -478,7 +500,7 @@ pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_win
|
||||
}
|
||||
let title = path.trim_end_matches('/').rsplit('/').next().filter(|n| !n.is_empty() && *n != "~").map(str::to_owned);
|
||||
let remote = RemoteLaunch { ssh_config: ssh_config.to_owned(), path: path.to_owned() };
|
||||
let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() })?;
|
||||
let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() }, false)?;
|
||||
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
|
||||
}
|
||||
|
||||
@@ -841,9 +863,18 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri:
|
||||
/// scale and its position is given in physical pixels.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
|
||||
// Placement setters leave maximized mode. Fit the restored frame, then reapply the saved state.
|
||||
// A restored fullscreen window already covers its display and must not be fitted to the work area.
|
||||
if window.is_fullscreen()? {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else {
|
||||
return Ok(());
|
||||
};
|
||||
let maximized = window.is_maximized()?;
|
||||
if maximized {
|
||||
window.unmaximize()?;
|
||||
}
|
||||
let scale = monitor.scale_factor();
|
||||
let area = monitor.work_area();
|
||||
let origin = area.position.to_logical::<f64>(scale);
|
||||
@@ -872,6 +903,9 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind
|
||||
)))?;
|
||||
window.set_size(tauri::LogicalSize::new(width, height))?;
|
||||
}
|
||||
if maximized {
|
||||
window.maximize()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,7 @@ export const commands = {
|
||||
pathCompletions: (from: IpcPath, prefix: string) => typedError<PathCompletion[], IdeError>(__TAURI_INVOKE("path_completions", { from, prefix })),
|
||||
/** Paths in an HTML document or stylesheet that name no existing file. */
|
||||
webUnresolvedReferences: (doc: DocId) => typedError<FileReference[], IdeError>(__TAURI_INVOKE("web_unresolved_references", { doc })),
|
||||
dependencyVersions: (doc: DocId, offset: number | null, request: RequestId) => typedError<DependencyVersions[], IdeError>(__TAURI_INVOKE("dependency_versions", { doc, offset, request })),
|
||||
/**
|
||||
* Misspelt words in the comments, string literals and declared names of
|
||||
* `doc` that `scope` includes (all of its text for prose). Cancel with
|
||||
@@ -550,6 +551,7 @@ export const commands = {
|
||||
* to start the shell takes tens of milliseconds, a frozen UI if run there.
|
||||
*/
|
||||
termSpawn: (options: TerminalOptions, onOutput: Channel<string>) => typedError<TerminalInfo, IdeError>(__TAURI_INVOKE("term_spawn", { options, onOutput })),
|
||||
termStoreImage: (id: TerminalId, data: number[], mime: string) => typedError<string, IdeError>(__TAURI_INVOKE("term_store_image", { id, data, mime })),
|
||||
termWrite: (id: TerminalId, data: string) => typedError<null, IdeError>(__TAURI_INVOKE("term_write", { id, data })),
|
||||
termResize: (id: TerminalId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("term_resize", { id, cols, rows })),
|
||||
termKill: (id: TerminalId) => typedError<null, IdeError>(__TAURI_INVOKE("term_kill", { id })),
|
||||
@@ -564,6 +566,7 @@ export const commands = {
|
||||
termAvailableShells: () => __TAURI_INVOKE<string[]>("term_available_shells"),
|
||||
/** Detected configs followed by user-defined ones (user ids win on conflict). */
|
||||
runConfigs: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_configs")),
|
||||
runDotnetResolve: (config: RunConfig_Deserialize, debug: boolean) => typedError<RunConfig_Serialize, IdeError>(__TAURI_INVOKE("run_dotnet_resolve", { config, debug })),
|
||||
runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })),
|
||||
/** Configuration templates: per-type defaults applied to new configurations. */
|
||||
runTemplates: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_templates")),
|
||||
@@ -1639,6 +1642,30 @@ export type ByteRange = {
|
||||
total: number,
|
||||
};
|
||||
|
||||
export type CMakeProfile = {
|
||||
name?: string,
|
||||
buildType?: string,
|
||||
generator?: string,
|
||||
cmakeArgs?: string[],
|
||||
/** Empty uses cmake-build-<lowercase build type>. */
|
||||
buildDir?: string,
|
||||
buildArgs?: string[],
|
||||
env?: { [key in string]: string },
|
||||
};
|
||||
|
||||
/** Absent profiles retain automatic discovery for existing projects. */
|
||||
export type CMakeSettings = CMakeSettings_Serialize | CMakeSettings_Deserialize;
|
||||
|
||||
/** Absent profiles retain automatic discovery for existing projects. */
|
||||
export type CMakeSettings_Deserialize = {
|
||||
profiles?: CMakeProfile[] | null,
|
||||
};
|
||||
|
||||
/** Absent profiles retain automatic discovery for existing projects. */
|
||||
export type CMakeSettings_Serialize = {
|
||||
profiles?: CMakeProfile[] | null,
|
||||
};
|
||||
|
||||
export type CallHierarchy = {
|
||||
items: HierarchyItem[],
|
||||
incomplete: ReferencesIncomplete | null,
|
||||
@@ -2444,6 +2471,17 @@ export type DeletedBranch = {
|
||||
unmerged: boolean,
|
||||
};
|
||||
|
||||
export type DependencyUpdate = {
|
||||
latest: string,
|
||||
replacement: string,
|
||||
};
|
||||
|
||||
export type DependencyVersions = {
|
||||
dependency: ManifestDependency,
|
||||
versions: string[],
|
||||
update: DependencyUpdate | null,
|
||||
};
|
||||
|
||||
/** Where a local file is deployed. */
|
||||
export type DeployedPath = {
|
||||
server: string,
|
||||
@@ -4265,6 +4303,14 @@ export type MainToolbarSettings = {
|
||||
hidden?: string[],
|
||||
};
|
||||
|
||||
export type ManifestDependency = {
|
||||
package: string,
|
||||
requirement: string,
|
||||
/** UTF-16 offsets of the string's contents (quotes excluded). */
|
||||
from: number,
|
||||
to: number,
|
||||
};
|
||||
|
||||
export type Markup = {
|
||||
/** `markdown` or `plaintext`. */
|
||||
kind: string,
|
||||
@@ -5635,6 +5681,7 @@ export type Settings_Deserialize = {
|
||||
deployment?: DeploymentSettings,
|
||||
/** Build, Execution, Deployment › Run Targets. */
|
||||
runTargets?: RunTarget[],
|
||||
cmake?: CMakeSettings_Deserialize,
|
||||
};
|
||||
|
||||
export type Settings_Serialize = {
|
||||
@@ -5690,6 +5737,7 @@ export type Settings_Serialize = {
|
||||
deployment: DeploymentSettings,
|
||||
/** Build, Execution, Deployment › Run Targets. */
|
||||
runTargets: RunTarget[],
|
||||
cmake: CMakeSettings_Serialize,
|
||||
};
|
||||
|
||||
export type Severity = "error" | "warning" | "info" | "hint";
|
||||
|
||||
+13
-1
@@ -21,6 +21,7 @@ import {
|
||||
Annotation,
|
||||
Compartment,
|
||||
Facet,
|
||||
StateEffect,
|
||||
type EditorState,
|
||||
type Extension,
|
||||
type RangeSet,
|
||||
@@ -332,14 +333,25 @@ export function contributedAt<T>(state: EditorState, key: string, pos: number):
|
||||
return state.languageDataAt<T>(key, pos)[0];
|
||||
}
|
||||
|
||||
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */
|
||||
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵). */
|
||||
export interface ContributedIntention {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
quickFix?: boolean;
|
||||
run: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** A background provider has new context actions for the current document. */
|
||||
export const intentionsChanged = StateEffect.define<null>();
|
||||
|
||||
/** Local fixes of a diagnostic range (hover link and Apply Quick Fix). */
|
||||
export const quickFixProviders = Facet.define<(view: EditorView, from: number, to: number) => ContributedIntention[]>();
|
||||
|
||||
export function contributedQuickFixes(view: EditorView, from: number, to: number): ContributedIntention[] {
|
||||
return view.state.facet(quickFixProviders).flatMap((provider) => provider(view, from, to));
|
||||
}
|
||||
|
||||
/** Providers of `ContributedIntention`s for the caret of `view` (cheap and synchronous: also drives the light bulb). */
|
||||
export const intentionProviders = Facet.define<(view: EditorView) => ContributedIntention[]>();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Diagnostic, Severity } from "@/bindings";
|
||||
import { commands, ideEventsReady, isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { publishLocalDiagnostics } from "@/platform/localDiagnostics";
|
||||
import { scrollbarMarks, type ScrollbarMark } from "@/editor/api";
|
||||
import { intentionsChanged, scrollbarMarks, type ScrollbarMark } from "@/editor/api";
|
||||
import { rangeToOffsets } from "./positions";
|
||||
import { lspDoc } from "./lsp";
|
||||
import { showHint } from "./hint";
|
||||
@@ -199,7 +199,10 @@ const diagnosticsSync = ViewPlugin.fromClass(
|
||||
if (!path || !this.view.dom.isConnected) return;
|
||||
const lint = toLintDiagnostics(this.view.state.doc, diagnosticsFor(path));
|
||||
const tr = setDiagnostics(this.view.state, lint);
|
||||
this.view.dispatch({ ...tr, effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint))] });
|
||||
this.view.dispatch({
|
||||
...tr,
|
||||
effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint)), intentionsChanged.of(null)],
|
||||
});
|
||||
});
|
||||
}
|
||||
destroy() {
|
||||
|
||||
@@ -98,7 +98,7 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
|
||||
fix.className = "cm-lsp-link-button";
|
||||
const keys = shortcutLabel("code.showIntentions");
|
||||
fix.textContent = keys ? `More actions… ${keys}` : "More actions…";
|
||||
if (d.severity === "error" || d.severity === "warning") offerQuickFix(view, meta, fix, from, to);
|
||||
if (d.severity !== "hint") offerQuickFix(view, meta, fix, from, to);
|
||||
fix.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
fix.addEventListener("click", () => {
|
||||
view.dispatch({ selection: EditorSelection.cursor(from) });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { setDiagnostics } from "@codemirror/lint";
|
||||
import type { CodeAction } from "@/bindings";
|
||||
|
||||
const resolved: CodeAction = {
|
||||
@@ -35,6 +36,7 @@ const {
|
||||
intentionPreview,
|
||||
lightsBulb,
|
||||
listedCodeActions,
|
||||
listedIntentions,
|
||||
withInspectionControls,
|
||||
} = await import("./intentions");
|
||||
const { setFileDiagnostics } = await import("./diagnostics");
|
||||
@@ -90,6 +92,27 @@ describe("inspection controls in ⌥↵ (issue #42)", () => {
|
||||
setFileDiagnostics("/p/two.py", "ruff", []);
|
||||
expect(withInspectionControls(view, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["info", "warning", "error"] as const)(
|
||||
"uses the effective %s severity for local and LSP fixes",
|
||||
(severity) => {
|
||||
const path = `/p/severity-${severity}.py`;
|
||||
setFileDiagnostics(path, "ruff", [{ ...unused, severity }]);
|
||||
const view = editorAt(path);
|
||||
view.dispatch(setDiagnostics(view.state, [{ from: 7, to: 9, severity, message: unused.message }]));
|
||||
const items = listedIntentions(view, [
|
||||
{ id: "refactor", label: "Extract method", icon: "bulb", run: vi.fn() },
|
||||
{ id: "local", label: "Local fix", quickFix: true, run: vi.fn() },
|
||||
{ id: "lsp", label: "LSP fix", quickFix: true, emphasis: true, run: vi.fn() },
|
||||
]);
|
||||
expect(items.map((i) => i.id)).toEqual(["lsp", "local", "refactor"]);
|
||||
expect(items.slice(0, 2).map((i) => i.icon)).toEqual(Array(2).fill(severity === "error" ? "bulb-fix" : "bulb"));
|
||||
const controls = withInspectionControls(view, items);
|
||||
expect(controls.slice(0, 2).every((i) => i.submenu?.length === 2)).toBe(true);
|
||||
expect(controls[2]!.submenu).toBeUndefined();
|
||||
setFileDiagnostics(path, "ruff", []);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("intention preview", () => {
|
||||
|
||||
@@ -9,7 +9,13 @@ import { forEachDiagnostic } from "@codemirror/lint";
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import { diff } from "@codemirror/merge";
|
||||
import { commands, isCancelled, type CodeAction } from "@/platform/ipc";
|
||||
import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api";
|
||||
import {
|
||||
contributedIntentions,
|
||||
contributedQuickFixes,
|
||||
intentionProviders,
|
||||
intentionsChanged,
|
||||
whenSynced,
|
||||
} from "@/editor/api";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
|
||||
import { offsetsToRange } from "./positions";
|
||||
@@ -32,6 +38,27 @@ const codeActionBackoff = featureBackoff();
|
||||
|
||||
const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix."));
|
||||
|
||||
/** The red bulb is reserved for errors; other inspections use the ordinary bulb. */
|
||||
export function quickFixIcon(view: EditorView): "bulb" | "bulb-fix" {
|
||||
const sel = view.state.selection.main;
|
||||
const line = view.state.doc.lineAt(sel.head);
|
||||
let error = false;
|
||||
forEachDiagnostic(view.state, (d, from, to) => {
|
||||
if (d.severity === "error" && from <= line.to && to >= line.from) error = true;
|
||||
});
|
||||
return error ? "bulb-fix" : "bulb";
|
||||
}
|
||||
|
||||
const isFixItem = (item: ListPopupItem) => item.quickFix ?? item.icon === "bulb-fix";
|
||||
|
||||
/** Local and language-server fixes share icons and the same stable ordering. */
|
||||
export function listedIntentions(view: EditorView, items: ListPopupItem[]): ListPopupItem[] {
|
||||
const rank = (item: ListPopupItem) => (item.disabled ? 4 : 0) + (isFixItem(item) ? 0 : 2) + (item.emphasis ? 0 : 1);
|
||||
return items
|
||||
.map((item) => (isFixItem(item) ? { ...item, quickFix: true, icon: quickFixIcon(view) } : item))
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
/** IDEA ordering: quick fixes first, preferred before others, disabled last; stable otherwise. */
|
||||
export function sortCodeActions(actions: readonly CodeAction[]): CodeAction[] {
|
||||
const rank = (a: CodeAction) => (a.disabledReason ? 4 : 0) + (isQuickFix(a) ? 0 : 2) + (a.isPreferred ? 0 : 1);
|
||||
@@ -104,15 +131,23 @@ async function fetchIntentions(view: EditorView): Promise<CodeAction[]> {
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** Enabled quick fixes of the problem at `from..to`, preferred first. */
|
||||
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<CodeAction[]> {
|
||||
export type EditorQuickFix = CodeAction | { title: string; run: () => void | Promise<void> };
|
||||
|
||||
/** Enabled quick fixes of the problem at `from..to`, including local inspections. */
|
||||
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<EditorQuickFix[]> {
|
||||
const local = contributedQuickFixes(view, from, to).map((fix) => ({ title: fix.label, run: fix.run }));
|
||||
const doc = docOf(view);
|
||||
if (!doc) return [];
|
||||
await whenSynced(view);
|
||||
const actions = await lspRequest(() =>
|
||||
commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]),
|
||||
);
|
||||
return sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason));
|
||||
if (!doc) return local;
|
||||
try {
|
||||
await whenSynced(view);
|
||||
const actions = await lspRequest(() =>
|
||||
commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]),
|
||||
);
|
||||
return [...local, ...sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason))];
|
||||
} catch (e) {
|
||||
if (local.length) return local;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +179,8 @@ export async function applyQuickFix(view: EditorView) {
|
||||
showHint(view, "No quick fixes available");
|
||||
}
|
||||
|
||||
export async function applyCodeAction(view: EditorView, action: CodeAction) {
|
||||
export async function applyCodeAction(view: EditorView, action: EditorQuickFix) {
|
||||
if ("run" in action) return action.run();
|
||||
const doc = docOf(view);
|
||||
if (!doc) return;
|
||||
if (action.disabledReason) {
|
||||
@@ -276,9 +312,10 @@ function actionItems(
|
||||
resolved?: Map<CodeAction, Promise<CodeAction>>,
|
||||
): ListPopupItem[] {
|
||||
return listedCodeActions(actions).map((a, i) => ({
|
||||
id: `${i}`,
|
||||
id: `lsp:${i}`,
|
||||
label: a.title,
|
||||
icon: isQuickFix(a) ? "bulb-fix" : "bulb",
|
||||
icon: isQuickFix(a) ? quickFixIcon(view) : "bulb",
|
||||
quickFix: isQuickFix(a),
|
||||
emphasis: a.isPreferred,
|
||||
disabled: a.disabledReason,
|
||||
// The edit the preview already resolved is applied as is.
|
||||
@@ -344,7 +381,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
|
||||
(i) => i.id !== SPELLING_INSPECTION,
|
||||
);
|
||||
if (!inspections.length) return items;
|
||||
const fixes = items.filter((i) => i.icon === "bulb-fix" && !i.submenu);
|
||||
const fixes = items.filter((i) => isFixItem(i) && !i.submenu);
|
||||
if (inspections.length === 1 && fixes.length) {
|
||||
const submenu = intentionControls(inspections[0]!);
|
||||
return items.map((i) => (fixes.includes(i) ? { ...i, submenu } : i));
|
||||
@@ -364,7 +401,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
|
||||
/** ⌥↵ */
|
||||
export async function showIntentions(view: EditorView) {
|
||||
try {
|
||||
// Typo fixes and feature-contributed actions come first and need no language server.
|
||||
// Local inspections need no language server.
|
||||
const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)];
|
||||
let actions: CodeAction[] = [];
|
||||
let failure: { error: unknown } | null = null;
|
||||
@@ -376,7 +413,9 @@ export async function showIntentions(view: EditorView) {
|
||||
}
|
||||
const sorted = listedCodeActions(actions);
|
||||
const resolved = new Map<CodeAction, Promise<CodeAction>>();
|
||||
const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]);
|
||||
const serverItems = actionItems(view, sorted, resolved);
|
||||
const previews = new Map(serverItems.map((item, i) => [item.id, sorted[i]!]));
|
||||
const items = withInspectionControls(view, listedIntentions(view, [...local, ...serverItems]));
|
||||
if (!items.length) {
|
||||
if (failure) throw failure.error;
|
||||
showHint(view, "No intentions available");
|
||||
@@ -388,7 +427,7 @@ export async function showIntentions(view: EditorView) {
|
||||
title: "Intentions",
|
||||
items,
|
||||
preview: (item, signal) => {
|
||||
const action = sorted[items.indexOf(item) - local.length];
|
||||
const action = previews.get(item.id);
|
||||
return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null);
|
||||
},
|
||||
});
|
||||
@@ -500,7 +539,13 @@ const bulbPlugin = ViewPlugin.fromClass(
|
||||
this.lookup();
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged || u.selectionSet || (u.focusChanged && u.view.hasFocus)) this.lookup();
|
||||
if (
|
||||
u.docChanged ||
|
||||
u.selectionSet ||
|
||||
(u.focusChanged && u.view.hasFocus) ||
|
||||
u.transactions.some((tr) => tr.effects.some((e) => e.is(intentionsChanged)))
|
||||
)
|
||||
this.lookup();
|
||||
else if (u.focusChanged) {
|
||||
this.lookup.cancel();
|
||||
// A blur dispatched from inside the update is not allowed.
|
||||
@@ -516,16 +561,16 @@ const bulbPlugin = ViewPlugin.fromClass(
|
||||
if (!doc || doc.readOnly || !this.view.dom.isConnected || !this.view.hasFocus) return;
|
||||
const state = this.view.state;
|
||||
const typoFix = spellingIntentions(this.view).length > 0;
|
||||
const local = typoFix || contributedIntentions(this.view).length > 0;
|
||||
const selection = state.selection.main;
|
||||
const ownFix = contributedQuickFixes(this.view, selection.from, selection.to).length > 0;
|
||||
const local = typoFix || ownFix || contributedIntentions(this.view).length > 0;
|
||||
const backoff = codeActionBackoff.active(doc.doc);
|
||||
if (backoff && !local) return;
|
||||
let actions: CodeAction[] = [];
|
||||
if (!backoff) {
|
||||
try {
|
||||
actions = await fetchIntentions(this.view);
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) codeActionBackoff.mark(doc.doc);
|
||||
if (!local) return;
|
||||
}
|
||||
}
|
||||
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus)
|
||||
@@ -533,7 +578,14 @@ const bulbPlugin = ViewPlugin.fromClass(
|
||||
const line = state.doc.lineAt(state.selection.main.head).number;
|
||||
const any = actions.some(lightsBulb) || local;
|
||||
this.view.dispatch({
|
||||
effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null),
|
||||
effects: setBulb.of(
|
||||
any
|
||||
? {
|
||||
line,
|
||||
quickFix: (typoFix || ownFix || actions.some(isQuickFix)) && quickFixIcon(this.view) === "bulb-fix",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
});
|
||||
}
|
||||
destroy() {
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface ListPopupItem {
|
||||
/** Second line / muted text after the label (code preview). */
|
||||
preview?: string;
|
||||
icon?: string;
|
||||
/** Inspection fix, independent of the icon chosen for its severity. */
|
||||
quickFix?: boolean;
|
||||
/** Symbol badge kind (`ui/icons` `SymbolBadge`) shown instead of an icon. */
|
||||
symbol?: string;
|
||||
emphasis?: boolean;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { basename, dirname, useProject } from "@/platform/project";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
|
||||
import { openProjectInWindow } from "./windows";
|
||||
import { openProjectWithPolicy } from "./windows";
|
||||
import styles from "./WorkspaceDialog.module.css";
|
||||
|
||||
const useWorkspaceDialog = create<{ mode: "create" | "manage" | null }>(() => ({ mode: null }));
|
||||
@@ -53,7 +53,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
|
||||
const included = adding.trim() ? [...new Set([...projects, adding.trim()])] : projects;
|
||||
if (mode === "create") {
|
||||
const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included));
|
||||
await openProjectInWindow(root, !!project);
|
||||
await openProjectWithPolicy(root);
|
||||
} else {
|
||||
const updated = await call(commands.workspaceUpdate(name.trim(), included));
|
||||
useProject.setState({ project: updated, settings: updated.settings });
|
||||
|
||||
@@ -3,13 +3,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog";
|
||||
import { openProjectInWindow } from "../windows";
|
||||
import { openProjectWithPolicy } from "../windows";
|
||||
|
||||
vi.mock("../windows", () => ({ openProjectInWindow: vi.fn(async () => {}) }));
|
||||
vi.mock("../windows", () => ({ openProjectWithPolicy: vi.fn(async () => {}) }));
|
||||
vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => false }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(openProjectWithPolicy).mockClear();
|
||||
useProject.setState({ project: null, settings: null, lightEdit: false });
|
||||
});
|
||||
|
||||
@@ -21,7 +22,21 @@ it("creates a workspace from the pending project field without an extra Add clic
|
||||
fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/frontend" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Create$/ }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith("/work/full", "Workspace", ["/work/frontend"]));
|
||||
expect(openProjectInWindow).toHaveBeenCalledWith("/work/full", false);
|
||||
expect(openProjectWithPolicy).toHaveBeenCalledWith("/work/full");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("uses the shared opening policy after creation from an already open project", async () => {
|
||||
useProject.setState({
|
||||
project: { root: "/work/frontend", name: "frontend", trusted: true, isGit: false, settings: {} },
|
||||
settings: { general: { openProjectIn: "ask" } },
|
||||
} as never);
|
||||
vi.spyOn(commands, "workspaceCreate").mockResolvedValue({ status: "ok", data: "/work/full" });
|
||||
showWorkspaceDialog("create");
|
||||
render(<WorkspaceDialogHost />);
|
||||
fireEvent.change(screen.getByLabelText("Workspace location"), { target: { value: "/work/full" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Create$/ }));
|
||||
await waitFor(() => expect(openProjectWithPolicy).toHaveBeenCalledWith("/work/full"));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const ipc = vi.hoisted(() => {
|
||||
return {
|
||||
ok,
|
||||
debugStep: vi.fn(() => ok(null)),
|
||||
debugResume: vi.fn(() => ok(null)),
|
||||
debugForceStepOver: vi.fn(() => ok(null)),
|
||||
debugRunTo: vi.fn(() => ok(null)),
|
||||
debugStepInTargets: vi.fn(() => ok([] as Array<{ id: number; label: string }>)),
|
||||
@@ -54,7 +55,7 @@ import type { DebugCapabilities } from "@/platform/ipc";
|
||||
import { registerDebugActions } from "../actions";
|
||||
import { breakpointsOf, loadBreakpoints } from "../breakpoints/store";
|
||||
import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special";
|
||||
import { publishContext, useDebug, type DebugSession } from "../state";
|
||||
import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state";
|
||||
import { focusDebugView, blurDebugView } from "../ui/focusedView";
|
||||
import { useOverlays } from "../ui/popoverStore";
|
||||
|
||||
@@ -97,6 +98,7 @@ function paused(p: Partial<DebugSession> = {}) {
|
||||
};
|
||||
useDebug.setState({ sessions: { 3: s }, active: 3 });
|
||||
publishContext();
|
||||
updateExecutionPoint();
|
||||
}
|
||||
|
||||
function caret(path: string, language: string, doc: string, line: number) {
|
||||
@@ -154,15 +156,41 @@ describe("debugger actions (IDEA parity)", () => {
|
||||
expect(getAction("debug.jumpToSource")!.keyWhen).toBe("debugFramesFocus || debugVariablesFocus");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["debug.resume", "debugResume"],
|
||||
["debug.stepOver", "debugStep"],
|
||||
["debug.stepInto", "debugStep"],
|
||||
["debug.stepOut", "debugStep"],
|
||||
["debug.forceStepOver", "debugForceStepOver"],
|
||||
["debug.forceStepInto", "debugStep"],
|
||||
["debug.smartStepInto", "debugStep"],
|
||||
["debug.runToCursor", "debugRunTo"],
|
||||
["debug.forceRunToCursor", "debugRunTo"],
|
||||
["debug.resetFrame", "debugRestartFrame"],
|
||||
] as const)("%s clears the old execution point before its request", async (id, command) => {
|
||||
paused({ caps: caps({ restartFrame: true }) });
|
||||
caret("/p/app.py", "python", "a\nb\nc", 2);
|
||||
const request = ipc[command].mockImplementationOnce(() => {
|
||||
expect(useExecutionPoint.getState().path).toBeNull();
|
||||
expect(useDebug.getState().sessions[3]?.status).toBe("running");
|
||||
return ipc.ok(null);
|
||||
});
|
||||
await executeAction(id);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Force Step Over / Force Step Into / Force Run to Cursor", async () => {
|
||||
paused();
|
||||
caret("/p/app.py", "python", "a\nb\nc\nd", 2);
|
||||
await executeAction("debug.forceStepOver");
|
||||
expect(ipc.debugForceStepOver).toHaveBeenCalledWith(3, 1);
|
||||
paused();
|
||||
await executeAction("debug.forceStepInto");
|
||||
expect(ipc.debugStep).toHaveBeenCalledWith(3, 1, "into");
|
||||
paused();
|
||||
await executeAction("debug.forceRunToCursor");
|
||||
expect(ipc.debugRunTo).toHaveBeenCalledWith(3, 1, "/p/app.py", 2, true);
|
||||
paused();
|
||||
await executeAction("debug.runToCursor");
|
||||
expect(ipc.debugRunTo).toHaveBeenLastCalledWith(3, 1, "/p/app.py", 2, false);
|
||||
});
|
||||
@@ -180,6 +208,7 @@ describe("debugger actions (IDEA parity)", () => {
|
||||
expect(ipc.debugStepInTargets).toHaveBeenCalledWith(3, 71);
|
||||
expect(useOverlays.getState().smartStep?.targets.map((t) => t.label)).toEqual(["str(i)", "parse(str(i))"]);
|
||||
|
||||
paused({ caps: caps({ stepInTargets: true }) });
|
||||
ipc.debugStepInTargets.mockReturnValueOnce(ipc.ok([{ id: 5, label: "parse()" }]));
|
||||
await executeAction("debug.smartStepInto");
|
||||
await settle();
|
||||
|
||||
@@ -166,6 +166,60 @@ describe("breakpoint gutter clicks", () => {
|
||||
});
|
||||
|
||||
describe("execution point", () => {
|
||||
it("keeps the execution line synchronized if inline rendering throws", async () => {
|
||||
useExecutionPoint.setState({ path: null, values: {} });
|
||||
const errors: unknown[] = [];
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "x\nx\nx",
|
||||
extensions: [executionPoint(ctx), EditorView.exceptionSink.of((e) => errors.push(e))],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
const values: Record<string, string> = Object.defineProperty({}, "x", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error("inline rendering failed");
|
||||
},
|
||||
});
|
||||
useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values });
|
||||
await flush();
|
||||
expect(errors).toHaveLength(1);
|
||||
useExecutionPoint.setState({ line: 2, values: {} });
|
||||
await flush();
|
||||
expect(view.dom.querySelectorAll(".cm-line")[2]!.className).toContain(styles.execLine);
|
||||
useExecutionPoint.setState({ path: null });
|
||||
await flush();
|
||||
expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps refreshing the execution line around Object.prototype names", async () => {
|
||||
useExecutionPoint.setState({ path: null, values: {} });
|
||||
const view = makeView("String text = Integer.toString(40);\nint count = 40;\ncount++;\ncount++;");
|
||||
useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { text: "40" } });
|
||||
await flush();
|
||||
useExecutionPoint.setState({ line: 3, values: { text: "40", count: "42" } });
|
||||
await flush();
|
||||
expect(view.dom.querySelector(`.${styles.execLine}`)?.textContent).toContain("count++");
|
||||
useExecutionPoint.setState({ path: null, values: {} });
|
||||
await flush();
|
||||
expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([2, null])("refreshes a cached editor state when remounted at %s", async (line) => {
|
||||
useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values: {} });
|
||||
const view = makeView("first\nsecond\nthird");
|
||||
const state = view.state;
|
||||
view.destroy();
|
||||
views.splice(views.indexOf(view), 1);
|
||||
useExecutionPoint.setState({ path: line == null ? null : ctx.path, line: line ?? 0, values: {} });
|
||||
const restored = new EditorView({ state, parent: document.body });
|
||||
views.push(restored);
|
||||
await flush();
|
||||
expect(restored.dom.querySelector(`.${styles.execLine}`)?.textContent ?? null).toBe(line == null ? null : "third");
|
||||
});
|
||||
|
||||
it("highlights the suspended line and shows inline values", async () => {
|
||||
const view = makeView("x = 1\ny = x + 1\nprint(y)");
|
||||
useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { x: "1", y: "2" } });
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StackFrame, Variable } from "@/platform/ipc";
|
||||
|
||||
const ipc = vi.hoisted(() => {
|
||||
const ok = <T>(data: T) => ({ status: "ok" as const, data });
|
||||
return {
|
||||
ok,
|
||||
debugCapabilities: vi.fn(async () => ok(null)),
|
||||
debugThreads: vi.fn(async () =>
|
||||
ok([
|
||||
{ id: 5, name: "http-nio-8179-exec-5" },
|
||||
{ id: 1, name: "main" },
|
||||
]),
|
||||
),
|
||||
debugStackTrace: vi.fn(async () => ok([] as StackFrame[])),
|
||||
debugScopes: vi.fn(async () => ok([{ name: "Locals", variablesReference: 10, expensive: false }])),
|
||||
debugVariables: vi.fn(async () => ok([] as Variable[])),
|
||||
};
|
||||
});
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
const { ok: _ok, ...commands } = ipc;
|
||||
return { ...real, commands: { ...real.commands, ...commands } };
|
||||
});
|
||||
vi.mock("@/workbench/contributions", async (orig) => ({
|
||||
...(await orig<typeof import("@/workbench/contributions")>()),
|
||||
maybeEditorService: () => ({ openFile: vi.fn(async () => {}) }),
|
||||
}));
|
||||
import { adoptSession, handleDebugEvent, selectThread, resumeExecution, useDebug, useExecutionPoint } from "../state";
|
||||
|
||||
const path = "/proj/MsMemberUtils.java";
|
||||
const frame = (line: number, id = 100): StackFrame => ({
|
||||
id,
|
||||
name: "queryMsTicket",
|
||||
path,
|
||||
line,
|
||||
column: 0,
|
||||
presentationHint: null,
|
||||
});
|
||||
const stopped = (threadId = 5, allThreadsStopped = false) =>
|
||||
handleDebugEvent(1, { kind: "stopped", threadId, allThreadsStopped, reason: "step", description: null });
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDebug.setState({ sessions: {}, active: null, stopSeq: 0 });
|
||||
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} });
|
||||
adoptSession(1, { adapter: "java-debug" });
|
||||
ipc.debugStackTrace.mockResolvedValue(ipc.ok([frame(6840)]));
|
||||
ipc.debugVariables.mockResolvedValue(ipc.ok([]));
|
||||
});
|
||||
|
||||
describe("execution point state", () => {
|
||||
it.each([false, true])("selects each stopped thread and its fresh top frame (all=%s)", async (all) => {
|
||||
stopped(5, all);
|
||||
await settle();
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 });
|
||||
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
|
||||
stopped(5, all);
|
||||
await settle();
|
||||
expect(useDebug.getState().sessions[1]).toMatchObject({ selectedThread: 5, selectedFrame: 100 });
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 });
|
||||
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(9)]));
|
||||
stopped(1, all);
|
||||
await settle();
|
||||
expect(useDebug.getState().sessions[1]?.selectedThread).toBe(1);
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 9 });
|
||||
});
|
||||
|
||||
it("clears the old point while a new stack or selected thread is loading", async () => {
|
||||
stopped();
|
||||
await settle();
|
||||
const pending = deferred<ReturnType<typeof ipc.ok<StackFrame[]>>>();
|
||||
ipc.debugStackTrace.mockReturnValueOnce(pending.promise);
|
||||
stopped();
|
||||
expect(useExecutionPoint.getState().path).toBeNull();
|
||||
pending.resolve(ipc.ok([frame(6842)]));
|
||||
await settle();
|
||||
const other = deferred<ReturnType<typeof ipc.ok<StackFrame[]>>>();
|
||||
ipc.debugStackTrace.mockReturnValueOnce(other.promise);
|
||||
const selecting = selectThread(1, 1);
|
||||
expect(useExecutionPoint.getState().path).toBeNull();
|
||||
other.resolve(ipc.ok([frame(9)]));
|
||||
await selecting;
|
||||
expect(useExecutionPoint.getState().line).toBe(9);
|
||||
});
|
||||
|
||||
it("ignores a stack response from an earlier stop on the same thread", async () => {
|
||||
const old = deferred<ReturnType<typeof ipc.ok<StackFrame[]>>>();
|
||||
ipc.debugStackTrace.mockReturnValueOnce(old.promise);
|
||||
stopped();
|
||||
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
|
||||
stopped();
|
||||
await settle();
|
||||
old.resolve(ipc.ok([frame(6840)]));
|
||||
await settle();
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 });
|
||||
expect(useDebug.getState().sessions[1]?.frames[0]?.line).toBe(6842);
|
||||
});
|
||||
|
||||
it("keeps the selected paused thread when only another thread continues", async () => {
|
||||
stopped();
|
||||
await settle();
|
||||
handleDebugEvent(1, { kind: "continued", threadId: 1, allThreadsContinued: false });
|
||||
expect(useExecutionPoint.getState().path).toBe(path);
|
||||
expect(useDebug.getState().sessions[1]?.status).toBe("paused");
|
||||
handleDebugEvent(1, { kind: "continued", threadId: 5, allThreadsContinued: false });
|
||||
expect(useExecutionPoint.getState().path).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["continued", "terminated"] as const)("clears the execution point on %s", async (kind) => {
|
||||
stopped();
|
||||
await settle();
|
||||
handleDebugEvent(1, kind === "continued" ? { kind, threadId: 5, allThreadsContinued: true } : { kind });
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path: null, values: {} });
|
||||
});
|
||||
|
||||
it("retains locals whose names are Object.prototype properties", async () => {
|
||||
const names = Object.getOwnPropertyNames(Object.prototype);
|
||||
ipc.debugVariables.mockResolvedValueOnce(
|
||||
ipc.ok(
|
||||
names.map((name) => ({ name, value: "known", typeName: null, variablesReference: 0, evaluateName: name })),
|
||||
),
|
||||
);
|
||||
stopped();
|
||||
await settle();
|
||||
for (const name of names) expect(Object.hasOwn(useExecutionPoint.getState().values, name), name).toBe(true);
|
||||
});
|
||||
it.each(["step", "continue"])("clears suspended data before a %s request resolves", async () => {
|
||||
stopped();
|
||||
await settle();
|
||||
const pending = deferred<void>();
|
||||
const request = vi.fn(() => {
|
||||
expect(useExecutionPoint.getState().path).toBeNull();
|
||||
expect(useDebug.getState().sessions[1]).toMatchObject({ status: "running", frames: [], selectedFrame: null });
|
||||
return pending.promise;
|
||||
});
|
||||
const result = resumeExecution(1, request);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
|
||||
stopped();
|
||||
await settle();
|
||||
pending.resolve();
|
||||
await result;
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 });
|
||||
});
|
||||
|
||||
it("restores suspended data if the command fails without a newer event", async () => {
|
||||
stopped();
|
||||
await settle();
|
||||
await expect(
|
||||
resumeExecution(1, async () => {
|
||||
throw new Error("rejected");
|
||||
}),
|
||||
).rejects.toThrow("rejected");
|
||||
expect(useDebug.getState().sessions[1]?.status).toBe("paused");
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 });
|
||||
});
|
||||
|
||||
it("does not restore an older stop when a failed command has already stopped again", async () => {
|
||||
stopped();
|
||||
await settle();
|
||||
let reject!: (e: Error) => void;
|
||||
const result = resumeExecution(
|
||||
1,
|
||||
() =>
|
||||
new Promise((_, r) => {
|
||||
reject = r;
|
||||
}),
|
||||
);
|
||||
const rejected = expect(result).rejects.toThrow("rejected");
|
||||
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
|
||||
stopped();
|
||||
await settle();
|
||||
reject(new Error("rejected"));
|
||||
await rejected;
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 });
|
||||
});
|
||||
|
||||
it("discards locals from an earlier stop even when the frame ID is reused", async () => {
|
||||
const old = deferred<ReturnType<typeof ipc.ok<Variable[]>>>();
|
||||
ipc.debugVariables.mockReturnValueOnce(old.promise);
|
||||
stopped();
|
||||
await settle();
|
||||
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
|
||||
ipc.debugVariables.mockResolvedValueOnce(
|
||||
ipc.ok([{ name: "count", value: "42", typeName: null, variablesReference: 0, evaluateName: "count" }]),
|
||||
);
|
||||
stopped();
|
||||
await settle();
|
||||
old.resolve(ipc.ok([{ name: "count", value: "40", typeName: null, variablesReference: 0, evaluateName: "count" }]));
|
||||
await settle();
|
||||
expect(useExecutionPoint.getState().values.count).toBe("42");
|
||||
});
|
||||
});
|
||||
@@ -321,3 +321,23 @@ describe("Go build configuration fields", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("netcoredbg launches the evaluated DLL with resolved profile arguments, env and cwd", () => {
|
||||
const c = rc({
|
||||
kind: "dotnet",
|
||||
command: "dotnet",
|
||||
debugAdapter: "netcoredbg",
|
||||
cwd: "/repo/work",
|
||||
env: { ASPNETCORE_URLS: "http://localhost:5165", DEMO: "override" },
|
||||
params: { targetPath: "/repo/custom/Renamed.dll", programArgs: "--name 'hello world'", profileArgs: "--profile" },
|
||||
});
|
||||
expect(launchConfigFor(c, "/repo")).toMatchObject({
|
||||
adapter: "netcoredbg",
|
||||
program: "/repo/custom/Renamed.dll",
|
||||
args: ["--name", "hello world"],
|
||||
cwd: "/repo/work",
|
||||
env: c.env,
|
||||
});
|
||||
expect(launchConfigFor({ ...c, params: { ...c.params, programArgs: "" } }, "/repo")?.args).toEqual(["--profile"]);
|
||||
expect(launchConfigFor({ ...c, params: {} }, "/repo")).toBeNull();
|
||||
});
|
||||
@@ -21,6 +21,9 @@ describe("missing debug adapters", () => {
|
||||
expect(
|
||||
missingAdapter("kotlin-debug-adapter", unavailable("debug adapter `kotlin-debug-adapter` not found (x)"))?.tool,
|
||||
).toBe("kotlin-debug-adapter");
|
||||
expect(missingAdapter("netcoredbg", unavailable("debug adapter `netcoredbg` not found (x)"))?.tool).toBe(
|
||||
"netcoredbg",
|
||||
);
|
||||
expect(missingAdapter("delve", unavailable("debug adapter `delve` not found (x)"))?.tool).toBe("delve");
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api";
|
||||
import { useExecutionPoint } from "../state";
|
||||
import { adoptSession, useDebug, useExecutionPoint } from "../state";
|
||||
import { useOverlays } from "../ui/popoverStore";
|
||||
import {
|
||||
calleeName,
|
||||
@@ -92,6 +92,8 @@ describe("picking in the editor", () => {
|
||||
});
|
||||
|
||||
function setup() {
|
||||
useDebug.setState({ sessions: {}, active: null });
|
||||
adoptSession(3, { status: "paused", selectedThread: 1, stoppedThread: 1 });
|
||||
const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false };
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }),
|
||||
|
||||
@@ -52,6 +52,12 @@ describe("value classification", () => {
|
||||
);
|
||||
expect(inlineValuesFor("return 1", values)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(Object.getOwnPropertyNames(Object.prototype))("only displays an own local named %s", (name) => {
|
||||
expect(inlineValuesFor(name, { count: "40" })).toBeNull();
|
||||
expect(inlineValuesFor(name, Object.fromEntries([[name, "known"]]))).toBe(`${name}: known`);
|
||||
expect(inlineValuesFor(name, Object.create({ [name]: "inherited" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const frame = (id: number, path: string | null, hint: string | null = null, line = 9): StackFrame => ({
|
||||
|
||||
@@ -24,6 +24,7 @@ import { debugUnsupportedReason, launchConfigFor } from "./launch";
|
||||
import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote";
|
||||
import {
|
||||
activeSession,
|
||||
resumeExecution,
|
||||
evaluate,
|
||||
isLive,
|
||||
rerunSession,
|
||||
@@ -89,7 +90,10 @@ function breakpointPathAllowed(path: string): boolean {
|
||||
export function runToLine(path: string, line: number) {
|
||||
const t = pausedTarget();
|
||||
if (t && breakpointPathAllowed(path))
|
||||
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false)));
|
||||
void report(
|
||||
"Run to Cursor failed",
|
||||
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, path, line, false))),
|
||||
);
|
||||
}
|
||||
|
||||
function notSupported(what: string, s: DebugSession) {
|
||||
@@ -115,15 +119,17 @@ async function smartStepInto() {
|
||||
if (!t) return;
|
||||
const frame = selectedFrame(t.s);
|
||||
if (!frame || !sessionSupports("stepInTargets", t.s)) {
|
||||
await call(commands.debugStep(t.s.id, t.thread, "into"));
|
||||
await resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into")));
|
||||
return;
|
||||
}
|
||||
const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id)));
|
||||
if (targets.length <= 1) {
|
||||
await call(
|
||||
targets[0]
|
||||
? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id)
|
||||
: commands.debugStep(t.s.id, t.thread, "into"),
|
||||
await resumeExecution(t.s.id, () =>
|
||||
call(
|
||||
targets[0]
|
||||
? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id)
|
||||
: commands.debugStep(t.s.id, t.thread, "into"),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -291,7 +297,9 @@ async function startConfig(arg: unknown) {
|
||||
await startJavaConfig(config, root, origin ?? config);
|
||||
return;
|
||||
}
|
||||
const launch = launchConfigFor(config, root);
|
||||
const dotnet = config.kind === "dotnet" && config.debugAdapter === "netcoredbg";
|
||||
const prepared = dotnet ? await call(commands.runDotnetResolve(config, true)) : config;
|
||||
const launch = launchConfigFor(prepared, root);
|
||||
if (!launch) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
@@ -317,7 +325,11 @@ export function registerDebugActions(): () => void {
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind)));
|
||||
if (t)
|
||||
void report(
|
||||
title,
|
||||
resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, kind))),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -400,7 +412,11 @@ export function registerDebugActions(): () => void {
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread)));
|
||||
if (t)
|
||||
void report(
|
||||
"Resume failed",
|
||||
resumeExecution(t.s.id, () => call(commands.debugResume(t.s.id, t.thread))),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -432,7 +448,11 @@ export function registerDebugActions(): () => void {
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread)));
|
||||
if (t)
|
||||
void report(
|
||||
"Force Step Over failed",
|
||||
resumeExecution(t.s.id, () => call(commands.debugForceStepOver(t.s.id, t.thread))),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -445,7 +465,11 @@ export function registerDebugActions(): () => void {
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into")));
|
||||
if (t)
|
||||
void report(
|
||||
"Force Step Into failed",
|
||||
resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -484,7 +508,10 @@ export function registerDebugActions(): () => void {
|
||||
notSupported("Reset Frame", t.s);
|
||||
return;
|
||||
}
|
||||
void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id)));
|
||||
void report(
|
||||
"Reset Frame failed",
|
||||
resumeExecution(t.s.id, () => call(commands.debugRestartFrame(t.s.id, frame.id))),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -531,7 +558,10 @@ export function registerDebugActions(): () => void {
|
||||
const t = pausedTarget();
|
||||
const c = cursor();
|
||||
if (t && c && breakpointPathAllowed(c.path))
|
||||
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false)));
|
||||
void report(
|
||||
"Run to Cursor failed",
|
||||
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -545,7 +575,10 @@ export function registerDebugActions(): () => void {
|
||||
const t = pausedTarget();
|
||||
const c = cursor();
|
||||
if (t && c && breakpointPathAllowed(c.path))
|
||||
void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true)));
|
||||
void report(
|
||||
"Force Run to Cursor failed",
|
||||
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,6 +49,7 @@ function currentExec(path: string): Exec | null {
|
||||
export function inlineValuesFor(text: string, values: Record<string, string>): string | null {
|
||||
const parts: string[] = [];
|
||||
for (const id of identifiersIn(text)) {
|
||||
if (!Object.hasOwn(values, id)) continue;
|
||||
const v = values[id];
|
||||
if (v === undefined) continue;
|
||||
parts.push(`${id}: ${inlineValue(v, 40)}`);
|
||||
@@ -80,33 +81,48 @@ export function executionPoint(ctx: EditorDocContext): Extension {
|
||||
provide: (f) => EditorView.decorations.compute([f], (state) => lineDecorations(state, state.field(f))),
|
||||
});
|
||||
|
||||
// Keep the execution line alive even if an inline-value renderer fails.
|
||||
const sync = ViewPlugin.fromClass(
|
||||
class {
|
||||
private unsub: () => void;
|
||||
private pending = false;
|
||||
private destroyed = false;
|
||||
constructor(readonly view: EditorView) {
|
||||
this.unsub = useExecutionPoint.subscribe(() => this.refresh());
|
||||
// Hidden tabs retain EditorState while their views are unmounted.
|
||||
this.refresh();
|
||||
}
|
||||
refresh() {
|
||||
if (this.pending) return;
|
||||
this.pending = true;
|
||||
queueMicrotask(() => {
|
||||
this.pending = false;
|
||||
if (this.destroyed) return;
|
||||
const next = currentExec(path);
|
||||
const cur = this.view.state.field(execField);
|
||||
const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next;
|
||||
// Locals can change without moving the execution point.
|
||||
this.view.dispatch(moved ? { effects: setExec.of(next) } : {});
|
||||
});
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.unsub();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const inline = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet = Decoration.none;
|
||||
private unsub: () => void;
|
||||
private pending = false;
|
||||
private destroyed = false;
|
||||
private unsubSettings: () => void;
|
||||
constructor(readonly view: EditorView) {
|
||||
this.decorations = this.build();
|
||||
// Settings › Debugger › Data Views › Show values inline.
|
||||
this.unsubSettings = onWorkflowSettingsChange(
|
||||
(s) => s.debugger.inlineValues,
|
||||
() => queueMicrotask(() => !this.destroyed && this.view.dispatch({})),
|
||||
);
|
||||
this.unsub = useExecutionPoint.subscribe(() => {
|
||||
if (this.pending) return;
|
||||
this.pending = true;
|
||||
queueMicrotask(() => {
|
||||
this.pending = false;
|
||||
if (this.destroyed) return;
|
||||
const next = currentExec(path);
|
||||
const cur = this.view.state.field(execField);
|
||||
const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next;
|
||||
// An empty transaction still rebuilds the inline values (locals arrive after the frame).
|
||||
this.view.dispatch(moved ? { effects: setExec.of(next) } : {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
@@ -136,12 +152,11 @@ export function executionPoint(ctx: EditorDocContext): Extension {
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.unsub();
|
||||
this.unsubSettings();
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
);
|
||||
|
||||
return [execField, inline];
|
||||
return [execField, sync, inline];
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { editorsFor, activeEditor, type EditorDocContext, type TextEditorHandle
|
||||
import { call, commands, type DebugSessionId, type StepInTarget } from "@/platform/ipc";
|
||||
import { suspendKeyboardDispatch } from "@/platform/keymap";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { useExecutionPoint } from "../state";
|
||||
import { useExecutionPoint, resumeExecution } from "../state";
|
||||
import { useOverlays, type InlineStepTarget } from "../ui/popoverStore";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
@@ -139,7 +139,7 @@ export function chooseStepTarget(id?: number) {
|
||||
const target = id ?? s.inline?.ranges[s.inline.index]?.id;
|
||||
cancelSmartStep();
|
||||
if (target == null) return;
|
||||
void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) =>
|
||||
void resumeExecution(s.session, () => call(commands.debugStepInTarget(s.session, s.thread, target))).catch((e) =>
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
|
||||
@@ -313,6 +313,15 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul
|
||||
if (adapter === "js-debug") return jsDebugLaunch(rc, cwd);
|
||||
if (adapter === "delve") return delveLaunch(rc, cwd, root);
|
||||
if (adapter === "godot") return godotLaunch(rc, cwd);
|
||||
if (adapter === "netcoredbg" && rc.kind === "dotnet") {
|
||||
if (!rc.params?.targetPath) return null;
|
||||
return {
|
||||
...base(rc, adapter, cwd),
|
||||
program: rc.params.targetPath,
|
||||
args: parseArgs(rc.params.programArgs?.trim() ? rc.params.programArgs : rc.params.profileArgs || ""),
|
||||
extraJson: JSON.stringify({ justMyCode: workflowSettings().debugger.skipLibraryCode }),
|
||||
};
|
||||
}
|
||||
// Adapters without a template (hidden in Edit Configurations): generic arguments.
|
||||
return {
|
||||
...base(rc, adapter, cwd),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IpcError } from "@/platform/ipc";
|
||||
/** Adapters the tools installer can provide (adapter id → tool name). */
|
||||
const INSTALLABLE: Record<string, { tool: string; title: string }> = {
|
||||
"kotlin-debug-adapter": { tool: "kotlin-debug-adapter", title: "The Kotlin debugger is not installed" },
|
||||
netcoredbg: { tool: "netcoredbg", title: "netcoredbg is not installed" },
|
||||
debugpy: { tool: "debugpy", title: "debugpy is not installed" },
|
||||
"js-debug": { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed" },
|
||||
delve: { tool: "delve", title: "Delve is not installed" },
|
||||
|
||||
@@ -644,6 +644,7 @@ export function selectSession(id: DebugSessionId) {
|
||||
|
||||
export async function selectThread(id: DebugSessionId, threadId: number) {
|
||||
patch(id, { selectedThread: threadId, frames: [], selectedFrame: null });
|
||||
updateExecutionPoint();
|
||||
await loadFrames(id, threadId, true);
|
||||
}
|
||||
|
||||
@@ -652,7 +653,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true)
|
||||
updateExecutionPoint();
|
||||
const s = useDebug.getState().sessions[id];
|
||||
const frame = s?.frames.find((f) => f.id === frameId);
|
||||
if (reveal && frame?.path) {
|
||||
if (reveal && useDebug.getState().active === id && frame?.path) {
|
||||
void maybeEditorService()?.openFile(frame.path, {
|
||||
selection: { line: frame.line, col: frame.column },
|
||||
focus: false,
|
||||
@@ -662,6 +663,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true)
|
||||
}
|
||||
|
||||
async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) {
|
||||
const before = useDebug.getState().sessions[id];
|
||||
let frames: StackFrame[] = [];
|
||||
try {
|
||||
frames = await call(commands.debugStackTrace(id, threadId));
|
||||
@@ -669,7 +671,7 @@ async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean)
|
||||
console.warn("stackTrace failed", e);
|
||||
}
|
||||
const s = useDebug.getState().sessions[id];
|
||||
if (!s || s.status !== "paused" || s.selectedThread !== threadId) return;
|
||||
if (!s || s.status !== "paused" || s.selectedThread !== threadId || s.frames !== before?.frames) return;
|
||||
patch(id, { frames });
|
||||
const top = frames.find((f) => f.path && f.presentationHint !== "label") ?? frames[0];
|
||||
if (top?.path && (s.stopReason === "breakpoint" || s.stopReason === "function breakpoint")) {
|
||||
@@ -707,19 +709,21 @@ async function loadThreads(id: DebugSessionId) {
|
||||
|
||||
/** Locals of `frameId` (first non-expensive scope) for inline values. */
|
||||
async function loadLocals(id: DebugSessionId, frameId: number) {
|
||||
const values: Record<string, string> = {};
|
||||
const frames = useDebug.getState().sessions[id]?.frames;
|
||||
const values: Record<string, string> = Object.create(null);
|
||||
try {
|
||||
const scopes = await call(commands.debugScopes(id, frameId));
|
||||
const scope = scopes.find((sc) => !sc.expensive);
|
||||
if (scope) {
|
||||
const vars = await call(commands.debugVariables(id, scope.variablesReference));
|
||||
for (const v of vars) if (v.name && !(v.name in values)) values[v.name] = v.value;
|
||||
for (const v of vars) if (v.name && !Object.hasOwn(values, v.name)) values[v.name] = v.value;
|
||||
}
|
||||
} catch {
|
||||
// No inline values.
|
||||
}
|
||||
const s = useDebug.getState().sessions[id];
|
||||
if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) return;
|
||||
if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames)
|
||||
return;
|
||||
useExecutionPoint.setState({ values });
|
||||
}
|
||||
|
||||
@@ -729,6 +733,7 @@ export async function showExecutionPoint(id: DebugSessionId) {
|
||||
if (!s || s.status !== "paused" || s.stoppedThread == null) return;
|
||||
if (s.selectedThread !== s.stoppedThread) {
|
||||
patch(id, { selectedThread: s.stoppedThread, frames: [], selectedFrame: null });
|
||||
updateExecutionPoint();
|
||||
await loadFrames(id, s.stoppedThread, false);
|
||||
}
|
||||
const cur = useDebug.getState().sessions[id];
|
||||
@@ -757,6 +762,32 @@ export function updateExecutionPoint() {
|
||||
useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {} });
|
||||
}
|
||||
|
||||
/** Invalidate suspended data before sending a command that resumes execution. */
|
||||
export async function resumeExecution(id: DebugSessionId, request: () => Promise<unknown>) {
|
||||
const before = useDebug.getState().sessions[id];
|
||||
if (!before || before.status !== "paused") return;
|
||||
const frames: StackFrame[] = [];
|
||||
patch(id, { status: "running", frames, selectedFrame: null, stoppedThread: null });
|
||||
updateExecutionPoint();
|
||||
try {
|
||||
await request();
|
||||
} catch (e) {
|
||||
const current = useDebug.getState().sessions[id];
|
||||
// A stop/continue/termination received during the request owns the newer state.
|
||||
if (current?.status === "running" && current.frames === frames) {
|
||||
patch(id, {
|
||||
status: before.status,
|
||||
frames: before.frames,
|
||||
selectedFrame: before.selectedFrame,
|
||||
stoppedThread: before.stoppedThread,
|
||||
});
|
||||
updateExecutionPoint();
|
||||
if (before.selectedFrame != null) void loadLocals(id, before.selectedFrame);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- watches -------------------------------------------------------------------
|
||||
|
||||
export function addWatch(expression: string) {
|
||||
@@ -862,6 +893,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
|
||||
});
|
||||
useDebug.setState((st) => ({ active: id, stopSeq: st.stopSeq + 1 }));
|
||||
publishContext();
|
||||
updateExecutionPoint();
|
||||
void loadThreads(id);
|
||||
void loadFrames(id, event.threadId, true);
|
||||
const dbg = workflowSettings().debugger;
|
||||
@@ -876,6 +908,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
|
||||
case "continued": {
|
||||
const cur = useDebug.getState().sessions[id]!;
|
||||
if (cur.status === "terminated") break;
|
||||
if (!event.allThreadsContinued && event.threadId !== (cur.selectedThread ?? cur.stoppedThread)) break;
|
||||
patch(id, {
|
||||
status: "running",
|
||||
frames: [],
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
updateBreakpoint,
|
||||
useBreakpoints,
|
||||
} from "../breakpoints/store";
|
||||
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state";
|
||||
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug, resumeExecution } from "../state";
|
||||
import {
|
||||
addMethodBreakpoint,
|
||||
removeAllSpecial,
|
||||
@@ -978,7 +978,9 @@ function SmartStepChooser() {
|
||||
const pick = (targetId: number) => {
|
||||
if (!state) return;
|
||||
close();
|
||||
void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) =>
|
||||
void resumeExecution(state.session, () =>
|
||||
call(commands.debugStepInTarget(state.session, state.thread, targetId)),
|
||||
).catch((e) =>
|
||||
notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { DependencyVersions } from "@/bindings";
|
||||
|
||||
const dependencyVersions = vi.fn();
|
||||
const cancelRequest = vi.fn(async () => {});
|
||||
const lspCodeActions = vi.fn(async () => ({ status: "error", error: { kind: "unavailable", message: "No server" } }));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
commands: { ...actual.commands, dependencyVersions, cancelRequest, lspCodeActions },
|
||||
};
|
||||
});
|
||||
const { activate, versionCompletions } = await import("./index");
|
||||
const { editorContributions, contributedIntentions } = await import("@/editor/api");
|
||||
const { diagnosticsFor } = await import("@/editor/lsp/diagnostics");
|
||||
const { lspDoc } = await import("@/editor/lsp/lsp");
|
||||
const { quickFixesAt, applyCodeAction, showIntentions } = await import("@/editor/lsp/intentions");
|
||||
const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { DEPENDENCY_INSPECTION } = await import("@/platform/inspections");
|
||||
const text = '# 中文 😀\n[dependencies]\ntokio = { version = "0.2", features = ["full"] }\n';
|
||||
const from = text.indexOf('"0.2"') + 1;
|
||||
const item: DependencyVersions = {
|
||||
dependency: { package: "tokio", requirement: "0.2", from, to: from + 3 },
|
||||
versions: ["2.0.0-beta.1", "1.10.0", "1.9.0", "0.2.1"],
|
||||
update: { latest: "1.10.0", replacement: "1.10" },
|
||||
};
|
||||
let deactivate: () => void;
|
||||
const views: EditorView[] = [];
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
dependencyVersions.mockResolvedValue({ status: "ok", data: [item] });
|
||||
useProject.setState({ project: { root: "/p" } as never, settings: {} as never });
|
||||
deactivate = activate();
|
||||
});
|
||||
afterEach(() => {
|
||||
views.splice(0).forEach((view) => view.destroy());
|
||||
deactivate();
|
||||
closePopup();
|
||||
useProject.setState({ project: null, settings: null });
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
function editor(path = "/p/Cargo.toml", readOnly = false) {
|
||||
const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly };
|
||||
const contribution = editorContributions().find((c) => c.id === "dependency-versions")!;
|
||||
const view = new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({
|
||||
doc: text,
|
||||
selection: { anchor: from },
|
||||
extensions: [lspDoc.of(ctx), contribution.extensions(ctx)],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
async function settle() {
|
||||
await vi.advanceTimersByTimeAsync(550);
|
||||
}
|
||||
|
||||
describe("Cargo dependency versions", () => {
|
||||
it("publishes a weak warning and fixes only the version through context actions", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
expect(dependencyVersions).toHaveBeenCalledWith(7, null, expect.any(Number));
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toMatchObject([
|
||||
{
|
||||
severity: "info",
|
||||
message: "A newer version is available: 1.10.0",
|
||||
range: { start: { line: 2, col: 21 }, end: { line: 2, col: 24 } },
|
||||
},
|
||||
]);
|
||||
const [fix] = contributedIntentions(view);
|
||||
expect(fix?.label).toBe("Update to 1.10");
|
||||
await fix!.run();
|
||||
expect(view.state.doc.toString()).toBe(text.replace('"0.2"', '"1.10"'));
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
});
|
||||
it("provides direct and hover quick fixes even without a language server", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
const [fix] = await quickFixesAt(view, from, from + 3);
|
||||
expect(fix?.title).toBe("Update to 1.10");
|
||||
await applyCodeAction(view, fix!);
|
||||
expect(view.state.doc.toString()).toContain('version = "1.10", features = ["full"]');
|
||||
});
|
||||
it.each(["0.10", "^0.10", "=0.10.3"])(
|
||||
"shows the exact replacement %s with weak-warning inspection controls",
|
||||
async (replacement) => {
|
||||
dependencyVersions.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [{ ...item, update: { latest: "0.10.3", replacement } }],
|
||||
});
|
||||
const view = editor();
|
||||
await settle();
|
||||
await showIntentions(view);
|
||||
const popup = useLspPopup.getState().popup;
|
||||
expect(popup?.kind).toBe("list");
|
||||
if (popup?.kind !== "list") throw new Error("Missing intentions");
|
||||
expect(popup.title).toBe("Intentions");
|
||||
expect(popup.items).toHaveLength(1);
|
||||
expect(popup.items[0]).toMatchObject({ label: `Update to ${replacement}`, icon: "bulb", quickFix: true });
|
||||
expect(popup.items[0]!.submenu).toHaveLength(2);
|
||||
await popup.items[0]!.run();
|
||||
expect(view.state.doc.toString()).toBe(text.replace('"0.2"', `"${replacement}"`));
|
||||
},
|
||||
);
|
||||
it("keeps diagnostics when one of two split editors closes", async () => {
|
||||
editor();
|
||||
const split = editor();
|
||||
await settle();
|
||||
split.destroy();
|
||||
views.pop();
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toHaveLength(1);
|
||||
});
|
||||
it("invalidates fixes immediately after edits and rejects a stale fix callback", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
const [fix] = contributedIntentions(view);
|
||||
view.dispatch({ changes: { from: 0, insert: "# edit\n" } });
|
||||
expect(contributedIntentions(view)).toEqual([]);
|
||||
await fix!.run();
|
||||
expect(view.state.doc.toString()).toBe("# edit\n" + text);
|
||||
});
|
||||
it("discards delayed responses after an edit, project switch or editor close", async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
dependencyVersions.mockImplementation(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
const view = editor();
|
||||
await settle();
|
||||
view.dispatch({ changes: { from: 0, insert: "# edit\n" } });
|
||||
resolve({ status: "ok", data: [item] });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
await settle();
|
||||
useProject.setState({ project: { root: "/other" } as never });
|
||||
resolve({ status: "ok", data: [item] });
|
||||
await settle();
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
expect(cancelRequest).toHaveBeenCalled();
|
||||
const other = editor("/other/Cargo.toml");
|
||||
await settle();
|
||||
other.destroy();
|
||||
views.pop();
|
||||
resolve({ status: "ok", data: [item] });
|
||||
await settle();
|
||||
expect(diagnosticsFor("/other/Cargo.toml")).toEqual([]);
|
||||
});
|
||||
it("stays silent offline, with compatible versions and for non-Cargo or readonly files", async () => {
|
||||
dependencyVersions.mockRejectedValue(new Error("offline"));
|
||||
editor();
|
||||
await settle();
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
dependencyVersions.mockResolvedValue({ status: "ok", data: [{ ...item, update: null }] });
|
||||
editor("/p/sub/Cargo.toml");
|
||||
await settle();
|
||||
expect(diagnosticsFor("/p/sub/Cargo.toml")).toEqual([]);
|
||||
dependencyVersions.mockClear();
|
||||
editor("/p/other.toml");
|
||||
editor("/p/readonly/Cargo.toml", true);
|
||||
await settle();
|
||||
expect(dependencyVersions).not.toHaveBeenCalled();
|
||||
});
|
||||
it("honours the inspection profile and cleans up on close", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
useProject.setState({
|
||||
settings: { inspections: { default: { [DEPENDENCY_INSPECTION]: { enabled: false } } } } as never,
|
||||
});
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
expect(contributedIntentions(view)).toEqual([]);
|
||||
view.destroy();
|
||||
views.pop();
|
||||
await settle();
|
||||
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
|
||||
});
|
||||
it("completes only at a parsed dependency version, preserving descending source order", async () => {
|
||||
const view = editor();
|
||||
const source = view.state.languageDataAt<(ctx: CompletionContext) => Promise<unknown>>(
|
||||
"openideaCompletion",
|
||||
from,
|
||||
)[0]!;
|
||||
const result = await source(new CompletionContext(view.state, from + 3, true, view));
|
||||
expect(result).toMatchObject({
|
||||
from,
|
||||
to: from + 3,
|
||||
filter: false,
|
||||
options: item.versions.map((label) => ({ label })),
|
||||
});
|
||||
expect(dependencyVersions).toHaveBeenLastCalledWith(7, from + 3, expect.any(Number));
|
||||
dependencyVersions.mockResolvedValue({ status: "ok", data: [] });
|
||||
expect(await source(new CompletionContext(view.state, 0, true, view))).toBeNull();
|
||||
});
|
||||
it("replaces the whole version and retains a simple requirement operator", () => {
|
||||
const state = EditorState.create({ doc: "^1.2" });
|
||||
const context = new CompletionContext(state, 4, true);
|
||||
const result = versionCompletions({ ...item, dependency: { ...item.dependency, from: 0, to: 4 } }, context);
|
||||
expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { EditorState, StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { ViewPlugin, type EditorView } from "@codemirror/view";
|
||||
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import {
|
||||
intentionProviders,
|
||||
intentionsChanged,
|
||||
quickFixProviders,
|
||||
registerEditorContribution,
|
||||
whenSynced,
|
||||
type ContributedIntention,
|
||||
type EditorDocContext,
|
||||
} from "@/editor/api";
|
||||
import { setFileDiagnostics } from "@/editor/lsp/diagnostics";
|
||||
import { offsetsToRange } from "@/editor/lsp/positions";
|
||||
import { call, commands, isTauri, nextRequestId, type DependencyVersions, type RequestId } from "@/platform/ipc";
|
||||
import {
|
||||
DEPENDENCY_INSPECTION,
|
||||
DEPENDENCY_PRODUCER,
|
||||
inspectionProfile,
|
||||
subscribeInspectionProfile,
|
||||
} from "@/platform/inspections";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
const setVersions = StateEffect.define<DependencyVersions[]>();
|
||||
const versionsField = StateField.define<DependencyVersions[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
if (tr.docChanged) return [];
|
||||
for (const effect of tr.effects) if (effect.is(setVersions)) return effect.value;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
const viewers = new Map<string, number>();
|
||||
|
||||
const enabled = () => inspectionProfile()[DEPENDENCY_INSPECTION]?.enabled !== false;
|
||||
|
||||
export function dependencyFixes(view: EditorView, from: number, to: number): ContributedIntention[] {
|
||||
if (!enabled() || view.state.readOnly) return [];
|
||||
const text = view.state.doc;
|
||||
return (view.state.field(versionsField, false) ?? []).flatMap(({ dependency, update }) => {
|
||||
if (!update || from > dependency.to || to < dependency.from) return [];
|
||||
const { replacement } = update;
|
||||
return [
|
||||
{
|
||||
id: `dependency.update:${dependency.from}`,
|
||||
label: tx`Update to ${replacement}`,
|
||||
quickFix: true,
|
||||
run: () => {
|
||||
if (view.state.doc !== text) return;
|
||||
view.dispatch({
|
||||
changes: { from: dependency.from, to: dependency.to, insert: replacement },
|
||||
userEvent: "input.dependency",
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function versionCompletions(item: DependencyVersions, ctx: CompletionContext): CompletionResult {
|
||||
const { dependency, versions } = item;
|
||||
const typed = ctx.state.sliceDoc(dependency.from, ctx.pos);
|
||||
const prefix = /^[\s^~<>=]*/.exec(typed)?.[0] ?? "";
|
||||
const filter = typed.slice(prefix.length);
|
||||
return {
|
||||
from: dependency.from,
|
||||
to: dependency.to,
|
||||
filter: false,
|
||||
options: versions
|
||||
.filter((v) => ctx.explicit || v.startsWith(filter))
|
||||
.map((version) => ({
|
||||
label: version,
|
||||
type: "constant",
|
||||
detail: dependency.package,
|
||||
apply: prefix + version,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function extension(doc: EditorDocContext): Extension {
|
||||
const plugin = ViewPlugin.define((view) => {
|
||||
if (!isTauri()) return {};
|
||||
viewers.set(doc.path, (viewers.get(doc.path) ?? 0) + 1);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let request: RequestId | null = null;
|
||||
let sequence = 0;
|
||||
let disposed = false;
|
||||
const clear = () => setFileDiagnostics(doc.path, DEPENDENCY_PRODUCER, []);
|
||||
const cancel = () => {
|
||||
sequence++;
|
||||
if (request !== null) void commands.cancelRequest(request);
|
||||
request = null;
|
||||
};
|
||||
const run = async () => {
|
||||
if (disposed || !enabled()) return;
|
||||
const mine = sequence;
|
||||
const text = view.state.doc;
|
||||
const root = useProject.getState().project?.root;
|
||||
const id = nextRequestId();
|
||||
request = id;
|
||||
try {
|
||||
await whenSynced(view);
|
||||
if (mine !== sequence || disposed) return;
|
||||
const items = await call(commands.dependencyVersions(doc.doc, null, id));
|
||||
if (mine !== sequence || disposed || view.state.doc !== text || useProject.getState().project?.root !== root)
|
||||
return;
|
||||
view.dispatch({ effects: [setVersions.of(items), intentionsChanged.of(null)] });
|
||||
setFileDiagnostics(
|
||||
doc.path,
|
||||
DEPENDENCY_PRODUCER,
|
||||
items.flatMap(({ dependency, update }) => {
|
||||
if (!update) return [];
|
||||
const { latest } = update;
|
||||
return [
|
||||
{
|
||||
range: offsetsToRange(text, dependency.from, dependency.to),
|
||||
severity: "info",
|
||||
message: tx`A newer version is available: ${latest}`,
|
||||
source: "Cargo",
|
||||
code: null,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Offline registries and cancelled checks never interrupt editing.
|
||||
} finally {
|
||||
if (request === id) request = null;
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
cancel();
|
||||
clear();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void run(), 500);
|
||||
};
|
||||
schedule();
|
||||
const offProfile = subscribeInspectionProfile(() => {
|
||||
schedule();
|
||||
view.dispatch({ effects: intentionsChanged.of(null) });
|
||||
});
|
||||
const offProject = useProject.subscribe((state, previous) => {
|
||||
if (state.project?.root !== previous.project?.root) {
|
||||
disposed = true;
|
||||
cancel();
|
||||
clearTimeout(timer);
|
||||
clear();
|
||||
}
|
||||
});
|
||||
return {
|
||||
update: (u) => {
|
||||
if (u.docChanged) schedule();
|
||||
},
|
||||
destroy: () => {
|
||||
disposed = true;
|
||||
cancel();
|
||||
clearTimeout(timer);
|
||||
offProfile();
|
||||
offProject();
|
||||
const remaining = (viewers.get(doc.path) ?? 1) - 1;
|
||||
if (remaining) viewers.set(doc.path, remaining);
|
||||
else {
|
||||
viewers.delete(doc.path);
|
||||
clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
const completion = async (ctx: CompletionContext): Promise<CompletionResult | null> => {
|
||||
if (!isTauri()) return null;
|
||||
const id = nextRequestId();
|
||||
const text = ctx.state.doc;
|
||||
const root = useProject.getState().project?.root;
|
||||
ctx.addEventListener("abort", () => void commands.cancelRequest(id));
|
||||
try {
|
||||
// The completion context is immutable; only use it while its document is current.
|
||||
const view = ctx.view;
|
||||
if (!view) return null;
|
||||
await whenSynced(view);
|
||||
if (ctx.aborted || view.state.doc !== text) return null;
|
||||
const items = await call(commands.dependencyVersions(doc.doc, ctx.pos, id));
|
||||
if (ctx.aborted || view.state.doc !== text || useProject.getState().project?.root !== root) return null;
|
||||
const item = items.find(({ dependency }) => dependency.from <= ctx.pos && ctx.pos <= dependency.to);
|
||||
return item ? versionCompletions(item, ctx) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return [
|
||||
versionsField,
|
||||
plugin,
|
||||
intentionProviders.of((view) => {
|
||||
const selection = view.state.selection.main;
|
||||
return dependencyFixes(view, selection.from, selection.to);
|
||||
}),
|
||||
quickFixProviders.of(dependencyFixes),
|
||||
EditorState.languageData.of(() => [{ openideaCompletion: completion }]),
|
||||
];
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
return registerEditorContribution({
|
||||
id: "dependency-versions",
|
||||
extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []),
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { activate as editorLsp } from "@/editor/lsp";
|
||||
import { activate as welcome } from "./welcome";
|
||||
import { activate as projectTree } from "./project-tree";
|
||||
import { activate as search } from "./search";
|
||||
import { activate as dependencies } from "./dependencies";
|
||||
import { activate as codeInsight } from "./code-insight";
|
||||
import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs";
|
||||
import { activate as terminal } from "./terminal";
|
||||
@@ -55,6 +56,7 @@ const features: Array<[string, Activate]> = [
|
||||
["project-tree", projectTree],
|
||||
["search", search],
|
||||
["code-insight", codeInsight],
|
||||
["dependencies", dependencies],
|
||||
["vcs", vcs],
|
||||
["terminal", terminal],
|
||||
["run", run],
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { RemoteEntry, Settings } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemoteProgress } from "@/platform/remote";
|
||||
import { ConnectHost } from "./ConnectDialog";
|
||||
import { closeConnectDialog, openConnectDialog, useConnectDialog } from "./model";
|
||||
|
||||
const { list, openRemote, executeAction } = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
openRemote: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return { ...real, commands: { ...real.commands, remoteBootstrapList: list } };
|
||||
});
|
||||
vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote }));
|
||||
vi.mock("@/platform/actions", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/platform/actions")>()),
|
||||
executeAction,
|
||||
}));
|
||||
|
||||
const entry = (path: string, isDir = true): RemoteEntry => ({
|
||||
name: path.slice(path.lastIndexOf("/") + 1),
|
||||
path,
|
||||
isDir,
|
||||
isSymlink: false,
|
||||
size: 0,
|
||||
mtime: null,
|
||||
permissions: "rwxr-xr-x",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
useProject.setState({
|
||||
project: null,
|
||||
opening: false,
|
||||
settings: {
|
||||
ssh: {
|
||||
configs: [
|
||||
{ id: "first", name: "First host", host: "first.example", user: "dev" },
|
||||
{ id: "selected", name: "Selected host", host: "selected.example", user: "dev" },
|
||||
],
|
||||
},
|
||||
} as Settings,
|
||||
});
|
||||
useRemoteProgress.setState({ progress: null, location: null });
|
||||
openConnectDialog();
|
||||
});
|
||||
|
||||
afterEach(() => closeConnectDialog());
|
||||
|
||||
it("browses the selected SSH host, navigates folders and opens one without a current project", async () => {
|
||||
let finish!: (result: { status: "ok"; data: RemoteEntry[] }) => void;
|
||||
list.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve)));
|
||||
openRemote.mockResolvedValue(true);
|
||||
render(<ConnectHost />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "SSH configuration" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: /Selected host/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse" }));
|
||||
expect(list).toHaveBeenCalledWith("selected", "~");
|
||||
expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Open" })).toBeDisabled();
|
||||
finish({
|
||||
status: "ok",
|
||||
data: [entry("/home/dev/.cache"), entry("/home/dev/projects"), entry("/home/dev/README.md", false)],
|
||||
});
|
||||
const folders = await screen.findByRole("list", { name: "Remote folders" });
|
||||
expect(
|
||||
within(folders)
|
||||
.getAllByRole("button")
|
||||
.map((button) => button.textContent),
|
||||
).toEqual(["..", "projects", ".cache"]);
|
||||
expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev");
|
||||
list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects/demo")] });
|
||||
fireEvent.click(within(folders).getByRole("button", { name: "projects" }));
|
||||
await screen.findByRole("button", { name: "demo" });
|
||||
expect(list).toHaveBeenLastCalledWith("selected", "/home/dev/projects");
|
||||
list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects")] });
|
||||
fireEvent.click(within(folders).getByRole("button", { name: ".." }));
|
||||
await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev"));
|
||||
expect(list).toHaveBeenLastCalledWith("selected", "/home/dev");
|
||||
list.mockResolvedValueOnce({ status: "ok", data: [] });
|
||||
fireEvent.click(within(folders).getByRole("button", { name: "projects" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev/projects"));
|
||||
expect(within(folders).getAllByRole("button")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
await waitFor(() => expect(openRemote).toHaveBeenCalledWith("selected", "/home/dev/projects"));
|
||||
expect(useConnectDialog.getState().open).toBe(false);
|
||||
expect(useProject.getState().project).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a connection error and lets the welcome window retry Browse", async () => {
|
||||
list.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
render(<ConnectHost />);
|
||||
fireEvent.change(screen.getByLabelText("Project directory:"), { target: { value: " /srv/projects " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Connection refused");
|
||||
expect(list).toHaveBeenCalledWith("first", "/srv/projects");
|
||||
expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled();
|
||||
list.mockResolvedValueOnce({ status: "ok", data: [] });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse" }));
|
||||
await screen.findByRole("list", { name: "Remote folders" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(openRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers SSH configuration from the welcome window and disables Browse until one exists", () => {
|
||||
useProject.setState({ settings: { ssh: { configs: [] } } as unknown as Settings });
|
||||
render(<ConnectHost />);
|
||||
expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Open" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure…" }));
|
||||
expect(executeAction).toHaveBeenCalledWith("file.settings", "tools.sshConfigurations");
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -48,11 +48,12 @@ import styles from "./Run.module.css";
|
||||
|
||||
/**
|
||||
* Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`),
|
||||
* verified against small programs. Others (netcoredbg, dart, php-debug, …) only got
|
||||
* verified against small programs. Others (dart, php-debug, …) only got
|
||||
* generic launch arguments and are left out until they have one; a configuration that
|
||||
* already names one keeps it.
|
||||
*/
|
||||
const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [
|
||||
{ id: "netcoredbg", label: "netcoredbg (.NET)" },
|
||||
{ id: "java-debug", label: "java-debug (Java)" },
|
||||
{ id: "kotlin-debug-adapter", label: "kotlin-debug-adapter (Kotlin)" },
|
||||
{ id: "debugpy", label: "debugpy (Python)" },
|
||||
@@ -254,6 +255,9 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
|
||||
})),
|
||||
},
|
||||
{ label: TASK_LABELS.build, icon: "build-project", run: () => addTask("build") },
|
||||
...(config.kind === "dotnet"
|
||||
? [{ label: TASK_LABELS.dotnetBuild, icon: "build-project", run: () => addTask("dotnetBuild") }]
|
||||
: []),
|
||||
{
|
||||
label: TASK_LABELS.tool,
|
||||
disabled: !externalTools.length,
|
||||
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user